diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index a28effb09..bb030f516 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -51,6 +51,7 @@ * is always owner-scoped and safe to read), so the block is `null` only when the * account has no recent readings. */ +import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { UNBOUNDED_REACH, reachFloor, @@ -240,11 +241,26 @@ export async function buildLabsSnapshotBlock( // Newest reading first: the first row seen per biomarker is its MOST RECENT // value, every later row of it feeds its history. Field-by-field `where` — // never a spread. + // + // With an analyte the match runs in SQL, before the row cap: matched in + // memory after it, the newest rows of every other marker used up the cap, + // and the asked-for marker lost its older readings (or vanished) without a + // word. A row's resolved name is its biomarker's when it is linked, its own + // otherwise; the in-memory check below stays the final word. const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, takenAt: floor ? { gte: floor, lte: now } : { lte: now }, + OR: wanted + ? [ + { + biomarkerId: null, + analyte: { contains: wanted, mode: "insensitive" }, + }, + { biomarker: { name: { contains: wanted, mode: "insensitive" } } }, + ] + : undefined, }, orderBy: { takenAt: "desc" }, take: MAX_ROWS, @@ -297,7 +313,12 @@ export async function buildLabsSnapshotBlock( const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; const existing = entries.get(key); if (existing) { - if (wanted && row.value !== null && resolved.unit === existing.unit) { + // One unit written two ways ("mg/dl", "mg/dL") is one series. + if ( + wanted && + row.value !== null && + sameLabUnit(resolved.unit, existing.unit) + ) { existing.earlier.push({ value: row.value, takenAt: row.takenAt, diff --git a/tests/integration/coach-labs-analyte-history.test.ts b/tests/integration/coach-labs-analyte-history.test.ts new file mode 100644 index 000000000..8a64acb14 --- /dev/null +++ b/tests/integration/coach-labs-analyte-history.test.ts @@ -0,0 +1,66 @@ +/** + * The Coach's history of one lab marker is read in full, whatever else the + * account has measured since. + * + * The block used to read the newest 4,000 lab rows of every marker and match + * the asked-for one in memory afterwards. An account with more than that many + * newer readings of other markers (a home glucose or INR log is enough) lost + * the asked-for marker's older readings, so its `count` and `firstTakenAt` + * named a shorter history than the record holds, or the marker vanished. The + * same history also left out a reading whose unit was written another way + * ("MG/DL" beside "mg/dL"). + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(() => truncateAllTables(prisma)); + +describe("buildLabsSnapshotBlock with an analyte", () => { + it("reads the marker's whole history past thousands of newer readings of other markers", async () => { + const user = await makeUser(); + const now = new Date(); + const daysAgo = (days: number) => new Date(now.getTime() - days * DAY_MS); + + // Three LDL readings, the oldest two years back; one of them stored with + // the unit written in capitals. + await prisma.labResult.createMany({ + data: [ + { takenAt: daysAgo(730), value: 151, unit: "MG/DL" }, + { takenAt: daysAgo(400), value: 142, unit: "mg/dL" }, + { takenAt: daysAgo(200), value: 128, unit: "mg/dL" }, + ].map((reading) => ({ + userId: user.id, + analyte: "LDL", + ...reading, + })), + }); + // 4,500 newer readings of another marker, all after the last LDL. + await prisma.labResult.createMany({ + data: Array.from({ length: 4_500 }, (_, index) => ({ + userId: user.id, + analyte: "Glucose", + value: 90 + (index % 20), + unit: "mg/dL", + takenAt: new Date(daysAgo(199).getTime() + index * 60_000), + })), + }); + + const block = await buildLabsSnapshotBlock(user.id, now, undefined, { + analyte: "ldl", + }); + + expect(block?.recent).toHaveLength(1); + const ldl = block!.recent[0]!; + expect(ldl.analyte).toBe("LDL"); + expect(ldl.value).toBe(128); + expect(ldl.history?.count).toBe(3); + expect(ldl.history?.firstTakenAt).toBe(daysAgo(730).toISOString()); + }); +});