Files
Epicure/apps/web/lib/grocery-categories.ts
Arnaud 51a323b054 fix: shopping list categories were English-only and untranslated, page too narrow
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>
2026-07-10 11:03:11 +02:00

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