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