Files
Epicure/apps/web/lib/pantry-shopping-match.ts
T
Arnaud b243cc3b8b feat: expand ingredient-alias seed list to ~137 entries, ignore accents in name matching (v0.86.0)
Ingredient-alias matching (pantry, can-cook, auto-deduct, shopping-list generation) now covers common bilingual EN/FR ingredients across all 8 grocery categories, up from ~10 staples.

Also: matching now ignores accents as well as case (NFD-normalize + strip combining marks) in both the alias resolver (lib/ingredient-match.ts) and the older name-fallback matcher (pantry-shopping-match.ts) — "café"/"Café"/"cafe" and "Épinard"/"epinard" all recognize as the same ingredient without needing every accent variant manually listed as an alias.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 19:07:57 +02:00

187 lines
7.6 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";
import { resolveIngredientKey, type IngredientAliasIndex } from "./ingredient-match";
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 {
// Accent-insensitive too ("café"/"cafe") — NFD splits an accented letter
// into base letter + combining mark, then the marks (U+0300-U+036F) are
// stripped.
const trimmed = name.toLowerCase().trim().normalize("NFD").replace(/[̀-ͯ]/g, "").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[],
aliasIndex?: IngredientAliasIndex
): 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);
}
// Index under both the plain normalized name and its alias-resolved
// canonical key (e.g. "sel fin" also indexes under salt's canonical
// id) — a shopping item written as "sel" then still finds it.
for (const key of new Set([normalizeName(p.rawName), aliasIndex ? resolveIngredientKey(p.rawName, aliasIndex) : null])) {
if (!key) continue;
const list = pantryByName.get(key) ?? [];
list.push(p);
pantryByName.set(key, 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)) ??
(aliasIndex ? pantryByName.get(resolveIngredientKey(item.rawName, aliasIndex)) : undefined);
}
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 };
});
}