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,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.64.0";
|
||||
export const APP_VERSION = "0.65.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,17 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.65.0",
|
||||
date: "2026-07-21 00:45",
|
||||
added: [
|
||||
"Save recipes for offline use: a \"Save offline\" button on any recipe pins it in the service worker cache so it opens with no connection. The offline fallback page now lists your saved (and recently visited) recipes instead of being a dead end.",
|
||||
"Marking a batch-cook dish as cooked while offline no longer just fails — it's queued and synced automatically once you're back online (via Background Sync where supported, otherwise as soon as the app detects a connection).",
|
||||
],
|
||||
fixed: [
|
||||
"The service worker's network-first pages never actually populated its cache on success, so the offline page's claim that \"recently visited recipes are available\" wasn't true. It does now.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.64.0",
|
||||
date: "2026-07-21 00:30",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { enqueuePendingAction, getPendingActions, deletePendingAction, type PendingAction } from "@/lib/offline-db";
|
||||
|
||||
type ServiceWorkerRegistrationWithSync = ServiceWorkerRegistration & {
|
||||
sync?: { register: (tag: string) => Promise<void> };
|
||||
};
|
||||
|
||||
export const SYNC_TAG = "epicure-sync";
|
||||
|
||||
/** Queues a mutation that failed because the browser is offline. Tries to
|
||||
* register a Background Sync so the service worker replays it as soon as
|
||||
* connectivity returns (Chromium only); Safari/Firefox fall back to the
|
||||
* "online" event listener in OfflineSyncManager. */
|
||||
export async function queueOfflineAction(action: { method: string; url: string; body: unknown; description: string }): Promise<void> {
|
||||
await enqueuePendingAction(action);
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const registration = (await navigator.serviceWorker.ready) as ServiceWorkerRegistrationWithSync;
|
||||
await registration.sync?.register(SYNC_TAG);
|
||||
} catch {
|
||||
// Background Sync unsupported or registration failed — the
|
||||
// online-event fallback in OfflineSyncManager still covers this.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replays queued actions in the order they were created, stopping at the
|
||||
* first failure so a dependent later action (e.g. add-items-to-a-just-
|
||||
* created-list) never runs ahead of the one it depends on. Returns how many
|
||||
* actions succeeded. Mirrors the service worker's own replay logic in
|
||||
* public/sw.js — kept for browsers with no Background Sync support. */
|
||||
export async function flushOfflineQueue(): Promise<number> {
|
||||
const pending = await getPendingActions();
|
||||
let synced = 0;
|
||||
for (const action of pending) {
|
||||
const ok = await replayAction(action);
|
||||
if (!ok) break;
|
||||
await deletePendingAction(action.id);
|
||||
synced += 1;
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
async function replayAction(action: PendingAction): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(action.url, {
|
||||
method: action.method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(action.body),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user