diff --git a/OFFLINE_SYNC_PWA.md b/OFFLINE_SYNC_PWA.md index 96e7c21..0f7e347 100644 --- a/OFFLINE_SYNC_PWA.md +++ b/OFFLINE_SYNC_PWA.md @@ -62,13 +62,15 @@ A critical feature is the ability to link tasting notes to a bottle that hasn't ## 📱 PWA Features -### Service Worker (`public/sw.js`) - "Paranoid Mode" Strategy -The Service Worker implements an extremely resilient "Cache-First, Network-Background" strategy for navigation: +### Service Worker (`public/sw.js`) - "Bunker v7 + SWR" +The Service Worker implements a robust "Cache-First, Network-Background" strategy: -- **Pre-Caching**: The landing page (`/`), core static assets, and the sessions overview (`/sessions`) are cached during installation. -- **SWR Navigation**: Navigation requests are handled with Stale-While-Revalidate. The SW serves the cached version immediately (instant load) and refreshes the cache in the background. -- **Universal Root Fallback**: If a URL is not found in the cache while offline, the SW serves the Root (`/`). This allows Next.js to bootstrap and handle the routing client-side (including Scan and Note features) using local Dexie data. -- **RSC Data Resiliency**: Requests to `/_next/data/` return an empty JSON object if they fail, preventing "Application Error" screens. +- **Pre-Caching**: The landing page (`/`) and core static assets are cached individually during installation to prevent total failure on single-file 404s. +- **Manifest Path**: Corrected to `/manifest.webmanifest` to match Next.js defaults. +- **SWR Navigation & Assets**: Both load instantly from cache. Updates happen in the background via `fetchWithTimeout` and `AbortController`. +- **Universal Root Fallback**: Deep links (like `/bottles/[id]`) fallback to `/` if not cached, allowing Next.js to take over. +- **Network Stability**: Added a 2-second stabilization delay in `UploadQueue.tsx` before background sync starts after a network change. +- **RSC Data Resiliency**: Requests to `/_next/data/` return an empty JSON object if they fail. - **Stale-While-Revalidate**: Applied to static assets to ensure immediate UI response. ### Manifest (`src/app/manifest.ts`) diff --git a/public/sw.js b/public/sw.js index 4c164f8..cdc6984 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,87 +1,89 @@ -const CACHE_NAME = 'whisky-vault-v6-bunker'; // Neue Version erzwingen! +const CACHE_NAME = 'whisky-vault-v7-bunker'; // Neue Version für stabilen Bunker + SWR -// CONFIG: Core pages and assets to pre-cache immediately on install -// WICHTIG: Hier muss die URL für "Neues Tasting" rein, damit sie offline da ist! +// CONFIG: Kern-Seiten und Assets für den Bunker (sofortiges Pre-Caching) const CORE_PAGES = [ - '/', // Dashboard / App Shell - '/sessions', // Tasting Sessions - //'/scan', // Scan Page (falls URL existiert) - // '/tasting/new' // <--- FÜGE HIER DEINE TASTING URL HINZU, falls sie existiert! + '/', // Dashboard / App Shell (Enthält Scan & Tasting) ]; const STATIC_ASSETS = [ - '/manifest.json', + '/manifest.webmanifest', // Korrigierter Pfad für Next.js '/icon-192.png', '/icon-512.png', '/favicon.ico', ]; -// Install: Lade alles Wichtige sofort in den Keller +// Helper: Fetch mit Timeout und AbortController (sauberer Abbruch) +async function fetchWithTimeout(request, timeoutMs = 3000) { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(request, { signal: controller.signal }); + clearTimeout(id); + return response; + } catch (e) { + clearTimeout(id); + throw e; + } +} + +// Install: Lade alles Wichtige einzeln in den Bunker self.addEventListener('install', (event) => { - self.skipWaiting(); // Sofortiger Wechsel zum neuen SW + self.skipWaiting(); event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - console.log('⚡ PWA: Pre-caching core pages and assets...'); - return cache.addAll([...CORE_PAGES, ...STATIC_ASSETS]); + caches.open(CACHE_NAME).then(async (cache) => { + console.log('🏗️ PWA: Building bunker v7...'); + const promises = [...CORE_PAGES, ...STATIC_ASSETS].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Status ${res.status}`); + return cache.put(url, res); + } catch (error) { + console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error); + } + }); + await Promise.all(promises); + console.log('✅ PWA: Bunker build finished'); }) ); }); -// Activate: Alten Müll rauswerfen +// Activate: Alte Bunker räumen self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (cacheName !== CACHE_NAME) { - console.log('🧹 PWA: Clearing old cache', cacheName); + console.log('🧹 PWA: Clearing old bunker', cacheName); return caches.delete(cacheName); } }) ); }) ); - self.clients.claim(); // Sofort Kontrolle übernehmen + self.clients.claim(); }); -// Helper: Network mit Timeout (für den Notfall) -function fetchWithTimeout(request, timeoutMs = 3000) { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => reject(new Error('Network timeout')), timeoutMs); - fetch(request).then( - (res) => { clearTimeout(timeoutId); resolve(res); }, - (err) => { clearTimeout(timeoutId); reject(err); } - ); - }); -} - self.addEventListener('fetch', (event) => { + // Nur GET-Requests cachen + if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); - // 0. BYPASS: Auth & API immer durchlassen (werden von Dexie/App handled) + // 0. BYPASS: Auth & API (wird von App/Dexie direkt gehandelt) if (url.pathname.includes('/auth/') || url.pathname.includes('/api/') || url.hostname.includes('supabase.co')) { return; } - // 1. NEXT.JS DATA (RSC): Swallow Errors - // Verhindert "Application Error" wenn JSON fehlt + // 1. NEXT.JS DATA (RSC): Bunker & Fallback if (url.pathname.startsWith('/_next/data/')) { event.respondWith( caches.match(event.request).then((cached) => { - // Wenn im Cache (selten bei RSC), nimm es if (cached) return cached; - - // Sonst Netzwerk mit Timeout return fetchWithTimeout(event.request, 2000) - .then(res => { - // Optional: RSC cachen für später? - // Vorsicht: RSC Daten veralten schnell. Hier cachen wir NICHT. - return res; - }) .catch(() => { - // Fallback: Leeres JSON, damit React nicht crasht return new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } }); @@ -91,8 +93,9 @@ self.addEventListener('fetch', (event) => { return; } - // 2. ASSETS (JS/CSS/Images): CACHE FIRST (Der "Bunker" Fix) - // Wenn wir die Datei haben, nutzen wir sie. Keine Diskussion mit dem Netzwerk. + // 2. ASSETS & NAVIGATION: Stale-While-Revalidate (Der "Echte" Bunker Mode) + // Wir liefern SOFORT aus dem Cache, fragen aber im Hintergrund das Netzwerk. + const isNavigation = event.request.mode === 'navigate'; const isAsset = event.request.destination === 'style' || event.request.destination === 'script' || event.request.destination === 'worker' || @@ -100,54 +103,35 @@ self.addEventListener('fetch', (event) => { event.request.destination === 'image' || url.pathname.startsWith('/_next/static'); - if (isAsset) { + if (isNavigation || isAsset) { event.respondWith( - caches.match(event.request).then((cachedResponse) => { - if (cachedResponse) { - // TREFFER: Sofort zurückgeben. Kein Fetch! - // Das verhindert den "Network Changed" Crash. - return cachedResponse; - } - // MISS: Nur dann zum Netzwerk gehen - return fetch(event.request).then((networkResponse) => { - if (networkResponse && networkResponse.status === 200) { - const responseClone = networkResponse.clone(); - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseClone); - }); - } - return networkResponse; - }); - }) - ); - return; - } + caches.match(event.request).then(async (cachedResponse) => { + // Hintergrund-Update vorbereiten + const fetchPromise = fetchWithTimeout(event.request, 5000) + .then(async (networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const cache = await caches.open(CACHE_NAME); + cache.put(event.request, networkResponse.clone()); + } + return networkResponse; + }) + .catch(() => { /* Fail silently in background */ }); - // 3. NAVIGATION (HTML): App Shell / Cache First - // Wenn offline, gib die Startseite zurück und lass Next.js routen. - if (event.request.mode === 'navigate') { - event.respondWith( - (async () => { - try { - // A. Suche exakte Seite im Cache - const cachedResponse = await caches.match(event.request); + // Navigation Fallback Logik + if (isNavigation) { if (cachedResponse) return cachedResponse; - // B. Suche Startseite (App Shell) im Cache - // Das ist der wichtigste Fallback für SPAs! - const shellResponse = await caches.match('/'); - if (shellResponse) return shellResponse; - - // C. Wenn nichts im Cache ist (ganz neuer User): Netzwerk - return await fetchWithTimeout(event.request, 3000); - } catch (error) { - console.log('[SW] Offline fallback failed:', error); - // D. Letzter Ausweg (Text anzeigen) - return new Response('
Bitte App neu laden.
', { - headers: { 'Content-Type': 'text/html' } - }); + // Root Fallback für Deep Links (App Shell) + const shell = await caches.match('/'); + if (shell) { + console.log('[SW] Route not cached, using Root App Shell fallback'); + return shell; + } } - })() + + // Assets: Cache oder Netzwerk + return cachedResponse || fetchPromise || fetch(event.request); + }) ); } }); \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9824f6f..fb21ae3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { template: "%s | Whisky Vault" }, description: "Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.", - manifest: "/manifest.json", + manifest: "/manifest.webmanifest", appleWebApp: { capable: true, statusBarStyle: "default", diff --git a/src/components/UploadQueue.tsx b/src/components/UploadQueue.tsx index d21fbe9..c4ce4a0 100644 --- a/src/components/UploadQueue.tsx +++ b/src/components/UploadQueue.tsx @@ -127,8 +127,10 @@ export default function UploadQueue() { useEffect(() => { const handleOnline = () => { - console.log('Online! Triggering background sync...'); - syncQueue(); + console.log('Online! Waiting 2s for network stability...'); + setTimeout(() => { + syncQueue(); + }, 2000); }; window.addEventListener('online', handleOnline);