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
@@ -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>
);
}