mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(labs): list every marker from a server-side per-biomarker overview
This commit is contained in:
1 parent
600145427b
commit
53e60f43f1
10 files changed
+650
-95
No files matched your search
@@ -14924,6 +14924,26 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a12
|
||||
/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."
|
||||
parameters: []
|
||||
responses:
|
||||
"200":
|
||||
description: Lab overview.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LabOverviewEnvelope"
|
||||
"401": *a1
|
||||
"403": *a11
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/labs/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -67584,6 +67604,60 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
LabOverviewEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/LabOverviewResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
LabOverviewResponse:
|
||||
type: object
|
||||
properties:
|
||||
markers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LabOverviewMarker"
|
||||
required:
|
||||
- markers
|
||||
additionalProperties: false
|
||||
LabOverviewMarker:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
trend:
|
||||
type: array
|
||||
items:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
latest:
|
||||
$ref: "#/components/schemas/LabResult"
|
||||
required:
|
||||
- key
|
||||
- count
|
||||
- trend
|
||||
- latest
|
||||
additionalProperties: false
|
||||
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.
|
||||
GetLabResultResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* `GET /api/labs/overview` against a real Postgres, on a long history.
|
||||
*
|
||||
* The Labs list used to group the newest 500 raw rows in the browser, so on
|
||||
* an account with thousands of readings every marker measured only long ago
|
||||
* had no row at all. The overview groups server-side over every live row;
|
||||
* this suite seeds 2,000 readings over 25 years and checks that each marker
|
||||
* comes back once, with its full count, its newest reading and a trend.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_ID = "user-labs-overview-test";
|
||||
const OTHER_USER_ID = "user-labs-overview-other";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
/** Days before now, so the fixture never ages out of its own windows. */
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
|
||||
|
||||
const FREQUENT_READINGS = 1_700;
|
||||
const RARE_MARKERS = 58;
|
||||
const RARE_READINGS_EACH = 5;
|
||||
const LEGACY_READINGS = 10;
|
||||
const TOTAL =
|
||||
FREQUENT_READINGS + RARE_MARKERS * RARE_READINGS_EACH + LEGACY_READINGS;
|
||||
|
||||
interface Seeded {
|
||||
frequentId: string;
|
||||
rareIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2,000 live readings over 25 years: one marker measured almost daily over
|
||||
* the last five years (1,700), 58 markers measured five times each between 25
|
||||
* and 6 years ago (290), and one legacy unlinked marker whose ten readings
|
||||
* spell the analyte in different cases (10). The newest 500 rows are all the
|
||||
* frequent marker, which is exactly the history the capped list lost.
|
||||
*/
|
||||
async function seedLongHistory(): Promise<Seeded> {
|
||||
const prisma = getPrismaClient();
|
||||
const frequent = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 99,
|
||||
},
|
||||
});
|
||||
const rare = await Promise.all(
|
||||
Array.from({ length: RARE_MARKERS }, (_, i) =>
|
||||
prisma.biomarker.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: `Rare marker ${String(i).padStart(2, "0")}`,
|
||||
unit: "U/L",
|
||||
lowerBound: 1,
|
||||
upperBound: 50,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const rows: Array<{
|
||||
userId: string;
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
takenAt: Date;
|
||||
}> = [];
|
||||
for (let i = 0; i < FREQUENT_READINGS; i++) {
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
biomarkerId: frequent.id,
|
||||
analyte: "Glucose",
|
||||
value: 80 + (i % 20),
|
||||
unit: "mg/dL",
|
||||
takenAt: daysAgo(1 + i),
|
||||
});
|
||||
}
|
||||
rare.forEach((marker, m) => {
|
||||
for (let k = 0; k < RARE_READINGS_EACH; k++) {
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
biomarkerId: marker.id,
|
||||
analyte: marker.name,
|
||||
value: 10 + k,
|
||||
unit: "U/L",
|
||||
// Between ~25 and ~6 years ago, one marker per fortnight-ish slot.
|
||||
takenAt: daysAgo(25 * 365 - m * 120 - k * 20),
|
||||
});
|
||||
}
|
||||
});
|
||||
for (let k = 0; k < LEGACY_READINGS; k++) {
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
biomarkerId: null,
|
||||
analyte: k % 2 === 0 ? "Vitamin X" : "VITAMIN x",
|
||||
value: 1 + k,
|
||||
unit: "nmol/L",
|
||||
takenAt: daysAgo(24 * 365 - k * 30),
|
||||
});
|
||||
}
|
||||
await prisma.labResult.createMany({ data: rows });
|
||||
|
||||
// Noise that must not count: a deleted reading of a rare marker, and
|
||||
// another account's reading.
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: rare[0].id,
|
||||
analyte: rare[0].name,
|
||||
value: 99,
|
||||
unit: "U/L",
|
||||
takenAt: daysAgo(0),
|
||||
deletedAt: daysAgo(0),
|
||||
},
|
||||
});
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OTHER_USER_ID,
|
||||
analyte: "Other account marker",
|
||||
value: 1,
|
||||
unit: "U/L",
|
||||
takenAt: daysAgo(0),
|
||||
},
|
||||
});
|
||||
return { frequentId: frequent.id, rareIds: rare.map((m) => m.id) };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{ id: USER_ID, username: "labs-ov", email: "labs-ov@example.test" },
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
username: "labs-ov-other",
|
||||
email: "labs-ov-other@example.test",
|
||||
},
|
||||
],
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
interface OverviewBody {
|
||||
data: {
|
||||
markers: Array<{
|
||||
key: string;
|
||||
count: number;
|
||||
trend: (number | null)[];
|
||||
latest: {
|
||||
id: string;
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
value: number | null;
|
||||
takenAt: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
async function getOverview(query = "") {
|
||||
const { GET } = await import("@/app/api/labs/overview/route");
|
||||
return GET(new NextRequest(`http://localhost/api/labs/overview${query}`));
|
||||
}
|
||||
|
||||
describe("GET /api/labs/overview on a 25-year history", () => {
|
||||
it("returns every marker once, with its full count and newest reading", async () => {
|
||||
const seeded = await seedLongHistory();
|
||||
|
||||
const res = await getOverview();
|
||||
expect(res.status).toBe(200);
|
||||
const { markers } = ((await res.json()) as OverviewBody).data;
|
||||
|
||||
// 1 frequent + 58 rare + 1 legacy, and nothing from the other account.
|
||||
expect(markers).toHaveLength(1 + RARE_MARKERS + 1);
|
||||
expect(markers.reduce((sum, m) => sum + m.count, 0)).toBe(TOTAL);
|
||||
expect(TOTAL).toBe(2_000);
|
||||
|
||||
const byKey = new Map(markers.map((m) => [m.key, m]));
|
||||
for (const id of seeded.rareIds) {
|
||||
const marker = byKey.get(`bm:${id}`);
|
||||
expect(marker, `marker ${id} missing`).toBeDefined();
|
||||
// The deleted reading neither counts nor becomes the newest one.
|
||||
expect(marker!.count).toBe(RARE_READINGS_EACH);
|
||||
expect(marker!.latest.value).toBe(14);
|
||||
expect(marker!.trend).toEqual([10, 11, 12, 13, 14]);
|
||||
}
|
||||
|
||||
const frequent = byKey.get(`bm:${seeded.frequentId}`)!;
|
||||
expect(frequent.count).toBe(FREQUENT_READINGS);
|
||||
// Capped trend, oldest first, ending on the newest reading.
|
||||
expect(frequent.trend).toHaveLength(50);
|
||||
expect(frequent.trend.at(-1)).toBe(frequent.latest.value);
|
||||
expect(frequent.latest.value).toBe(80);
|
||||
|
||||
// Legacy rows group case-insensitively by analyte.
|
||||
const legacy = byKey.get("analyte:vitamin x")!;
|
||||
expect(legacy.count).toBe(LEGACY_READINGS);
|
||||
expect(legacy.latest.biomarkerId).toBeNull();
|
||||
expect(legacy.latest.value).toBe(LEGACY_READINGS);
|
||||
|
||||
// Newest reading first.
|
||||
expect(markers[0].key).toBe(`bm:${seeded.frequentId}`);
|
||||
const times = markers.map((m) => Date.parse(m.latest.takenAt));
|
||||
expect([...times].sort((a, b) => b - a)).toEqual(times);
|
||||
|
||||
expect(
|
||||
markers.some((m) => m.latest.analyte === "Other account marker"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("covers the markers a 500-row page of the list endpoint drops", async () => {
|
||||
await seedLongHistory();
|
||||
const { GET } = await import("@/app/api/labs/route");
|
||||
const page = await GET(
|
||||
new NextRequest("http://localhost/api/labs?limit=500&sortDir=desc"),
|
||||
);
|
||||
const pageBody = (await page.json()) as {
|
||||
data: { results: Array<{ biomarkerId: string | null }> };
|
||||
};
|
||||
const onPage = new Set(pageBody.data.results.map((r) => r.biomarkerId));
|
||||
// The old list's view of this account: one marker.
|
||||
expect(onPage.size).toBe(1);
|
||||
|
||||
const { markers } = ((await (await getOverview()).json()) as OverviewBody)
|
||||
.data;
|
||||
expect(markers).toHaveLength(1 + RARE_MARKERS + 1);
|
||||
});
|
||||
|
||||
it("refuses a query parameter instead of ignoring it", async () => {
|
||||
const res = await getOverview("?limit=500");
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("answers an empty list for an account without readings", async () => {
|
||||
const res = await getOverview();
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as OverviewBody).data.markers).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user