Files
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

668 lines
26 KiB
TypeScript

"use client";
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";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTranslations } from "next-intl";
import { BatchCookGenerateDialog } from "@/components/recipe/batch-cook-generate-dialog";
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
type Recipe = { id: string; title: string };
type BatchDish = { id: string; name: string };
type Entry = {
id: string;
day: Day;
mealType: MealType;
servings: number;
recipe: Recipe | null;
batchDish: BatchDish | null;
note: string | null;
};
type UserRecipe = { id: string; title: string; isBatchCook: boolean; batchDishes: BatchDish[] };
function AddEntryModal({
weekStart,
day,
mealType,
userRecipes,
onAdded,
onClose,
dayLabel,
mealLabel,
}: {
weekStart: string;
day: Day;
mealType: MealType;
userRecipes: UserRecipe[];
onAdded: (entry: Entry) => void;
onClose: () => void;
dayLabel: string;
mealLabel: string;
}) {
const [search, setSearch] = useState("");
const [servings, setServings] = useState("2");
const [saving, setSaving] = useState(false);
const [pickingDishFor, setPickingDishFor] = useState<UserRecipe | null>(null);
const t = useTranslations("mealPlan");
const filtered = userRecipes.filter((r) =>
r.title.toLowerCase().includes(search.toLowerCase())
);
async function add(recipe: UserRecipe, batchDish?: BatchDish) {
setSaving(true);
try {
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
day,
mealType,
recipeId: recipe.id,
batchDishId: batchDish?.id,
servings: parseInt(servings) || 2,
}),
});
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, batchDish: batchDish ?? null, note: null });
onClose();
} finally {
setSaving(false);
}
}
function selectRecipe(recipe: UserRecipe) {
if (recipe.isBatchCook && recipe.batchDishes.length > 0) {
setPickingDishFor(recipe);
return;
}
void add(recipe);
}
if (pickingDishFor) {
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("pickDishTitle", { recipe: pickingDishFor.title })}</DialogTitle>
<DialogDescription>{t("pickDishDescription")}</DialogDescription>
</DialogHeader>
<div className="max-h-56 overflow-y-auto space-y-1">
{pickingDishFor.batchDishes.map((dish) => (
<button
key={dish.id}
onClick={() => { void add(pickingDishFor, dish); }}
disabled={saving}
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
{dish.name}
</button>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => setPickingDishFor(null)} disabled={saving}>
{t("back")}
</Button>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("addTo", { day: dayLabel, meal: mealLabel })}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder={t("searchRecipes")}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<Input
type="number"
min={1}
max={50}
value={servings}
onChange={(e) => setServings(e.target.value)}
placeholder={t("servings")}
/>
<div className="max-h-56 overflow-y-auto space-y-1">
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">{t("noRecipes")}</p>
) : (
filtered.map((recipe) => (
<button
key={recipe.id}
onClick={() => selectRecipe(recipe)}
disabled={saving}
className="w-full flex items-center justify-between gap-2 text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
<span>{recipe.title}</span>
{recipe.isBatchCook && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground border rounded-full px-2 py-0.5">
{t("batchCookBadge")}
</span>
)}
</button>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function MealPlanner({
weekStart,
initialEntries,
userRecipes,
hasNutritionGoals = false,
}: {
weekStart: string;
initialEntries: Entry[];
userRecipes: UserRecipe[];
hasNutritionGoals?: boolean;
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
const [cookedIds, setCookedIds] = useState<Set<string>>(new Set());
const [markingCookedIds, setMarkingCookedIds] = useState<Set<string>>(new Set());
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
const [showAiModal, setShowAiModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
const [aiPhase, setAiPhase] = useState("");
const [dietaryPrefs, setDietaryPrefs] = useState("");
const [aiServings, setAiServings] = useState("2");
const [usePantry, setUsePantry] = useState(true);
const [pantryMode, setPantryMode] = useState(false);
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
const [targetNutritionGoals, setTargetNutritionGoals] = useState(false);
const [clearTarget, setClearTarget] = useState<{ type: "day"; day: Day; label: string } | { type: "week" } | null>(null);
const [clearing, setClearing] = useState(false);
const t = useTranslations("mealPlan");
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"));
const phases = [
[1500, t("aiPhasePlanning")],
[5000, t("aiPhaseSelecting")],
[10000, t("aiPhaseBalancing")],
[16000, t("aiPhaseFinalizing")],
] as const;
const timers = phases.map(([delay, label]) =>
setTimeout(() => setAiPhase(label), delay)
);
try {
const res = await fetch("/api/v1/ai/meal-plan/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
weekStart,
dietaryPrefs: dietaryPrefs.trim() || undefined,
servings: parseInt(aiServings) || 2,
usePantry,
pantryMode,
difficulty: aiDifficulty || undefined,
targetNutritionGoals: hasNutritionGoals && targetNutritionGoals,
}),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? t("generationFailed"));
}
const data = await res.json() as {
entries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }>;
};
// Merge generated entries into state
setEntries((prev) => {
const updated = [...prev];
for (const e of data.entries) {
const idx = updated.findIndex((u) => u.day === e.day && u.mealType === e.mealType);
const newEntry: Entry = {
id: e.id,
day: e.day as Day,
mealType: e.mealType as MealType,
servings: parseInt(aiServings) || 2,
recipe: { id: e.recipeId, title: e.recipeTitle },
batchDish: null,
note: null,
};
if (idx >= 0) updated[idx] = newEntry;
else updated.push(newEntry);
}
return updated;
});
setShowAiModal(false);
toast.success(t("aiGenerated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("generationFailed"));
} finally {
timers.forEach(clearTimeout);
setAiGenerating(false);
setAiPhase("");
}
}
const DAYS: { key: Day; label: string }[] = [
{ key: "mon", label: t("days.mon") },
{ key: "tue", label: t("days.tue") },
{ key: "wed", label: t("days.wed") },
{ key: "thu", label: t("days.thu") },
{ key: "fri", label: t("days.fri") },
{ key: "sat", label: t("days.sat") },
{ key: "sun", label: t("days.sun") },
];
const MEAL_TYPES: { key: MealType; label: string }[] = [
{ key: "breakfast", label: t("meals.breakfast") },
{ key: "lunch", label: t("meals.lunch") },
{ key: "dinner", label: t("meals.dinner") },
{ key: "snack", label: t("meals.snack") },
];
const getEntry = (day: Day, mealType: MealType) =>
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,
]);
}, []);
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"));
}
}
async function confirmClear() {
if (!clearTarget) return;
const ids = (
clearTarget.type === "day" ? entries.filter((e) => e.day === clearTarget.day) : entries
).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`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
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 {
setClearing(false);
setClearTarget(null);
}
}
async function markCooked(entry: Entry) {
if (!entry.recipe || markingCookedIds.has(entry.id)) return;
setMarkingCookedIds((prev) => new Set(prev).add(entry.id));
try {
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
});
if (!res.ok) {
toast.error(t("markCookedFailed"));
return;
}
setCookedIds((prev) => new Set(prev).add(entry.id));
toast.success(t("markCookedSuccess"));
} catch {
toast.error(t("markCookedFailed"));
} finally {
setMarkingCookedIds((prev) => {
const next = new Set(prev);
next.delete(entry.id);
return next;
});
}
}
const addingDay = adding ? DAYS.find((d) => d.key === adding.day) : null;
const addingMeal = adding ? MEAL_TYPES.find((m) => m.key === adding.mealType) : null;
return (
<>
{/* AI Generate buttons */}
<div className="flex justify-end gap-2 mb-4">
{entries.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setClearTarget({ type: "week" })} className="gap-2 text-muted-foreground">
<Trash2 className="h-4 w-4" />
{t("clearWeek")}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setShowBatchModal(true)} className="gap-2">
<ChefHat className="h-4 w-4" />
{t("batchCooking")}
</Button>
<Button size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
<Sparkles className="h-4 w-4" />
{t("generateWithAi")}
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse table-fixed">
<colgroup>
<col className="w-24" />
{DAYS.map(({ key }) => (
<col key={key} />
))}
</colgroup>
<thead>
<tr>
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
{DAYS.map(({ key, label }) => {
const dayHasEntries = entries.some((e) => e.day === key);
return (
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">
<span className="group inline-flex items-center gap-1">
{label}
{dayHasEntries && (
<button
onClick={() => setClearTarget({ type: "day", day: key, label })}
title={t("clearDay")}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{MEAL_TYPES.map(({ key: mealType, label }) => (
<tr key={mealType} className="border-t">
<td className="py-3 pr-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</span>
</td>
{DAYS.map(({ key: day }) => {
const entry = getEntry(day, mealType);
return (
<td key={day} className="py-2 px-1 align-top">
{entry?.recipe ? (
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
<Link
href={`/recipes/${entry.recipe.id}`}
className="block pr-8 hover:underline"
>
<div className="font-medium line-clamp-2">{entry.batchDish?.name ?? entry.recipe.title}</div>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1">
{t("servingsAbbrev", { count: entry.servings })}
{cookedIds.has(entry.id) && (
<Check className="h-3 w-3 text-green-600 shrink-0" />
)}
</div>
</Link>
<div className="absolute top-1 right-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => markCooked(entry)}
disabled={markingCookedIds.has(entry.id)}
title={t("markCooked")}
className="text-muted-foreground hover:text-primary disabled:opacity-50"
>
<ChefHat className="h-3 w-3" />
</button>
<button
onClick={() => removeEntry(entry)}
title={t("removeEntry")}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
) : (
<button
onClick={() => setAdding({ day, mealType })}
className="w-full min-h-[52px] rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground/40" />
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{adding && addingDay && addingMeal && (
<AddEntryModal
weekStart={weekStart}
day={adding.day}
mealType={adding.mealType}
userRecipes={userRecipes}
onAdded={handleAdded}
onClose={() => setAdding(null)}
dayLabel={addingDay.label}
mealLabel={addingMeal.label}
/>
)}
{/* AI generation modal */}
{showAiModal && (
<Dialog open onOpenChange={(open) => { if (!aiGenerating) setShowAiModal(open); }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{t("generateWithAi")}
</DialogTitle>
<DialogDescription>{t("aiModalDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("dietaryPrefs")}</label>
<Input
placeholder={t("dietaryPrefsPlaceholder")}
value={dietaryPrefs}
onChange={(e) => setDietaryPrefs(e.target.value)}
disabled={aiGenerating}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("servings")}</label>
<Input
type="number"
min={1}
max={20}
value={aiServings}
onChange={(e) => setAiServings(e.target.value)}
disabled={aiGenerating}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("difficulty")}</label>
<Select value={aiDifficulty} onValueChange={(v) => setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}>
<SelectTrigger>
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4"
checked={usePantry}
onChange={(e) => {
setUsePantry(e.target.checked);
if (!e.target.checked) setPantryMode(false);
}}
disabled={aiGenerating}
/>
<span className="text-sm font-medium">{t("includePantryItems")}</span>
</label>
{usePantry && (
<label className="flex items-start gap-2 cursor-pointer select-none pl-6">
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
checked={pantryMode}
onChange={(e) => setPantryMode(e.target.checked)}
disabled={aiGenerating}
/>
<span className="space-y-0.5">
<span className="text-sm font-medium block">{t("pantryMode")}</span>
<span className="text-sm text-muted-foreground block">{t("pantryModeDescription")}</span>
</span>
</label>
)}
</div>
{hasNutritionGoals && (
<label className="flex items-start gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
checked={targetNutritionGoals}
onChange={(e) => setTargetNutritionGoals(e.target.checked)}
disabled={aiGenerating}
/>
<span className="space-y-0.5">
<span className="text-sm font-medium block">{t("targetNutritionGoals")}</span>
<span className="text-sm text-muted-foreground block">{t("targetNutritionGoalsDescription")}</span>
</span>
</label>
)}
<FakeProgressBar active={aiGenerating} durationMs={20000} label={aiPhase} />
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setShowAiModal(false)} disabled={aiGenerating}>
{t("cancel")}
</Button>
<Button onClick={() => { void generateWithAi(); }} disabled={aiGenerating} className="gap-2">
{aiGenerating ? (
<>
<span className="animate-spin inline-block h-3.5 w-3.5 border-2 border-current border-t-transparent rounded-full" />
{t("generating")}
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5" />
{t("generate")}
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
<BatchCookGenerateDialog open={showBatchModal} onOpenChange={setShowBatchModal} />
<AlertDialog open={clearTarget !== null} onOpenChange={(open) => !open && setClearTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{clearTarget?.type === "day" ? t("clearDayConfirmTitle", { day: clearTarget.label }) : t("clearWeekConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>{t("clearConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={clearing}>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={clearing}
onClick={() => { void confirmClear(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}