import { EXPIRY_THRESHOLDS, type ExpiryStatus, type StorageLocationType } from "@app/shared-types"; export interface ExpiryInput { bestBeforeDate?: string | null; useByDate?: string | null; openedAt?: string | null; frozenAt?: string | null; thawedAt?: string | null; storageLocationType?: StorageLocationType | null; /** Riktvärde i dagar efter öppning/inköp per plats (från canonical ingredient). */ shelfLifeGuidance?: Partial> | null; purchasedAt?: string | null; } export interface ExpiryResult { status: ExpiryStatus; /** Dagar kvar till den mest bindande gränsen. Negativt = passerad. */ daysLeft: number | null; /** Vilken gräns som styr klassningen. */ limitingFactor: "use_by" | "best_before" | "opened_guidance" | "purchased_guidance" | "none"; /** Bäst före passerad men troligen ätbar – kommunicera skillnaden (spec §13). */ pastBestBefore: boolean; } const DAY_MS = 86_400_000; function daysBetween(from: Date, to: Date): number { return Math.floor((startOfDay(to).getTime() - startOfDay(from).getTime()) / DAY_MS); } /** * All kalenderaritmetik sker i UTC (deterministiskt oavsett serverns tidszon). * Datumsträngar ("2026-08-05") är kalenderdatum och tolkas som UTC-midnatt; * resultatet blir identiskt på en server i Stockholm, Auckland eller UTC. */ function startOfDay(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } function parse(dateStr: string | null | undefined): Date | null { if (!dateStr) return null; const d = new Date(`${dateStr}T00:00:00Z`); return Number.isNaN(d.getTime()) ? null : d; } /** * Deterministisk bäst före-klassning (spec §13). * * Regler: * - Fryst vara ("frozenAt" satt, ej upptinad): datumklockan pausad → "fresh" * (kvalitetsförsämring hanteras som vägledning i UI, inte som utgång). * - "Sista förbrukningsdag" är hård gräns → efter den: "expired". * - "Bäst före" passerad: "expired"-klass används INTE automatiskt – varan * flaggas "use_soon/expiring" med pastBestBefore=true. Appen får aldrig * garantera säkerhet (spec §61.3) – användaren avgör. * - Öppnad vara: öppningsdatum + riktvärde per förvaringsplats kan vara mer * bindande än tryckt datum. */ export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): ExpiryResult { const isFrozen = Boolean(input.frozenAt) && !input.thawedAt; if (isFrozen) { return { status: "fresh", daysLeft: null, limitingFactor: "none", pastBestBefore: false }; } const candidates: Array<{ factor: ExpiryResult["limitingFactor"]; date: Date }> = []; const useBy = parse(input.useByDate); if (useBy) candidates.push({ factor: "use_by", date: useBy }); const bestBefore = parse(input.bestBeforeDate); if (bestBefore) candidates.push({ factor: "best_before", date: bestBefore }); const guidanceDays = input.storageLocationType ? input.shelfLifeGuidance?.[input.storageLocationType] : undefined; const opened = parse(input.openedAt); if (opened && guidanceDays != null) { candidates.push({ factor: "opened_guidance", date: new Date(opened.getTime() + guidanceDays * DAY_MS), }); } else if (!useBy && !bestBefore && guidanceDays != null) { const purchased = parse(input.purchasedAt); if (purchased) { candidates.push({ factor: "purchased_guidance", date: new Date(purchased.getTime() + guidanceDays * DAY_MS), }); } } if (candidates.length === 0) { return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false }; } // Mest bindande = tidigaste datum. candidates.sort((a, b) => a.date.getTime() - b.date.getTime()); const limiting = candidates[0]!; const daysLeft = daysBetween(today, limiting.date); const pastBestBefore = bestBefore ? daysBetween(today, bestBefore) < 0 : false; let status: ExpiryStatus; if (daysLeft < 0) { // Hård gräns endast för sista förbrukningsdag; annars "expiring" + flagga. status = limiting.factor === "use_by" ? "expired" : "expiring"; } else if (daysLeft <= EXPIRY_THRESHOLDS.EXPIRING_DAYS) { status = "expiring"; } else if (daysLeft <= EXPIRY_THRESHOLDS.USE_SOON_DAYS) { status = "use_soon"; } else { status = "fresh"; } return { status, daysLeft, limitingFactor: limiting.factor, pastBestBefore }; } /** Sorteringsnyckel: mest brådskande först, okända sist. */ export function urgencyRank(result: ExpiryResult): number { const order: Record = { expired: 0, expiring: 1, use_soon: 2, fresh: 3, unknown: 4, }; return order[result.status] * 10_000 + (result.daysLeft ?? 9_000); }