128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
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<Record<StorageLocationType, number>> | 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<ExpiryStatus, number> = {
|
||
expired: 0,
|
||
expiring: 1,
|
||
use_soon: 2,
|
||
fresh: 3,
|
||
unknown: 4,
|
||
};
|
||
return order[result.status] * 10_000 + (result.daysLeft ?? 9_000);
|
||
}
|