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
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, recipes, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Meal Plan" };
function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date();
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addWeeks(d: Date, n: number): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + n * 7);
return copy;
}
export default async function MealPlanPage({
searchParams,
}: {
searchParams: Promise<{ week?: string }>;
}) {
const { week } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const monday = getMonday(week);
const weekStart = toDateStr(monday);
const prevWeek = toDateStr(addWeeks(monday, -1));
const nextWeek = toDateStr(addWeeks(monday, 1));
const sunday = addWeeks(monday, 1);
sunday.setDate(sunday.getDate() - 1);
const [plan, userRecipes] = await Promise.all([
db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: {
entries: {
with: { recipe: { with: { photos: true } } },
},
},
}),
db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id),
orderBy: desc(recipes.updatedAt),
columns: { id: true, title: true },
}),
]);
const entries = (plan?.entries ?? []).map((e) => ({
id: e.id,
day: e.day,
mealType: e.mealType,
servings: e.servings,
note: e.note,
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
}));
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Meal Plan</h1>
<p className="text-muted-foreground mt-1">{label}</p>
</div>
<div className="flex items-center gap-2">
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ShoppingCart className="h-4 w-4" />
Shopping lists
</Link>
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronLeft className="h-4 w-4" />
</Link>
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</div>
<WeeklyNutritionBar weekStart={weekStart} />
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, pantryItems, eq, asc } from "@epicure/db";
import { PantryManager } from "@/components/meal-plan/pantry-manager";
import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
export const metadata: Metadata = { title: "Pantry" };
export default async function PantryPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const items = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id),
orderBy: asc(pantryItems.rawName),
});
return (
<div className="space-y-6">
<PantryPageHeader />
<PantryManager initialItems={items.map((i) => ({
id: i.id,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
expiresAt: i.expiresAt?.toISOString() ?? null,
}))} />
</div>
);
}
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
type Params = { params: Promise<{ id: string }> };
export const metadata: Metadata = { title: "Shopping List" };
export default async function ShoppingListPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)),
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
});
if (!list) notFound();
return (
<div className="space-y-6 max-w-lg">
<div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
<p className="text-muted-foreground mt-1">
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
</p>
</div>
<ShoppingListView
listId={id}
initialItems={list.items.map((i) => ({
id: i.id,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
aisle: i.aisle,
checked: i.checked,
}))}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, desc } from "@epicure/db";
import { ShoppingListsPageContent } from "@/components/shopping-lists/shopping-lists-page-content";
export const metadata: Metadata = { title: "Shopping Lists" };
export default async function ShoppingListsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const lists = await db.query.shoppingLists.findMany({
where: eq(shoppingLists.userId, session.user.id),
orderBy: desc(shoppingLists.createdAt),
with: { items: { columns: { id: true, checked: true } } },
});
return (
<ShoppingListsPageContent
lists={lists.map((list) => ({
id: list.id,
name: list.name,
generatedAt: list.generatedAt ? list.generatedAt.toISOString() : null,
totalItems: list.items.length,
checkedItems: list.items.filter((i) => i.checked).length,
}))}
/>
);
}