diff --git a/packages/ai-contracts/src/gemini.ts b/packages/ai-contracts/src/gemini.ts index 5aa2cb7..780f857 100644 --- a/packages/ai-contracts/src/gemini.ts +++ b/packages/ai-contracts/src/gemini.ts @@ -182,13 +182,30 @@ const geminiFridgeResponseSchema = z.object({ imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]), }); -function parseDate(value: string | null | undefined): string | null { +export function parseDate(value: string | null | undefined): string | null { if (!value) return null; const normalized = value.trim(); if (!normalized) return null; - // Accept ISO dates (YYYY-MM-DD) only. + // Accept ISO dates (YYYY-MM-DD). if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized; - // Try common Swedish/EU formats. + // Kompakta stämplar utan separator (vanligt på svensk förpackning): YYYYMMDD / YYMMDD. + if (/^\d{8}$/.test(normalized)) { + const y = +normalized.slice(0, 4), + m = +normalized.slice(4, 6), + d = +normalized.slice(6, 8); + if (y > 2000 && m >= 1 && m <= 12 && d >= 1 && d <= 31) + return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + return null; + } + if (/^\d{6}$/.test(normalized)) { + const y = 2000 + +normalized.slice(0, 2), + m = +normalized.slice(2, 4), + d = +normalized.slice(4, 6); + if (m >= 1 && m <= 12 && d >= 1 && d <= 31) + return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + return null; + } + // Separatorformat (- / .). const parts = normalized.split(/[-/.]/); if (parts.length === 3) { const [aStr, bStr, cStr] = parts; @@ -203,6 +220,9 @@ function parseDate(value: string | null | undefined): string | null { // DD-MM-YYYY if (c > 2000 && b <= 12 && a <= 31) return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`; + // YY-MM-DD (2-siffrigt år först — vanligast på svensk bäst-före-stämpel). + if (a <= 99 && b >= 1 && b <= 12 && c >= 1 && c <= 31) + return `${2000 + a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`; } return null; } diff --git a/packages/ai-contracts/test/gemini.test.ts b/packages/ai-contracts/test/gemini.test.ts index 90c916b..97de31a 100644 --- a/packages/ai-contracts/test/gemini.test.ts +++ b/packages/ai-contracts/test/gemini.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { GeminiAamosClient, MemoryBudgetStore } from "../src/gemini.js"; +import { GeminiAamosClient, MemoryBudgetStore, parseDate } from "../src/gemini.js"; import type { AamosRequestEnvelope } from "../src/tasks.js"; const FIXTURE_RESPONSE = { @@ -159,3 +159,14 @@ describe("GeminiAamosClient", () => { expect(result.error).toContain("Kontraktsfel"); }); }); + +describe("parseDate (svenska bäst-före-stämplar)", () => { + it("läser 2-siffrigt år och kompakta format", () => { + expect(parseDate("25-08-30")).toBe("2025-08-30"); // YY-MM-DD + expect(parseDate("250830")).toBe("2025-08-30"); // YYMMDD + expect(parseDate("20250830")).toBe("2025-08-30"); // YYYYMMDD + expect(parseDate("2025-08-30")).toBe("2025-08-30"); // ISO + expect(parseDate("30-08-2025")).toBe("2025-08-30"); // DD-MM-YYYY + expect(parseDate("skräp")).toBeNull(); + }); +}); diff --git a/packages/inventory-engine/src/expiry.ts b/packages/inventory-engine/src/expiry.ts index f5e0757..4281357 100644 --- a/packages/inventory-engine/src/expiry.ts +++ b/packages/inventory-engine/src/expiry.ts @@ -79,15 +79,11 @@ export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): Ex 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), - }); - } } + // shelfLifeGuidance = hållbarhet EFTER öppning (entities.ts). Vi gissar ALDRIG + // utgång från inköpsdatum på en oöppnad vara — utan riktigt tryckt datum blir + // status "unknown" (spec: "ingen data → unknown, aldrig gissning"). Den gamla + // purchased_guidance-grenen gav falska "går ut snart"-varningar på förseglade varor. if (candidates.length === 0) { return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false }; diff --git a/packages/inventory-engine/test/engine.test.ts b/packages/inventory-engine/test/engine.test.ts index 4fb7f3c..fba5519 100644 --- a/packages/inventory-engine/test/engine.test.ts +++ b/packages/inventory-engine/test/engine.test.ts @@ -54,6 +54,16 @@ describe("bäst före-klassning (spec §13)", () => { it("ingen data → unknown, aldrig gissning", () => { expect(classifyExpiry({}, TODAY).status).toBe("unknown"); }); + it("förseglad vara: inköpsdatum + riktvärde men INGET tryckt datum → unknown", () => { + // Regressionsvakt: gamla purchased_guidance-grenen gissade utgång från inköp + // på oöppnade varor → falska "går ut snart"-varningar (Bug B). + const result = classifyExpiry( + { purchasedAt: "2026-08-01", storageLocationType: "fridge", shelfLifeGuidance: { fridge: 7 } }, + TODAY, + ); + expect(result.status).toBe("unknown"); + expect(result.limitingFactor).toBe("none"); + }); it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => { // Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte // automatiskt dålig – användaren ska lukta/smaka. Appen får aldrig