mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(coach): read one lab marker's history in full
With an analyte, the Coach's labs block read the newest 4,000 lab rows of every marker and matched the asked-for one in memory afterwards. An account with that many newer readings of other markers lost the marker's older readings, so its count and first date named a shorter history than the record holds, or the marker vanished. The match now runs in SQL before the row cap, on the biomarker's name for a linked row and the row's own name otherwise, and the history compares units with sameLabUnit, so a reading stored as "MG/DL" beside "mg/dL" stays in the series.
This commit is contained in:
1 parent
466dfb3785
commit
c15c1d18f3
2 files changed
+88
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user