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
+43
View File
@@ -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 });
}
+55
View File
@@ -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 });
}
+44
View File
@@ -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 });
}