Files
Cibello-app/packages/recipe-engine/src/matching.ts
T
2026-08-05 19:21:11 +07:00

104 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (01). */
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 §1718).
* 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),
};
}