mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
/**
|
|
* Per-marker overview of an account's lab history (`GET /api/labs/overview`).
|
|
*
|
|
* The Labs list shows one row per biomarker: its newest reading, how many
|
|
* readings it has, and a small trend. It used to build that by fetching the
|
|
* newest 500 raw rows and grouping them in the browser, so on a long history
|
|
* every marker whose readings all lay beyond the 500th row vanished from the
|
|
* list without a word. The grouping now happens here, over every live row, so
|
|
* the answer does not depend on how many readings the other markers have.
|
|
*
|
|
* Grouping matches what the list always did: by the linked biomarker, or for a
|
|
* legacy unlinked row case-insensitively by its analyte name.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { serialiseLabResult } from "@/lib/labs/serialise";
|
|
|
|
/**
|
|
* Readings per marker that feed the trend sparkline, newest kept. The
|
|
* sparkline is a 72px glyph; more points than this only overlap. The count
|
|
* beside it is the full count, not this cap.
|
|
*/
|
|
export const LAB_OVERVIEW_TREND_POINTS = 50;
|
|
|
|
export interface LabOverviewMarker {
|
|
/** `bm:<biomarkerId>`, or `analyte:<lower-cased name>` for a legacy row. */
|
|
key: string;
|
|
/** Every live reading of this marker. */
|
|
count: number;
|
|
/** Values of the newest readings, oldest first; null for a qualitative one. */
|
|
trend: (number | null)[];
|
|
/** The newest reading, as the list endpoint serialises a row. */
|
|
latest: ReturnType<typeof serialiseLabResult>;
|
|
}
|
|
|
|
/**
|
|
* One window-function read ranks every live row inside its marker, keeps the
|
|
* newest `LAB_OVERVIEW_TREND_POINTS` of each, and a second read loads the
|
|
* newest row per marker with its biomarker so it serialises exactly like a
|
|
* list row. Markers come back newest reading first.
|
|
*/
|
|
export async function loadLabOverview(
|
|
userId: string,
|
|
): Promise<LabOverviewMarker[]> {
|
|
// Parameter-bound throughout. `id` breaks a `taken_at` tie the same way the
|
|
// list endpoint's order does, so "newest" means the same row on both.
|
|
const ranked = await prisma.$queryRaw<
|
|
Array<{
|
|
id: string;
|
|
grp: string;
|
|
value: number | null;
|
|
rn: number;
|
|
n: number;
|
|
}>
|
|
>`
|
|
SELECT r."id", r."grp", r."value", r."rn"::int AS "rn", r."n"::int AS "n"
|
|
FROM (
|
|
SELECT g."id", g."grp", g."value",
|
|
row_number() OVER (
|
|
PARTITION BY g."grp" ORDER BY g."taken_at" DESC, g."id" DESC
|
|
) AS "rn",
|
|
count(*) OVER (PARTITION BY g."grp") AS "n"
|
|
FROM (
|
|
SELECT l."id", l."value", l."taken_at",
|
|
CASE
|
|
WHEN l."biomarker_id" IS NOT NULL THEN 'bm:' || l."biomarker_id"
|
|
ELSE 'analyte:' || lower(l."analyte")
|
|
END AS "grp"
|
|
FROM "lab_results" l
|
|
WHERE l."user_id" = ${userId} AND l."deleted_at" IS NULL
|
|
) g
|
|
) r
|
|
WHERE r."rn" <= ${LAB_OVERVIEW_TREND_POINTS}
|
|
`;
|
|
if (ranked.length === 0) return [];
|
|
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
latestId: string | null;
|
|
count: number;
|
|
points: Array<[number, number | null]>;
|
|
}
|
|
>();
|
|
for (const row of ranked) {
|
|
let group = groups.get(row.grp);
|
|
if (!group) {
|
|
group = { latestId: null, count: row.n, points: [] };
|
|
groups.set(row.grp, group);
|
|
}
|
|
if (row.rn === 1) group.latestId = row.id;
|
|
group.points.push([row.rn, row.value]);
|
|
}
|
|
|
|
const latestIds = [...groups.values()]
|
|
.map((g) => g.latestId)
|
|
.filter((id): id is string => id !== null);
|
|
const rows = await prisma.labResult.findMany({
|
|
where: { userId, id: { in: latestIds } },
|
|
include: {
|
|
biomarker: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
unit: true,
|
|
lowerBound: true,
|
|
upperBound: true,
|
|
panel: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
|
|
const markers: LabOverviewMarker[] = [];
|
|
for (const [key, group] of groups) {
|
|
const row = group.latestId ? byId.get(group.latestId) : undefined;
|
|
// Deleted between the two reads: the marker is gone, not half-shown.
|
|
if (!row) continue;
|
|
markers.push({
|
|
key,
|
|
count: group.count,
|
|
trend: group.points.sort((a, b) => b[0] - a[0]).map(([, value]) => value),
|
|
latest: serialiseLabResult(row, row.biomarker ?? null),
|
|
});
|
|
}
|
|
return markers.sort((a, b) => {
|
|
const byTime = Date.parse(b.latest.takenAt) - Date.parse(a.latest.takenAt);
|
|
if (byTime !== 0) return byTime;
|
|
return a.latest.id < b.latest.id ? 1 : a.latest.id > b.latest.id ? -1 : 0;
|
|
});
|
|
}
|