From 00ca8b9d68cd4c24b0d2a4ecf2e61ec5bfb8af44 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 22:17:15 +0200 Subject: [PATCH] feat: live updates on shared meal plans Two separate components (the owner's own week view and collaborators' shared view) hit two different API surfaces for the same underlying entries, so neither ever saw the other's edits without a manual reload. Both now poll a new lean GET on their respective entries routes every 4s, merged with the same dirty-until-ref guard used for shopping lists so a poll can't stomp an in-flight local edit. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 ++ .../meal-plans/[weekStart]/entries/route.ts | 35 ++++++++++++++ .../shared/[mealPlanId]/entries/route.ts | 27 +++++++++++ .../web/components/meal-plan/meal-planner.tsx | 46 ++++++++++++++++++- .../meal-plan/shared-meal-plan-view.tsx | 44 +++++++++++++++++- apps/web/lib/changelog.ts | 9 +++- apps/web/package.json | 2 +- package.json | 2 +- 8 files changed, 163 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34458b2..74b753b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 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.18.0 — 2026-07-13 22:16 + +### Added +- **Live updates on shared meal plans**: entries added, removed, or cleared by a collaborator now appear automatically, both on the owner's own week view and everyone else's shared view. + ## 0.17.0 — 2026-07-13 22:06 ### Added diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts index 3cdb3b1..8809546 100644 --- a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts @@ -26,6 +26,41 @@ async function getOrCreatePlan(userId: string, weekStart: string) { return { id, userId, weekStart }; } +// Polled by MealPlanner (components/meal-plan/meal-planner.tsx) so entries +// added/removed by a collaborator via the shared view show up here too — a +// lean shape (no photos join) unlike the full plan GET at +// meal-plans/[weekStart]/route.ts, since this runs every few seconds. +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + }); + if (!plan) return NextResponse.json({ entries: [] }); + + const entries = await db.query.mealPlanEntries.findMany({ + where: eq(mealPlanEntries.mealPlanId, plan.id), + with: { + recipe: { columns: { id: true, title: true } }, + batchDish: { columns: { id: true, name: true } }, + }, + }); + + return NextResponse.json({ + entries: entries.map((e) => ({ + id: e.id, + day: e.day, + mealType: e.mealType, + servings: e.servings, + recipe: e.recipe, + batchDish: e.batchDish, + note: e.note, + })), + }); +} + export async function POST(req: NextRequest, { params }: Params) { const { session, response } = await requireSession(); if (response) return response; diff --git a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts index 14aeb80..af582b2 100644 --- a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts +++ b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts @@ -7,6 +7,33 @@ import { dispatchWebhook } from "@/lib/webhooks"; type Params = { params: Promise<{ mealPlanId: string }> }; +// Polled by SharedMealPlanView so every collaborator sees each other's +// changes, and the plan owner's own tab (a different component/URL, per +// meal-plans/[weekStart]/entries) picks up edits made from here too. +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { mealPlanId } = await params; + + const access = await getMealPlanAccessById(mealPlanId, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const entries = await db.query.mealPlanEntries.findMany({ + where: eq(mealPlanEntries.mealPlanId, mealPlanId), + with: { recipe: { columns: { id: true, title: true } } }, + }); + + return NextResponse.json({ + entries: entries.map((e) => ({ + id: e.id, + day: e.day, + mealType: e.mealType, + servings: e.servings, + recipe: e.recipe, + })), + }); +} + const Schema = z.object({ day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]), diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx index 5f0fefc..83c9e35 100644 --- a/apps/web/components/meal-plan/meal-planner.tsx +++ b/apps/web/components/meal-plan/meal-planner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import Link from "next/link"; import { Plus, Trash2, Sparkles, ChefHat, Check } from "lucide-react"; import { toast } from "sonner"; @@ -206,6 +206,40 @@ export function MealPlanner({ const tRecipe = useTranslations("recipe"); const tCommon = useTranslations("common"); + // Live updates: a collaborator can edit this same plan through the shared + // view (a different URL/component) — poll so those changes show up here + // without a manual refresh, same pattern as shopping-list-view.tsx. + const dirtyUntilRef = useRef>(new Map()); + const pendingDeleteIdsRef = useRef>(new Set()); + const DIRTY_MS = 4000; + + function markDirty(...ids: string[]) { + const until = Date.now() + DIRTY_MS; + for (const id of ids) dirtyUntilRef.current.set(id, until); + } + + useEffect(() => { + const interval = setInterval(() => { + fetch(`/api/v1/meal-plans/${weekStart}/entries`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { entries: Entry[] } | null) => { + if (!data) return; + const now = Date.now(); + setEntries((prev) => { + const prevById = new Map(prev.map((e) => [e.id, e])); + return data.entries + .filter((s) => !pendingDeleteIdsRef.current.has(s.id)) + .map((s) => { + const dirty = (dirtyUntilRef.current.get(s.id) ?? 0) > now; + return dirty ? (prevById.get(s.id) ?? s) : s; + }); + }); + }) + .catch(() => {}); + }, 4000); + return () => clearInterval(interval); + }, [weekStart]); + async function generateWithAi() { setAiGenerating(true); setAiPhase(t("aiPhaseAnalyzing")); @@ -290,6 +324,7 @@ export function MealPlanner({ entries.find((e) => e.day === day && e.mealType === mealType); const handleAdded = useCallback((entry: Entry) => { + markDirty(entry.id); setEntries((prev) => [ ...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)), entry, @@ -297,10 +332,12 @@ export function MealPlanner({ }, []); async function removeEntry(entry: Entry) { + pendingDeleteIdsRef.current.add(entry.id); const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/${entry.id}`, { method: "DELETE" }); if (res.ok) { setEntries((prev) => prev.filter((e) => e.id !== entry.id)); } else { + pendingDeleteIdsRef.current.delete(entry.id); toast.error(t("removeFailed")); } } @@ -312,6 +349,7 @@ export function MealPlanner({ ).map((e) => e.id); if (ids.length === 0) { setClearTarget(null); return; } + for (const id of ids) pendingDeleteIdsRef.current.add(id); setClearing(true); try { const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/bulk`, { @@ -319,7 +357,11 @@ export function MealPlanner({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); - if (!res.ok) { toast.error(t("removeFailed")); return; } + if (!res.ok) { + for (const id of ids) pendingDeleteIdsRef.current.delete(id); + toast.error(t("removeFailed")); + return; + } setEntries((prev) => prev.filter((e) => !ids.includes(e.id))); toast.success(t("clearedSuccess", { count: ids.length })); } finally { diff --git a/apps/web/components/meal-plan/shared-meal-plan-view.tsx b/apps/web/components/meal-plan/shared-meal-plan-view.tsx index aa2884b..e81518f 100644 --- a/apps/web/components/meal-plan/shared-meal-plan-view.tsx +++ b/apps/web/components/meal-plan/shared-meal-plan-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Trash2 } from "lucide-react"; @@ -38,6 +38,40 @@ export function SharedMealPlanView({ const [entries, setEntries] = useState(initialEntries); const [addingCell, setAddingCell] = useState(null); + // Live updates: other collaborators (or the owner, via the separate + // MealPlanner component on their own /meal-plan page) can edit this same + // plan — poll and merge, guarding this tab's own in-flight edits, same + // pattern as shopping-list-view.tsx. + const dirtyUntilRef = useRef>(new Map()); + const pendingDeleteIdsRef = useRef>(new Set()); + const DIRTY_MS = 4000; + + const markDirty = useCallback((id: string) => { + dirtyUntilRef.current.set(id, Date.now() + DIRTY_MS); + }, []); + + useEffect(() => { + const interval = setInterval(() => { + fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { entries: Entry[] } | null) => { + if (!data) return; + const now = Date.now(); + setEntries((prev) => { + const prevById = new Map(prev.map((e) => [e.id, e])); + return data.entries + .filter((s) => !pendingDeleteIdsRef.current.has(s.id)) + .map((s) => { + const dirty = (dirtyUntilRef.current.get(s.id) ?? 0) > now; + return dirty ? (prevById.get(s.id) ?? s) : s; + }); + }); + }) + .catch(() => {}); + }, 4000); + return () => clearInterval(interval); + }, [mealPlanId]); + function cellKey(day: Day, mealType: MealType) { return `${day}-${mealType}`; } @@ -50,6 +84,7 @@ export function SharedMealPlanView({ }); if (!res.ok) { toast.error(t("addFailed")); return; } const { id } = await res.json() as { id: string }; + markDirty(id); const recipe = userRecipes.find((r) => r.id === recipeId) ?? null; setEntries((prev) => [ ...prev.filter((e) => !(e.day === day && e.mealType === mealType)), @@ -59,10 +94,15 @@ export function SharedMealPlanView({ } async function removeEntry(entry: Entry) { + pendingDeleteIdsRef.current.add(entry.id); const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries?entryId=${entry.id}`, { method: "DELETE", }); - if (!res.ok) { toast.error(t("removeFailed")); return; } + if (!res.ok) { + pendingDeleteIdsRef.current.delete(entry.id); + toast.error(t("removeFailed")); + return; + } setEntries((prev) => prev.filter((e) => e.id !== entry.id)); } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index f753cfc..628b39a 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.17.0"; +export const APP_VERSION = "0.18.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.18.0", + date: "2026-07-13 22:16", + added: [ + "**Live updates on shared meal plans**: entries added, removed, or cleared by a collaborator now appear automatically, both on the owner's own week view and everyone else's shared view.", + ], + }, { version: "0.17.0", date: "2026-07-13 22:06", diff --git a/apps/web/package.json b/apps/web/package.json index 43844e6..186e5b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.17.0", + "version": "0.18.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 6750883..86cb5f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.17.0", + "version": "0.18.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",