feat: offline save-for-later + background sync for mark-cooked (v0.65.0)
Adds a "Save offline" button on the recipe page that force-refetches the current page so the service worker's cache picks up a fresh copy right now, plus a small IndexedDB-backed list (lib/offline-db.ts) of what's been saved. The /offline fallback page now reads that list and renders it instead of being a dead end with just a "go back" link. Also fixes the service worker's network-first fetch handler, which never wrote successful responses into its cache -- meaning the existing offline page's claim that "recently visited recipes are available" was never actually true. It populates the cache on every successful GET now. Background sync: marking a batch-cook dish as cooked while offline (the only existing mark-cooked call site in the app) now queues the request in IndexedDB instead of just failing, and registers a Background Sync (public/sw.js's "sync" listener replays the queue) for Chromium; lib/offline-queue.ts's online-event fallback covers Safari/Firefox, which never fire that event at all. Both replay paths read/write the same IndexedDB store so either one drains it. Also removes apps/web/public/manifest.json, a stale static manifest that layout.tsx used to link to before the previous commit pointed it at the real generated route (app/manifest.ts) -- it had gone unnoticed and unused since. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "Epicure",
|
||||
"short_name": "Epicure",
|
||||
"description": "Your personal AI-powered recipe book.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#18181b",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "maskable" },
|
||||
{ "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
+79
-4
@@ -35,10 +35,85 @@ self.addEventListener("fetch", event => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Network-first for other pages
|
||||
// Network-first for other pages, but keep a copy of every successful
|
||||
// response — this is what actually makes "recently visited recipes are
|
||||
// available offline" (see /offline) true, and what a "Save for offline"
|
||||
// action (save-offline-button.tsx) piggybacks on by re-fetching a page
|
||||
// it wants pinned.
|
||||
event.respondWith(
|
||||
fetch(request).catch(() =>
|
||||
caches.match(request).then(cached => cached ?? caches.match("/offline"))
|
||||
)
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE_NAME).then(c => c.put(request, clone));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(request).then(cached => cached ?? caches.match("/offline")))
|
||||
);
|
||||
});
|
||||
|
||||
// --- Background Sync: replay mutations queued while offline ---
|
||||
// Mirrors lib/offline-queue.ts's flushOfflineQueue, duplicated here because
|
||||
// this file is registered as a classic (non-module) script and can't import
|
||||
// app code. Both read/write the same IndexedDB database — keep the store
|
||||
// names and shapes in sync with lib/offline-db.ts if either changes.
|
||||
const DB_NAME = "epicure-offline";
|
||||
const PENDING_ACTIONS_STORE = "pendingActions";
|
||||
const SYNC_TAG = "epicure-sync";
|
||||
|
||||
function openOfflineDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function getPendingActions(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(PENDING_ACTIONS_STORE, "readonly");
|
||||
const req = tx.objectStore(PENDING_ACTIONS_STORE).getAll();
|
||||
req.onsuccess = () => resolve(req.result.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePendingAction(db, id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(PENDING_ACTIONS_STORE, "readwrite");
|
||||
tx.objectStore(PENDING_ACTIONS_STORE).delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function replayPendingActions() {
|
||||
const db = await openOfflineDb();
|
||||
const pending = await getPendingActions(db);
|
||||
let synced = 0;
|
||||
for (const action of pending) {
|
||||
let ok = false;
|
||||
try {
|
||||
const res = await fetch(action.url, {
|
||||
method: action.method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(action.body),
|
||||
});
|
||||
ok = res.ok;
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) break;
|
||||
await deletePendingAction(db, action.id);
|
||||
synced += 1;
|
||||
}
|
||||
if (synced > 0) {
|
||||
const clients = await self.clients.matchAll();
|
||||
for (const client of clients) client.postMessage({ type: "epicure:offline-synced", count: synced });
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("sync", event => {
|
||||
if (event.tag === SYNC_TAG) event.waitUntil(replayPendingActions());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user