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:
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user