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>
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|