From 3636ab27aefdeecf71b845a6ce3aa655b0b14302 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 08:10:39 +0200 Subject: [PATCH] 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. --- apps/web/app/(app)/meal-plan/page.tsx | 102 +++++ apps/web/app/(app)/pantry/page.tsx | 31 ++ .../app/(app)/shopping-lists/[id]/page.tsx | 45 ++ apps/web/app/(app)/shopping-lists/page.tsx | 30 ++ .../[weekStart]/entries/[entryId]/route.ts | 22 + .../meal-plans/[weekStart]/entries/route.ts | 61 +++ .../meal-plans/[weekStart]/nutrition/route.ts | 85 ++++ .../api/v1/meal-plans/[weekStart]/route.ts | 43 ++ apps/web/app/api/v1/pantry/[id]/route.ts | 43 ++ apps/web/app/api/v1/pantry/bulk/route.ts | 55 +++ apps/web/app/api/v1/pantry/route.ts | 44 ++ .../[id]/items/[itemId]/route.ts | 23 + .../api/v1/shopping-lists/[id]/items/route.ts | 44 ++ .../app/api/v1/shopping-lists/[id]/route.ts | 54 +++ apps/web/app/api/v1/shopping-lists/route.ts | 91 ++++ .../web/components/meal-plan/meal-planner.tsx | 398 ++++++++++++++++++ .../meal-plan/new-shopping-list-button.tsx | 69 +++ .../components/meal-plan/pantry-manager.tsx | 111 +++++ .../meal-plan/shopping-list-view.tsx | 119 ++++++ .../nutrition/nutrition-goals-form.tsx | 118 ++++++ .../nutrition/weekly-nutrition-bar.tsx | 118 ++++++ .../components/pantry/pantry-page-header.tsx | 22 + .../shopping-lists-page-content.tsx | 63 +++ 23 files changed, 1791 insertions(+) create mode 100644 apps/web/app/(app)/meal-plan/page.tsx create mode 100644 apps/web/app/(app)/pantry/page.tsx create mode 100644 apps/web/app/(app)/shopping-lists/[id]/page.tsx create mode 100644 apps/web/app/(app)/shopping-lists/page.tsx create mode 100644 apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts create mode 100644 apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts create mode 100644 apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts create mode 100644 apps/web/app/api/v1/meal-plans/[weekStart]/route.ts create mode 100644 apps/web/app/api/v1/pantry/[id]/route.ts create mode 100644 apps/web/app/api/v1/pantry/bulk/route.ts create mode 100644 apps/web/app/api/v1/pantry/route.ts create mode 100644 apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts create mode 100644 apps/web/app/api/v1/shopping-lists/[id]/items/route.ts create mode 100644 apps/web/app/api/v1/shopping-lists/[id]/route.ts create mode 100644 apps/web/app/api/v1/shopping-lists/route.ts create mode 100644 apps/web/components/meal-plan/meal-planner.tsx create mode 100644 apps/web/components/meal-plan/new-shopping-list-button.tsx create mode 100644 apps/web/components/meal-plan/pantry-manager.tsx create mode 100644 apps/web/components/meal-plan/shopping-list-view.tsx create mode 100644 apps/web/components/nutrition/nutrition-goals-form.tsx create mode 100644 apps/web/components/nutrition/weekly-nutrition-bar.tsx create mode 100644 apps/web/components/pantry/pantry-page-header.tsx create mode 100644 apps/web/components/shopping-lists/shopping-lists-page-content.tsx diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx new file mode 100644 index 0000000..ddb4352 --- /dev/null +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -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 ( +
+
+
+

Meal Plan

+

{label}

+
+
+ + + Shopping lists + + + + + + + +
+
+ + + +
+ ); +} diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx new file mode 100644 index 0000000..2c1cbc0 --- /dev/null +++ b/apps/web/app/(app)/pantry/page.tsx @@ -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 ( +
+ + ({ + id: i.id, + rawName: i.rawName, + quantity: i.quantity, + unit: i.unit, + expiresAt: i.expiresAt?.toISOString() ?? null, + }))} /> +
+ ); +} diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx new file mode 100644 index 0000000..541b879 --- /dev/null +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -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 ( +
+
+

{list.name}

+

+ {list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""} +

+
+ ({ + id: i.id, + rawName: i.rawName, + quantity: i.quantity, + unit: i.unit, + aisle: i.aisle, + checked: i.checked, + }))} + /> +
+ ); +} diff --git a/apps/web/app/(app)/shopping-lists/page.tsx b/apps/web/app/(app)/shopping-lists/page.tsx new file mode 100644 index 0000000..57e13f2 --- /dev/null +++ b/apps/web/app/(app)/shopping-lists/page.tsx @@ -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 ( + ({ + 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, + }))} + /> + ); +} diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts new file mode 100644 index 0000000..595024a --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ weekStart: string; entryId: string }> }; + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart, entryId } = await params; + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + }); + if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + await db.delete(mealPlanEntries).where( + and(eq(mealPlanEntries.id, entryId), eq(mealPlanEntries.mealPlanId, plan.id)) + ); + + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts new file mode 100644 index 0000000..01fd683 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { dispatchWebhook } from "@/lib/webhooks"; + +type Params = { params: Promise<{ weekStart: string }> }; + +const Schema = z.object({ + day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), + mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]), + recipeId: z.string().optional(), + servings: z.number().int().min(1).max(100).default(2), + note: z.string().max(500).optional(), +}); + +async function getOrCreatePlan(userId: string, weekStart: string) { + const existing = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)), + }); + if (existing) return existing; + + const id = crypto.randomUUID(); + await db.insert(mealPlans).values({ id, userId, weekStart }); + return { id, userId, weekStart }; +} + +export async function POST(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const plan = await getOrCreatePlan(session!.user.id, weekStart); + + // Remove existing entry for same day+mealType before inserting + await db.delete(mealPlanEntries).where( + and( + eq(mealPlanEntries.mealPlanId, plan.id), + eq(mealPlanEntries.day, parsed.data.day), + eq(mealPlanEntries.mealType, parsed.data.mealType) + ) + ); + + const entryId = crypto.randomUUID(); + await db.insert(mealPlanEntries).values({ + id: entryId, + mealPlanId: plan.id, + day: parsed.data.day, + mealType: parsed.data.mealType, + recipeId: parsed.data.recipeId, + servings: parsed.data.servings, + note: parsed.data.note, + }); + + void dispatchWebhook(session!.user.id, "meal_plan.updated", { weekStart, day: parsed.data.day, mealType: parsed.data.mealType }); + return NextResponse.json({ id: entryId }, { status: 201 }); +} diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts new file mode 100644 index 0000000..0aadb78 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, mealPlans, mealPlanEntries, recipes, userNutritionGoals, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ weekStart: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { weekStart } = await params; + const userId = session!.user.id; + + // Find the meal plan for this week + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)), + }); + + const totals = { calories: 0, protein: 0, carbs: 0, fat: 0 }; + + if (plan) { + // Fetch all entries with their recipes + const entries = await db.query.mealPlanEntries.findMany({ + where: eq(mealPlanEntries.mealPlanId, plan.id), + with: { + recipe: { + columns: { + id: true, + baseServings: true, + nutritionData: true, + }, + }, + }, + }); + + for (const entry of entries) { + const recipe = entry.recipe; + if (!recipe || !recipe.nutritionData?.perServing) continue; + + const { calories, proteinG, carbsG, fatG } = recipe.nutritionData.perServing; + const servings = entry.servings ?? recipe.baseServings; + + totals.calories += Math.round(calories * servings); + totals.protein += Math.round(proteinG * servings); + totals.carbs += Math.round(carbsG * servings); + totals.fat += Math.round(fatG * servings); + } + } + + // Fetch user's nutrition goals + const goals = await db.query.userNutritionGoals.findFirst({ + where: eq(userNutritionGoals.userId, userId), + }); + + const goalsData = goals + ? { + caloriesKcal: goals.caloriesKcal, + proteinG: goals.proteinG, + carbsG: goals.carbsG, + fatG: goals.fatG, + } + : null; + + // Calculate coverage percentages + const coverage = { + calories: + goalsData?.caloriesKcal + ? Math.round((totals.calories / goalsData.caloriesKcal) * 100) + : 0, + protein: + goalsData?.proteinG + ? Math.round((totals.protein / goalsData.proteinG) * 100) + : 0, + carbs: + goalsData?.carbsG + ? Math.round((totals.carbs / goalsData.carbsG) * 100) + : 0, + fat: + goalsData?.fatG + ? Math.round((totals.fat / goalsData.fatG) * 100) + : 0, + }; + + return NextResponse.json({ totals, goals: goalsData, coverage }); +} diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/route.ts new file mode 100644 index 0000000..c32e630 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, mealPlans, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ weekStart: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + with: { + entries: { + with: { + recipe: { + with: { photos: true }, + }, + }, + }, + }, + }); + + if (!plan) return NextResponse.json({ weekStart, entries: [] }); + return NextResponse.json(plan); +} + +export async function POST(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const existing = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + }); + + if (existing) return NextResponse.json(existing); + + const id = crypto.randomUUID(); + await db.insert(mealPlans).values({ id, userId: session!.user.id, weekStart }); + return NextResponse.json({ id, weekStart }, { status: 201 }); +} diff --git a/apps/web/app/api/v1/pantry/[id]/route.ts b/apps/web/app/api/v1/pantry/[id]/route.ts new file mode 100644 index 0000000..d31798d --- /dev/null +++ b/apps/web/app/api/v1/pantry/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, pantryItems, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PUT(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const item = await db.query.pantryItems.findFirst({ where: and(eq(pantryItems.id, id), eq(pantryItems.userId, session!.user.id)) }); + if (!item) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json() as unknown; + const parsed = z.object({ + rawName: z.string().min(1).max(200).optional(), + quantity: z.string().nullable().optional(), + unit: z.string().nullable().optional(), + expiresAt: z.string().datetime().nullable().optional(), + }).safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const data = parsed.data; + await db.update(pantryItems).set({ + ...(data.rawName && { rawName: data.rawName }), + ...(data.quantity !== undefined && { quantity: data.quantity ?? undefined }), + ...(data.unit !== undefined && { unit: data.unit ?? undefined }), + ...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined }), + }).where(eq(pantryItems.id, id)); + + return NextResponse.json({ updated: true }); +} + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + await db.delete(pantryItems).where(and(eq(pantryItems.id, id), eq(pantryItems.userId, session!.user.id))); + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/pantry/bulk/route.ts b/apps/web/app/api/v1/pantry/bulk/route.ts new file mode 100644 index 0000000..f495f9f --- /dev/null +++ b/apps/web/app/api/v1/pantry/bulk/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, pantryItems, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const Schema = z.object({ + items: z.array(z.object({ + rawName: z.string().min(1).max(200), + quantity: z.string().optional(), + unit: z.string().optional(), + })).min(1), +}); + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const userId = session!.user.id; + const existing = await db.query.pantryItems.findMany({ + where: eq(pantryItems.userId, userId), + }); + + for (const incoming of parsed.data.items) { + const key = incoming.rawName.toLowerCase(); + const match = existing.find( + (e) => e.rawName.toLowerCase() === key && (e.unit ?? "") === (incoming.unit ?? "") + ); + + if (match) { + const existingQty = match.quantity ? parseFloat(match.quantity) : null; + const incomingQty = incoming.quantity ? parseFloat(incoming.quantity) : null; + if (existingQty !== null && incomingQty !== null && !isNaN(existingQty) && !isNaN(incomingQty)) { + const merged = existingQty + incomingQty; + await db.update(pantryItems) + .set({ quantity: String(merged) }) + .where(eq(pantryItems.id, match.id)); + } + // if quantities aren't numeric, leave as-is (item already exists) + } else { + await db.insert(pantryItems).values({ + id: crypto.randomUUID(), + userId, + rawName: incoming.rawName, + quantity: incoming.quantity, + unit: incoming.unit, + }); + } + } + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/pantry/route.ts b/apps/web/app/api/v1/pantry/route.ts new file mode 100644 index 0000000..ecb94ca --- /dev/null +++ b/apps/web/app/api/v1/pantry/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, pantryItems, eq, desc } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const Schema = z.object({ + rawName: z.string().min(1).max(200), + quantity: z.string().optional(), + unit: z.string().optional(), + expiresAt: z.string().datetime().optional(), +}); + +export async function GET(_req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const items = await db.query.pantryItems.findMany({ + where: eq(pantryItems.userId, session!.user.id), + orderBy: desc(pantryItems.createdAt), + }); + + return NextResponse.json(items); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const id = crypto.randomUUID(); + await db.insert(pantryItems).values({ + id, + userId: session!.user.id, + rawName: parsed.data.rawName, + quantity: parsed.data.quantity, + unit: parsed.data.unit, + expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : undefined, + }); + + return NextResponse.json({ id }, { status: 201 }); +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts new file mode 100644 index 0000000..95157a9 --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string; itemId: string }> }; + +export async function PUT(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id, itemId } = await params; + + const list = await db.query.shoppingLists.findFirst({ + where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json() as { checked?: boolean }; + await db.update(shoppingListItems) + .set({ checked: body.checked ?? false }) + .where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id))); + + return NextResponse.json({ updated: true }); +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts new file mode 100644 index 0000000..80e0119 --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const AddItemsSchema = z.object({ + items: z.array(z.object({ + rawName: z.string().min(1), + quantity: z.string().optional(), + unit: z.string().optional(), + aisle: z.string().optional(), + })).min(1), +}); + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const list = await db.query.shoppingLists.findFirst({ + where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json() as unknown; + const parsed = AddItemsSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + await db.insert(shoppingListItems).values( + parsed.data.items.map((item) => ({ + id: crypto.randomUUID(), + listId: id, + rawName: item.rawName, + quantity: item.quantity, + unit: item.unit, + aisle: item.aisle, + checked: false, + })) + ); + + return NextResponse.json({ ok: true }, { status: 201 }); +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/route.ts new file mode 100644 index 0000000..e643dbf --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { dispatchWebhook } from "@/lib/webhooks"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + 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) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(list); +} + +const PatchSchema = z.object({ completed: z.boolean() }); + +export async function PATCH(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const list = await db.query.shoppingLists.findFirst({ + where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = PatchSchema.safeParse(await req.json()); + if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + if (body.data.completed) { + // Mark all items as checked + await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id)); + void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: list.name }); + } + + return NextResponse.json({ updated: true }); +} + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + await db.delete(shoppingLists).where(and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id))); + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/shopping-lists/route.ts b/apps/web/app/api/v1/shopping-lists/route.ts new file mode 100644 index 0000000..260a216 --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, eq, and, desc, inArray } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const CreateSchema = z.object({ + name: z.string().min(1).max(100), + items: z.array(z.object({ + rawName: z.string().min(1), + quantity: z.string().optional(), + unit: z.string().optional(), + aisle: z.string().optional(), + })).optional(), + fromMealPlanWeek: z.string().optional(), +}); + +export async function GET(_req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const lists = await db.query.shoppingLists.findMany({ + where: eq(shoppingLists.userId, session!.user.id), + orderBy: desc(shoppingLists.createdAt), + with: { items: { orderBy: (t, { asc }) => asc(t.aisle) } }, + }); + + return NextResponse.json(lists); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const data = parsed.data; + const listId = crypto.randomUUID(); + + let items: Array<{ rawName: string; quantity?: string; unit?: string; aisle?: string }> = data.items ?? []; + + if (data.fromMealPlanWeek) { + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, data.fromMealPlanWeek)), + with: { entries: true }, + }); + + if (plan) { + const recipeIds = plan.entries.map((e) => e.recipeId).filter(Boolean) as string[]; + if (recipeIds.length > 0) { + const ings = await db.query.recipeIngredients.findMany({ + where: inArray(recipeIngredients.recipeId, recipeIds), + }); + + // Simple merge by rawName (case-insensitive) + const merged = new Map(); + for (const ing of ings) { + const key = ing.rawName.toLowerCase(); + if (!merged.has(key)) { + merged.set(key, { rawName: ing.rawName, quantity: ing.quantity ?? undefined, unit: ing.unit ?? undefined }); + } + } + items = Array.from(merged.values()); + } + } + } + + await db.insert(shoppingLists).values({ + id: listId, + userId: session!.user.id, + name: data.name, + generatedAt: data.fromMealPlanWeek ? new Date() : undefined, + }); + + if (items.length > 0) { + await db.insert(shoppingListItems).values( + items.map((item) => ({ + id: crypto.randomUUID(), + listId, + rawName: item.rawName, + quantity: item.quantity, + unit: item.unit, + aisle: item.aisle, + checked: false, + })) + ); + } + + return NextResponse.json({ id: listId }, { status: 201 }); +} diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx new file mode 100644 index 0000000..355000b --- /dev/null +++ b/apps/web/components/meal-plan/meal-planner.tsx @@ -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 ( + onClose()}> + + + {t("addTo", { day: dayLabel, meal: mealLabel })} + +
+ setSearch(e.target.value)} + autoFocus + /> + setServings(e.target.value)} + placeholder={t("servings")} + /> +
+ {filtered.length === 0 ? ( +

{t("noRecipes")}

+ ) : ( + filtered.map((recipe) => ( + + )) + )} +
+
+
+
+ ); +} + +export function MealPlanner({ + weekStart, + initialEntries, + userRecipes, +}: { + weekStart: string; + initialEntries: Entry[]; + userRecipes: UserRecipe[]; +}) { + const [entries, setEntries] = useState(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 */} +
+ +
+ +
+ + + + {DAYS.map(({ key }) => ( + + ))} + + + + + ))} + + + + {MEAL_TYPES.map(({ key: mealType, label }) => ( + + + {DAYS.map(({ key: day }) => { + const entry = getEntry(day, mealType); + return ( + + ); + })} + + ))} + +
+ {DAYS.map(({ key, label }) => ( + {label}
+ {label} + + {entry?.recipe ? ( +
+
{entry.recipe.title}
+
{entry.servings} srv
+ +
+ ) : ( + + )} +
+
+ + {adding && addingDay && addingMeal && ( + setAdding(null)} + dayLabel={addingDay.label} + mealLabel={addingMeal.label} + /> + )} + + {/* AI generation modal */} + {showAiModal && ( + { if (!aiGenerating) setShowAiModal(open); }}> + + + + + {t("generateWithAi")} + + {t("aiModalDescription")} + +
+
+ + setDietaryPrefs(e.target.value)} + disabled={aiGenerating} + /> +
+
+ + setAiServings(e.target.value)} + disabled={aiGenerating} + /> +
+
+ + {usePantry && ( + + )} +
+ +
+ + +
+
+
+
+ )} + + ); +} diff --git a/apps/web/components/meal-plan/new-shopping-list-button.tsx b/apps/web/components/meal-plan/new-shopping-list-button.tsx new file mode 100644 index 0000000..163b726 --- /dev/null +++ b/apps/web/components/meal-plan/new-shopping-list-button.tsx @@ -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 ( + <> + + + + New shopping list +
+
+ + setName(e.target.value)} placeholder="Weekly groceries" /> +
+
+ + setWeekStart(e.target.value)} /> +

Picks Monday of the selected week

+
+
+ + +
+
+
+
+ + ); +} diff --git a/apps/web/components/meal-plan/pantry-manager.tsx b/apps/web/components/meal-plan/pantry-manager.tsx new file mode 100644 index 0000000..e74492a --- /dev/null +++ b/apps/web/components/meal-plan/pantry-manager.tsx @@ -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(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 ( +
+ {/* Add form */} +
+ setName(e.target.value)} placeholder="Item name" className="flex-1 min-w-[160px]" onKeyDown={(e) => e.key === "Enter" && add()} /> + setQuantity(e.target.value)} placeholder="Qty" className="w-20" /> + setUnit(e.target.value)} placeholder="Unit" className="w-20" /> + setExpiresAt(e.target.value)} className="w-36" /> + +
+ + {/* Item list */} + {items.length === 0 ? ( +

No pantry items yet.

+ ) : ( +
+ {sorted.map((item) => { + const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; + const expiring = days !== null && days <= 3; + const expired = days !== null && days < 0; + return ( +
+
+
+ {item.rawName} + {item.quantity && {item.quantity}{item.unit ? ` ${item.unit}` : ""}} + {expired && Expired} + {expiring && !expired && Expires in {days}d} +
+ {item.expiresAt && !expired && !expiring && ( +

Expires {new Date(item.expiresAt).toLocaleDateString()}

+ )} +
+ +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx new file mode 100644 index 0000000..2054317 --- /dev/null +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -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(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>((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

This list is empty.

; + } + + return ( +
+
+

{checkedCount}/{items.length} checked

+ {checkedCount > 0 && ( + + )} +
+ + {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => ( +
+ {Object.keys(grouped).length > 1 && ( +

{aisle}

+ )} +
+ {aisleItems.map((item) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/apps/web/components/nutrition/nutrition-goals-form.tsx b/apps/web/components/nutrition/nutrition-goals-form.tsx new file mode 100644 index 0000000..ff31e14 --- /dev/null +++ b/apps/web/components/nutrition/nutrition-goals-form.tsx @@ -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( + initialGoals?.caloriesKcal != null ? String(initialGoals.caloriesKcal) : "" + ); + const [proteinG, setProteinG] = useState( + initialGoals?.proteinG != null ? String(initialGoals.proteinG) : "" + ); + const [carbsG, setCarbsG] = useState( + initialGoals?.carbsG != null ? String(initialGoals.carbsG) : "" + ); + const [fatG, setFatG] = useState( + initialGoals?.fatG != null ? String(initialGoals.fatG) : "" + ); + const [saving, setSaving] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + + const body: Record = {}; + 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 ( +
+
+
+ + setCaloriesKcal(e.target.value)} + placeholder="e.g. 2000" + /> +
+
+ + setProteinG(e.target.value)} + placeholder="e.g. 50" + /> +
+
+ + setCarbsG(e.target.value)} + placeholder="e.g. 250" + /> +
+
+ + setFatG(e.target.value)} + placeholder="e.g. 70" + /> +
+
+ +
+ ); +} diff --git a/apps/web/components/nutrition/weekly-nutrition-bar.tsx b/apps/web/components/nutrition/weekly-nutrition-bar.tsx new file mode 100644 index 0000000..0353061 --- /dev/null +++ b/apps/web/components/nutrition/weekly-nutrition-bar.tsx @@ -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(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 ( +
+ {bars.map((bar) => { + if (bar.goal == null) return null; + const width = Math.min(bar.percentage, 100); + return ( +
+
+ {bar.label} + + {bar.value} / {bar.goal} {bar.unit} + +
+
+
+
+
+ ); + })} +
+ ); +} diff --git a/apps/web/components/pantry/pantry-page-header.tsx b/apps/web/components/pantry/pantry-page-header.tsx new file mode 100644 index 0000000..d192249 --- /dev/null +++ b/apps/web/components/pantry/pantry-page-header.tsx @@ -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 ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + + {t("canCook")} + +
+ ); +} diff --git a/apps/web/components/shopping-lists/shopping-lists-page-content.tsx b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx new file mode 100644 index 0000000..04d42af --- /dev/null +++ b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx @@ -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 ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ + {lists.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
+ {lists.map((list) => ( + +
+

{list.name}

+

+ {list.totalItems === 0 + ? t("listEmpty") + : t("items", { checked: list.checkedItems, total: list.totalItems })} + {list.generatedAt && ` · ${t("generated")}`} +

+
+
+ {list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`} +
+ + ))} +
+ )} +
+ ); +}