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)
|
||||
? 'text-text-primary'
|
||||
: 'text-text-tertiary'}">{store.name}</span>
|
||||
{#if isBroken}
|
||||
<span class="text-2xs text-red-400/50">failing</span>
|
||||
{:else if store.last_scrape_ok === null}
|
||||
<span class="text-2xs text-text-tertiary/50">untested</span>
|
||||
{/if}
|
||||
<span class="ml-auto text-2xs {isBroken ? 'text-red-400/50' : 'text-text-tertiary/40'}">
|
||||
{#if isBroken}
|
||||
failing
|
||||
{:else if store.last_scrape_ok === null}
|
||||
untested
|
||||
{:else if store.avg_duration_ms}
|
||||
~{store.avg_duration_ms}ms
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -123,6 +123,37 @@
|
||||
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('');
|
||||
|
||||
async function handleShare() {
|
||||
@@ -221,14 +252,17 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#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'
|
||||
? 'bg-surface-raised border-surface-border text-text-secondary'
|
||||
: store.status === 'done'
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-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 hover:bg-red-500/20 cursor-pointer'}">
|
||||
|
||||
<!-- Status indicator -->
|
||||
{#if store.status === 'searching'}
|
||||
<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>
|
||||
@@ -254,9 +288,9 @@
|
||||
<span>{store.resultCount} products</span>
|
||||
<span class="text-emerald-600">{store.duration}ms</span>
|
||||
{:else}
|
||||
<span class="truncate max-w-[150px]" title={store.error}>{store.error}</span>
|
||||
<span class="truncate max-w-[150px]">{store.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,8 @@ function slugify(text: string): string {
|
||||
|
||||
export function getAllStores(): StoreWithCategory[] {
|
||||
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
|
||||
LEFT JOIN categories c ON s.category_id = c.id
|
||||
ORDER BY s.name
|
||||
|
||||
Reference in New Issue
Block a user