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>
This commit is contained in:
Arnaud
2026-07-10 11:03:11 +02:00
parent 5afc7cd182
commit 51a323b054
7 changed files with 202 additions and 61 deletions
@@ -39,7 +39,7 @@ export default async function ShoppingListPage({ params }: Params) {
const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart";
return (
<div className="space-y-6 max-w-lg">
<div className="space-y-6 max-w-2xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
@@ -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({
</div>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="sm:w-48">
<SelectValue />
<SelectValue>
{(v: SortMode) => ({ category: t("sortByCategory"), alpha: t("sortByAlpha"), unchecked: t("sortByUnchecked") })[v]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="category">{t("sortByCategory")}</SelectItem>
@@ -323,11 +340,12 @@ export function ShoppingListView({
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<ItemGroup
key={aisle}
aisle={aisle}
aisleLabel={categoryLabel(aisle)}
items={aisleItems}
showHeader={Object.keys(grouped).length > 1}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
@@ -340,6 +358,7 @@ export function ShoppingListView({
items={flatItems}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
@@ -371,25 +390,27 @@ export function ShoppingListView({
}
function ItemGroup({
aisle,
aisleLabel,
items,
showHeader,
readOnly,
categoryOptions,
categoryLabel,
tShopping,
onToggle,
onChangeCategory,
onDeleteRequest,
onDragEnd,
}: {
aisle: string;
aisleLabel: string;
items: Item[];
showHeader: boolean;
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
onDragEnd: (event: DragEndEvent) => void;
}) {
@@ -398,7 +419,7 @@ function ItemGroup({
return (
<div className="space-y-2">
{showHeader && (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
)}
<div className="rounded-xl border divide-y">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
@@ -410,6 +431,7 @@ function ItemGroup({
readOnly={readOnly}
draggable={!readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
@@ -427,6 +449,7 @@ function FlatItemList({
items,
readOnly,
categoryOptions,
categoryLabel,
tShopping,
onToggle,
onChangeCategory,
@@ -435,9 +458,10 @@ function FlatItemList({
items: Item[];
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
}) {
return (
@@ -448,6 +472,7 @@ function FlatItemList({
item={item}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
@@ -490,14 +515,16 @@ type ItemRowProps = {
item: Item;
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
dragHandle?: React.ReactNode;
};
function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
const [newCategory, setNewCategory] = useState("");
return (
<div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
{dragHandle}
@@ -534,12 +561,39 @@ function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChang
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuSubContent className="w-56">
{categoryOptions.map((category) => (
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
{category}
{categoryLabel(category)}
</DropdownMenuItem>
))}
<DropdownMenuItem onClick={() => onChangeCategory(item, null)}>
{tShopping("aisleOther")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex items-center gap-1 p-1.5" onClick={(e) => e.stopPropagation()}>
<Input
value={newCategory}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="ghost"
className="h-7 shrink-0 px-2 text-xs"
disabled={!newCategory.trim()}
onClick={() => { onChangeCategory(item, newCategory.trim()); setNewCategory(""); }}
>
{tShopping("addCategory")}
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
+25 -14
View File
@@ -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,
};
}
+69 -26
View File
@@ -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();
+11 -4
View File
@@ -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<string, { rawName: string; ingredientId: string | null; unit: string | null; entries: (string | null)[] }>();
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) => {
+14 -1
View File
@@ -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",
+14 -1
View File
@@ -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",