5afc7cd182
- Fixed a real i18n bug: the checked-count line called the wrong translation namespace and rendered the literal key on screen - Shopping lists can now be renamed and deleted from both the list index and detail pages (API already supported delete; rename was net new) - Root-caused "long list UI is off": meal-plan-generated lists never set an aisle, so every item fell into one undifferentiated "Other" bucket despite the grouping UI existing. Added a keyword-based aisle guesser wired into list generation (fallback only, never overrides an explicit aisle) plus a one-click "auto-categorize" for existing lists - Items can now be reordered by drag-and-drop within a category (dnd-kit), recategorized via a dropdown, deleted, searched, and sorted (category / alphabetical / unchecked-first); searching flattens the grouped view - Fixed a separate bug: AI-generated ingredients sometimes embedded the quantity/unit in the name itself (e.g. "2 cups flour" as one string). Added extractIngredientQuantity() as a Zod transform at both recipe create/update routes (the choke point every creation path funnels through) to split it back out, plus schema descriptions on the AI ingredient schemas as a prevention layer New migration 0028 (shopping_list_items.sort_order), left unapplied like the others. Verified with typecheck, lint, and a clean --no-cache docker build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
105 lines
5.3 KiB
TypeScript
105 lines
5.3 KiB
TypeScript
import { generateObject } from "ai";
|
|
import { z } from "zod";
|
|
import { resolveModel, type AiConfig } from "../factory";
|
|
|
|
const MealPlanSchema = z.object({
|
|
entries: z.array(z.object({
|
|
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
|
|
mealType: z.enum(["breakfast", "lunch", "dinner"]),
|
|
recipe: z.object({
|
|
title: z.string().max(150),
|
|
description: z.string().max(300),
|
|
ingredients: z.array(z.object({
|
|
rawName: z.string().describe("Ingredient name only, e.g. 'flour' — 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(20),
|
|
steps: z.array(z.object({
|
|
instruction: z.string(),
|
|
})).max(12),
|
|
prepMins: z.number().int().min(0).max(180).optional(),
|
|
cookMins: z.number().int().min(0).max(360).optional(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
}),
|
|
servings: z.number().int().min(1).max(20),
|
|
})),
|
|
});
|
|
|
|
export type GeneratedMealPlan = z.infer<typeof MealPlanSchema>;
|
|
|
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
|
|
|
export async function generateMealPlan(
|
|
options: {
|
|
dietaryPrefs?: string;
|
|
servings?: number;
|
|
pantryItems?: string[];
|
|
days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
|
pantryMode?: boolean;
|
|
difficulty?: "easy" | "medium" | "hard";
|
|
nutritionGoals?: {
|
|
caloriesKcal?: number | null;
|
|
proteinG?: number | null;
|
|
carbsG?: number | null;
|
|
fatG?: number | null;
|
|
};
|
|
},
|
|
config?: AiConfig & { userContext?: string },
|
|
locale?: string
|
|
): Promise<GeneratedMealPlan> {
|
|
const model = resolveModel(config);
|
|
const lang = LANG[locale ?? "en"] ?? "English";
|
|
const servings = options.servings ?? 2;
|
|
const days = options.days ?? ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
const pantryMode = options.pantryMode ?? false;
|
|
const difficulty = options.difficulty;
|
|
|
|
const dietaryClause = options.dietaryPrefs?.trim()
|
|
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
|
|
: "";
|
|
const difficultyClause = difficulty
|
|
? `All recipes must be ${difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[difficulty]}.`
|
|
: "";
|
|
const pantryClause =
|
|
options.pantryItems && options.pantryItems.length > 0
|
|
? `Available pantry ingredients to use: ${options.pantryItems.slice(0, 20).join(", ")}.`
|
|
: "";
|
|
|
|
const goals = options.nutritionGoals;
|
|
const goalParts: string[] = [];
|
|
if (goals?.caloriesKcal) goalParts.push(`~${goals.caloriesKcal} kcal`);
|
|
if (goals?.proteinG) goalParts.push(`${goals.proteinG}g protein`);
|
|
if (goals?.carbsG) goalParts.push(`${goals.carbsG}g carbs`);
|
|
if (goals?.fatG) goalParts.push(`${goals.fatG}g fat`);
|
|
const nutritionClause =
|
|
goalParts.length > 0
|
|
? `The user has a daily nutrition target of approximately ${goalParts.join(", ")} in total across breakfast, lunch, and dinner combined. Design each day's meals so that, together, they roughly add up to these daily targets — favor recipes and portions that plausibly fit (e.g. protein-forward mains if protein is high relative to calories, lighter sides if calories are constrained). This is a directional guide, not a strict constraint: never sacrifice food safety, coherence, or the dietary requirements above to hit a number exactly.`
|
|
: "";
|
|
|
|
const systemPrompt = pantryMode
|
|
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`
|
|
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`;
|
|
|
|
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
|
|
|
|
const { object } = await generateObject({
|
|
model,
|
|
schema: MealPlanSchema,
|
|
system: systemPromptWithContext,
|
|
prompt: [
|
|
`Generate a meal plan for ${days.length} day(s): ${days.join(", ")}.`,
|
|
`Include breakfast, lunch, and dinner for each day.`,
|
|
`Plan for ${servings} servings per meal.`,
|
|
dietaryClause,
|
|
difficultyClause,
|
|
pantryClause,
|
|
nutritionClause,
|
|
pantryMode
|
|
? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased."
|
|
: "Ensure nutritional balance across the week. Vary ingredients and cooking styles.",
|
|
].filter(Boolean).join(" "),
|
|
});
|
|
|
|
return object;
|
|
}
|