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
This commit is contained in:
2026-01-29 10:09:51 +01:00
parent 86f28fa1b7
commit d9e9ae049e
4 changed files with 210 additions and 45 deletions

View File

@@ -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;
}
</style>
{#if mounted && pwaStore.isStandalone}
@@ -204,6 +229,20 @@
</button>
{/if}
{#if pwaStore.isSyncing && pwaStore.syncProgress}
<div class="progress-container">
<div class="progress-text">{pwaStore.syncProgress.message}</div>
{#if pwaStore.syncProgress.imageProgress}
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(pwaStore.syncProgress.imageProgress.completed / pwaStore.syncProgress.imageProgress.total) * 100}%"
></div>
</div>
{/if}
</div>
{/if}
{#if pwaStore.error}
<div class="status" style="color: var(--nord11);">
{pwaStore.error}

View File

@@ -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<SyncResult> {
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<void> {
}
}
async function precacheThumbnails(recipes: BriefRecipeType[]): Promise<void> {
type ImageCacheProgress = {
completed: number;
total: number;
done: boolean;
};
type ProgressCallback = (progress: ImageCacheProgress) => void;
async function precacheThumbnails(
recipes: BriefRecipeType[],
onProgress?: ProgressCallback
): Promise<void> {
// 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
});
}

View File

@@ -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<typeof setInterval> | 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;
}
},

View File

@@ -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);
});
}