feat: add batch-cooking sessions (single "mega recipe" with dish sections)

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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-12 01:23:37 +02:00
parent 68f0f490b9
commit 78d0599ffc
16 changed files with 5343 additions and 22 deletions
+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, 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 (
<BatchCookingPageContent
sessions={sessions.map((r) => ({
id: r.id,
title: r.title,
description: r.description,
baseServings: r.baseServings,
createdAt: r.createdAt.toISOString(),
dishCount: r.batchDishes.length,
}))}
/>
);
}
+40 -21
View File
@@ -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) {
<KeepScreenAwake />
{/* Header */}
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
{recipe.isBatchCook && (
<Badge variant="secondary" className="shrink-0">{m.recipe.batchCookBadge}</Badge>
)}
</div>
{!isOwner && recipe.author.username && (
<Link
href={`/u/${recipe.author.username}`}
@@ -366,30 +374,41 @@ export default async function RecipePage({ params }: Params) {
<Separator />
<div className="space-y-6">
<h2 className="text-xl font-semibold">{m.recipe.instructions}</h2>
<ol className="space-y-6">
{recipe.steps.map((step, i) => (
<li key={step.id} className="flex gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
{i + 1}
</div>
<div className="flex-1 space-y-1 pt-1">
<p className="leading-relaxed">{step.instruction}</p>
{step.timerSeconds && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{step.timerSeconds >= 60
? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}`
: `${step.timerSeconds}s`}
</p>
)}
</div>
</li>
))}
</ol>
{recipe.isBatchCook ? (
<BatchCookSteps steps={recipe.steps} />
) : (
<ol className="space-y-6">
{recipe.steps.map((step, i) => (
<li key={step.id} className="flex gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
{i + 1}
</div>
<div className="flex-1 space-y-1 pt-1">
<p className="leading-relaxed">{step.instruction}</p>
{step.timerSeconds && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{step.timerSeconds >= 60
? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}`
: `${step.timerSeconds}s`}
</p>
)}
</div>
</li>
))}
</ol>
)}
</div>
</>
)}
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
<>
<Separator />
<BatchCookDishes dishes={recipe.batchDishes} />
</>
)}
{recipe.photos.length > 1 && (
<>
<Separator />
+1
View File
@@ -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,
@@ -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 });
}
@@ -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 (
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t("batchCookDishesTitle")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{dishes.map((dish) => (
<div key={dish.id} className="rounded-xl border p-4 space-y-3">
<div>
<h3 className="font-semibold">{dish.name}</h3>
{dish.description && <p className="text-sm text-muted-foreground mt-0.5">{dish.description}</p>}
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1">
<Refrigerator className="h-3.5 w-3.5" />
{t("batchCookFridgeDays", { days: dish.fridgeDays })}
</span>
{dish.freezerFriendly && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2.5 py-1">
<Snowflake className="h-3.5 w-3.5" />
{t("batchCookFreezerFriendly")}
</span>
)}
</div>
{dish.freezerNote && (
<p className="text-xs text-muted-foreground">{dish.freezerNote}</p>
)}
<div className="flex gap-2 rounded-lg bg-muted/50 p-3">
<ChefHat className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">{t("batchCookDayOf")}</p>
<p className="text-sm text-muted-foreground mt-0.5">{dish.dayOfInstructions}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -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 (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="icon" onClick={() => onChange(Math.max(0, value - 1))}>
<Minus className="h-4 w-4" />
</Button>
<span className="w-8 text-center font-medium tabular-nums">{value}</span>
<Button type="button" variant="outline" size="icon" onClick={() => onChange(Math.min(max, value + 1))}>
<Plus className="h-4 w-4" />
</Button>
</div>
);
}
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ChefHat className="h-5 w-5 text-primary" />
{t("wizardTitle")}
</DialogTitle>
<DialogDescription>{t("wizardDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>{t("dinners")}</Label>
<Counter value={dinners} onChange={setDinners} />
</div>
<div className="flex items-center justify-between">
<Label>{t("lunches")}</Label>
<Counter value={lunches} onChange={setLunches} />
</div>
<div className="flex items-center justify-between">
<Label>{t("servingsPerMeal")}</Label>
<Counter value={servings} onChange={setServings} max={12} />
</div>
<div className="space-y-2">
<Label>{tCommon("difficulty")}</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
<SelectTrigger>
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="batch-dietary">{t("dietaryPrefs")}</Label>
<Input
id="batch-dietary"
value={dietaryPrefs}
onChange={(e) => setDietaryPrefs(e.target.value)}
placeholder={t("dietaryPrefsPlaceholder")}
disabled={busy}
/>
</div>
</div>
<FakeProgressBar active={busy} durationMs={20000} label={busy ? t("generating") : undefined} />
<div className="flex gap-2 justify-end pt-1">
<Button variant="ghost" onClick={handleClose} disabled={busy}>
{tCommon("cancel")}
</Button>
<Button onClick={() => { void handleGenerate(); }} disabled={!canSubmit || busy}>
{busy ? (
<><Loader2 className="h-4 w-4 animate-spin" />{t("generating")}</>
) : (
<><ChefHat className="h-4 w-4" />{t("generate")}</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="space-y-8">
{groups.map((group, gi) => (
<div key={gi} className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<ol className="space-y-6">
{group.steps.map((step) => {
stepNumber += 1;
return (
<li key={step.id} className="flex gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
{stepNumber}
</div>
<p className="flex-1 leading-relaxed pt-1">{step.instruction}</p>
</li>
);
})}
</ol>
</div>
))}
</div>
);
}
@@ -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 (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm: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>
<Button size="sm" onClick={() => setOpen(true)}>
<PlusCircle className="h-4 w-4" />
{t("newSession")}
</Button>
</div>
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
<ChefHat className="h-12 w-12 text-muted-foreground/40" />
<p className="text-muted-foreground text-sm">{t("empty")}</p>
<Button size="sm" onClick={() => setOpen(true)}>
<PlusCircle className="h-4 w-4" />
{t("newSession")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{sessions.map((s) => (
<Link
key={s.id}
href={`/recipes/${s.id}`}
className="group rounded-xl border p-4 hover:shadow-sm transition-shadow space-y-2"
>
<h2 className="font-semibold group-hover:text-primary transition-colors line-clamp-1">{s.title}</h2>
{s.description && <p className="text-sm text-muted-foreground line-clamp-2">{s.description}</p>}
<div className="flex items-center gap-3 text-xs text-muted-foreground pt-1">
<span className="flex items-center gap-1">
<Utensils className="h-3.5 w-3.5" />
{t("dishCount", { count: s.dishCount })}
</span>
<span>{new Date(s.createdAt).toLocaleDateString(locale)}</span>
</div>
</Link>
))}
</div>
)}
<BatchCookGenerateDialog open={open} onOpenChange={setOpen} />
</div>
);
}
@@ -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({
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/batch-cooking" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
<ChefHat className="h-4 w-4" />
{t("batchCooking")}
</Link>
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
@@ -0,0 +1,92 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
const BatchCookSchema = z.object({
title: z.string().max(150),
description: z.string().max(300),
dishes: z.array(z.object({
name: z.string().max(100),
description: z.string().max(200),
fridgeDays: z.number().int().min(1).max(7),
freezerFriendly: z.boolean(),
freezerNote: z.string().max(150).optional(),
dayOfInstructions: z.string().max(300),
})).min(1).max(6),
ingredients: z.array(z.object({
rawName: z.string().describe("Ingredient name only — never include the quantity or unit here."),
quantity: z.number().optional().describe("A number only — never combined with the unit."),
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'g' — never combined with the quantity or name."),
})).max(50),
steps: z.array(z.object({
appliesTo: z.array(z.string()).describe("Which dish(es) this step contributes to, using exact names from dishes[]. Empty array = shared mise-en-place step for everyone (Préparation phase). One name = a step specific to that dish's assembly. Two or more names = a step that simultaneously advances multiple dishes."),
instruction: z.string(),
})).max(50),
});
export type GeneratedBatchCook = z.infer<typeof BatchCookSchema>;
const LANG: Record<string, string> = { en: "English", fr: "French" };
export async function generateBatchCook(
options: {
dinners?: number;
lunches?: number;
servings?: number;
dietaryPrefs?: string;
difficulty?: "easy" | "medium" | "hard";
},
config?: AiConfig & { userContext?: string },
locale?: string
): Promise<GeneratedBatchCook> {
const model = resolveModel(config);
const lang = LANG[locale ?? "en"] ?? "English";
const dinners = options.dinners ?? 4;
const lunches = options.lunches ?? 0;
const servings = options.servings ?? 4;
const difficulty = options.difficulty;
const mealParts: string[] = [];
if (dinners > 0) mealParts.push(`${dinners} dinner(s)`);
if (lunches > 0) mealParts.push(`${lunches} lunch(es)`);
const totalDishes = Math.max(1, Math.min(6, dinners + lunches > 0 ? dinners + lunches : 4));
const dietaryClause = options.dietaryPrefs?.trim()
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
: "";
const difficultyClause = difficulty
? `Keep techniques at a ${difficulty} skill level.`
: "";
const systemPrompt = `You are a professional meal-prep chef writing ONE unified "batch cooking" recipe page — not several separate recipes. This mirrors real French batch-cooking guides (cuisine-addict.com style): one shopping list, then a single prep session structured in two phases, covering multiple meals for the days ahead.
Structure:
1. A merged, deduplicated ingredient list across all dishes (identical rawName spelling for anything shared).
2. Steps, tagged individually with which dish(es) they belong to (appliesTo — empty for shared prep, one name for a dish-specific step, several names when a step genuinely advances multiple dishes at once, e.g. two things share the same oven bake, or a base is cooked once then split):
- Phase "Préparation": shared mise-en-place done first — peeling, chopping, pre-cooking grains/proteins, etc. Group by task; tag with dish names only if a prep step is specific to one dish.
- Phase "Assemblage": the remaining steps that turn prepped components into finished dishes. Actively look for steps that serve multiple dishes at once (shared oven timing, a base split several ways) and tag those with multiple names instead of duplicating the step.
Before designing the dishes, pick 2-3 "anchor" ingredients for the week — substantial vegetables, fruits, or proteins bought in bulk. Design each dish around reusing these anchors, prepared in a genuinely different form each time (e.g. a vegetable roasted in one dish, pureed into a soup in another, shredded raw in a salad in a third). Every dish must share at least one anchor ingredient with another dish — this is a structural requirement, not incidental. Beyond the anchors, pantry staples (oil, garlic, onion, salt) can repeat freely.
Dishes must be genuinely distinct — different techniques and flavor profiles (a tart, a braise, a soup, a stuffed vegetable, a grain salad, a curry), never the same base dish renamed. Favor formats that store well for several days (braises, soups, tarts, grain salads, stuffed vegetables, curries) over ones that degrade fast, unless dayOfInstructions explicitly compensates.
Every dish needs: fridgeDays, freezerFriendly, and a freezerNote if applicable (duration + thawing tip). dayOfInstructions must name the exact reheat method and time, AND a concrete fresh finishing touch (stir in cream, cook fresh grain, add a fresh side, sear a protein, grate cheese) — never generic ("reheat and enjoy" is unacceptable).
For ingredients: quantity is a number only, unit is a separate string. Never combine them. Respond in ${lang}.`;
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
const { object } = await generateObject({
model,
schema: BatchCookSchema,
system: systemPromptWithContext,
prompt: [
`Generate a batch-cooking session covering ${mealParts.join(" and ") || `${totalDishes} meals`}.`,
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
dietaryClause,
difficultyClause,
].filter(Boolean).join(" "),
});
return object;
}
+26
View File
@@ -62,6 +62,12 @@
"ingredients": "Ingredients",
"instructions": "Instructions",
"photos": "Photos",
"batchCookBadge": "Batch cooking",
"batchCookPrep": "Prep",
"batchCookDishesTitle": "Dishes & storage",
"batchCookFridgeDays": "Fridge: {days}d",
"batchCookFreezerFriendly": "Freezer-friendly",
"batchCookDayOf": "On the day",
"servings": "{count} servings",
"prep": "{mins}m prep",
"cook": "{mins}m cook",
@@ -535,6 +541,25 @@
"resendSuccess": "Verification email sent — check your inbox",
"or": "or"
},
"batchCooking": {
"title": "Batch cooking",
"subtitle": "Cook once, eat well all week",
"empty": "No batch-cooking sessions yet",
"newSession": "New batch-cooking session",
"dishCount": "{count, plural, one {# dish} other {# dishes}}",
"wizardTitle": "Plan a batch-cooking session",
"wizardDescription": "Choose how many meals to cover — we'll design a shared shopping list and one prep session for all of them.",
"dinners": "Dinners",
"lunches": "Lunches",
"servingsPerMeal": "Servings per meal",
"dietaryPrefs": "Dietary preferences",
"dietaryPrefsPlaceholder": "e.g. vegetarian, no shellfish",
"anyDifficulty": "Any",
"generate": "Generate",
"generating": "Generating… this can take a minute",
"generateSuccess": "Batch-cooking session created",
"generateFailed": "Failed to generate"
},
"recipes": {
"title": "Recipes",
"subtitle": "Your personal recipe library",
@@ -543,6 +568,7 @@
"newSubtitle": "Build your recipe from scratch",
"importUrl": "Import URL",
"generate": "Generate",
"batchCooking": "Batch cooking",
"search": "Search recipes…",
"noRecipes": "No recipes yet",
"noMatch": "No recipes match",
+26
View File
@@ -62,6 +62,12 @@
"ingredients": "Ingrédients",
"instructions": "Instructions",
"photos": "Photos",
"batchCookBadge": "Batch cooking",
"batchCookPrep": "Préparation",
"batchCookDishesTitle": "Plats & conservation",
"batchCookFridgeDays": "Frigo : {days}j",
"batchCookFreezerFriendly": "Se congèle",
"batchCookDayOf": "Le jour J",
"servings": "{count} portions",
"prep": "{mins}m prép.",
"cook": "{mins}m cuisson",
@@ -526,6 +532,25 @@
"resendingSending": "Envoi en cours…",
"or": "ou"
},
"batchCooking": {
"title": "Batch cooking",
"subtitle": "Cuisinez une fois, mangez bien toute la semaine",
"empty": "Aucune session de batch cooking pour l'instant",
"newSession": "Nouvelle session de batch cooking",
"dishCount": "{count, plural, one {# plat} other {# plats}}",
"wizardTitle": "Planifier une session de batch cooking",
"wizardDescription": "Choisissez combien de repas couvrir — on génère une liste de courses partagée et une seule session de préparation pour tout.",
"dinners": "Dîners",
"lunches": "Déjeuners",
"servingsPerMeal": "Portions par repas",
"dietaryPrefs": "Préférences alimentaires",
"dietaryPrefsPlaceholder": "ex : végétarien, sans crustacés",
"anyDifficulty": "Peu importe",
"generate": "Générer",
"generating": "Génération… ça peut prendre une minute",
"generateSuccess": "Session de batch cooking créée",
"generateFailed": "Échec de la génération"
},
"recipes": {
"title": "Recettes",
"subtitle": "Votre bibliothèque personnelle de recettes",
@@ -534,6 +559,7 @@
"newSubtitle": "Créez votre recette depuis zéro",
"importUrl": "Importer une URL",
"generate": "Générer",
"batchCooking": "Batch cooking",
"search": "Rechercher des recettes…",
"noRecipes": "Aucune recette pour l'instant",
"noMatch": "Aucune recette ne correspond",
@@ -0,0 +1,17 @@
CREATE TABLE "recipe_batch_dishes" (
"id" text PRIMARY KEY NOT NULL,
"recipe_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"order" integer DEFAULT 0 NOT NULL,
"fridge_days" integer NOT NULL,
"freezer_friendly" boolean DEFAULT false NOT NULL,
"freezer_note" text,
"day_of_instructions" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "recipe_steps" ADD COLUMN "applies_to" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "recipes" ADD COLUMN "is_batch_cook" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "recipe_batch_dishes" ADD CONSTRAINT "recipe_batch_dishes_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "recipe_batch_dishes_recipe_idx" ON "recipe_batch_dishes" USING btree ("recipe_id");--> statement-breakpoint
CREATE INDEX "recipes_batch_cook_idx" ON "recipes" USING btree ("is_batch_cook");
File diff suppressed because it is too large Load Diff
@@ -204,6 +204,13 @@
"when": 1783671061049,
"tag": "0028_smiling_ezekiel_stane",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1783810665920,
"tag": "0029_tired_crystal",
"breakpoints": true
}
]
}
+22
View File
@@ -45,12 +45,14 @@ export const recipes = pgTable("recipes", {
prepMins: integer("prep_mins"),
cookMins: integer("cook_mins"),
tags: text("tags").array().notNull().default([]),
isBatchCook: boolean("is_batch_cook").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [
index("recipes_author_idx").on(t.authorId),
index("recipes_visibility_idx").on(t.visibility),
index("recipes_dietary_tags_gin").using("gin", t.dietaryTags),
index("recipes_batch_cook_idx").on(t.isBatchCook),
]);
export const ingredients = pgTable("ingredients", {
@@ -82,10 +84,25 @@ export const recipeSteps = pgTable("recipe_steps", {
instruction: text("instruction").notNull(),
timerSeconds: integer("timer_seconds"),
photoUrl: text("photo_url"),
appliesTo: text("applies_to").array().notNull().default([]),
}, (t) => [
index("recipe_steps_recipe_idx").on(t.recipeId),
]);
export const recipeBatchDishes = pgTable("recipe_batch_dishes", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
order: integer("order").notNull().default(0),
fridgeDays: integer("fridge_days").notNull(),
freezerFriendly: boolean("freezer_friendly").notNull().default(false),
freezerNote: text("freezer_note"),
dayOfInstructions: text("day_of_instructions").notNull(),
}, (t) => [
index("recipe_batch_dishes_recipe_idx").on(t.recipeId),
]);
export const recipePhotos = pgTable("recipe_photos", {
id: text("id").primaryKey(),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
@@ -128,6 +145,11 @@ export const recipesRelations = relations(recipes, ({ one, many }) => ({
notes: many(recipeNotes),
childVariations: many(recipeVariations, { relationName: "parent" }),
parentVariations: many(recipeVariations, { relationName: "child" }),
batchDishes: many(recipeBatchDishes),
}));
export const recipeBatchDishesRelations = relations(recipeBatchDishes, ({ one }) => ({
recipe: one(recipes, { fields: [recipeBatchDishes.recipeId], references: [recipes.id] }),
}));
export const recipeIngredientsRelations = relations(recipeIngredients, ({ one }) => ({