Files
Epicure/apps/web/components/meal-plan/shared-meal-plan-view.tsx
T
Arnaud 00ca8b9d68 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>
2026-07-13 22:17:15 +02:00

171 lines
6.4 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
const DAYS: Day[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const MEAL_TYPES: MealType[] = ["breakfast", "lunch", "dinner", "snack"];
type Entry = {
id: string;
day: Day;
mealType: MealType;
servings: number;
recipe: { id: string; title: string } | null;
};
type UserRecipe = { id: string; title: string };
export function SharedMealPlanView({
mealPlanId,
initialEntries,
userRecipes,
canEdit,
}: {
mealPlanId: string;
initialEntries: Entry[];
userRecipes: UserRecipe[];
canEdit: boolean;
}) {
const t = useTranslations("mealPlan");
const [entries, setEntries] = useState<Entry[]>(initialEntries);
const [addingCell, setAddingCell] = useState<string | null>(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<Map<string, number>>(new Map());
const pendingDeleteIdsRef = useRef<Set<string>>(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}`;
}
async function addEntry(day: Day, mealType: MealType, recipeId: string) {
const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ day, mealType, recipeId, servings: 2 }),
});
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)),
{ id, day, mealType, servings: 2, recipe },
]);
setAddingCell(null);
}
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) {
pendingDeleteIdsRef.current.delete(entry.id);
toast.error(t("removeFailed"));
return;
}
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm min-w-[640px]">
<thead>
<tr>
<th className="text-left p-2 text-muted-foreground font-medium"></th>
{DAYS.map((day) => (
<th key={day} className="text-left p-2 text-muted-foreground font-medium capitalize">{day}</th>
))}
</tr>
</thead>
<tbody>
{MEAL_TYPES.map((mealType) => (
<tr key={mealType} className="border-t">
<td className="p-2 text-muted-foreground font-medium capitalize align-top">{mealType}</td>
{DAYS.map((day) => {
const entry = entries.find((e) => e.day === day && e.mealType === mealType);
const key = cellKey(day, mealType);
return (
<td key={key} className="p-2 align-top min-w-[120px]">
{entry ? (
<div className="flex items-start justify-between gap-1 rounded-lg border p-2">
<span className="text-xs">{entry.recipe?.title ?? "—"}</span>
{canEdit && (
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => void removeEntry(entry)} aria-label={t("removeEntry")}>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
) : canEdit ? (
addingCell === key ? (
<Select onValueChange={(v) => void addEntry(day, mealType, v as string)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={t("pickRecipe")} />
</SelectTrigger>
<SelectContent>
{userRecipes.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.title}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<button
className="w-full h-8 rounded-lg border border-dashed text-xs text-muted-foreground hover:bg-muted/30"
onClick={() => setAddingCell(key)}
>
{t("addEntry")}
</button>
)
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}