docs: add offline sync & PWA documentation

feat: improve PWA resilience with SWR and navigation timeouts
This commit is contained in:
2025-12-20 22:52:04 +01:00
parent e0436987a1
commit b7ac0957d1
2 changed files with 151 additions and 25 deletions

91
OFFLINE_SYNC_PWA.md Normal file
View File

@@ -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.

View File

@@ -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 = [ const ASSETS_TO_CACHE = [
'/manifest.json', '/manifest.json',
'/icon-192.png', '/icon-192.png',
@@ -29,6 +29,26 @@ self.addEventListener('activate', (event) => {
self.clients.claim(); 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) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
@@ -40,32 +60,47 @@ self.addEventListener('fetch', (event) => {
const isSupabaseRequest = url.hostname.includes('supabase.co'); const isSupabaseRequest = url.hostname.includes('supabase.co');
if (isAuthRequest || isApiRequest || isSupabaseRequest) { if (isAuthRequest || isApiRequest || isSupabaseRequest) {
// console.log('[SW] Bypassing cache for:', url.pathname);
return; return;
} }
// Network first for all other requests, especially navigation // 1. ASSETS & APP SHELL: Stale-While-Revalidate
event.respondWith( const isAsset = event.request.destination === 'style' ||
fetch(event.request) event.request.destination === 'script' ||
.then((response) => { event.request.destination === 'worker' ||
// Optionally cache successful GET requests for assets event.request.destination === 'font' ||
if ( event.request.destination === 'image' ||
event.request.method === 'GET' && url.pathname.startsWith('/_next/static');
response.status === 200 &&
(url.pathname.startsWith('/_next/static/') || ASSETS_TO_CACHE.includes(url.pathname)) if (isAsset) {
) { event.respondWith(
const responseClone = response.clone(); caches.match(event.request).then((cachedResponse) => {
caches.open(CACHE_NAME).then((cache) => { const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, responseClone); if (networkResponse && networkResponse.status === 200) {
}); const responseClone = networkResponse.clone();
} caches.open(CACHE_NAME).then((cache) => {
return response; cache.put(event.request, responseClone);
});
}
return networkResponse;
});
return cachedResponse || fetchPromise;
}) })
.catch((err) => { );
if (event.request.mode === 'navigate') { return;
console.log('[SW] Navigation failed, attempting cache fallback for:', url.pathname); }
}
return caches.match(event.request); // 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;
}); });