/** * 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. */ 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(); const pantryByName = new Map(); 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(); for (const ing of ingredients) { const unitKey = normalizeUnit(ing.unit); const groupKey = ing.ingredientId ? `id:${ing.ingredientId}:${unitKey}` : `name:${normalizeName(ing.rawName)}:${unitKey}`; let group = groups.get(groupKey); if (!group) { group = { rawName: ing.rawName, ingredientId: ing.ingredientId, unit: ing.unit, entries: [] }; groups.set(groupKey, group); } group.entries.push(ing.quantity); } 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 }; }); }