feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix
- Fixed a real i18n bug: the checked-count line called the wrong translation namespace and rendered the literal key on screen - Shopping lists can now be renamed and deleted from both the list index and detail pages (API already supported delete; rename was net new) - Root-caused "long list UI is off": meal-plan-generated lists never set an aisle, so every item fell into one undifferentiated "Other" bucket despite the grouping UI existing. Added a keyword-based aisle guesser wired into list generation (fallback only, never overrides an explicit aisle) plus a one-click "auto-categorize" for existing lists - Items can now be reordered by drag-and-drop within a category (dnd-kit), recategorized via a dropdown, deleted, searched, and sorted (category / alphabetical / unchecked-first); searching flattens the grouped view - Fixed a separate bug: AI-generated ingredients sometimes embedded the quantity/unit in the name itself (e.g. "2 cups flour" as one string). Added extractIngredientQuantity() as a Zod transform at both recipe create/update routes (the choke point every creation path funnels through) to split it back out, plus schema descriptions on the AI ingredient schemas as a prevention layer New migration 0028 (shopping_list_items.sort_order), left unapplied like the others. Verified with typecheck, lint, and a clean --no-cache docker build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { parseQuantity } from "./parse-quantity";
|
||||
|
||||
const KNOWN_UNITS = new Set([
|
||||
"cup", "cups", "c",
|
||||
"tablespoon", "tablespoons", "tbsp", "tbsps", "tbs",
|
||||
"teaspoon", "teaspoons", "tsp", "tsps",
|
||||
"gram", "grams", "g",
|
||||
"kilogram", "kilograms", "kg",
|
||||
"ounce", "ounces", "oz",
|
||||
"pound", "pounds", "lb", "lbs",
|
||||
"milliliter", "milliliters", "millilitre", "millilitres", "ml",
|
||||
"liter", "liters", "litre", "litres", "l",
|
||||
"pinch", "pinches", "dash", "dashes",
|
||||
"clove", "cloves", "slice", "slices", "piece", "pieces",
|
||||
"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
|
||||
]);
|
||||
|
||||
// Leading numeric token: plain int/decimal, unicode fraction, mixed number ("1 1/2"), or
|
||||
// simple fraction ("1/2") — mirrors what parseQuantity already knows how to parse.
|
||||
const LEADING_NUMBER =
|
||||
/^\s*(\d+\s+\d+\s*\/\s*\d+|\d+\s*\/\s*\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])\s*/;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 };
|
||||
|
||||
const numberToken = match[1]!.trim();
|
||||
const parsedQuantity = parseQuantity(numberToken);
|
||||
if (parsedQuantity === undefined) return { rawName, quantity, unit };
|
||||
|
||||
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;
|
||||
const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/);
|
||||
if (unitMatch && !extractedUnit) {
|
||||
const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase();
|
||||
if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) {
|
||||
extractedUnit = "fl oz";
|
||||
rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim();
|
||||
} else if (KNOWN_UNITS.has(candidate)) {
|
||||
extractedUnit = 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user