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

@@ -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)),
};
}