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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 22:17:15 +02:00
parent aa67868b96
commit 00ca8b9d68
8 changed files with 163 additions and 7 deletions
+44 -2
View File
@@ -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<Map<string, number>>(new Map());
const pendingDeleteIdsRef = useRef<Set<string>>(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 {