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:
Arnaud
2026-07-21 00:04:17 +02:00
parent 5f270dee08
commit 9ba2996022
17 changed files with 405 additions and 29 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.65.0 — 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.
## 0.64.0 — 2026-07-21 00:30
### Fixed
+2
View File
@@ -12,6 +12,7 @@ import { DrinkPairingButton } from "@/components/recipe/drink-pairing-button";
import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button";
import { PrintButton } from "@/components/recipe/print-button";
import { ShareRecipeButton } from "@/components/recipe/share-recipe-button";
import { SaveOfflineButton } from "@/components/recipe/save-offline-button";
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
@@ -244,6 +245,7 @@ export default async function RecipePage({ params }: Params) {
/>
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
<PrintButton recipeId={id} />
<ExportMarkdownButton
markdown={recipeToMarkdown({
+2
View File
@@ -6,6 +6,7 @@ import { auth } from "@/lib/auth/server";
import type { Locale } from "@/lib/i18n/provider";
import { SwRegister } from "@/components/pwa/sw-register";
import { InstallPrompt } from "@/components/pwa/install-prompt";
import { OfflineSyncManager } from "@/components/pwa/offline-sync-manager";
import { getMarketingLocale } from "@/lib/marketing-locale";
import "./globals.css";
@@ -59,6 +60,7 @@ export default async function RootLayout({
<Providers initialLocale={initialLocale}>{children}</Providers>
<SwRegister />
<InstallPrompt />
<OfflineSyncManager />
</body>
</html>
);
+2
View File
@@ -11,7 +11,9 @@ export default function manifest(): MetadataRoute.Manifest {
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" },
],
};
}
+28 -2
View File
@@ -1,12 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getSavedRecipesOffline, type SavedRecipe } from "@/lib/offline-db";
export default function OfflinePage() {
const [saved, setSaved] = useState<SavedRecipe[] | null>(null);
useEffect(() => {
getSavedRecipesOffline().then(setSaved).catch(() => setSaved([]));
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">You're offline</h1>
<h1 className="text-2xl font-semibold">You&apos;re offline</h1>
<p className="max-w-sm text-muted-foreground">
Your recently visited recipes are available. Connect to internet to load new content.
Recipes you&apos;ve saved for offline, or recently visited, are available below. Connect to the internet to load new content.
</p>
{saved && saved.length > 0 && (
<ul className="w-full max-w-sm space-y-2 text-left">
{saved.map((r) => (
<li key={r.id}>
<Link
href={`/recipes/${r.id}`}
className="block rounded-md border px-3 py-2 text-sm hover:bg-accent"
>
{r.title}
</Link>
</li>
))}
</ul>
)}
<Link
href="/recipes"
className="mt-2 underline underline-offset-4 hover:text-primary"
@@ -0,0 +1,37 @@
"use client";
import { useEffect } from "react";
import { toast } from "sonner";
import { flushOfflineQueue } from "@/lib/offline-queue";
/** Drains any offline-queued actions (lib/offline-queue.ts) once the browser
* regains connectivity. Background Sync (public/sw.js) already covers this
* on Chromium, but Safari/Firefox never fire that API at all, so this is
* the only path that works there — safe to run alongside it since both
* read/delete from the same IndexedDB queue and a queue drained by one has
* nothing left for the other to redo. */
export function OfflineSyncManager() {
useEffect(() => {
async function flush() {
const synced = await flushOfflineQueue();
if (synced > 0) toast.success(`Synced ${synced} offline action${synced === 1 ? "" : "s"}`);
}
if (navigator.onLine) void flush();
window.addEventListener("online", flush);
function onSwMessage(e: MessageEvent) {
if (e.data?.type === "epicure:offline-synced" && e.data.count > 0) {
toast.success(`Synced ${e.data.count} offline action${e.data.count === 1 ? "" : "s"}`);
}
}
navigator.serviceWorker?.addEventListener("message", onSwMessage);
return () => {
window.removeEventListener("online", flush);
navigator.serviceWorker?.removeEventListener("message", onSwMessage);
};
}, []);
return null;
}
@@ -7,6 +7,7 @@ import { Snowflake, Refrigerator, ChefHat, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n/provider";
import { stripMarkdown } from "@/lib/utils";
import { queueOfflineAction } from "@/lib/offline-queue";
type Dish = {
id: string;
@@ -33,12 +34,31 @@ export function BatchCookDishes({ recipeId, dishes: initialDishes }: { recipeId:
async function markCooked(dishId: string) {
setMarkingId(dishId);
const body = { batchDishId: dishId, deductFromPantry: false };
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ batchDishId: dishId, deductFromPantry: false }),
});
let res: Response;
try {
res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch {
// fetch() throwing (rather than resolving with a non-ok status)
// means the network itself is unreachable — queue it for the
// service worker (or the online-event fallback) to replay later,
// rather than treating it as a real failure.
await queueOfflineAction({
method: "POST",
url: `/api/v1/recipes/${recipeId}/cooked`,
body,
description: `Mark dish ${dishId} as cooked`,
});
const now = new Date().toISOString();
setDishes((prev) => prev.map((d) => (d.id === dishId ? { ...d, cookedAt: now } : d)));
toast.info(t("batchCookMarkQueuedOffline"));
return;
}
if (!res.ok) {
toast.error(t("batchCookMarkFailed"));
return;
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import { Download, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isRecipeSavedOffline, saveRecipeOffline, unsaveRecipeOffline } from "@/lib/offline-db";
export function SaveOfflineButton({ recipeId, recipeTitle }: { recipeId: string; recipeTitle: string }) {
const pathname = usePathname();
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
isRecipeSavedOffline(recipeId).then(setSaved).catch(() => {});
}, [recipeId]);
async function toggle() {
setBusy(true);
try {
if (saved) {
await unsaveRecipeOffline(recipeId);
setSaved(false);
toast.success("Removed from offline recipes");
return;
}
// Re-fetching the current page (rather than relying on the visit that
// already happened) forces the service worker's fetch handler to pin
// a fresh copy in its cache right now, regardless of how the normal
// cache eviction/versioning plays out later.
await fetch(pathname, { cache: "reload" });
await saveRecipeOffline(recipeId, recipeTitle);
setSaved(true);
toast.success("Saved for offline — photos and linked pages aren't included");
} catch {
toast.error("Couldn't save this recipe for offline use");
} finally {
setBusy(false);
}
}
return (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={() => { void toggle(); }}>
{saved ? <Check className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
{saved ? "Saved offline" : "Save offline"}
</Button>
);
}
+12 -1
View File
@@ -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",
+100
View File
@@ -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);
});
}
+56
View File
@@ -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;
}
}
+1
View File
@@ -146,6 +146,7 @@
"batchCookMarkCooked": "Mark as cooked today",
"batchCookMarkSuccess": "Marked as cooked",
"batchCookMarkFailed": "Failed to mark as cooked",
"batchCookMarkQueuedOffline": "Saved offline — will sync when you're back online",
"batchCookExpiresOn": "Cooked — good until {date}",
"servings": "{count} servings",
"prep": "{mins}m prep",
+1
View File
@@ -146,6 +146,7 @@
"batchCookMarkCooked": "Marquer comme cuisiné aujourd'hui",
"batchCookMarkSuccess": "Marqué comme cuisiné",
"batchCookMarkFailed": "Échec du marquage",
"batchCookMarkQueuedOffline": "Enregistré hors ligne — sera synchronisé au retour de la connexion",
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
"servings": "{count} portions",
"prep": "{mins}m prép.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.64.0",
"version": "0.65.0",
"private": true,
"scripts": {
"dev": "next dev",
-15
View File
@@ -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
View File
@@ -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());
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.64.0",
"version": "0.65.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",