feat(labs): list every marker from a server-side per-biomarker overview

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:03:06 +02:00
1 parent 600145427b
commit 53e60f43f1
10 files changed
+650 -95

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
/**
* 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;
});
}
+45
View File
@@ -12,6 +12,7 @@ import { z } from "zod/v4";
import {
createLabResultSchema,
labOverviewQuerySchema,
listLabResultsSchema,
updateLabResultSchema,
} from "@/lib/validations/labs";
@@ -116,6 +117,29 @@ const listResponse = z
})
.meta({ id: "ListLabResultsResponse" });
const labOverviewQuery = labOverviewQuerySchema.meta({
id: "LabOverviewQuery",
description:
"The overview takes no query parameters; it always covers the whole live history. An unknown parameter is refused with 422.",
});
const labOverviewMarker = z
.object({
key: z.string(),
count: z.number().int(),
trend: z.array(z.number().nullable()),
latest: labResultRow,
})
.meta({
id: "LabOverviewMarker",
description:
"One biomarker in the caller's lab history. `key` is `bm:<biomarkerId>` for a catalog-linked marker or `analyte:<lower-cased name>` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row.",
});
const labOverviewResponse = z
.object({ markers: z.array(labOverviewMarker) })
.meta({ id: "LabOverviewResponse" });
const notFound = {
"404": {
description: "Lab result not found (or owned by another user).",
@@ -175,6 +199,27 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/labs/overview": {
get: {
tags: ["Labs"],
summary: "Per-marker overview of the caller's lab history",
description:
"One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut off by a page size.",
requestParams: { query: labOverviewQuery },
responses: {
...recordRefusal(),
"200": {
description: "Lab overview.",
content: {
"application/json": {
schema: dataEnvelope(labOverviewResponse, "LabOverviewEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/labs/{id}": {
get: {
tags: ["Labs"],
+5
View File
@@ -29,6 +29,11 @@ export const labKeys = {
params.sortDir,
] as const,
// The Labs list: one entry per biomarker over the whole history
// (`GET /api/labs/overview`). The `lab-results` prefix lets every result
// mutation's `labResults()` invalidation evict it.
labResultsOverview: () => ["lab-results", "overview"] as const,
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
// that exact analyte name, one row, to find its marker's page. Its own key
// because the page size differs from every list read; the `lab-results`
+7
View File
@@ -306,6 +306,13 @@ export const listLabResultsSchema = z.object({
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
/**
* The per-marker overview takes no parameters: it always covers the whole
* live history. Strict, so a stray `limit` is refused rather than silently
* ignored by a caller expecting it to cap something.
*/
export const labOverviewQuerySchema = z.object({}).strict();
/**
* Reference-range classification lives in `@/lib/labs/reference-range` so the
* API response, the doctor-report PDF, and the lab list card all share one