feat(meal-plan): weekly planner, pantry, shopping lists, nutrition tracking

AI-generated weekly meal plans with pantry-awareness. Manual entry per slot.
Pantry inventory management. Auto-generated shopping lists from meal plan.
Weekly nutrition bar chart vs daily goals. Nutrition goals settings.
This commit is contained in:
Arnaud
2026-07-01 08:10:39 +02:00
parent 9d02a69250
commit 3636ab27ae
23 changed files with 1791 additions and 0 deletions
@@ -0,0 +1,398 @@
"use client";
import { useState, useCallback } from "react";
import { Plus, Trash2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
type Recipe = { id: string; title: string };
type Entry = {
id: string;
day: Day;
mealType: MealType;
servings: number;
recipe: Recipe | null;
note: string | null;
};
type UserRecipe = { id: string; title: string };
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 t = useTranslations("mealPlan");
const filtered = userRecipes.filter((r) =>
r.title.toLowerCase().includes(search.toLowerCase())
);
async function add(recipe: UserRecipe) {
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, servings: parseInt(servings) || 2 }),
});
if (!res.ok) { toast.error("Failed to add"); return; }
const { id } = await res.json() as { id: string };
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, note: null });
onClose();
} finally {
setSaving(false);
}
}
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={() => add(recipe)}
disabled={saving}
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
{recipe.title}
</button>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function MealPlanner({
weekStart,
initialEntries,
userRecipes,
}: {
weekStart: string;
initialEntries: Entry[];
userRecipes: UserRecipe[];
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
const [showAiModal, setShowAiModal] = 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 t = useTranslations("mealPlan");
async function generateWithAi() {
setAiGenerating(true);
setAiPhase("Analyzing your preferences…");
const phases = [
[1500, "Planning breakfast, lunch & dinner…"],
[5000, "Selecting recipes for each day…"],
[10000, "Balancing nutrition across the week…"],
[16000, "Finalizing your meal plan…"],
] 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,
}),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Generation failed");
}
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 },
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 : "Generation failed");
} 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) => {
setEntries((prev) => [
...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)),
entry,
]);
}, []);
async function removeEntry(entry: Entry) {
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 {
toast.error("Failed to remove");
}
}
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 button */}
<div className="flex justify-end mb-4">
<Button variant="outline" 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 }) => (
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</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]">
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
<div className="text-muted-foreground mt-0.5">{entry.servings} srv</div>
<button
onClick={() => removeEntry(entry)}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</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="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-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>
<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>
)}
</>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Plus, ShoppingCart } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function NewShoppingListButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [weekStart, setWeekStart] = useState("");
const [saving, setSaving] = useState(false);
async function create() {
if (!name.trim()) return;
setSaving(true);
try {
const res = await fetch("/api/v1/shopping-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
fromMealPlanWeek: weekStart || undefined,
}),
});
if (!res.ok) { toast.error("Failed to create"); return; }
const { id } = await res.json() as { id: string };
toast.success("List created");
setOpen(false);
setName(""); setWeekStart("");
router.push(`/shopping-lists/${id}`);
} finally {
setSaving(false);
}
}
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" /> New list
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>New shopping list</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Weekly groceries" />
</div>
<div className="space-y-2">
<Label>Generate from meal plan week (optional)</Label>
<Input type="date" value={weekStart} onChange={(e) => setWeekStart(e.target.value)} />
<p className="text-xs text-muted-foreground">Picks Monday of the selected week</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? "Creating…" : "Create"}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type PantryItem = {
id: string;
rawName: string;
quantity: string | null;
unit: string | null;
expiresAt: string | null;
};
function daysUntilExpiry(dateStr: string): number {
const diff = new Date(dateStr).getTime() - Date.now();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
const [items, setItems] = useState<PantryItem[]>(initialItems);
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [adding, setAdding] = useState(false);
async function add() {
if (!name.trim()) return;
setAdding(true);
try {
const res = await fetch("/api/v1/pantry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rawName: name.trim(),
quantity: quantity.trim() || undefined,
unit: unit.trim() || undefined,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
}),
});
if (!res.ok) { toast.error("Failed to add"); return; }
const { id } = await res.json() as { id: string };
setItems((prev) => [...prev, { id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }]);
setName(""); setQuantity(""); setUnit(""); setExpiresAt("");
} finally {
setAdding(false);
}
}
async function remove(id: string) {
const res = await fetch(`/api/v1/pantry/${id}`, { method: "DELETE" });
if (res.ok) setItems((prev) => prev.filter((i) => i.id !== id));
else toast.error("Failed to remove");
}
const sorted = [...items].sort((a, b) => {
if (a.expiresAt && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
if (a.expiresAt) return -1;
if (b.expiresAt) return 1;
return a.rawName.localeCompare(b.rawName);
});
return (
<div className="space-y-6 max-w-2xl">
{/* Add form */}
<div className="flex gap-2 flex-wrap">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Item name" className="flex-1 min-w-[160px]" onKeyDown={(e) => e.key === "Enter" && add()} />
<Input value={quantity} onChange={(e) => setQuantity(e.target.value)} placeholder="Qty" className="w-20" />
<Input value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="Unit" className="w-20" />
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} className="w-36" />
<Button onClick={add} disabled={!name.trim() || adding} size="sm">
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{/* Item list */}
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">No pantry items yet.</p>
) : (
<div className="rounded-xl border divide-y">
{sorted.map((item) => {
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
const expiring = days !== null && days <= 3;
const expired = days !== null && days < 0;
return (
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{item.rawName}</span>
{item.quantity && <span className="text-xs text-muted-foreground">{item.quantity}{item.unit ? ` ${item.unit}` : ""}</span>}
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />Expired</span>}
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />Expires in {days}d</span>}
</div>
{item.expiresAt && !expired && !expiring && (
<p className="text-xs text-muted-foreground">Expires {new Date(item.expiresAt).toLocaleDateString()}</p>
)}
</div>
<button onClick={() => remove(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Check, Package, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
type Item = {
id: string;
rawName: string;
quantity: string | null;
unit: string | null;
aisle: string | null;
checked: boolean;
};
export function ShoppingListView({
listId,
initialItems,
}: {
listId: string;
initialItems: Item[];
}) {
const [items, setItems] = useState<Item[]>(initialItems);
const [movingToPantry, setMovingToPantry] = useState(false);
const checkedItems = items.filter((i) => i.checked);
async function moveToPantry() {
if (checkedItems.length === 0) return;
setMovingToPantry(true);
try {
const res = await fetch("/api/v1/pantry/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: checkedItems.map((i) => ({
rawName: i.rawName,
quantity: i.quantity ?? undefined,
unit: i.unit ?? undefined,
})),
}),
});
if (!res.ok) { toast.error("Failed to move items to pantry"); return; }
toast.success(`${checkedItems.length} item${checkedItems.length !== 1 ? "s" : ""} added to pantry`);
} finally {
setMovingToPantry(false);
}
}
async function toggleItem(item: Item) {
const next = !item.checked;
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checked: next }),
});
}
const grouped = items.reduce<Record<string, Item[]>>((acc, item) => {
const key = item.aisle ?? "Other";
(acc[key] ??= []).push(item);
return acc;
}, {});
const checkedCount = items.filter((i) => i.checked).length;
if (items.length === 0) {
return <p className="text-muted-foreground text-sm">This list is empty.</p>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{checkedCount}/{items.length} checked</p>
{checkedCount > 0 && (
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
Move {checkedCount} to pantry
</Button>
)}
</div>
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<div key={aisle} className="space-y-2">
{Object.keys(grouped).length > 1 && (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
)}
<div className="rounded-xl border divide-y">
{aisleItems.map((item) => (
<button
key={item.id}
onClick={() => toggleItem(item)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 text-left transition-colors"
>
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
item.checked ? "bg-primary border-primary" : "border-input"
)}>
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
{item.rawName}
</span>
{(item.quantity || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{item.quantity}{item.unit ? ` ${item.unit}` : ""}
</span>
)}
</button>
))}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type NutritionGoals = {
caloriesKcal: number | null;
proteinG: number | null;
carbsG: number | null;
fatG: number | null;
} | null;
interface NutritionGoalsFormProps {
initialGoals: NutritionGoals;
}
export function NutritionGoalsForm({ initialGoals }: NutritionGoalsFormProps) {
const [caloriesKcal, setCaloriesKcal] = useState<string>(
initialGoals?.caloriesKcal != null ? String(initialGoals.caloriesKcal) : ""
);
const [proteinG, setProteinG] = useState<string>(
initialGoals?.proteinG != null ? String(initialGoals.proteinG) : ""
);
const [carbsG, setCarbsG] = useState<string>(
initialGoals?.carbsG != null ? String(initialGoals.carbsG) : ""
);
const [fatG, setFatG] = useState<string>(
initialGoals?.fatG != null ? String(initialGoals.fatG) : ""
);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
const body: Record<string, number> = {};
if (caloriesKcal !== "") body.caloriesKcal = parseInt(caloriesKcal, 10);
if (proteinG !== "") body.proteinG = parseInt(proteinG, 10);
if (carbsG !== "") body.carbsG = parseInt(carbsG, 10);
if (fatG !== "") body.fatG = parseInt(fatG, 10);
try {
const res = await fetch("/api/v1/users/me/nutrition-goals", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
toast.error("Failed to save nutrition goals");
return;
}
toast.success("Nutrition goals saved");
} catch {
toast.error("Failed to save nutrition goals");
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="caloriesKcal">Calories (kcal/day)</Label>
<Input
id="caloriesKcal"
type="number"
min={0}
value={caloriesKcal}
onChange={(e) => setCaloriesKcal(e.target.value)}
placeholder="e.g. 2000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="proteinG">Protein (g/day)</Label>
<Input
id="proteinG"
type="number"
min={0}
value={proteinG}
onChange={(e) => setProteinG(e.target.value)}
placeholder="e.g. 50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="carbsG">Carbs (g/day)</Label>
<Input
id="carbsG"
type="number"
min={0}
value={carbsG}
onChange={(e) => setCarbsG(e.target.value)}
placeholder="e.g. 250"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fatG">Fat (g/day)</Label>
<Input
id="fatG"
type="number"
min={0}
value={fatG}
onChange={(e) => setFatG(e.target.value)}
placeholder="e.g. 70"
/>
</div>
</div>
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save goals"}
</Button>
</form>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
type NutritionTotals = {
calories: number;
protein: number;
carbs: number;
fat: number;
};
type NutritionGoals = {
caloriesKcal: number | null;
proteinG: number | null;
carbsG: number | null;
fatG: number | null;
} | null;
type NutritionCoverage = {
calories: number;
protein: number;
carbs: number;
fat: number;
};
type NutritionResponse = {
totals: NutritionTotals;
goals: NutritionGoals;
coverage: NutritionCoverage;
};
interface WeeklyNutritionBarProps {
weekStart: string;
}
type BarItem = {
label: string;
value: number;
goal: number | null;
unit: string;
percentage: number;
colorClass: string;
};
export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
const [data, setData] = useState<NutritionResponse | null>(null);
useEffect(() => {
fetch(`/api/v1/meal-plans/${weekStart}/nutrition`)
.then((res) => (res.ok ? res.json() : null))
.then((json) => setData(json))
.catch(() => setData(null));
}, [weekStart]);
if (!data || !data.goals) return null;
const { totals, goals, coverage } = data;
const bars: BarItem[] = [
{
label: "Calories",
value: totals.calories,
goal: goals.caloriesKcal,
unit: "kcal",
percentage: coverage.calories,
colorClass: "bg-orange-500",
},
{
label: "Protein",
value: totals.protein,
goal: goals.proteinG,
unit: "g",
percentage: coverage.protein,
colorClass: "bg-blue-500",
},
{
label: "Carbs",
value: totals.carbs,
goal: goals.carbsG,
unit: "g",
percentage: coverage.carbs,
colorClass: "bg-green-500",
},
{
label: "Fat",
value: totals.fat,
goal: goals.fatG,
unit: "g",
percentage: coverage.fat,
colorClass: "bg-yellow-500",
},
];
return (
<div className="space-y-3">
{bars.map((bar) => {
if (bar.goal == null) return null;
const width = Math.min(bar.percentage, 100);
return (
<div key={bar.label} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium">{bar.label}</span>
<span className="text-muted-foreground">
{bar.value} / {bar.goal} {bar.unit}
</span>
</div>
<div className="h-2 w-full rounded bg-muted overflow-hidden">
<div
className={`h-full rounded ${bar.colorClass}`}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,22 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ChefHat } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function PantryPageHeader() {
const t = useTranslations("pantry");
return (
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<Link href="/recipes/can-cook" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChefHat className="h-4 w-4" />
{t("canCook")}
</Link>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ShoppingCart } from "lucide-react";
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
type ShoppingListItem = {
id: string;
name: string;
generatedAt: string | null;
totalItems: number;
checkedItems: number;
};
type Props = {
lists: ShoppingListItem[];
};
export function ShoppingListsPageContent({ lists }: Props) {
const t = useTranslations("shoppingLists");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<NewShoppingListButton />
</div>
{lists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
<ShoppingCart className="h-12 w-12 text-muted-foreground/40" />
<p className="text-muted-foreground text-sm">{t("empty")}</p>
</div>
) : (
<div className="space-y-3 max-w-lg">
{lists.map((list) => (
<Link
key={list.id}
href={`/shopping-lists/${list.id}`}
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
>
<div className="space-y-1">
<h2 className="font-semibold">{list.name}</h2>
<p className="text-sm text-muted-foreground">
{list.totalItems === 0
? t("listEmpty")
: t("items", { checked: list.checkedItems, total: list.totalItems })}
{list.generatedAt && ` · ${t("generated")}`}
</p>
</div>
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold">
{list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
</div>
</Link>
))}
</div>
)}
</div>
);
}