002f14ced0
Shopping list add already worked generically for batch-cook recipes — no code needed there. New: mark a specific batch-cook dish as "cooked today", track its fridge expiry (cookingHistory.batchDishId), surface a "Leftovers expiring soon" widget on the pantry page, and send a daily push+email reminder via a new /api/internal/cron/leftover-reminders endpoint (mirrors the weekly-digest cron pattern; doesn't use the social notifications table, which requires a non-null actor and isn't built for self-reminders). Also fixes, from user-reported bugs: - Recipe cards showed no batch-cook badge/dish-count/prep-time in some views — added dishCount + prepMins/cookMins (now generated by the AI and persisted) to the card component and /recipes query. - Batch-cook descriptions occasionally contained raw markdown (**bold**) — added explicit "plain prose only" prompt instructions and a stripMarkdown() defensive fallback at render time. - Truncated/cut-off descriptions — the generateObject call had no maxOutputTokens set, so long structured responses could get cut off mid-field; now capped explicitly at 8000. - Generate dialogs (batch-cook + the main AI dialog) could show buttons unreachable once the progress bar appeared mid-generation — restructured so the action row is pinned outside the scrollable content area, not affected by content height changes. - /api/internal/* routes were being redirected to /login by middleware before their own CRON_SECRET check ever ran (pre-existing bug, affected the weekly-digest cron too) — added to PUBLIC_PATHS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
100 lines
6.4 KiB
TypeScript
100 lines
6.4 KiB
TypeScript
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).describe("Plain prose only — no markdown formatting (no **bold**, no bullet points, no headers)."),
|
|
prepMins: z.number().int().min(0).max(300).describe("Total hands-on prep time for the whole session, in minutes."),
|
|
cookMins: z.number().int().min(0).max(300).describe("Total passive cook/bake/simmer time for the whole session, in minutes."),
|
|
dishes: z.array(z.object({
|
|
name: z.string().max(100),
|
|
description: z.string().max(200).describe("Plain prose only — no markdown formatting."),
|
|
fridgeDays: z.number().int().min(1).max(7),
|
|
freezerFriendly: z.boolean(),
|
|
freezerNote: z.string().max(150).optional(),
|
|
dayOfInstructions: z.string().max(300).describe("Plain prose only — no markdown formatting."),
|
|
})).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.
|
|
|
|
Estimate prepMins (total hands-on active work across the whole session) and cookMins (total passive oven/stovetop/simmer time) for the whole session, in minutes.
|
|
|
|
Write every description and instruction as plain prose — never use markdown formatting (no **bold**, no bullet lists, no headers, no asterisks). Keep every field within its stated length limit; a shorter, complete sentence is always better than a longer one that gets cut off. 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,
|
|
maxOutputTokens: 8000,
|
|
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;
|
|
}
|