fix(labs): match scanned duplicates on the reading's calendar day

The scan commit and the review screen looked for an existing reading in
the UTC day of the scanned date. A hand-entered morning draw east of UTC
sits on the previous UTC day, so scanning the same report wrote the
reading a second time. Both checks now compare calendar days: a date-only
reading's stated date, or the user's day of a hand-entered instant.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:37:59 +02:00
1 parent 9662a3d104
commit a67ba9ee66
5 files changed
+173 -23

No files matched your search

@@ -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<typeof import("@/lib/tz/resolver")>()),
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);
});
});
+25 -16
View File
@@ -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<boolean> {
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<string>();
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;
}
@@ -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);
});
});
+17 -7
View File
@@ -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<OcrExtractedRowDto> {
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({
+37
View File
@@ -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)),
};
}