import type { Unit } from "@app/shared-types"; import { convert, type IngredientUnitInfo } from "@app/nutrition-engine"; import { classifyExpiry, type ExpiryInput } from "@app/inventory-engine"; export interface PantryItem extends ExpiryInput { id: string; canonicalIngredientId?: string | null; quantity: number; unit: Unit; } export interface RecipeIngredientRequirement { canonicalIngredientId: string; displayNameSv: string; quantity: number; unit: Unit; optional: boolean; } export interface IngredientMatch { canonicalIngredientId: string; displayNameSv: string; required: number; unit: Unit; availableInUnit: number; covered: boolean; optional: boolean; /** Mest brådskande status bland matchande lagerposter. */ mostUrgentDaysLeft: number | null; usesExpiringItem: boolean; } export interface CoverageResult { /** Andel obligatoriska ingredienser som täcks helt (0–1). */ coverage: number; matches: IngredientMatch[]; missing: IngredientMatch[]; /** Ingredienser som finns hemma och snart går ut – guld för rekommendationen. */ expiringUsed: IngredientMatch[]; } /** * Matcha receptets ingredienser mot hushållets lager (spec §17–18). * Enhetskonvertering via nutrition-engine; poster som inte kan konverteras * räknas som otäckta i stället för att gissas. */ export function computeCoverage( requirements: RecipeIngredientRequirement[], pantry: PantryItem[], unitInfo: Map, today: Date = new Date(), ): CoverageResult { const byIngredient = new Map(); for (const item of pantry) { if (!item.canonicalIngredientId || item.quantity <= 0) continue; const list = byIngredient.get(item.canonicalIngredientId) ?? []; list.push(item); byIngredient.set(item.canonicalIngredientId, list); } const matches: IngredientMatch[] = []; for (const req of requirements) { const stock = byIngredient.get(req.canonicalIngredientId) ?? []; const info = unitInfo.get(req.canonicalIngredientId) ?? {}; let available = 0; let mostUrgent: number | null = null; let usesExpiring = false; for (const item of stock) { const inReqUnit = convert(item.quantity, item.unit, req.unit, info); if (inReqUnit == null) continue; available += inReqUnit; const expiry = classifyExpiry(item, today); if (expiry.daysLeft != null) { mostUrgent = mostUrgent == null ? expiry.daysLeft : Math.min(mostUrgent, expiry.daysLeft); } if (expiry.status === "expiring" || expiry.status === "use_soon") usesExpiring = true; } matches.push({ canonicalIngredientId: req.canonicalIngredientId, displayNameSv: req.displayNameSv, required: req.quantity, unit: req.unit, availableInUnit: Math.round(available * 1000) / 1000, covered: available + 1e-9 >= req.quantity, optional: req.optional, mostUrgentDaysLeft: mostUrgent, usesExpiringItem: usesExpiring, }); } const mandatory = matches.filter((m) => !m.optional); const coveredCount = mandatory.filter((m) => m.covered).length; const coverage = mandatory.length === 0 ? 1 : coveredCount / mandatory.length; return { coverage: Math.round(coverage * 100) / 100, matches, missing: matches.filter((m) => !m.covered), expiringUsed: matches.filter((m) => m.covered && m.usesExpiringItem), }; }