diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts index ed84c475c..e77e0b794 100644 --- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -21,6 +21,11 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +const tzMock = vi.hoisted(() => ({ zone: "UTC" })); +vi.mock("@/lib/tz/resolver", async (importOriginal) => ({ + ...(await importOriginal()), + resolveUserTimezone: vi.fn(async () => tzMock.zone), +})); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -84,6 +89,7 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); + tzMock.zone = "UTC"; vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); @@ -133,3 +139,62 @@ describe("POST /api/labs/ocr/commit dedup", () => { expect(body.data.skipped).toHaveLength(0); }); }); + +describe("POST /api/labs/ocr/commit dedup across the user's day", () => { + /** + * A database stand-in that honours the `takenAt` range the route asks for, + * so the test sees what the real query would return. + */ + function liveReadings(rows: Array<{ value: number; takenAt: Date }>) { + vi.mocked(prisma.labResult.findMany).mockImplementation(((args: { + where: { takenAt: { gte?: Date; lt?: Date; lte?: Date } }; + }) => { + const { gte, lt, lte } = args.where.takenAt; + return Promise.resolve( + rows + .filter( + (r) => + (!gte || r.takenAt >= gte) && + (!lt || r.takenAt < lt) && + (!lte || r.takenAt <= lte), + ) + .map((r) => ({ ...r, valueText: null })), + ); + }) as never); + } + + it("skips a scanned reading already entered by hand on the same local day east of UTC", async () => { + tzMock.zone = "Asia/Tokyo"; + // Entered by hand: 2026-06-10 08:00 in Tokyo, the 9th in UTC. + liveReadings([ + { value: 95, takenAt: new Date("2026-06-09T23:00:00.000Z") }, + ]); + // The scan of the same report states the 10th (stored at noon UTC). + const res = await POST( + postReq({ + rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }], + }), + ); + const body = (await res.json()) as { + data: { skipped: Array<{ reason: string }> }; + }; + expect(body.data.skipped).toEqual([ + { analyte: "Glucose", reason: "duplicate" }, + ]); + expect(prisma.labResult.create).not.toHaveBeenCalled(); + }); + + it("writes a reading from the neighbouring local day", async () => { + tzMock.zone = "Asia/Tokyo"; + // 2026-06-09 08:00 in Tokyo: a different day from the scanned 10th. + liveReadings([ + { value: 95, takenAt: new Date("2026-06-08T23:00:00.000Z") }, + ]); + await POST( + postReq({ + rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }], + }), + ); + expect(prisma.labResult.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 5fd00dc2b..6f193ef36 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -47,29 +47,37 @@ import { type OcrCommitRow, type OcrSkippedRowDto, } from "@/lib/validations/labs-ocr"; +import { + labReadingDay, + labReadingDaySearchRange, +} from "@/lib/labs/reading-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; export const POST = apiHandler(withIdempotency<[NextRequest]>(commitOcrRows)); -/** True when a live reading already records this analyte+day+value. */ +/** + * True when a live reading already records this analyte+day+value. The day + * is the reading's calendar day (`labReadingDay`), not its UTC day: a + * hand-entered morning draw east of UTC sits on the previous UTC day, and the + * UTC window let the scan of the same report write it again. + */ async function isDuplicate( userId: string, row: OcrCommitRow, + tz: string, ): Promise { - const dayStart = new Date(row.takenAt); - dayStart.setUTCHours(0, 0, 0, 0); - const dayEnd = new Date(row.takenAt); - dayEnd.setUTCHours(23, 59, 59, 999); - - const candidates = await prisma.labResult.findMany({ + const day = labReadingDay(new Date(row.takenAt), tz); + const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, analyte: { equals: row.analyte.trim(), mode: "insensitive" }, - takenAt: { gte: dayStart, lte: dayEnd }, + takenAt: labReadingDaySearchRange(day), }, - select: { value: true, valueText: true }, - take: 25, + select: { value: true, valueText: true, takenAt: true }, + take: 100, }); + const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day); for (const c of candidates) { if (row.value !== undefined && c.value !== null && c.value === row.value) { return true; @@ -86,15 +94,15 @@ async function isDuplicate( } /** - * Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day - * + the value dimension that `isDuplicate` matches on. Two rows in ONE document + * Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + the + * reading's calendar day + the value dimension that `isDuplicate` matches on. Two rows in ONE document * that resolve to the same key are the same reading; only the first is written. * Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends * on a prior row autocommitting before the next row's live query runs. */ -function inBatchKey(row: OcrCommitRow): string { +function inBatchKey(row: OcrCommitRow, tz: string): string { const analyte = row.analyte.trim().toLowerCase(); - const dayKey = new Date(row.takenAt).toISOString().slice(0, 10); + const dayKey = labReadingDay(new Date(row.takenAt), tz); const valuePart = row.value !== undefined ? `n:${row.value}` @@ -145,9 +153,10 @@ async function commitOcrRows(request: NextRequest) { // is benign and self-healing — the manual lab-write path mints the same way. const writtenInBatch = new Set(); + const tz = await resolveUserTimezone(user.id); for (const row of parsed.data.rows) { - const key = inBatchKey(row); - if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) { + const key = inBatchKey(row, tz); + if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) { skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" }); continue; } diff --git a/src/lib/labs/__tests__/reading-day.test.ts b/src/lib/labs/__tests__/reading-day.test.ts new file mode 100644 index 000000000..e0d2c452f --- /dev/null +++ b/src/lib/labs/__tests__/reading-day.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { labReadingDay, labReadingDaySearchRange } from "../reading-day"; + +describe("labReadingDay", () => { + it("reads a date-only reading as its stated date in every zone", () => { + const noon = new Date("2026-06-10T12:00:00.000Z"); + const legacyMidnight = new Date("2026-06-10T00:00:00.000Z"); + for (const tz of ["America/Los_Angeles", "Pacific/Kiritimati", "UTC"]) { + expect(labReadingDay(noon, tz)).toBe("2026-06-10"); + expect(labReadingDay(legacyMidnight, tz)).toBe("2026-06-10"); + } + }); + + it("reads a hand-entered instant on the user's own day", () => { + const draw = new Date("2026-06-09T23:00:00.000Z"); // 08:00 on the 10th in Tokyo + expect(labReadingDay(draw, "Asia/Tokyo")).toBe("2026-06-10"); + expect(labReadingDay(draw, "America/Los_Angeles")).toBe("2026-06-09"); + }); + + it("searches a range that holds the day's readings in any zone", () => { + const range = labReadingDaySearchRange("2026-06-10"); + const kiritimatiMorning = new Date("2026-06-09T10:30:00.000Z"); // 00:30 on the 10th at UTC+14 + const utcMinus12Late = new Date("2026-06-11T10:30:00.000Z"); // 22:30 on the 10th at UTC-12 + expect(kiritimatiMorning >= range.gte && kiritimatiMorning < range.lt).toBe( + true, + ); + expect(utcMinus12Late >= range.gte && utcMinus12Late < range.lt).toBe(true); + }); +}); diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index 4329211b8..bd1872833 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -36,6 +36,11 @@ import { type OcrExtractedRowDto, OCR_MAX_ROWS, } from "@/lib/validations/labs-ocr"; +import { + labReadingDay, + labReadingDaySearchRange, +} from "@/lib/labs/reading-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; const SYSTEM_PROMPT = `You transcribe a photograph or PDF of a laboratory test report into structured data. @@ -133,6 +138,7 @@ async function annotateRow( userId: string, row: ExtractedRow, reportDate: string | null, + tz: string, ): Promise { const analyte = row.analyte.trim(); const takenAt = normaliseDate(row.takenAt) ?? reportDate; @@ -149,18 +155,21 @@ async function annotateRow( // a likely re-scan. Uses the existing (userId, analyte, takenAt) index. let duplicateOf: string | null = null; if (takenAt) { - const dayStart = new Date(`${takenAt}T00:00:00.000Z`); - const dayEnd = new Date(`${takenAt}T23:59:59.999Z`); - const candidates = await prisma.labResult.findMany({ + // The reading's calendar day, the same rule the commit applies, so the + // review screen flags exactly the rows the commit will skip. + const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, analyte: { equals: analyte, mode: "insensitive" }, - takenAt: { gte: dayStart, lte: dayEnd }, + takenAt: labReadingDaySearchRange(takenAt), }, - select: { id: true, value: true, valueText: true }, - take: 25, + select: { id: true, value: true, valueText: true, takenAt: true }, + take: 100, }); + const candidates = rows.filter( + (c) => labReadingDay(c.takenAt, tz) === takenAt, + ); for (const c of candidates) { if (row.value !== null && c.value !== null && c.value === row.value) { duplicateOf = c.id; @@ -279,11 +288,12 @@ export async function runOcrExtraction( // noticing that a report layout or a language is unsupported and never // hearing about it. A count only: the string is the user's document. let unreadableRanges = 0; + const tz = await resolveUserTimezone(args.userId); for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) { if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) { unreadableRanges += 1; } - rows.push(await annotateRow(args.userId, row, reportDate)); + rows.push(await annotateRow(args.userId, row, reportDate, tz)); } annotate({ diff --git a/src/lib/labs/reading-day.ts b/src/lib/labs/reading-day.ts new file mode 100644 index 000000000..8c3318c4d --- /dev/null +++ b/src/lib/labs/reading-day.ts @@ -0,0 +1,37 @@ +/** + * The calendar day a lab reading belongs to. + * + * Two kinds of reading share `LabResult.takenAt`. A scanned or document + * reading states a date only, stored at noon UTC (rows from before the noon + * anchor sit at UTC midnight); its day is that date. A reading entered by + * hand carries the real instant of the draw; its day is the user's day of + * that instant. Cutting both on the UTC day, as the duplicate checks did, + * put a morning draw east of UTC on the previous day, so a scan of the same + * report wrote the reading a second time. + */ +import { dateOnlyKey, dbDate } from "@/lib/tz/date-only"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +const DAY_MS = 86_400_000; +const NOON_MS = 43_200_000; + +export function labReadingDay(takenAt: Date, tz: string): string { + const msOfDay = ((takenAt.getTime() % DAY_MS) + DAY_MS) % DAY_MS; + if (msOfDay === 0 || msOfDay === NOON_MS) return dateOnlyKey(takenAt); + return userDayKey(takenAt, tz); +} + +/** + * A `takenAt` range wide enough to hold every reading whose + * {@link labReadingDay} is `day`, in any zone from UTC-12 to UTC+14. It + * over-selects by design; filter the rows with `labReadingDay` afterwards. + */ +export function labReadingDaySearchRange(day: string): { + gte: Date; + lt: Date; +} { + return { + gte: dbDate(shiftDateKey(day, -1)), + lt: dbDate(shiftDateKey(day, 2)), + }; +}