From 78d0599ffc8aaa410f49f771560069f3bc8332db Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 01:23:37 +0200 Subject: [PATCH] feat: add batch-cooking sessions (single "mega recipe" with dish sections) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AI-generated batch-cooking flow: pick N dinners/lunches + servings, get one unified recipe covering a full prep session — merged shopping list, steps tagged per-dish (a step can advance several dishes at once, e.g. a shared oven bake), and per-dish storage (fridge/freezer) + exact day-of reheat/finishing instructions. Schema: recipes.isBatchCook, recipeSteps.appliesTo (dish names), new recipeBatchDishes table. Batch recipes are excluded from the normal /recipes list and live under their own /batch-cooking section. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/batch-cooking/page.tsx | 31 + apps/web/app/(app)/recipes/[id]/page.tsx | 61 +- apps/web/app/(app)/recipes/page.tsx | 1 + .../api/v1/ai/batch-cook/generate/route.ts | 119 + .../components/recipe/batch-cook-dishes.tsx | 58 + .../recipe/batch-cook-generate-dialog.tsx | 156 + .../components/recipe/batch-cook-steps.tsx | 53 + .../recipe/batch-cooking-page-content.tsx | 71 + apps/web/components/recipe/recipes-header.tsx | 6 +- .../lib/ai/features/generate-batch-cook.ts | 92 + apps/web/messages/en.json | 26 + apps/web/messages/fr.json | 26 + .../db/src/migrations/0029_tired_crystal.sql | 17 + .../db/src/migrations/meta/0029_snapshot.json | 4619 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/recipes.ts | 22 + 16 files changed, 5343 insertions(+), 22 deletions(-) create mode 100644 apps/web/app/(app)/batch-cooking/page.tsx create mode 100644 apps/web/app/api/v1/ai/batch-cook/generate/route.ts create mode 100644 apps/web/components/recipe/batch-cook-dishes.tsx create mode 100644 apps/web/components/recipe/batch-cook-generate-dialog.tsx create mode 100644 apps/web/components/recipe/batch-cook-steps.tsx create mode 100644 apps/web/components/recipe/batch-cooking-page-content.tsx create mode 100644 apps/web/lib/ai/features/generate-batch-cook.ts create mode 100644 packages/db/src/migrations/0029_tired_crystal.sql create mode 100644 packages/db/src/migrations/meta/0029_snapshot.json diff --git a/apps/web/app/(app)/batch-cooking/page.tsx b/apps/web/app/(app)/batch-cooking/page.tsx new file mode 100644 index 0000000..614f1bc --- /dev/null +++ b/apps/web/app/(app)/batch-cooking/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, recipes, eq, and, desc } from "@epicure/db"; +import { BatchCookingPageContent } from "@/components/recipe/batch-cooking-page-content"; + +export const metadata: Metadata = {}; + +export default async function BatchCookingPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + + const sessions = await db.query.recipes.findMany({ + where: and(eq(recipes.authorId, session.user.id), eq(recipes.isBatchCook, true)), + orderBy: desc(recipes.createdAt), + with: { batchDishes: true }, + }); + + return ( + ({ + id: r.id, + title: r.title, + description: r.description, + baseServings: r.baseServings, + createdAt: r.createdAt.toISOString(), + dishCount: r.batchDishes.length, + }))} + /> + ); +} diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 414edfa..569966b 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -34,6 +34,8 @@ import { CommentsSection } from "@/components/social/comments-section"; import { getPublicUrl } from "@/lib/storage"; import { cn } from "@/lib/utils"; import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel"; +import { BatchCookSteps } from "@/components/recipe/batch-cook-steps"; +import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes"; import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { recipeToMarkdown } from "@/lib/markdown/recipe"; @@ -71,6 +73,7 @@ export default async function RecipePage({ params }: Params) { steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: { orderBy: (t, { asc }) => asc(t.order) }, author: { columns: { id: true, name: true, username: true, avatarUrl: true } }, + batchDishes: { orderBy: (t, { asc }) => asc(t.order) }, }, }), db.select({ avgScore: avg(ratings.score), total: count() }).from(ratings).where(eq(ratings.recipeId, id)), @@ -109,7 +112,12 @@ export default async function RecipePage({ params }: Params) { {/* Header */}
-

{recipe.title}

+
+

{recipe.title}

+ {recipe.isBatchCook && ( + {m.recipe.batchCookBadge} + )} +
{!isOwner && recipe.author.username && (

{m.recipe.instructions}

-
    - {recipe.steps.map((step, i) => ( -
  1. -
    - {i + 1} -
    -
    -

    {step.instruction}

    - {step.timerSeconds && ( -

    - - {step.timerSeconds >= 60 - ? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}` - : `${step.timerSeconds}s`} -

    - )} -
    -
  2. - ))} -
+ {recipe.isBatchCook ? ( + + ) : ( +
    + {recipe.steps.map((step, i) => ( +
  1. +
    + {i + 1} +
    +
    +

    {step.instruction}

    + {step.timerSeconds && ( +

    + + {step.timerSeconds >= 60 + ? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}` + : `${step.timerSeconds}s`} +

    + )} +
    +
  2. + ))} +
+ )}
)} + {recipe.isBatchCook && recipe.batchDishes.length > 0 && ( + <> + + + + )} + {recipe.photos.length > 1 && ( <> diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index fec4ec2..efce939 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -54,6 +54,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear const where = and( eq(recipes.authorId, session.user.id), + eq(recipes.isBatchCook, false), query ? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`)) : undefined, diff --git a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts new file mode 100644 index 0000000..04750ee --- /dev/null +++ b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, recipes, recipeIngredients, recipeSteps, recipeBatchDishes } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; +import { generateBatchCook } from "@/lib/ai/features/generate-batch-cook"; +import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { parseQuantity } from "@/lib/parse-quantity"; + +const Schema = z.object({ + dinners: z.number().int().min(0).max(6).default(4), + lunches: z.number().int().min(0).max(6).default(0), + servings: z.number().int().min(1).max(12).default(4), + dietaryPrefs: z.string().max(200).optional(), + difficulty: z.enum(["easy", "medium", "hard"]).optional(), +}); + +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", issues: parsed.error.issues }, { status: 400 }); + } + if (parsed.data.dinners + parsed.data.lunches < 1) { + return NextResponse.json({ error: "Choose at least one dinner or lunch" }, { status: 400 }); + } + + const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 3, 60); + if (limited) return limited; + + const userId = session!.user.id; + const locale = (session!.user as { locale?: string }).locale ?? "en"; + const [configResult, privateBio] = await Promise.all([ + resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)), + getUserPrivateBio(userId), + ]); + if (!configResult.ok) return configResult.response; + const config = configResult.data; + + const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => + generateBatchCook( + { + dinners: parsed.data.dinners, + lunches: parsed.data.lunches, + servings: parsed.data.servings, + dietaryPrefs: parsed.data.dietaryPrefs, + difficulty: parsed.data.difficulty, + }, + { ...config, userContext: privateBio ?? undefined }, + locale + ) + ); + if (!result.ok) return result.response; + const plan = result.data; + + const recipeId = crypto.randomUUID(); + + await db.transaction(async (tx) => { + await tx.insert(recipes).values({ + id: recipeId, + authorId: userId, + title: plan.title, + description: plan.description, + baseServings: parsed.data.servings, + visibility: "private", + aiGenerated: true, + isBatchCook: true, + language: locale, + }); + + if (plan.ingredients.length > 0) { + await tx.insert(recipeIngredients).values( + plan.ingredients.map((ing, i) => ({ + id: crypto.randomUUID(), + recipeId, + rawName: ing.rawName, + quantity: parseQuantity(ing.quantity) ?? null, + unit: ing.unit ?? null, + order: i, + })) + ); + } + + if (plan.steps.length > 0) { + await tx.insert(recipeSteps).values( + plan.steps.map((step, i) => ({ + id: crypto.randomUUID(), + recipeId, + instruction: step.instruction, + order: i, + appliesTo: step.appliesTo, + })) + ); + } + + if (plan.dishes.length > 0) { + await tx.insert(recipeBatchDishes).values( + plan.dishes.map((dish, i) => ({ + id: crypto.randomUUID(), + recipeId, + name: dish.name, + description: dish.description, + order: i, + fridgeDays: dish.fridgeDays, + freezerFriendly: dish.freezerFriendly, + freezerNote: dish.freezerNote ?? null, + dayOfInstructions: dish.dayOfInstructions, + })) + ); + } + }); + + return NextResponse.json({ id: recipeId }); +} diff --git a/apps/web/components/recipe/batch-cook-dishes.tsx b/apps/web/components/recipe/batch-cook-dishes.tsx new file mode 100644 index 0000000..5425d81 --- /dev/null +++ b/apps/web/components/recipe/batch-cook-dishes.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Snowflake, Refrigerator, ChefHat } from "lucide-react"; + +type Dish = { + id: string; + name: string; + description: string | null; + fridgeDays: number; + freezerFriendly: boolean; + freezerNote: string | null; + dayOfInstructions: string; +}; + +export function BatchCookDishes({ dishes }: { dishes: Dish[] }) { + const t = useTranslations("recipe"); + + return ( +
+

{t("batchCookDishesTitle")}

+
+ {dishes.map((dish) => ( +
+
+

{dish.name}

+ {dish.description &&

{dish.description}

} +
+ +
+ + + {t("batchCookFridgeDays", { days: dish.fridgeDays })} + + {dish.freezerFriendly && ( + + + {t("batchCookFreezerFriendly")} + + )} +
+ {dish.freezerNote && ( +

{dish.freezerNote}

+ )} + +
+ +
+

{t("batchCookDayOf")}

+

{dish.dayOfInstructions}

+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/recipe/batch-cook-generate-dialog.tsx b/apps/web/components/recipe/batch-cook-generate-dialog.tsx new file mode 100644 index 0000000..9bb4ce6 --- /dev/null +++ b/apps/web/components/recipe/batch-cook-generate-dialog.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { ChefHat, Loader2, Minus, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; + +function Counter({ value, onChange, max = 6 }: { value: number; onChange: (v: number) => void; max?: number }) { + return ( +
+ + {value} + +
+ ); +} + +export function BatchCookGenerateDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const t = useTranslations("batchCooking"); + const tCommon = useTranslations("common"); + const tRecipe = useTranslations("recipe"); + const router = useRouter(); + + const [dinners, setDinners] = useState(4); + const [lunches, setLunches] = useState(0); + const [servings, setServings] = useState(4); + const [dietaryPrefs, setDietaryPrefs] = useState(""); + const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); + const [busy, setBusy] = useState(false); + + function handleClose() { + if (busy) return; + onOpenChange(false); + } + + async function handleGenerate() { + setBusy(true); + try { + const res = await fetch("/api/v1/ai/batch-cook/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dinners, + lunches, + servings, + dietaryPrefs: dietaryPrefs.trim() || undefined, + difficulty: difficulty || undefined, + }), + }); + if (!res.ok) { + const err = await res.json() as { error?: string }; + toast.error(err.error ?? t("generateFailed")); + return; + } + const { id } = await res.json() as { id: string }; + toast.success(t("generateSuccess")); + onOpenChange(false); + router.push(`/recipes/${id}`); + } finally { + setBusy(false); + } + } + + const canSubmit = dinners + lunches > 0; + + return ( + + + + + + {t("wizardTitle")} + + {t("wizardDescription")} + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setDietaryPrefs(e.target.value)} + placeholder={t("dietaryPrefsPlaceholder")} + disabled={busy} + /> +
+
+ + + +
+ + +
+
+
+ ); +} diff --git a/apps/web/components/recipe/batch-cook-steps.tsx b/apps/web/components/recipe/batch-cook-steps.tsx new file mode 100644 index 0000000..8d8c14e --- /dev/null +++ b/apps/web/components/recipe/batch-cook-steps.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +type Step = { + id: string; + instruction: string; + appliesTo: string[]; +}; + +function sectionLabel(appliesTo: string[], prepLabel: string): string { + return appliesTo.length === 0 ? prepLabel : appliesTo.join(" + "); +} + +export function BatchCookSteps({ steps }: { steps: Step[] }) { + const t = useTranslations("recipe"); + const prepLabel = t("batchCookPrep"); + + const groups: Array<{ label: string; steps: Step[] }> = []; + for (const step of steps) { + const label = sectionLabel(step.appliesTo, prepLabel); + const last = groups[groups.length - 1]; + if (last && last.label === label) last.steps.push(step); + else groups.push({ label, steps: [step] }); + } + + let stepNumber = 0; + + return ( +
+ {groups.map((group, gi) => ( +
+

+ {group.label} +

+
    + {group.steps.map((step) => { + stepNumber += 1; + return ( +
  1. +
    + {stepNumber} +
    +

    {step.instruction}

    +
  2. + ); + })} +
+
+ ))} +
+ ); +} diff --git a/apps/web/components/recipe/batch-cooking-page-content.tsx b/apps/web/components/recipe/batch-cooking-page-content.tsx new file mode 100644 index 0000000..851ab79 --- /dev/null +++ b/apps/web/components/recipe/batch-cooking-page-content.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useTranslations, useLocale } from "next-intl"; +import { ChefHat, PlusCircle, Utensils } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { BatchCookGenerateDialog } from "@/components/recipe/batch-cook-generate-dialog"; + +type Session = { + id: string; + title: string; + description: string | null; + baseServings: number; + createdAt: string; + dishCount: number; +}; + +export function BatchCookingPageContent({ sessions }: { sessions: Session[] }) { + const t = useTranslations("batchCooking"); + const locale = useLocale(); + const [open, setOpen] = useState(false); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

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

{t("empty")}

+ +
+ ) : ( +
+ {sessions.map((s) => ( + +

{s.title}

+ {s.description &&

{s.description}

} +
+ + + {t("dishCount", { count: s.dishCount })} + + {new Date(s.createdAt).toLocaleDateString(locale)} +
+ + ))} +
+ )} + + +
+ ); +} diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index 7855c14..84314fc 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react"; import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; -import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown } from "lucide-react"; +import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown, ChefHat } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -122,6 +122,10 @@ export function RecipesHeader({

+ + + {t("batchCooking")} +