From b7ac0957d1c7cfc2df21586fd917fb4882b2c23f Mon Sep 17 00:00:00 2001 From: robin Date: Sat, 20 Dec 2025 22:52:04 +0100 Subject: [PATCH] docs: add offline sync & PWA documentation feat: improve PWA resilience with SWR and navigation timeouts --- OFFLINE_SYNC_PWA.md | 91 +++++++++++++++++++++++++++++++++++++++++++++ public/sw.js | 85 +++++++++++++++++++++++++++++------------- 2 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 OFFLINE_SYNC_PWA.md diff --git a/OFFLINE_SYNC_PWA.md b/OFFLINE_SYNC_PWA.md new file mode 100644 index 0000000..f9f5b7e --- /dev/null +++ b/OFFLINE_SYNC_PWA.md @@ -0,0 +1,91 @@ +# Offline Sync & PWA Architecture Documentation + +This document describes the offline capabilities and synchronization mechanism implemented in the Whisky Vault application. It is designed to be easily parsed by both humans and AI agents. + +## 🏗️ Architecture Overview + +The application uses a **Stale-While-Revalidate (SWR)** pattern for reading data and a **Background Sync Queue** for writing data. + +### Technical Stack +- **Database Engine**: [Dexie.js](https://dexie.org/) (IndexedDB wrapper) +- **Framework**: Next.js (App Router) +- **State Management**: `dexie-react-hooks` (`useLiveQuery`) +- **Backend/Auth**: Supabase +- **PWA**: Service Worker (`sw.js`) & Web App Manifest + +--- + +## 🗄️ Database Schema (IndexedDB) + +The database `WhiskyVault` is defined in `src/lib/db.ts`. + +| Table | Indexing | Purpose | +| :--- | :--- | :--- | +| `pending_scans` | `++id, temp_id, timestamp` | Stores images (Base64) and metadata of bottles scanned while offline. | +| `pending_tastings` | `++id, bottle_id, pending_bottle_id` | Stores tasting notes. Can link to a real `bottle_id` or a `pending_bottle_id`. | +| `cache_tags` | `id, category, name` | Local cache for whisky tags (aroma, flavor, etc.). | +| `cache_buddies` | `id, name` | Local cache for user's buddies. | +| `cache_bottles` | `id, name, distillery` | Local cache for bottle details (SWR). | +| `cache_tastings` | `id, bottle_id, created_at` | Local cache for tasting notes (SWR). | + +--- + +## 🔄 Synchronization Logic + +### 1. Reading Data (SWR Pattern) +Implemented in hooks like `useBottleData`. + +```mermaid +graph TD + A[Component Mounts] --> B[useLiveQuery: Get from Dexie] + B --> C[UI renders cached data] + C --> D{Is Online?} + D -- Yes --> E[Fetch from Supabase] + E --> F[Update Dexie Table] + F --> G[useLiveQuery triggers re-render] + D -- No --> H[Keep showing cache] +``` + +### 2. Writing Data (Background Sync) +Implemented in `src/components/UploadQueue.tsx`. + +#### The Reconciliation Logic +A critical feature is the ability to link tasting notes to a bottle that hasn't been synced to the server yet. + +1. **Offline Scan**: User takes a photo. It's stored in `pending_scans` with a `temp_id` (UUID). +2. **Offline Tasting**: User adds a note. It's stored in `pending_tastings` with `pending_bottle_id` set to the scan's `temp_id`. +3. **Sync Phase 1 (Scans)**: When online, `magicScan` (AI analysis) and `saveBottle` are called. A real `bottle_id` is returned from Supabase. +4. **Reconciliation**: The code searches `pending_tastings` for all entries matching the `temp_id` and updates them with the real `bottle_id`. +5. **Sync Phase 2 (Tastings)**: The reconciled tasting notes are then uploaded to Supabase. + +--- + +## 📱 PWA Features + +### Service Worker (`public/sw.js`) +- **Strategy**: Network-First. Attempts to fetch from network, falls back to cache if offline. +- **Cache Bypassing**: Explicitly bypasses cache for: + - `*/auth/*` + - `*/api/*` + - `*.supabase.co/*` +- **Asset Caching**: Caches static assets (`manifest.json`, icons) during installation. + +### Manifest (`src/app/manifest.ts`) +Defines the app's appearance when installed: +- **Display**: `standalone` (removes browser UI) +- **Theme Color**: `#d97706` (Amber) +- **Background**: `#000000` + +--- + +## 🛠️ Key Components & Hooks + +- `src/lib/db.ts`: Database definition and TypeScript interfaces. +- `src/hooks/useBottleData.ts`: Example of the SWR pattern implementation. +- `src/components/UploadQueue.tsx`: The "Sync Engine" UI and logic coordinator. +- `src/components/PWARegistration.tsx`: Client-side Service Worker registration handler. + +## ⚠️ Edge Case Handling +- **Partial Sync**: If a scan succeeds but a tasting fails, the tasting remains in the queue with the now-valid `bottle_id`. +- **Duplicate Scans**: The system relies on the user not submitting the same scan multiple times while offline (UI prevents multiple clicks). +- **Stale Cache**: Caches are overwritten on every successful online fetch to ensure eventual consistency. diff --git a/public/sw.js b/public/sw.js index d520c73..fbbe611 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'whisky-vault-v2'; // Increment version to force update +const CACHE_NAME = 'whisky-vault-v3'; // Increment version to apply new strategy const ASSETS_TO_CACHE = [ '/manifest.json', '/icon-192.png', @@ -29,6 +29,26 @@ self.addEventListener('activate', (event) => { self.clients.claim(); }); +// Helper for Network-First with Timeout +function fetchWithTimeout(request, timeoutMs = 3000) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Network timeout')); + }, timeoutMs); + + fetch(request).then( + (response) => { + clearTimeout(timeoutId); + resolve(response); + }, + (err) => { + clearTimeout(timeoutId); + reject(err); + } + ); + }); +} + self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); @@ -40,32 +60,47 @@ self.addEventListener('fetch', (event) => { const isSupabaseRequest = url.hostname.includes('supabase.co'); if (isAuthRequest || isApiRequest || isSupabaseRequest) { - // console.log('[SW] Bypassing cache for:', url.pathname); return; } - // Network first for all other requests, especially navigation - event.respondWith( - fetch(event.request) - .then((response) => { - // Optionally cache successful GET requests for assets - if ( - event.request.method === 'GET' && - response.status === 200 && - (url.pathname.startsWith('/_next/static/') || ASSETS_TO_CACHE.includes(url.pathname)) - ) { - const responseClone = response.clone(); - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseClone); - }); - } - return response; + // 1. ASSETS & APP SHELL: Stale-While-Revalidate + const isAsset = event.request.destination === 'style' || + event.request.destination === 'script' || + event.request.destination === 'worker' || + event.request.destination === 'font' || + event.request.destination === 'image' || + url.pathname.startsWith('/_next/static'); + + if (isAsset) { + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + const fetchPromise = 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 cachedResponse || fetchPromise; }) - .catch((err) => { - if (event.request.mode === 'navigate') { - console.log('[SW] Navigation failed, attempting cache fallback for:', url.pathname); - } - return caches.match(event.request); - }) - ); + ); + return; + } + + // 2. NAVIGATION: Network-First with 3s Timeout + if (event.request.mode === 'navigate') { + event.respondWith( + fetchWithTimeout(event.request, 3000) + .catch(() => { + console.log('[SW] Navigation network failure or timeout, falling back to cache'); + return caches.match(event.request); + }) + ); + return; + } + + // Default: Network-Only or default fetch + return; });