51a323b054
Confirmed from a real screenshot: every item in an all-French shopping list
was uncategorized because guessAisle() only matched English keywords —
"sometimes reorganize doesn't work" was actually "always fails on non-English
ingredient names". Also fixed:
- Categories are now translated (grocery-categories.ts stores locale-
independent slugs like "produce", translated for display via
shoppingLists.categories.* instead of storing/rendering the English label
directly)
- Added French keyword coverage to the aisle guesser so auto-categorization
actually works for French recipes
- Users can now type a custom category name from the item's category menu
- The sort-mode dropdown was showing the raw value ("category") instead of
its label — base-ui's Select.Value has no built-in value->label lookup,
it needs an explicit render function, which no Select in this call site
had
- Widened the shopping list detail page (max-w-lg -> max-w-2xl)
- Root-caused a second bug visible in the same screenshot: ingredients with
the quantity embedded in the name (e.g. "1 avocat") still showed that way
even though quantity/unit were already populated separately, because the
extraction helper only fired when quantity was empty. Now it always
cleans the name but only backfills quantity/unit when they're missing,
and runs at shopping-list generation time too (not just recipe save) so
it also cleans up already-contaminated recipes, not just new ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
6.4 KiB
TypeScript
140 lines
6.4 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 (in both
|
|
* English and French, since this app is bilingual), 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.
|
|
*
|
|
* Stored/canonical values are locale-independent slugs (`"produce"`, not
|
|
* `"Produce"`) — translate them for display via the `shoppingLists.categories.*`
|
|
* i18n keys. A category value that ISN'T one of these keys is a user-created
|
|
* custom category and should be displayed as-is (no translation expected).
|
|
*/
|
|
|
|
export const GROCERY_CATEGORIES = [
|
|
"produce",
|
|
"dairyEggs",
|
|
"meatSeafood",
|
|
"bakery",
|
|
"frozen",
|
|
"pantry",
|
|
"spicesCondiments",
|
|
"beverages",
|
|
] as const;
|
|
|
|
export type GroceryCategory = (typeof GROCERY_CATEGORIES)[number];
|
|
|
|
// Ordered map of category -> keywords (English + French). 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",
|
|
// French
|
|
"laitue", "épinard", "epinard", "chou", "roquette", "carotte", "céleri", "celeri",
|
|
"oignon", "ail", "échalote", "echalote", "poireau", "pomme de terre", "patate",
|
|
"tomate", "concombre", "courgette", "courge", "poivron", "piment", "brocoli",
|
|
"chou-fleur", "champignon", "avocat", "citron", "orange", "pomme", "banane",
|
|
"fraise", "framboise", "myrtille", "raisin", "melon", "pêche", "peche", "poire",
|
|
"prune", "mangue", "ananas", "coriandre", "persil", "basilic", "menthe", "aneth",
|
|
"thym", "romarin", "gingembre", "maïs", "mais", "pois", "haricot", "asperge",
|
|
"radis", "betterave", "fenouil", "herbes", "salade",
|
|
]],
|
|
["dairyEggs", [
|
|
"milk", "cream", "yogurt", "yoghurt", "butter", "cheese", "egg", "eggs",
|
|
"sour cream", "cottage cheese", "mascarpone", "ricotta", "buttermilk",
|
|
"half and half", "creme fraiche",
|
|
// French
|
|
"lait", "crème", "creme", "yaourt", "beurre", "fromage", "œuf", "oeuf",
|
|
"œufs", "oeufs", "crème fraîche", "creme fraiche", "fromage blanc",
|
|
]],
|
|
["meatSeafood", [
|
|
"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",
|
|
// French
|
|
"poulet", "bœuf", "boeuf", "porc", "agneau", "dinde", "lard", "lardon",
|
|
"saucisse", "jambon", "steak", "viande hachée", "viande hachee", "saumon",
|
|
"thon", "crevette", "cabillaud", "poisson", "crabe", "homard", "coquille",
|
|
"moule", "palourde", "canard",
|
|
]],
|
|
["bakery", [
|
|
"bread", "baguette", "roll", "bun", "bagel", "tortilla", "pita", "naan",
|
|
"croissant", "muffin", "brioche", "loaf",
|
|
// French
|
|
"pain", "baguette", "brioche", "croissant", "viennoiserie",
|
|
]],
|
|
["frozen", [
|
|
"frozen", "ice cream", "popsicle", "frozen peas", "frozen berries",
|
|
// French
|
|
"surgelé", "surgele", "surgelés", "surgeles", "glace",
|
|
]],
|
|
["beverages", [
|
|
"juice", "soda", "water", "coffee", "tea", "wine", "beer", "sparkling",
|
|
"kombucha", "cider",
|
|
// French
|
|
"jus", "eau", "café", "cafe", "thé", "the", "vin", "bière", "biere",
|
|
"cidre",
|
|
]],
|
|
["spicesCondiments", [
|
|
"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",
|
|
// French
|
|
"sel", "cumin", "paprika", "cannelle", "muscade", "origan", "curcuma",
|
|
"poudre de curry", "curry", "épice", "epice", "vanille", "moutarde",
|
|
"mayonnaise", "sauce soja", "vinaigre", "huile d'olive", "huile de coco",
|
|
"huile de sésame", "huile de sesame", "huile végétale", "huile vegetale",
|
|
"miel", "sirop d'érable", "sirop d'erable", "confiture", "sauce",
|
|
"herbes de provence",
|
|
]],
|
|
["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",
|
|
// French
|
|
"farine", "sucre", "riz", "pâtes", "pates", "nouille", "spaghetti",
|
|
"quinoa", "avoine", "céréale", "cereale", "haricot", "lentille", "pois chiche",
|
|
"bouillon", "levure", "levure chimique", "bicarbonate", "maïzena", "maizena",
|
|
"chapelure", "noix", "amande", "cacahuète", "cacahuete", "noix de cajou",
|
|
"chocolat", "cacao", "lait de coco", "concentré de tomate", "concentre de tomate",
|
|
"graines de sésame", "graines de sesame", "graine",
|
|
]],
|
|
];
|
|
|
|
/**
|
|
* Guesses a grocery category from a raw ingredient name via simple keyword
|
|
* matching (English + French). 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;
|
|
}
|