fix(inventory): sluta gissa utgangsdatum + las 2-siffriga bast-fore-stamplar
CI / Typecheck, test & build (push) Failing after 46s

BUG: forseglad vara utan tryckt datum fick fabricerat utgang (purchasedAt + shelfLifeGuidance, som ar hallbarhet EFTER oppning) -> falska 'gar ut snart'-varningar OCH gav sken av att datum inte lastes. Nu: ingen data -> unknown (hedrar appens egen princip 'aldrig gissa'). parseDate laser aven YY-MM-DD/YYMMDD/YYYYMMDD (svenska stamplar) som forr slangdes. Regressionstester tillagda. 36/36 gront.
This commit is contained in:
Claude
2026-08-17 19:22:54 +00:00
parent 3ed12b274d
commit e66d9aee72
4 changed files with 49 additions and 12 deletions
+23 -3
View File
@@ -182,13 +182,30 @@ const geminiFridgeResponseSchema = z.object({
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]), 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; if (!value) return null;
const normalized = value.trim(); const normalized = value.trim();
if (!normalized) return null; 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; 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(/[-/.]/); const parts = normalized.split(/[-/.]/);
if (parts.length === 3) { if (parts.length === 3) {
const [aStr, bStr, cStr] = parts; const [aStr, bStr, cStr] = parts;
@@ -203,6 +220,9 @@ function parseDate(value: string | null | undefined): string | null {
// DD-MM-YYYY // DD-MM-YYYY
if (c > 2000 && b <= 12 && a <= 31) if (c > 2000 && b <= 12 && a <= 31)
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`; 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; return null;
} }
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; 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"; import type { AamosRequestEnvelope } from "../src/tasks.js";
const FIXTURE_RESPONSE = { const FIXTURE_RESPONSE = {
@@ -159,3 +159,14 @@ describe("GeminiAamosClient", () => {
expect(result.error).toContain("Kontraktsfel"); 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();
});
});
+4 -8
View File
@@ -79,15 +79,11 @@ export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): Ex
factor: "opened_guidance", factor: "opened_guidance",
date: new Date(opened.getTime() + guidanceDays * DAY_MS), 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) { if (candidates.length === 0) {
return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false }; return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
@@ -54,6 +54,16 @@ describe("bäst före-klassning (spec §13)", () => {
it("ingen data → unknown, aldrig gissning", () => { it("ingen data → unknown, aldrig gissning", () => {
expect(classifyExpiry({}, TODAY).status).toBe("unknown"); 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", () => { 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 // 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 // automatiskt dålig användaren ska lukta/smaka. Appen får aldrig