/** * 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; }; 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; } function normalizeUnit(unit: string | null | undefined): string { return (unit ?? "").toLowerCase().trim(); } 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 }; }); }