b243cc3b8b
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>
187 lines
7.6 KiB
TypeScript
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 };
|
|
});
|
|
}
|