feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix

- 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>
This commit is contained in:
Arnaud
2026-07-10 10:29:28 +02:00
parent d62e2a6383
commit 5afc7cd182
24 changed files with 5549 additions and 76 deletions
@@ -10,9 +10,9 @@ const MealPlanSchema = z.object({
title: z.string().max(150),
description: z.string().max(300),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
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(),
+7 -3
View File
@@ -17,9 +17,13 @@ export const stepSchema = z.object({
export function ingredientSchema<Q extends z.ZodTypeAny>(quantity: Q) {
return z.object({
rawName: z.string(),
quantity: quantity.optional(),
unit: z.string().optional(),
rawName: z.string().describe(
"The ingredient name ONLY — e.g. 'flour', 'egg', 'olive oil'. Never include the " +
"quantity or unit here (not '2 cups flour', not '3 eggs'); those go in the separate " +
"quantity and unit fields."
),
quantity: quantity.optional().describe("A number only, e.g. 0.25, 1.5, 2 — never combined with the unit."),
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'tbsp', 'g', 'ml' — never combined with the quantity or name."),
note: z.string().optional(),
});
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { resolveModel, type AiConfig } from "../factory";
const ScaledIngredientsSchema = z.object({
ingredients: z.array(
z.object({
rawName: z.string(),
quantity: z.string(),
rawName: z.string().describe("Ingredient name only, e.g. 'flour' — never include the scaled quantity or unit here."),
quantity: z.string().describe("The scaled quantity as a number only, e.g. '3' or '1.5'."),
unit: z.string().nullable(),
note: z.string().optional(),
})
+1 -1
View File
@@ -6,7 +6,7 @@ const TranslationOutputSchema = z.object({
title: z.string(),
description: z.string(),
ingredients: z.array(z.object({
rawName: z.string(),
rawName: z.string().describe("Translated ingredient name only — never add the quantity or unit, those are handled separately and unaffected by translation."),
note: z.string().optional(),
})),
steps: z.array(z.object({
@@ -0,0 +1,68 @@
import { parseQuantity } from "./parse-quantity";
const KNOWN_UNITS = new Set([
"cup", "cups", "c",
"tablespoon", "tablespoons", "tbsp", "tbsps", "tbs",
"teaspoon", "teaspoons", "tsp", "tsps",
"gram", "grams", "g",
"kilogram", "kilograms", "kg",
"ounce", "ounces", "oz",
"pound", "pounds", "lb", "lbs",
"milliliter", "milliliters", "millilitre", "millilitres", "ml",
"liter", "liters", "litre", "litres", "l",
"pinch", "pinches", "dash", "dashes",
"clove", "cloves", "slice", "slices", "piece", "pieces",
"can", "cans", "jar", "jars", "package", "packages", "pkg",
"bunch", "bunches", "sprig", "sprigs", "stick", "sticks",
"quart", "quarts", "qt", "pint", "pints", "pt",
"fl", // matches the first token of "fl oz" — handled below
]);
// Leading numeric token: plain int/decimal, unicode fraction, mixed number ("1 1/2"), or
// simple fraction ("1/2") — mirrors what parseQuantity already knows how to parse.
const LEADING_NUMBER =
/^\s*(\d+\s+\d+\s*\/\s*\d+|\d+\s*\/\s*\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])\s*/;
/**
* Some AI-generated (or pasted/imported) ingredients arrive with the quantity baked into
* the name itself (e.g. rawName: "2 cups flour", quantity/unit left empty) instead of the
* expected separate fields. Detects a leading quantity (+ optional recognized unit) in
* rawName and pulls it out, so the ingredient name shown to the user is just "flour", not
* "2 cups flour". Never runs when an explicit quantity was already provided — an entry
* with real quantity/unit fields is trusted as-is, this is only a fallback for the case
* where the model (or a copy-pasted ingredient line) merged everything into the name.
*/
export function extractIngredientQuantity(
rawName: string,
quantity: string | undefined,
unit: string | undefined
): { rawName: string; quantity: string | undefined; unit: string | undefined } {
if (quantity !== undefined && quantity !== "") return { rawName, quantity, unit };
const match = rawName.match(LEADING_NUMBER);
if (!match) return { rawName, quantity, unit };
const numberToken = match[1]!.trim();
const parsedQuantity = parseQuantity(numberToken);
if (parsedQuantity === undefined) return { rawName, quantity, unit };
let rest = rawName.slice(match[0].length).trim();
if (!rest) return { rawName, quantity, unit }; // nothing left — not actually a name+quantity string
let extractedUnit = unit;
const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/);
if (unitMatch && !extractedUnit) {
const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase();
if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) {
extractedUnit = "fl oz";
rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim();
} else if (KNOWN_UNITS.has(candidate)) {
extractedUnit = candidate;
rest = unitMatch[2]!.trim();
}
}
if (!rest) return { rawName, quantity, unit }; // stripping the unit ate the whole string — bail out
return { rawName: rest, quantity: parsedQuantity, unit: extractedUnit };
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Lightweight keyword-based aisle guesser for shopping list items.
*
* This is intentionally NOT an exhaustive ingredient database — just a few dozen
* common keyword -> category mappings covering typical recipe ingredients, so
* items generated from a meal plan (which never have an explicit `aisle` set
* today) land in a reasonable category instead of an undifferentiated "Other"
* bucket. When nothing matches, returns `null` and the caller falls back to
* "Other" as before.
*
* Only ever used as a FALLBACK when an item doesn't already have an explicit
* `aisle` — never overrides a user- or API-provided value.
*/
export const GROCERY_CATEGORIES = [
"Produce",
"Dairy & Eggs",
"Meat & Seafood",
"Bakery",
"Frozen",
"Pantry",
"Spices & Condiments",
"Beverages",
] as const;
export type GroceryCategory = (typeof GROCERY_CATEGORIES)[number];
// Ordered map of category -> keywords. Checked in order, first match wins, so
// more specific keywords should generally come before more generic ones.
const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [
["Produce", [
"lettuce", "spinach", "kale", "arugula", "cabbage", "carrot", "celery", "onion",
"garlic", "shallot", "scallion", "leek", "potato", "sweet potato", "tomato",
"cucumber", "zucchini", "squash", "pepper", "chili", "chile", "broccoli",
"cauliflower", "mushroom", "avocado", "lemon", "lime", "orange", "apple",
"banana", "berry", "berries", "grape", "melon", "peach", "pear", "plum",
"mango", "pineapple", "cilantro", "parsley", "basil", "mint", "dill",
"thyme", "rosemary", "ginger", "corn", "peas", "beans", "asparagus",
"radish", "beet", "fennel", "herb", "greens",
]],
["Dairy & Eggs", [
"milk", "cream", "yogurt", "yoghurt", "butter", "cheese", "egg", "eggs",
"sour cream", "cottage cheese", "mascarpone", "ricotta", "buttermilk",
"half and half", "creme fraiche",
]],
["Meat & Seafood", [
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "sausage", "ham",
"steak", "ground beef", "mince", "salmon", "tuna", "shrimp", "prawn",
"cod", "tilapia", "fish", "crab", "lobster", "scallop", "mussel", "clam",
"chorizo", "prosciutto", "duck",
]],
["Bakery", [
"bread", "baguette", "roll", "bun", "bagel", "tortilla", "pita", "naan",
"croissant", "muffin", "brioche", "loaf",
]],
["Frozen", [
"frozen", "ice cream", "popsicle", "frozen peas", "frozen berries",
]],
["Beverages", [
"juice", "soda", "water", "coffee", "tea", "wine", "beer", "sparkling",
"kombucha", "cider",
]],
["Spices & Condiments", [
"salt", "pepper flakes", "cumin", "paprika", "cinnamon", "nutmeg",
"oregano", "turmeric", "cayenne", "curry powder", "chili powder", "spice",
"vanilla", "ketchup", "mustard", "mayo", "mayonnaise", "soy sauce",
"hot sauce", "vinegar", "olive oil", "vegetable oil", "sesame oil",
"honey", "maple syrup", "jam", "sauce", "dressing", "salsa",
]],
["Pantry", [
"flour", "sugar", "rice", "pasta", "noodle", "spaghetti", "quinoa",
"oats", "oatmeal", "cereal", "beans", "lentil", "chickpea", "canned",
"stock", "broth", "bouillon", "yeast", "baking powder", "baking soda",
"cornstarch", "breadcrumb", "nut", "almond", "walnut", "peanut", "cashew",
"chocolate", "cocoa", "coconut milk", "tomato paste", "tomato sauce",
"crushed tomato",
]],
];
/**
* Guesses a grocery aisle/category from a raw ingredient name via simple
* keyword matching. Returns `null` when nothing matches (caller should fall
* back to "Other").
*/
export function guessAisle(rawName: string): GroceryCategory | null {
const name = rawName.toLowerCase().trim();
if (!name) return null;
for (const [category, keywords] of CATEGORY_KEYWORDS) {
for (const keyword of keywords) {
if (name.includes(keyword)) return category;
}
}
return null;
}