Files
Epicure/apps/web/public/sw.js
T
Arnaud 9ba2996022 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>
2026-07-21 00:04:17 +02:00

120 lines
3.8 KiB
JavaScript

const CACHE_NAME = "epicure-v1";
const SHELL_ASSETS = ["/", "/recipes", "/offline"];
self.addEventListener("install", event => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL_ASSETS)));
self.skipWaiting();
});
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", event => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
// Skip API routes
if (url.pathname.startsWith("/api/")) return;
// Cache-first for cook mode pages
if (url.pathname.includes("/cook")) {
event.respondWith(
caches.match(request).then(cached =>
cached ??
fetch(request).then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(c => c.put(request, clone));
return res;
})
)
);
return;
}
// 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)
.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());
});