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

+36
View File
@@ -0,0 +1,36 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
/**
* `GET /api/labs/overview` — one entry per biomarker over the caller's whole
* live lab history: the newest reading, the reading count and a short trend.
* The Labs list renders from this instead of grouping a capped page of raw
* rows, so a marker measured only long ago still has its row. Same read gate
* as `GET /api/labs` (`requireRecordAuth("read", "labs")`).
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "labs");
const parsed = labOverviewQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
annotate({
action: { name: "labs.overview.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const markers = await loadLabOverview(user.id);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length },
});
return apiSuccess({ markers });
});
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDto, LabResultListResponse } from "../types";
import type { LabOverviewResponse, LabResultDto } from "../types";
/**
* Every row of the compact list is five grid cells from `lg` up. The range
@@ -55,22 +55,29 @@ function renderList(results: LabResultDto[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
const data: LabResultListResponse = {
results,
meta: { total: results.length, limit: 500, offset: 0 },
};
queryClient.setQueryData(
queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
// The overview the server would send for these readings: one marker per
// biomarker (or lower-cased analyte), newest reading as `latest`.
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
byKey.set(key, [...(byKey.get(key) ?? []), r]);
}
const data: LabOverviewResponse = {
markers: [...byKey.entries()].map(([key, readings]) => {
const ordered = [...readings].sort(
(a, b) => Date.parse(a.takenAt) - Date.parse(b.takenAt),
);
return {
key,
count: ordered.length,
trend: ordered.map((r) => r.value),
latest: ordered[ordered.length - 1],
};
}),
data,
);
};
queryClient.setQueryData(queryKeys.labResultsOverview(), data);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
+38 -79
View File
@@ -26,57 +26,44 @@ import { useLabDate, useLabNumber } from "./use-lab-format";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDto, LabResultListResponse } from "./types";
import type {
LabOverviewMarkerDto,
LabOverviewResponse,
LabResultDto,
} from "./types";
interface MarkerGroup {
/** Stable group key: the biomarker id, or `analyte:<lower>` for legacy rows. */
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
key: string;
/** Link target when the group is catalog-linked; null for legacy rows. */
biomarkerId: string | null;
analyte: string;
panel: string | null;
unit: string;
readings: LabResultDto[];
/** Every live reading of the marker, counted by the server. */
count: number;
/** Values of the newest readings, oldest first, for the sparkline. */
trend: (number | null)[];
latest: LabResultDto;
}
/**
* Group readings by their linked biomarker (or, for legacy un-linked rows,
* case-insensitively by analyte). A catalog-linked group deep-links to its
* detail chart; legacy groups render inert until the backfill links them.
* One group per marker, as the server resolved it over the whole history. A
* catalog-linked group deep-links to its detail chart; legacy groups render
* inert until the backfill links them. The server already orders by the
* newest reading.
*/
function groupReadings(results: LabResultDto[]): MarkerGroup[] {
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
const bucket = byKey.get(key);
if (bucket) bucket.push(r);
else byKey.set(key, [r]);
}
const groups: MarkerGroup[] = [];
for (const [key, readings] of byKey.entries()) {
const ordered = [...readings].sort(
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
key,
biomarkerId: latest.biomarkerId,
analyte: latest.analyte,
panel: latest.panel,
unit: latest.unit,
readings: ordered,
latest,
});
}
return groups.sort(
(a, b) =>
new Date(b.latest.takenAt).getTime() -
new Date(a.latest.takenAt).getTime(),
);
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
return markers.map((marker) => ({
key: marker.key,
biomarkerId: marker.latest.biomarkerId,
analyte: marker.latest.analyte,
panel: marker.latest.panel,
unit: marker.latest.unit,
count: marker.count,
trend: marker.trend,
latest: marker.latest,
}));
}
/**
@@ -157,26 +144,17 @@ export function LabList({
// (no catalog match) carry no subtitle rather than a fabricated one.
// v1.24 — the per-marker description moved to the biomarker detail page
// (beneath the heading); the overview rows no longer carry it.
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
});
// One entry per biomarker over the whole history, grouped server-side: a
// capped page of raw rows dropped every marker measured only long ago.
const { data, isLoading, isError, refetch } = useQuery({
queryKey: listKey,
queryFn: () =>
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
queryKey: queryKeys.labResultsOverview(),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const groups = useMemo(() => {
const base = groupReadings(data?.results ?? []);
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings`
// already returns most-recent-first; `recentAsc` reverses, the alpha
const base = toGroups(data?.markers ?? []);
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. The server
// order is most-recent-first; `recentAsc` reverses, the alpha
// options sort by analyte name (#43), and `manual` applies the persisted
// biomarker order (legacy un-linked groups, which carry no biomarkerId,
// sort after the ordered block).
@@ -195,14 +173,7 @@ export function LabList({
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
}
return base;
}, [data?.results, prefs.sortDir, prefs.order]);
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
// calm "showing latest N of M" hint when the cap truncates so the count is
// never silently wrong — proper cursor paging is a later iteration.
const total = data?.meta?.total ?? 0;
const shown = data?.results?.length ?? 0;
const truncated = total > shown;
}, [data?.markers, prefs.sortDir, prefs.order]);
const listEmpty = !isLoading && !isError && groups.length === 0;
useEffect(() => {
@@ -266,11 +237,6 @@ export function LabList({
// branch above carries `lab-list-loading` instead, so the marker
// cannot stand for a silhouette.
<div data-slot="lab-list" className="space-y-3">
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
</p>
) : null}
{/* `@container` makes the columns follow the width the list has, not the
window's: below `@5xl` (64rem of card) the row is the `lg` table it
always was (the two tiers exclude each other, so the result does not
@@ -319,7 +285,7 @@ export function LabList({
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
@@ -343,7 +309,7 @@ export function LabList({
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
@@ -380,13 +346,6 @@ export function LabList({
groups.length > 1 && "sm:grid-cols-2",
)}
>
{truncated ? (
<li className="sm:col-span-2">
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
</p>
</li>
) : null}
{groups.map((group) => {
return (
<li key={group.key} className="contents">
@@ -432,10 +391,10 @@ export function LabList({
{labDate(group.latest.takenAt)}
</span>
<LabConvertedNote reading={group.latest} />
{group.readings.length > 1 ? (
{group.count > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
count: group.count,
})}
</span>
) : null}
@@ -447,7 +406,7 @@ export function LabList({
/>
</div>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
+15
View File
@@ -65,6 +65,21 @@ export interface LabResultListResponse {
meta: { total: number; limit: number; offset: number };
}
/** One biomarker in `GET /api/labs/overview`. */
export interface LabOverviewMarkerDto {
/** `bm:<biomarkerId>`, or `analyte:<lower-cased name>` for a legacy row. */
key: string;
/** Every live reading of the marker. */
count: number;
/** Values of the newest readings, oldest first; null for a qualitative one. */
trend: (number | null)[];
latest: LabResultDto;
}
export interface LabOverviewResponse {
markers: LabOverviewMarkerDto[];
}
/** A user-scoped Biomarker catalog entry as the API serialises it. */
export interface BiomarkerDto {
id: string;
+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