Files
Epicure/apps/web/lib/offline-db.ts
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

101 lines
3.7 KiB
TypeScript

"use client";
// Thin promise wrapper around the browser's native IndexedDB — no added
// dependency. The service worker (public/sw.js) opens the same database by
// name/version/store names using the raw API directly (it can't import this
// module, since it's registered as a classic, non-module script), so any
// change here must be mirrored there.
const DB_NAME = "epicure-offline";
const DB_VERSION = 1;
export const SAVED_RECIPES_STORE = "savedRecipes";
export const PENDING_ACTIONS_STORE = "pendingActions";
export type SavedRecipe = { id: string; title: string; savedAt: string };
export type PendingAction = {
id: string;
method: string;
url: string;
body: unknown;
description: string;
createdAt: string;
};
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SAVED_RECIPES_STORE)) {
db.createObjectStore(SAVED_RECIPES_STORE, { keyPath: "id" });
}
if (!db.objectStoreNames.contains(PENDING_ACTIONS_STORE)) {
db.createObjectStore(PENDING_ACTIONS_STORE, { keyPath: "id" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function withStore<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => T): Promise<T> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(store, mode);
const result = fn(tx.objectStore(store));
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
});
}
export async function saveRecipeOffline(id: string, title: string): Promise<void> {
await withStore(SAVED_RECIPES_STORE, "readwrite", (s) => {
s.put({ id, title, savedAt: new Date().toISOString() });
});
}
export async function unsaveRecipeOffline(id: string): Promise<void> {
await withStore(SAVED_RECIPES_STORE, "readwrite", (s) => {
s.delete(id);
});
}
export async function isRecipeSavedOffline(id: string): Promise<boolean> {
const record = await withStore<SavedRecipe | undefined>(SAVED_RECIPES_STORE, "readonly", (s) => {
let result: SavedRecipe | undefined;
s.get(id).onsuccess = (e) => { result = (e.target as IDBRequest<SavedRecipe | undefined>).result; };
return result;
});
// The transaction's oncomplete fires after the get's onsuccess callback
// runs, so `record` is populated by the time withStore resolves.
return record !== undefined;
}
export async function getSavedRecipesOffline(): Promise<SavedRecipe[]> {
return withStore<SavedRecipe[]>(SAVED_RECIPES_STORE, "readonly", (s) => {
let result: SavedRecipe[] = [];
s.getAll().onsuccess = (e) => { result = (e.target as IDBRequest<SavedRecipe[]>).result; };
return result;
});
}
export async function enqueuePendingAction(action: Omit<PendingAction, "id" | "createdAt">): Promise<void> {
await withStore(PENDING_ACTIONS_STORE, "readwrite", (s) => {
s.put({ ...action, id: crypto.randomUUID(), createdAt: new Date().toISOString() });
});
}
export async function getPendingActions(): Promise<PendingAction[]> {
const actions = await withStore<PendingAction[]>(PENDING_ACTIONS_STORE, "readonly", (s) => {
let result: PendingAction[] = [];
s.getAll().onsuccess = (e) => { result = (e.target as IDBRequest<PendingAction[]>).result; };
return result;
});
return actions.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
export async function deletePendingAction(id: string): Promise<void> {
await withStore(PENDING_ACTIONS_STORE, "readwrite", (s) => {
s.delete(id);
});
}