From 53e60f43f12cad4943ee616df88f260f4e47c7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:03:06 +0200 Subject: [PATCH] feat(labs): list every marker from a server-side per-biomarker overview --- docs/api/openapi.yaml | 74 +++++ src/app/api/labs/overview/route.ts | 36 +++ .../labs/__tests__/lab-list-columns.test.tsx | 39 ++- src/components/labs/lab-list.tsx | 117 +++----- src/components/labs/types.ts | 15 + src/lib/labs/overview.ts | 131 +++++++++ src/lib/openapi/routes/labs.ts | 45 +++ src/lib/query-keys/labs.ts | 5 + src/lib/validations/labs.ts | 7 + .../labs-overview-long-history.test.ts | 276 ++++++++++++++++++ 10 files changed, 650 insertions(+), 95 deletions(-) create mode 100644 src/app/api/labs/overview/route.ts create mode 100644 src/lib/labs/overview.ts create mode 100644 tests/integration/labs-overview-long-history.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17fd03bc0..f48efd348 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:` for a catalog-linked marker or + `analyte:` 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: diff --git a/src/app/api/labs/overview/route.ts b/src/app/api/labs/overview/route.ts new file mode 100644 index 000000000..c25a41ec5 --- /dev/null +++ b/src/app/api/labs/overview/route.ts @@ -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 }); +}); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 44c7505b6..4b570c544 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -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(); + 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( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 77d7e72cc..e98e04ffa 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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:` for legacy rows. */ + /** Stable group key: `bm:`, or `analyte:` 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(); - 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("/api/labs?limit=500&sortDir=desc"), + queryKey: queryKeys.labResultsOverview(), + queryFn: () => apiGet("/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.
- {truncated ? ( -

- {t("labs.showingLatestOf", { shown, total })} -

- ) : 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({
reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> @@ -343,7 +309,7 @@ export function LabList({
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 ? ( -
  • -

    - {t("labs.showingLatestOf", { shown, total })} -

    -
  • - ) : null} {groups.map((group) => { return (
  • @@ -432,10 +391,10 @@ export function LabList({ {labDate(group.latest.takenAt)} - {group.readings.length > 1 ? ( + {group.count > 1 ? ( {t("labs.readingsCount", { - count: group.readings.length, + count: group.count, })} ) : null} @@ -447,7 +406,7 @@ export function LabList({ />
  • reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 90e7f60fb..512038fc1 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -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:`, or `analyte:` 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; diff --git a/src/lib/labs/overview.ts b/src/lib/labs/overview.ts new file mode 100644 index 000000000..cd89e02dc --- /dev/null +++ b/src/lib/labs/overview.ts @@ -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:`, or `analyte:` 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; +} + +/** + * 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 { + // 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; + }); +} diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 3fcc7e5ca..27a25f337 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -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:` for a catalog-linked marker or `analyte:` 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 = { }, }, }, + "/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"], diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index c15eb86e1..ee585e878 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -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=` (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` diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 6bbc3ef0d..834ef6721 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -306,6 +306,13 @@ export const listLabResultsSchema = z.object({ export type ListLabResultsInput = z.infer; +/** + * 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 diff --git a/tests/integration/labs-overview-long-history.test.ts b/tests/integration/labs-overview-long-history.test.ts new file mode 100644 index 000000000..bf0bbaa6c --- /dev/null +++ b/tests/integration/labs-overview-long-history.test.ts @@ -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 { + 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([]); + }); +});