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"; const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart";
return ( 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 className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1> <h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
@@ -85,7 +85,23 @@ export function ShoppingListView({
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [autoCategorizing, setAutoCategorizing] = 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 checkedItems = items.filter((i) => i.checked);
const isSearching = query.trim().length > 0; 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; if (readOnly) return;
const nextAisle = category === t("aisleOther") ? null : category;
const prevAisle = item.aisle; const prevAisle = item.aisle;
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i)); setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
try { try {
@@ -307,7 +322,9 @@ export function ShoppingListView({
</div> </div>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}> <Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="sm:w-48"> <SelectTrigger className="sm:w-48">
<SelectValue /> <SelectValue>
{(v: SortMode) => ({ category: t("sortByCategory"), alpha: t("sortByAlpha"), unchecked: t("sortByUnchecked") })[v]}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="category">{t("sortByCategory")}</SelectItem> <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]) => ( Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<ItemGroup <ItemGroup
key={aisle} key={aisle}
aisle={aisle} aisleLabel={categoryLabel(aisle)}
items={aisleItems} items={aisleItems}
showHeader={Object.keys(grouped).length > 1} showHeader={Object.keys(grouped).length > 1}
readOnly={readOnly} readOnly={readOnly}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping} tShopping={tShopping}
onToggle={toggleItem} onToggle={toggleItem}
onChangeCategory={changeCategory} onChangeCategory={changeCategory}
@@ -340,6 +358,7 @@ export function ShoppingListView({
items={flatItems} items={flatItems}
readOnly={readOnly} readOnly={readOnly}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping} tShopping={tShopping}
onToggle={toggleItem} onToggle={toggleItem}
onChangeCategory={changeCategory} onChangeCategory={changeCategory}
@@ -371,25 +390,27 @@ export function ShoppingListView({
} }
function ItemGroup({ function ItemGroup({
aisle, aisleLabel,
items, items,
showHeader, showHeader,
readOnly, readOnly,
categoryOptions, categoryOptions,
categoryLabel,
tShopping, tShopping,
onToggle, onToggle,
onChangeCategory, onChangeCategory,
onDeleteRequest, onDeleteRequest,
onDragEnd, onDragEnd,
}: { }: {
aisle: string; aisleLabel: string;
items: Item[]; items: Item[];
showHeader: boolean; showHeader: boolean;
readOnly: boolean; readOnly: boolean;
categoryOptions: string[]; categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>; tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void; onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void; onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void; onDeleteRequest: (item: Item) => void;
onDragEnd: (event: DragEndEvent) => void; onDragEnd: (event: DragEndEvent) => void;
}) { }) {
@@ -398,7 +419,7 @@ function ItemGroup({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{showHeader && ( {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"> <div className="rounded-xl border divide-y">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
@@ -410,6 +431,7 @@ function ItemGroup({
readOnly={readOnly} readOnly={readOnly}
draggable={!readOnly} draggable={!readOnly}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping} tShopping={tShopping}
onToggle={onToggle} onToggle={onToggle}
onChangeCategory={onChangeCategory} onChangeCategory={onChangeCategory}
@@ -427,6 +449,7 @@ function FlatItemList({
items, items,
readOnly, readOnly,
categoryOptions, categoryOptions,
categoryLabel,
tShopping, tShopping,
onToggle, onToggle,
onChangeCategory, onChangeCategory,
@@ -435,9 +458,10 @@ function FlatItemList({
items: Item[]; items: Item[];
readOnly: boolean; readOnly: boolean;
categoryOptions: string[]; categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>; tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void; onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void; onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void; onDeleteRequest: (item: Item) => void;
}) { }) {
return ( return (
@@ -448,6 +472,7 @@ function FlatItemList({
item={item} item={item}
readOnly={readOnly} readOnly={readOnly}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping} tShopping={tShopping}
onToggle={onToggle} onToggle={onToggle}
onChangeCategory={onChangeCategory} onChangeCategory={onChangeCategory}
@@ -490,14 +515,16 @@ type ItemRowProps = {
item: Item; item: Item;
readOnly: boolean; readOnly: boolean;
categoryOptions: string[]; categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>; tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void; onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void; onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void; onDeleteRequest: (item: Item) => void;
dragHandle?: React.ReactNode; 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 ( return (
<div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors"> <div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
{dragHandle} {dragHandle}
@@ -534,12 +561,39 @@ function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChang
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger> <DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent className="w-56">
{categoryOptions.map((category) => ( {categoryOptions.map((category) => (
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}> <DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
{category} {categoryLabel(category)}
</DropdownMenuItem> </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> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
+25 -14
View File
@@ -11,11 +11,16 @@ const KNOWN_UNITS = new Set([
"milliliter", "milliliters", "millilitre", "millilitres", "ml", "milliliter", "milliliters", "millilitre", "millilitres", "ml",
"liter", "liters", "litre", "litres", "l", "liter", "liters", "litre", "litres", "l",
"pinch", "pinches", "dash", "dashes", "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", "can", "cans", "jar", "jars", "package", "packages", "pkg",
"bunch", "bunches", "sprig", "sprigs", "stick", "sticks", "bunch", "bunches", "sprig", "sprigs", "stick", "sticks",
"quart", "quarts", "qt", "pint", "pints", "pt", "quart", "quarts", "qt", "pint", "pints", "pt",
"fl", // matches the first token of "fl oz" — handled below "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 // 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 * 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 * the name itself (e.g. rawName: "2 cups flour"), sometimes ALONGSIDE an already-populated
* expected separate fields. Detects a leading quantity (+ optional recognized unit) in * quantity/unit pair (e.g. rawName: "1 avocat", quantity: "2", unit: "pcs" — the redundant
* rawName and pulls it out, so the ingredient name shown to the user is just "flour", not * "1" was never stripped because an earlier version of this function only looked at
* "2 cups flour". Never runs when an explicit quantity was already provided — an entry * rawName when quantity/unit were both empty). This always strips a leading
* with real quantity/unit fields is trusted as-is, this is only a fallback for the case * quantity(+unit) token from the NAME when one is present, so the displayed ingredient is
* where the model (or a copy-pasted ingredient line) merged everything into the name. * 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( export function extractIngredientQuantity(
rawName: string, rawName: string,
quantity: string | undefined, quantity: string | undefined,
unit: string | undefined unit: string | undefined
): { 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); const match = rawName.match(LEADING_NUMBER);
if (!match) return { rawName, quantity, unit }; if (!match) return { rawName, quantity, unit };
@@ -49,20 +55,25 @@ export function extractIngredientQuantity(
let rest = rawName.slice(match[0].length).trim(); let rest = rawName.slice(match[0].length).trim();
if (!rest) return { rawName, quantity, unit }; // nothing left — not actually a name+quantity string 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+(.*)$/); const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/);
if (unitMatch && !extractedUnit) { if (unitMatch) {
const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase(); const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase();
if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) { if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) {
extractedUnit = "fl oz"; strippedUnit = "fl oz";
rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim(); rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim();
} else if (KNOWN_UNITS.has(candidate)) { } else if (KNOWN_UNITS.has(candidate)) {
extractedUnit = candidate; strippedUnit = candidate;
rest = unitMatch[2]!.trim(); rest = unitMatch[2]!.trim();
} }
} }
if (!rest) return { rawName, quantity, unit }; // stripping the unit ate the whole string — bail out 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. * Lightweight keyword-based aisle guesser for shopping list items.
* *
* This is intentionally NOT an exhaustive ingredient database — just a few dozen * This is intentionally NOT an exhaustive ingredient database — just a few dozen
* common keyword -> category mappings covering typical recipe ingredients, so * common keyword -> category mappings covering typical recipe ingredients (in both
* items generated from a meal plan (which never have an explicit `aisle` set * English and French, since this app is bilingual), so items generated from a meal
* today) land in a reasonable category instead of an undifferentiated "Other" * plan (which never have an explicit `aisle` set today) land in a reasonable
* bucket. When nothing matches, returns `null` and the caller falls back to * category instead of an undifferentiated "Other" bucket. When nothing matches,
* "Other" as before. * 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 * Only ever used as a FALLBACK when an item doesn't already have an explicit
* `aisle` — never overrides a user- or API-provided value. * `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 = [ export const GROCERY_CATEGORIES = [
"Produce", "produce",
"Dairy & Eggs", "dairyEggs",
"Meat & Seafood", "meatSeafood",
"Bakery", "bakery",
"Frozen", "frozen",
"Pantry", "pantry",
"Spices & Condiments", "spicesCondiments",
"Beverages", "beverages",
] as const; ] as const;
export type GroceryCategory = (typeof GROCERY_CATEGORIES)[number]; export type GroceryCategory = (typeof GROCERY_CATEGORIES)[number];
// Ordered map of category -> keywords. Checked in order, first match wins, so // Ordered map of category -> keywords (English + French). Checked in order, first
// more specific keywords should generally come before more generic ones. // match wins, so more specific keywords should generally come before more generic ones.
const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [ const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [
["Produce", [ ["produce", [
"lettuce", "spinach", "kale", "arugula", "cabbage", "carrot", "celery", "onion", "lettuce", "spinach", "kale", "arugula", "cabbage", "carrot", "celery", "onion",
"garlic", "shallot", "scallion", "leek", "potato", "sweet potato", "tomato", "garlic", "shallot", "scallion", "leek", "potato", "sweet potato", "tomato",
"cucumber", "zucchini", "squash", "pepper", "chili", "chile", "broccoli", "cucumber", "zucchini", "squash", "pepper", "chili", "chile", "broccoli",
@@ -37,50 +42,88 @@ const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [
"mango", "pineapple", "cilantro", "parsley", "basil", "mint", "dill", "mango", "pineapple", "cilantro", "parsley", "basil", "mint", "dill",
"thyme", "rosemary", "ginger", "corn", "peas", "beans", "asparagus", "thyme", "rosemary", "ginger", "corn", "peas", "beans", "asparagus",
"radish", "beet", "fennel", "herb", "greens", "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", "milk", "cream", "yogurt", "yoghurt", "butter", "cheese", "egg", "eggs",
"sour cream", "cottage cheese", "mascarpone", "ricotta", "buttermilk", "sour cream", "cottage cheese", "mascarpone", "ricotta", "buttermilk",
"half and half", "creme fraiche", "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", "chicken", "beef", "pork", "lamb", "turkey", "bacon", "sausage", "ham",
"steak", "ground beef", "mince", "salmon", "tuna", "shrimp", "prawn", "steak", "ground beef", "mince", "salmon", "tuna", "shrimp", "prawn",
"cod", "tilapia", "fish", "crab", "lobster", "scallop", "mussel", "clam", "cod", "tilapia", "fish", "crab", "lobster", "scallop", "mussel", "clam",
"chorizo", "prosciutto", "duck", "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", "bread", "baguette", "roll", "bun", "bagel", "tortilla", "pita", "naan",
"croissant", "muffin", "brioche", "loaf", "croissant", "muffin", "brioche", "loaf",
// French
"pain", "baguette", "brioche", "croissant", "viennoiserie",
]], ]],
["Frozen", [ ["frozen", [
"frozen", "ice cream", "popsicle", "frozen peas", "frozen berries", "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", "juice", "soda", "water", "coffee", "tea", "wine", "beer", "sparkling",
"kombucha", "cider", "kombucha", "cider",
// French
"jus", "eau", "café", "cafe", "thé", "the", "vin", "bière", "biere",
"cidre",
]], ]],
["Spices & Condiments", [ ["spicesCondiments", [
"salt", "pepper flakes", "cumin", "paprika", "cinnamon", "nutmeg", "salt", "pepper flakes", "cumin", "paprika", "cinnamon", "nutmeg",
"oregano", "turmeric", "cayenne", "curry powder", "chili powder", "spice", "oregano", "turmeric", "cayenne", "curry powder", "chili powder", "spice",
"vanilla", "ketchup", "mustard", "mayo", "mayonnaise", "soy sauce", "vanilla", "ketchup", "mustard", "mayo", "mayonnaise", "soy sauce",
"hot sauce", "vinegar", "olive oil", "vegetable oil", "sesame oil", "hot sauce", "vinegar", "olive oil", "vegetable oil", "sesame oil",
"honey", "maple syrup", "jam", "sauce", "dressing", "salsa", "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", "flour", "sugar", "rice", "pasta", "noodle", "spaghetti", "quinoa",
"oats", "oatmeal", "cereal", "beans", "lentil", "chickpea", "canned", "oats", "oatmeal", "cereal", "beans", "lentil", "chickpea", "canned",
"stock", "broth", "bouillon", "yeast", "baking powder", "baking soda", "stock", "broth", "bouillon", "yeast", "baking powder", "baking soda",
"cornstarch", "breadcrumb", "nut", "almond", "walnut", "peanut", "cashew", "cornstarch", "breadcrumb", "nut", "almond", "walnut", "peanut", "cashew",
"chocolate", "cocoa", "coconut milk", "tomato paste", "tomato sauce", "chocolate", "cocoa", "coconut milk", "tomato paste", "tomato sauce",
"crushed tomato", "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 * Guesses a grocery category from a raw ingredient name via simple keyword
* keyword matching. Returns `null` when nothing matches (caller should fall * matching (English + French). Returns `null` when nothing matches (caller
* back to "Other"). * should fall back to "Other").
*/ */
export function guessAisle(rawName: string): GroceryCategory | null { export function guessAisle(rawName: string): GroceryCategory | null {
const name = rawName.toLowerCase().trim(); 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. * untouched and just flag `inPantry` so the user can decide for themselves.
*/ */
import { extractIngredientQuantity } from "./extract-ingredient-quantity";
export type PantrySourceItem = { export type PantrySourceItem = {
ingredientId: string | null; ingredientId: string | null;
rawName: string; 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)[] }>(); const groups = new Map<string, { rawName: string; ingredientId: string | null; unit: string | null; entries: (string | null)[] }>();
for (const ing of ingredients) { 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 const groupKey = ing.ingredientId
? `id:${ing.ingredientId}:${unitKey}` ? `id:${ing.ingredientId}:${unitKey}`
: `name:${normalizeName(ing.rawName)}:${unitKey}`; : `name:${normalizeName(cleaned.rawName)}:${unitKey}`;
let group = groups.get(groupKey); let group = groups.get(groupKey);
if (!group) { 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); groups.set(groupKey, group);
} }
group.entries.push(ing.quantity); group.entries.push(cleaned.quantity ?? null);
} }
return Array.from(groups.values()).map((group) => { return Array.from(groups.values()).map((group) => {
+14 -1
View File
@@ -826,7 +826,20 @@
"listDeleted": "List deleted", "listDeleted": "List deleted",
"listDeleteFailed": "Failed to delete list", "listDeleteFailed": "Failed to delete list",
"changeCategory": "Change category", "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": { "collections": {
"title": "Collections", "title": "Collections",
+14 -1
View File
@@ -814,7 +814,20 @@
"listDeleted": "Liste supprimée", "listDeleted": "Liste supprimée",
"listDeleteFailed": "Échec de la suppression de la liste", "listDeleteFailed": "Échec de la suppression de la liste",
"changeCategory": "Changer de catégorie", "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": { "collections": {
"title": "Collections", "title": "Collections",