Add avg response time on search page + click-to-retry store badges
1. Search page: each store now shows its average response time (e.g., "~850ms") pulled from scrape_logs. Shows "untested" or "failing" for stores without successful scrapes. 2. Results page: store progress badges are now clickable buttons. Click a completed or failed store to re-search just that store. Old results from that store are removed, new ones stream in. Useful for retrying stores that had connection errors. Hover tooltip shows "Click to retry" or "Click to refresh". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -183,11 +183,15 @@
|
|||||||
: selectedStoreIds.has(store.id)
|
: selectedStoreIds.has(store.id)
|
||||||
? 'text-text-primary'
|
? 'text-text-primary'
|
||||||
: 'text-text-tertiary'}">{store.name}</span>
|
: 'text-text-tertiary'}">{store.name}</span>
|
||||||
|
<span class="ml-auto text-2xs {isBroken ? 'text-red-400/50' : 'text-text-tertiary/40'}">
|
||||||
{#if isBroken}
|
{#if isBroken}
|
||||||
<span class="text-2xs text-red-400/50">failing</span>
|
failing
|
||||||
{:else if store.last_scrape_ok === null}
|
{:else if store.last_scrape_ok === null}
|
||||||
<span class="text-2xs text-text-tertiary/50">untested</span>
|
untested
|
||||||
|
{:else if store.avg_duration_ms}
|
||||||
|
~{store.avg_duration_ms}ms
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,6 +123,37 @@
|
|||||||
excludedStores = next;
|
excludedStores = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryStore(storeId, storeName) {
|
||||||
|
// Mark store as searching again
|
||||||
|
storeProgress = storeProgress.map((s) =>
|
||||||
|
s.id === storeId ? { ...s, status: 'searching', error: null, resultCount: 0, duration: 0 } : s
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove old results from this store
|
||||||
|
results = results.filter((r) => r.storeId !== storeId);
|
||||||
|
|
||||||
|
// Re-search just this store
|
||||||
|
searchProductsStream(query, { stores: String(storeId) }, (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'store_complete':
|
||||||
|
results = [...results, ...event.results];
|
||||||
|
storeProgress = storeProgress.map((s) =>
|
||||||
|
s.id === event.storeId
|
||||||
|
? { ...s, status: 'done', resultCount: event.resultCount, duration: event.duration }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'store_error':
|
||||||
|
storeProgress = storeProgress.map((s) =>
|
||||||
|
s.id === event.storeId
|
||||||
|
? { ...s, status: 'error', error: event.error, duration: event.duration }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let shareMessage = $state('');
|
let shareMessage = $state('');
|
||||||
|
|
||||||
async function handleShare() {
|
async function handleShare() {
|
||||||
@@ -221,14 +252,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each storeProgress as store}
|
{#each storeProgress as store}
|
||||||
<div class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-all duration-300
|
<button
|
||||||
|
onclick={() => store.status !== 'searching' && retryStore(store.id, store.name)}
|
||||||
|
disabled={store.status === 'searching'}
|
||||||
|
title={store.status === 'searching' ? 'Searching...' : store.status === 'error' ? `Click to retry — ${store.error}` : `Click to refresh ${store.name}`}
|
||||||
|
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-all duration-300
|
||||||
{store.status === 'searching'
|
{store.status === 'searching'
|
||||||
? 'bg-surface-raised border-surface-border text-text-secondary'
|
? 'bg-surface-raised border-surface-border text-text-secondary'
|
||||||
: store.status === 'done'
|
: store.status === 'done'
|
||||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20 cursor-pointer'
|
||||||
: 'bg-red-500/10 border-red-500/20 text-red-400'}">
|
: 'bg-red-500/10 border-red-500/20 text-red-400 hover:bg-red-500/20 cursor-pointer'}">
|
||||||
|
|
||||||
<!-- Status indicator -->
|
|
||||||
{#if store.status === 'searching'}
|
{#if store.status === 'searching'}
|
||||||
<svg class="w-3.5 h-3.5 animate-spin text-accent-text" fill="none" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5 animate-spin text-accent-text" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@@ -254,9 +288,9 @@
|
|||||||
<span>{store.resultCount} products</span>
|
<span>{store.resultCount} products</span>
|
||||||
<span class="text-emerald-600">{store.duration}ms</span>
|
<span class="text-emerald-600">{store.duration}ms</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="truncate max-w-[150px]" title={store.error}>{store.error}</span>
|
<span class="truncate max-w-[150px]">{store.error}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ function slugify(text: string): string {
|
|||||||
|
|
||||||
export function getAllStores(): StoreWithCategory[] {
|
export function getAllStores(): StoreWithCategory[] {
|
||||||
return queryAll(`
|
return queryAll(`
|
||||||
SELECT s.*, c.name as category_name, c.color as category_color
|
SELECT s.*, c.name as category_name, c.color as category_color,
|
||||||
|
(SELECT ROUND(AVG(duration_ms)) FROM scrape_logs WHERE store_id = s.id AND success = 1) as avg_duration_ms
|
||||||
FROM stores s
|
FROM stores s
|
||||||
LEFT JOIN categories c ON s.category_id = c.id
|
LEFT JOIN categories c ON s.category_id = c.id
|
||||||
ORDER BY s.name
|
ORDER BY s.name
|
||||||
|
|||||||
Reference in New Issue
Block a user