From d9e9ae049e9a66893a91f0ce53e6abe83558a2d2 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 29 Jan 2026 10:09:51 +0100 Subject: [PATCH] feat: add sync progress tracking with image download status - Service worker reports image caching progress back to main thread - Sync progress shows current phase (recipes, pages, data, images) - Display progress bar for image downloads in sync tooltip - Use mediapath for thumbnail URLs (with hash for cache busting) - Serve cached thumbnails as fallback for full/placeholder when offline --- src/lib/components/OfflineSyncButton.svelte | 39 ++++++++ src/lib/offline/sync.ts | 100 ++++++++++++++++--- src/lib/stores/pwa.svelte.ts | 15 ++- src/service-worker.ts | 101 ++++++++++++++------ 4 files changed, 210 insertions(+), 45 deletions(-) diff --git a/src/lib/components/OfflineSyncButton.svelte b/src/lib/components/OfflineSyncButton.svelte index 9e3d42e..549c84d 100644 --- a/src/lib/components/OfflineSyncButton.svelte +++ b/src/lib/components/OfflineSyncButton.svelte @@ -146,6 +146,31 @@ font-size: 0.75rem; color: var(--nord4); } + + .progress-container { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .progress-text { + font-size: 0.75rem; + color: var(--nord4); + } + + .progress-bar { + width: 100%; + height: 4px; + background: var(--nord3); + border-radius: 2px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: var(--nord14); + transition: width 150ms ease-out; + } {#if mounted && pwaStore.isStandalone} @@ -204,6 +229,20 @@ {/if} + {#if pwaStore.isSyncing && pwaStore.syncProgress} +
+
{pwaStore.syncProgress.message}
+ {#if pwaStore.syncProgress.imageProgress} +
+
+
+ {/if} +
+ {/if} + {#if pwaStore.error}
{pwaStore.error} diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index 008d8ca..bc83a6e 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -12,16 +12,31 @@ const glaubeRoutes = Object.keys(glaubePageModules).map(path => { .replace('/+page.svelte', '') || '/glaube'; }); +export type SyncProgress = { + phase: 'recipes' | 'pages' | 'data' | 'images'; + message: string; + imageProgress?: { + completed: number; + total: number; + }; +}; + export type SyncResult = { success: boolean; recipeCount: number; error?: string; }; +export type SyncProgressCallback = (progress: SyncProgress) => void; + export async function downloadAllRecipes( - fetchFn: typeof fetch = fetch + fetchFn: typeof fetch = fetch, + onProgress?: SyncProgressCallback ): Promise { try { + // Phase 1: Download recipe data + onProgress?.({ phase: 'recipes', message: 'Downloading recipes...' }); + const response = await fetchFn('/api/rezepte/offline-db'); if (!response.ok) { @@ -36,15 +51,29 @@ export async function downloadAllRecipes( // Save to IndexedDB await saveAllRecipes(data.brief, data.full); + onProgress?.({ phase: 'recipes', message: `Saved ${data.brief.length} recipes` }); - // Pre-cache the main recipe pages HTML (needed for offline shell) + // Phase 2: Pre-cache the main pages HTML + onProgress?.({ phase: 'pages', message: 'Caching pages...' }); await precacheMainPages(fetchFn); - // Pre-cache __data.json for all recipes (needed for client-side navigation) + // Phase 3: Pre-cache __data.json for all recipes + onProgress?.({ phase: 'data', message: 'Caching navigation data...' }); await precacheRecipeData(data.brief); - // Pre-cache thumbnail images via service worker - await precacheThumbnails(data.brief); + // Phase 4: Pre-cache thumbnail images via service worker + onProgress?.({ phase: 'images', message: 'Caching images...', imageProgress: { completed: 0, total: data.brief.length } }); + + await precacheThumbnails(data.brief, (imgProgress) => { + onProgress?.({ + phase: 'images', + message: `Caching images (${imgProgress.completed}/${imgProgress.total})...`, + imageProgress: { + completed: imgProgress.completed, + total: imgProgress.total + } + }); + }); return { success: true, @@ -184,28 +213,71 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise { } } -async function precacheThumbnails(recipes: BriefRecipeType[]): Promise { +type ImageCacheProgress = { + completed: number; + total: number; + done: boolean; +}; + +type ProgressCallback = (progress: ImageCacheProgress) => void; + +async function precacheThumbnails( + recipes: BriefRecipeType[], + onProgress?: ProgressCallback +): Promise { // Only attempt if service worker is available if (!('serviceWorker' in navigator)) return; const registration = await navigator.serviceWorker.ready; if (!registration.active) return; - // Collect all thumbnail URLs + // Collect all thumbnail URLs using mediapath (includes hash for cache busting) const thumbnailUrls: string[] = []; for (const recipe of recipes) { if (recipe.images && recipe.images.length > 0) { const mediapath = recipe.images[0].mediapath; - // Thumbnail path format: /static/rezepte/thumb/{short_name}.webp - thumbnailUrls.push(`/static/rezepte/thumb/${recipe.short_name}.webp`); + // Thumbnail path format: /static/rezepte/thumb/{mediapath} + thumbnailUrls.push(`/static/rezepte/thumb/${mediapath}`); } } - // Send message to service worker to cache these URLs - if (thumbnailUrls.length > 0) { - registration.active.postMessage({ + if (thumbnailUrls.length === 0) return; + + // Generate unique request ID + const requestId = `img-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + // Create a promise that resolves when caching is complete + return new Promise((resolve) => { + function handleMessage(event: MessageEvent) { + if (event.data?.type === 'CACHE_IMAGES_PROGRESS' && event.data.requestId === requestId) { + if (onProgress) { + onProgress({ + completed: event.data.completed, + total: event.data.total, + done: event.data.done + }); + } + + if (event.data.done) { + navigator.serviceWorker.removeEventListener('message', handleMessage); + resolve(); + } + } + } + + navigator.serviceWorker.addEventListener('message', handleMessage); + + // Send message to service worker to cache these URLs + registration.active!.postMessage({ type: 'CACHE_IMAGES', - urls: thumbnailUrls + urls: thumbnailUrls, + requestId }); - } + + // Timeout fallback in case messages don't arrive + setTimeout(() => { + navigator.serviceWorker.removeEventListener('message', handleMessage); + resolve(); + }, 5 * 60 * 1000); // 5 minute timeout + }); } diff --git a/src/lib/stores/pwa.svelte.ts b/src/lib/stores/pwa.svelte.ts index f8249f6..374d58a 100644 --- a/src/lib/stores/pwa.svelte.ts +++ b/src/lib/stores/pwa.svelte.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment'; import { isOfflineDataAvailable, getLastSync, clearOfflineData } from '$lib/offline/db'; -import { downloadAllRecipes, type SyncResult } from '$lib/offline/sync'; +import { downloadAllRecipes, type SyncResult, type SyncProgress } from '$lib/offline/sync'; const AUTO_SYNC_INTERVAL = 30 * 60 * 1000; // 30 minutes const LAST_SYNC_KEY = 'bocken-last-sync-time'; @@ -13,6 +13,7 @@ type PWAState = { error: string | null; isStandalone: boolean; isInitialized: boolean; + syncProgress: SyncProgress | null; }; function createPWAStore() { @@ -23,7 +24,8 @@ function createPWAStore() { recipeCount: 0, error: null, isStandalone: false, - isInitialized: false + isInitialized: false, + syncProgress: null }); let autoSyncInterval: ReturnType | null = null; @@ -96,6 +98,9 @@ function createPWAStore() { get isInitialized() { return state.isInitialized; }, + get syncProgress() { + return state.syncProgress; + }, async initialize() { if (!browser || state.isInitialized) return; @@ -185,9 +190,12 @@ function createPWAStore() { state.isSyncing = true; state.error = null; + state.syncProgress = null; try { - const result = await downloadAllRecipes(fetchFn); + const result = await downloadAllRecipes(fetchFn, (progress) => { + state.syncProgress = progress; + }); if (result.success) { state.isOfflineAvailable = true; @@ -201,6 +209,7 @@ function createPWAStore() { return result; } finally { state.isSyncing = false; + state.syncProgress = null; } }, diff --git a/src/service-worker.ts b/src/service-worker.ts index d2971a6..04f0f1b 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -113,27 +113,47 @@ sw.addEventListener('fetch', (event) => { return; } - // Handle recipe images (thumbnails and full images) + // Handle recipe images (thumbnails, full images, and placeholders) if ( url.pathname.startsWith('/static/rezepte/') && - (url.pathname.includes('/thumb/') || url.pathname.includes('/full/')) + (url.pathname.includes('/thumb/') || url.pathname.includes('/full/') || url.pathname.includes('/placeholder/')) ) { event.respondWith( - caches.open(CACHE_IMAGES).then((cache) => - cache.match(event.request).then((cached) => { - if (cached) return cached; + (async () => { + const cache = await caches.open(CACHE_IMAGES); - return fetch(event.request).then((response) => { - if (response.ok) { + // Try exact match first + const cached = await cache.match(event.request); + if (cached) return cached; + + // Try to fetch from network + try { + const response = await fetch(event.request); + if (response.ok) { + // Cache thumbnails for offline use + if (url.pathname.includes('/thumb/')) { cache.put(event.request, response.clone()); } - return response; - }).catch(() => { - // Return a placeholder or let the browser handle the error - return new Response('', { status: 404 }); - }); - }) - ) + } + return response; + } catch { + // Network failed - try to serve thumbnail as fallback for full/placeholder + if (url.pathname.includes('/full/') || url.pathname.includes('/placeholder/')) { + // Extract filename and try to find cached thumbnail + const filename = url.pathname.split('/').pop(); + if (filename) { + const thumbUrl = `/static/rezepte/thumb/${filename}`; + const thumbCached = await cache.match(thumbUrl); + if (thumbCached) { + return thumbCached; + } + } + } + + // No fallback available + return new Response('', { status: 404 }); + } + })() ); return; } @@ -243,16 +263,35 @@ sw.addEventListener('message', (event) => { if (event.data?.type === 'CACHE_IMAGES') { const urls: string[] = event.data.urls; - caches.open(CACHE_IMAGES).then((cache) => { + const requestId = event.data.requestId; + + caches.open(CACHE_IMAGES).then(async (cache) => { // Cache images in batches to avoid overwhelming the network const batchSize = 10; - let index = 0; + const total = urls.length; + let completed = 0; - function cacheBatch() { - const batch = urls.slice(index, index + batchSize); - if (batch.length === 0) return; + // Report progress to all clients + async function reportProgress(done: boolean = false) { + const clients = await sw.clients.matchAll(); + for (const client of clients) { + client.postMessage({ + type: 'CACHE_IMAGES_PROGRESS', + requestId, + completed, + total, + done + }); + } + } - Promise.all( + // Report initial state + await reportProgress(); + + for (let i = 0; i < urls.length; i += batchSize) { + const batch = urls.slice(i, i + batchSize); + + await Promise.all( batch.map((url) => fetch(url) .then((response) => { @@ -263,17 +302,23 @@ sw.addEventListener('message', (event) => { .catch(() => { // Ignore failed image fetches }) + .finally(() => { + completed++; + }) ) - ).then(() => { - index += batchSize; - if (index < urls.length) { - // Small delay between batches - setTimeout(cacheBatch, 100); - } - }); + ); + + // Report progress after each batch + await reportProgress(); + + // Small delay between batches + if (i + batchSize < urls.length) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } } - cacheBatch(); + // Report completion + await reportProgress(true); }); }