104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
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<string, IngredientUnitInfo>,
|
||
today: Date = new Date(),
|
||
): CoverageResult {
|
||
const byIngredient = new Map<string, PantryItem[]>();
|
||
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),
|
||
};
|
||
}
|