mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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