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:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user