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
+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;
}