Files
Epicure/apps/web/lib/pantry-shopping-match.ts
Arnaud 51a323b054 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>
2026-07-10 11:03:11 +02:00

175 lines
6.9 KiB
TypeScript

/**
* Conservative pantry-awareness for shopping list generation.
*
* Matching strategy:
* 1. Reliable path: if both the shopping item and a pantry item reference the same
* normalized `ingredientId`, they're the same ingredient.
* 2. Fallback: normalize free-text `rawName` (case/whitespace/simple plural) and match exactly.
* No fuzzy/AI matching — when normalization doesn't produce an exact match, treat as unmatched.
*
* Quantity handling: only subtract pantry quantity from the needed quantity when both
* are parseable numbers in the same (normalized) unit. Otherwise leave the needed quantity
* 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;
quantity: string | null;
unit: string | null;
};
export type ShoppingSourceItem = {
rawName: string;
quantity?: string;
unit?: string;
ingredientId?: string | null;
};
export type PantryAdjustedItem = {
rawName: string;
quantity?: string;
unit?: string;
inPantry: boolean;
};
export function normalizeName(name: string): string {
const trimmed = name.toLowerCase().trim().replace(/\s+/g, " ");
// Simple plural trim — strip a single trailing "s" for words longer than 3 chars.
return trimmed.length > 3 && trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed;
}
export function normalizeUnit(unit: string | null | undefined): string {
return (unit ?? "").toLowerCase().trim();
}
export function formatQuantity(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, "");
}
/**
* Reduces (or flags) each shopping item's needed quantity based on what's already in the pantry.
* Never removes an item outright when the match is uncertain — always returns one entry per input item.
*/
export function applyPantryToItems(
items: ShoppingSourceItem[],
pantry: PantrySourceItem[]
): PantryAdjustedItem[] {
const pantryByIngredientId = new Map<string, PantrySourceItem[]>();
const pantryByName = new Map<string, PantrySourceItem[]>();
for (const p of pantry) {
if (p.ingredientId) {
const list = pantryByIngredientId.get(p.ingredientId) ?? [];
list.push(p);
pantryByIngredientId.set(p.ingredientId, list);
}
const nameKey = normalizeName(p.rawName);
const list = pantryByName.get(nameKey) ?? [];
list.push(p);
pantryByName.set(nameKey, list);
}
return items.map((item) => {
let matches: PantrySourceItem[] | undefined;
if (item.ingredientId && pantryByIngredientId.has(item.ingredientId)) {
matches = pantryByIngredientId.get(item.ingredientId);
} else {
matches = pantryByName.get(normalizeName(item.rawName));
}
if (!matches || matches.length === 0) {
return { rawName: item.rawName, quantity: item.quantity, unit: item.unit, inPantry: false };
}
const neededQty = item.quantity ? parseFloat(item.quantity) : NaN;
const neededUnit = normalizeUnit(item.unit);
if (!isNaN(neededQty) && neededQty > 0) {
// Only sum pantry entries whose unit matches the needed unit (including both-empty).
const compatible = matches.filter((m) => normalizeUnit(m.unit) === neededUnit);
const parseableQuantities = compatible
.map((m) => (m.quantity ? parseFloat(m.quantity) : NaN))
.filter((n) => !isNaN(n));
if (compatible.length > 0 && parseableQuantities.length === compatible.length) {
const havingQty = parseableQuantities.reduce((sum, n) => sum + n, 0);
const remaining = neededQty - havingQty;
if (remaining <= 0) {
// Fully covered — still surfaced in the list (flagged), not silently dropped.
return { rawName: item.rawName, quantity: undefined, unit: item.unit, inPantry: true };
}
return { rawName: item.rawName, quantity: formatQuantity(remaining), unit: item.unit, inPantry: true };
}
}
// Matched the ingredient, but quantities/units aren't reliably comparable — flag only.
return { rawName: item.rawName, quantity: item.quantity, unit: item.unit, inPantry: true };
});
}
export type MergeSourceIngredient = {
rawName: string;
quantity: string | null;
unit: string | null;
ingredientId: string | null;
};
export type MergedIngredient = {
rawName: string;
quantity?: string;
unit?: string;
ingredientId: string | null;
};
/**
* Merges ingredients across recipes (e.g. an entire week's meal plan) so a shopping
* list doesn't show the same ingredient once per recipe. Groups by normalized
* ingredientId (if present) or normalized name+unit, then SUMS quantities across the
* group — the earlier version of this logic kept only the first occurrence and
* silently dropped every other recipe's requirement, which under-shopped whenever the
* same ingredient appeared in more than one recipe.
*
* Items with the same name but incompatible/unparseable units are kept as separate
* line items rather than guessed at — never invent a converted total.
*/
export function mergeIngredients(ingredients: MergeSourceIngredient[]): MergedIngredient[] {
const groups = new Map<string, { rawName: string; ingredientId: string | null; unit: string | null; entries: (string | null)[] }>();
for (const ing of ingredients) {
// 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(cleaned.rawName)}:${unitKey}`;
let group = groups.get(groupKey);
if (!group) {
group = { rawName: cleaned.rawName, ingredientId: ing.ingredientId, unit: cleaned.unit ?? null, entries: [] };
groups.set(groupKey, group);
}
group.entries.push(cleaned.quantity ?? null);
}
return Array.from(groups.values()).map((group) => {
const parsed = group.entries.map((q) => (q ? parseFloat(q) : NaN));
const allParseable = parsed.length > 0 && parsed.every((n) => !isNaN(n));
if (allParseable) {
const total = parsed.reduce((sum, n) => sum + n, 0);
return { rawName: group.rawName, quantity: formatQuantity(total), unit: group.unit ?? undefined, ingredientId: group.ingredientId };
}
// Unparseable or mixed with/without quantity — can't sum reliably, don't guess.
// Fall back to no quantity shown rather than an incorrect total.
return { rawName: group.rawName, quantity: undefined, unit: group.unit ?? undefined, ingredientId: group.ingredientId };
});
}