docs: add offline sync & PWA documentation
feat: improve PWA resilience with SWR and navigation timeouts
This commit is contained in:
91
OFFLINE_SYNC_PWA.md
Normal file
91
OFFLINE_SYNC_PWA.md
Normal 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.
|
||||||
67
public/sw.js
67
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 = [
|
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
|
||||||
|
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(
|
event.respondWith(
|
||||||
fetch(event.request)
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
.then((response) => {
|
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
||||||
// Optionally cache successful GET requests for assets
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
if (
|
const responseClone = networkResponse.clone();
|
||||||
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) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
cache.put(event.request, responseClone);
|
cache.put(event.request, responseClone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return response;
|
return networkResponse;
|
||||||
|
});
|
||||||
|
return cachedResponse || fetchPromise;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
);
|
||||||
if (event.request.mode === 'navigate') {
|
return;
|
||||||
console.log('[SW] Navigation failed, attempting cache fallback for:', url.pathname);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 caches.match(event.request);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: Network-Only or default fetch
|
||||||
|
return;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user