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
+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"