diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be3153..0244898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 3c1b875..0b8bcd2 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -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) { /> + {children} + ); diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 40919c0..808d644 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -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" }, ], }; } diff --git a/apps/web/app/offline/page.tsx b/apps/web/app/offline/page.tsx index de55900..4437c81 100644 --- a/apps/web/app/offline/page.tsx +++ b/apps/web/app/offline/page.tsx @@ -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(null); + + useEffect(() => { + getSavedRecipesOffline().then(setSaved).catch(() => setSaved([])); + }, []); + return (
-

You're offline

+

You're offline

- Your recently visited recipes are available. Connect to internet to load new content. + Recipes you've saved for offline, or recently visited, are available below. Connect to the internet to load new content.

+ + {saved && saved.length > 0 && ( +
    + {saved.map((r) => ( +
  • + + {r.title} + +
  • + ))} +
+ )} + { + 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; +} diff --git a/apps/web/components/recipe/batch-cook-dishes.tsx b/apps/web/components/recipe/batch-cook-dishes.tsx index 0ad05b0..6d4dc58 100644 --- a/apps/web/components/recipe/batch-cook-dishes.tsx +++ b/apps/web/components/recipe/batch-cook-dishes.tsx @@ -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; diff --git a/apps/web/components/recipe/save-offline-button.tsx b/apps/web/components/recipe/save-offline-button.tsx new file mode 100644 index 0000000..bdb859e --- /dev/null +++ b/apps/web/components/recipe/save-offline-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index c126c03..cd5ac4f 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/offline-db.ts b/apps/web/lib/offline-db.ts new file mode 100644 index 0000000..ef7e909 --- /dev/null +++ b/apps/web/lib/offline-db.ts @@ -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 { + 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(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => T): Promise { + 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 { + await withStore(SAVED_RECIPES_STORE, "readwrite", (s) => { + s.put({ id, title, savedAt: new Date().toISOString() }); + }); +} + +export async function unsaveRecipeOffline(id: string): Promise { + await withStore(SAVED_RECIPES_STORE, "readwrite", (s) => { + s.delete(id); + }); +} + +export async function isRecipeSavedOffline(id: string): Promise { + const record = await withStore(SAVED_RECIPES_STORE, "readonly", (s) => { + let result: SavedRecipe | undefined; + s.get(id).onsuccess = (e) => { result = (e.target as IDBRequest).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 { + return withStore(SAVED_RECIPES_STORE, "readonly", (s) => { + let result: SavedRecipe[] = []; + s.getAll().onsuccess = (e) => { result = (e.target as IDBRequest).result; }; + return result; + }); +} + +export async function enqueuePendingAction(action: Omit): Promise { + await withStore(PENDING_ACTIONS_STORE, "readwrite", (s) => { + s.put({ ...action, id: crypto.randomUUID(), createdAt: new Date().toISOString() }); + }); +} + +export async function getPendingActions(): Promise { + const actions = await withStore(PENDING_ACTIONS_STORE, "readonly", (s) => { + let result: PendingAction[] = []; + s.getAll().onsuccess = (e) => { result = (e.target as IDBRequest).result; }; + return result; + }); + return actions.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +export async function deletePendingAction(id: string): Promise { + await withStore(PENDING_ACTIONS_STORE, "readwrite", (s) => { + s.delete(id); + }); +} diff --git a/apps/web/lib/offline-queue.ts b/apps/web/lib/offline-queue.ts new file mode 100644 index 0000000..46e1671 --- /dev/null +++ b/apps/web/lib/offline-queue.ts @@ -0,0 +1,56 @@ +"use client"; + +import { enqueuePendingAction, getPendingActions, deletePendingAction, type PendingAction } from "@/lib/offline-db"; + +type ServiceWorkerRegistrationWithSync = ServiceWorkerRegistration & { + sync?: { register: (tag: string) => Promise }; +}; + +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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ff21d14..71b2dd9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 5fc7216..c2686e8 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -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.", diff --git a/apps/web/package.json b/apps/web/package.json index 1477ac6..b01eaeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.64.0", + "version": "0.65.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json deleted file mode 100644 index 2de4478..0000000 --- a/apps/web/public/manifest.json +++ /dev/null @@ -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" } - ] -} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 2e8a4f8..dc64dc8 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -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()); +}); diff --git a/package.json b/package.json index 6bf91e5..be0962c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.64.0", + "version": "0.65.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",