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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user