mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user