From 51a323b054a47a204e50bd4bcb6313597bd61a6e Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 10 Jul 2026 11:03:11 +0200 Subject: [PATCH] fix: shopping list categories were English-only and untranslated, page too narrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/(app)/shopping-lists/[id]/page.tsx | 2 +- .../meal-plan/shopping-list-view.tsx | 82 +++++++++++++--- apps/web/lib/extract-ingredient-quantity.ts | 39 +++++--- apps/web/lib/grocery-categories.ts | 95 ++++++++++++++----- apps/web/lib/pantry-shopping-match.ts | 15 ++- apps/web/messages/en.json | 15 ++- apps/web/messages/fr.json | 15 ++- 7 files changed, 202 insertions(+), 61 deletions(-) diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx index 4310f3a..c2cdde5 100644 --- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -39,7 +39,7 @@ export default async function ShoppingListPage({ params }: Params) { const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart"; return ( -
+

{list.name}

diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index e8a14a5..c3f52b0 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -85,7 +85,23 @@ export function ShoppingListView({ const [deleting, setDeleting] = useState(false); const [autoCategorizing, setAutoCategorizing] = useState(false); - const categoryOptions = useMemo(() => [...GROCERY_CATEGORIES, t("aisleOther")], [t]); + // Predefined categories, plus any custom category already in use on this list + // (so a custom category someone created stays selectable for other items too), + // plus "Other" (represented as `null` under the hood) always last. + const customCategoriesInUse = useMemo( + () => Array.from(new Set(items.map((i) => i.aisle).filter((a): a is string => !!a && !(GROCERY_CATEGORIES as readonly string[]).includes(a)))).sort(), + [items] + ); + const categoryOptions = useMemo( + () => [...GROCERY_CATEGORIES, ...customCategoriesInUse], + [customCategoriesInUse] + ); + + function categoryLabel(category: string): string { + return (GROCERY_CATEGORIES as readonly string[]).includes(category) + ? tShopping(`categories.${category}`) + : category; + } const checkedItems = items.filter((i) => i.checked); const isSearching = query.trim().length > 0; @@ -132,9 +148,8 @@ export function ShoppingListView({ } } - async function changeCategory(item: Item, category: string) { + async function changeCategory(item: Item, nextAisle: string | null) { if (readOnly) return; - const nextAisle = category === t("aisleOther") ? null : category; const prevAisle = item.aisle; setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i)); try { @@ -307,7 +322,9 @@ export function ShoppingListView({
setNewCategory(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && newCategory.trim()) { + onChangeCategory(item, newCategory.trim()); + setNewCategory(""); + } + }} + placeholder={tShopping("newCategoryPlaceholder")} + className="h-7 text-xs" + /> + +
diff --git a/apps/web/lib/extract-ingredient-quantity.ts b/apps/web/lib/extract-ingredient-quantity.ts index 97f6909..5673b5b 100644 --- a/apps/web/lib/extract-ingredient-quantity.ts +++ b/apps/web/lib/extract-ingredient-quantity.ts @@ -11,11 +11,16 @@ const KNOWN_UNITS = new Set([ "milliliter", "milliliters", "millilitre", "millilitres", "ml", "liter", "liters", "litre", "litres", "l", "pinch", "pinches", "dash", "dashes", - "clove", "cloves", "slice", "slices", "piece", "pieces", + "clove", "cloves", "slice", "slices", "piece", "pieces", "pcs", "pc", "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 + // French + "tasse", "tasses", "cas", "cac", "gramme", "grammes", "kilogramme", + "kilogrammes", "litre", "litres", "pincée", "pincees", "tranche", "tranches", + "gousse", "gousses", "sachet", "sachets", "boîte", "boite", "boîtes", "boites", + "morceau", "morceaux", "pièce", "pièces", ]); // Leading numeric token: plain int/decimal, unicode fraction, mixed number ("1 1/2"), or @@ -25,20 +30,21 @@ const LEADING_NUMBER = /** * 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. + * the name itself (e.g. rawName: "2 cups flour"), sometimes ALONGSIDE an already-populated + * quantity/unit pair (e.g. rawName: "1 avocat", quantity: "2", unit: "pcs" — the redundant + * "1" was never stripped because an earlier version of this function only looked at + * rawName when quantity/unit were both empty). This always strips a leading + * quantity(+unit) token from the NAME when one is present, so the displayed ingredient is + * just "avocat", never "1 avocat" — but only ever backfills the quantity/unit fields + * themselves when they weren't already provided, so an existing explicit quantity/unit + * (e.g. from summing across recipes) is never overwritten by whatever number happened to + * be sitting in 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 }; @@ -49,20 +55,25 @@ export function extractIngredientQuantity( 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; + let strippedUnit: string | undefined; const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/); - if (unitMatch && !extractedUnit) { + if (unitMatch) { const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase(); if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) { - extractedUnit = "fl oz"; + strippedUnit = "fl oz"; rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim(); } else if (KNOWN_UNITS.has(candidate)) { - extractedUnit = candidate; + strippedUnit = 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 }; + const hasExplicitQuantity = quantity !== undefined && quantity !== ""; + return { + rawName: rest, + quantity: hasExplicitQuantity ? quantity : parsedQuantity, + unit: unit !== undefined && unit !== "" ? unit : strippedUnit, + }; } diff --git a/apps/web/lib/grocery-categories.ts b/apps/web/lib/grocery-categories.ts index 595ec34..7858f68 100644 --- a/apps/web/lib/grocery-categories.ts +++ b/apps/web/lib/grocery-categories.ts @@ -2,33 +2,38 @@ * 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. + * 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", - "Dairy & Eggs", - "Meat & Seafood", - "Bakery", - "Frozen", - "Pantry", - "Spices & Condiments", - "Beverages", + "produce", + "dairyEggs", + "meatSeafood", + "bakery", + "frozen", + "pantry", + "spicesCondiments", + "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. +// 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", [ + ["produce", [ "lettuce", "spinach", "kale", "arugula", "cabbage", "carrot", "celery", "onion", "garlic", "shallot", "scallion", "leek", "potato", "sweet potato", "tomato", "cucumber", "zucchini", "squash", "pepper", "chili", "chile", "broccoli", @@ -37,50 +42,88 @@ const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [ "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", ]], - ["Dairy & Eggs", [ + ["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", ]], - ["Meat & Seafood", [ + ["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", [ + ["bakery", [ "bread", "baguette", "roll", "bun", "bagel", "tortilla", "pita", "naan", "croissant", "muffin", "brioche", "loaf", + // French + "pain", "baguette", "brioche", "croissant", "viennoiserie", ]], - ["Frozen", [ + ["frozen", [ "frozen", "ice cream", "popsicle", "frozen peas", "frozen berries", + // French + "surgelé", "surgele", "surgelés", "surgeles", "glace", ]], - ["Beverages", [ + ["beverages", [ "juice", "soda", "water", "coffee", "tea", "wine", "beer", "sparkling", "kombucha", "cider", + // French + "jus", "eau", "café", "cafe", "thé", "the", "vin", "bière", "biere", + "cidre", ]], - ["Spices & Condiments", [ + ["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", [ + ["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 aisle/category from a raw ingredient name via simple - * keyword matching. Returns `null` when nothing matches (caller should fall - * back to "Other"). + * 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(); diff --git a/apps/web/lib/pantry-shopping-match.ts b/apps/web/lib/pantry-shopping-match.ts index 6b11935..06cf76b 100644 --- a/apps/web/lib/pantry-shopping-match.ts +++ b/apps/web/lib/pantry-shopping-match.ts @@ -12,6 +12,8 @@ * untouched and just flag `inPantry` so the user can decide for themselves. */ +import { extractIngredientQuantity } from "./extract-ingredient-quantity"; + export type PantrySourceItem = { ingredientId: string | null; rawName: string; @@ -138,17 +140,22 @@ export function mergeIngredients(ingredients: MergeSourceIngredient[]): MergedIn const groups = new Map(); for (const ing of ingredients) { - const unitKey = normalizeUnit(ing.unit); + // Clean up ingredients whose stored rawName still has a quantity baked into it + // (older AI-generated recipes, before that was fixed at the source) BEFORE grouping — + // otherwise "1 avocat" and a cleanly-named "avocat" from another recipe would + // normalize to different group keys and never merge. + const cleaned = extractIngredientQuantity(ing.rawName, ing.quantity ?? undefined, ing.unit ?? undefined); + const unitKey = normalizeUnit(cleaned.unit); const groupKey = ing.ingredientId ? `id:${ing.ingredientId}:${unitKey}` - : `name:${normalizeName(ing.rawName)}:${unitKey}`; + : `name:${normalizeName(cleaned.rawName)}:${unitKey}`; let group = groups.get(groupKey); if (!group) { - group = { rawName: ing.rawName, ingredientId: ing.ingredientId, unit: ing.unit, entries: [] }; + group = { rawName: cleaned.rawName, ingredientId: ing.ingredientId, unit: cleaned.unit ?? null, entries: [] }; groups.set(groupKey, group); } - group.entries.push(ing.quantity); + group.entries.push(cleaned.quantity ?? null); } return Array.from(groups.values()).map((group) => { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 42747d6..8d0c6e8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -826,7 +826,20 @@ "listDeleted": "List deleted", "listDeleteFailed": "Failed to delete list", "changeCategory": "Change category", - "deleteItem": "Delete item" + "deleteItem": "Delete item", + "aisleOther": "Other", + "newCategoryPlaceholder": "New category…", + "addCategory": "Add", + "categories": { + "produce": "Produce", + "dairyEggs": "Dairy & Eggs", + "meatSeafood": "Meat & Seafood", + "bakery": "Bakery", + "frozen": "Frozen", + "pantry": "Pantry", + "spicesCondiments": "Spices & Condiments", + "beverages": "Beverages" + } }, "collections": { "title": "Collections", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index c6fa28f..bb83a86 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -814,7 +814,20 @@ "listDeleted": "Liste supprimée", "listDeleteFailed": "Échec de la suppression de la liste", "changeCategory": "Changer de catégorie", - "deleteItem": "Supprimer l'article" + "deleteItem": "Supprimer l'article", + "aisleOther": "Autre", + "newCategoryPlaceholder": "Nouvelle catégorie…", + "addCategory": "Ajouter", + "categories": { + "produce": "Fruits & légumes", + "dairyEggs": "Produits laitiers & œufs", + "meatSeafood": "Viande & poisson", + "bakery": "Boulangerie", + "frozen": "Surgelés", + "pantry": "Épicerie", + "spicesCondiments": "Épices & condiments", + "beverages": "Boissons" + } }, "collections": { "title": "Collections",