From 600145427b4410bf3e89ea192817004a89f04a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 14:58:15 +0200 Subject: [PATCH 1/6] docs(measurements): describe the 10-minute heart-rate bucket in the batch route header --- src/app/api/measurements/batch/route.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index fa0c55400..4b42d4efb 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -24,10 +24,12 @@ * · per-day cumulative totals — `stats::` (Steps, * Active Energy, Sleep Duration, Walking/Running Distance, * Flights Climbed); - * · v1.19.0 (iOS #34) hourly heart-rate buckets — - * `stats:HKQuantityTypeIdentifierHeartRate:` - * carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops - * uploading one row per raw HR sample. + * · v1.19.0 (iOS #34) heart-rate buckets, 10 minutes wide since + * v1.30.7 — `stats:HKQuantityTypeIdentifierHeartRate:` + * where `` is the ISO-8601 UTC 10-minute boundary + * (minutes 00/10/20/30/40/50), carrying the bucket's AVERAGE bpm + * as one PULSE row, so iOS stops uploading one row per raw HR + * sample. * Both surface as `updated` (the row's value is overwritten) rather * than `duplicate` (the new value dropped). Sample-class externalIds * (every other prefix) keep the strict immutable `duplicate` @@ -153,11 +155,11 @@ const batchEntrySchema = z.object({ // device's own verdict / severity; resolved to a `rhythmClassification` // by `mapAppleHealthEntry`. Ignored for every non-event identifier. categoryValue: z.number().int().min(0).max(20).optional(), - // v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly - // heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry - // the hour's MIN / MAX so a client renders the intra-hour band without + // v1.19.2 (iOS #34 extension) — per-bucket spread for the 10-minute + // heart-rate bucket. `value` carries the bucket's AVERAGE bpm; these carry + // its MIN / MAX so a client renders the band inside the bucket without // re-uploading raw samples. Persisted ONLY on a well-formed - // `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored + // `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored // NULL) for every other entry. Optional + backward-compatible: a pre-W192 // iOS build omits them and the bucket keeps the avg-only contract. valueMin: z.number().finite().optional(), 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 2/6] 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([]); + }); +}); From 7c905249fb99ca108e26f7ea7128cbee1d7cb324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:05:51 +0200 Subject: [PATCH 3/6] fix(medications): page the side-effect list and name every GLP-1 medication in the timeline --- docs/api/openapi.yaml | 17 +++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + src/app/api/insights/glp1-timeline/route.ts | 11 +- .../insights/glp1-substance-curve-summary.tsx | 28 ++--- .../__tests__/side-effects-section.test.tsx | 114 +++++++++++++++++- .../medications/side-effects-section.tsx | 79 ++++++++++-- src/lib/openapi/routes/insights/schemas.ts | 5 + 13 files changed, 228 insertions(+), 33 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f48efd348..449bb3d13 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -61229,9 +61229,26 @@ components: additionalProperties: false description: "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them." + medications: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + description: "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the + entry names, to know which medications are GLP-1: a medication whose events are all older than the newest + `limit` entries has none in `entries`. Empty when `hasGlp1` is false." required: - hasGlp1 - entries + - medications additionalProperties: false description: "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. diff --git a/messages/de.json b/messages/de.json index e7a8a1dd0..ba509f4b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1386,6 +1386,7 @@ "emptyState": "Noch keine Nebenwirkungen erfasst. Tracking hilft dir und deiner Ärztin, Muster über die Zeit zu erkennen.", "recentTitle": "Letzte 30 Tage", "loading": "Wird geladen…", + "loadMore": "Weitere laden", "deleteCta": "Löschen", "deleteError": "Eintrag konnte nicht gelöscht werden", "errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen", diff --git a/messages/en.json b/messages/en.json index 11a517a80..7577e51fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1386,6 +1386,7 @@ "emptyState": "No side effects logged yet. Tracking can help your doctor and surface patterns over time.", "recentTitle": "Last 30 days", "loading": "Loading…", + "loadMore": "Load more", "deleteCta": "Delete", "deleteError": "Could not delete the entry", "errorSeverity": "Severity must be between 1 and 5", diff --git a/messages/es.json b/messages/es.json index 43c808b32..aaa14812a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1386,6 +1386,7 @@ "emptyState": "Aún no hay efectos secundarios registrados. Hacer seguimiento puede ayudar a tu médico y revelar patrones a lo largo del tiempo.", "recentTitle": "Últimos 30 días", "loading": "Cargando…", + "loadMore": "Cargar más", "deleteCta": "Eliminar", "deleteError": "No se pudo eliminar la entrada", "errorSeverity": "La gravedad debe estar entre 1 y 5", diff --git a/messages/fr.json b/messages/fr.json index 3f3de9ecd..24d926b6c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1386,6 +1386,7 @@ "emptyState": "Aucun effet secondaire consigné. Le suivi peut aider votre médecin et révéler des tendances dans le temps.", "recentTitle": "30 derniers jours", "loading": "Chargement…", + "loadMore": "Charger plus", "deleteCta": "Supprimer", "deleteError": "Impossible de supprimer l'entrée", "errorSeverity": "La sévérité doit être entre 1 et 5", diff --git a/messages/it.json b/messages/it.json index 4853184ed..71f3977a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1386,6 +1386,7 @@ "emptyState": "Nessun effetto collaterale registrato. Tracciarli può aiutare il medico e far emergere schemi nel tempo.", "recentTitle": "Ultimi 30 giorni", "loading": "Caricamento…", + "loadMore": "Carica altri", "deleteCta": "Elimina", "deleteError": "Impossibile eliminare la voce", "errorSeverity": "La gravità deve essere tra 1 e 5", diff --git a/messages/ko.json b/messages/ko.json index 122c25d92..04eb45df2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1386,6 +1386,7 @@ "emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.", "recentTitle": "최근 30일", "loading": "불러오는 중…", + "loadMore": "더 불러오기", "deleteCta": "삭제", "deleteError": "기록을 삭제하지 못했어요", "errorSeverity": "심각도는 1에서 5 사이여야 해요", diff --git a/messages/pl.json b/messages/pl.json index 5907e3c21..68e21c4cd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1386,6 +1386,7 @@ "emptyState": "Nie zarejestrowano jeszcze skutków ubocznych. Śledzenie może pomóc lekarzowi i ujawnić wzorce w czasie.", "recentTitle": "Ostatnie 30 dni", "loading": "Ładowanie…", + "loadMore": "Załaduj więcej", "deleteCta": "Usuń", "deleteError": "Nie udało się usunąć wpisu", "errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5", diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 67af7ff9c..2b59c53c9 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -68,7 +68,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); if (meds.length === 0) { - return apiSuccess({ hasGlp1: false, entries: [] }); + return apiSuccess({ hasGlp1: false, entries: [], medications: [] }); } const ninetyDaysAgo = new Date(); @@ -173,5 +173,12 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) }); + // `medications` is every GLP-1 medication, independent of `limit`: a + // consumer that needs the set (the substance-curve summary) must not + // derive it from a capped page of entries. + return apiSuccess({ + hasGlp1: true, + entries: entries.slice(0, limit), + medications: meds.map((med) => ({ id: med.id, name: med.name })), + }); }); diff --git a/src/components/insights/glp1-substance-curve-summary.tsx b/src/components/insights/glp1-substance-curve-summary.tsx index 9fc178a08..80c6a7044 100644 --- a/src/components/insights/glp1-substance-curve-summary.tsx +++ b/src/components/insights/glp1-substance-curve-summary.tsx @@ -9,9 +9,12 @@ * * Gating: reads `GET /api/insights/glp1-timeline` — the server-authoritative * GLP-1 signal. When `hasGlp1` is false the block renders nothing. The set of - * GLP-1 medication names comes from the timeline's entries (every entry is - * from a `treatmentClass === "GLP1"` medication), and the passed medication - * list is filtered to that set so non-GLP-1 medications never render a curve. + * GLP-1 medications comes from the response's `medications` list, which the + * server fills from every `treatmentClass === "GLP1"` medication independent + * of the entry cap, and the passed medication list is filtered to that set so + * non-GLP-1 medications never render a curve. (It used to be derived from the + * names in a capped page of entries, which dropped a medication whose events + * were all older than that page.) * * The chart carries its own `MedicationDetailSection` card chrome + estimate * disclaimer, so it reads as one system with the surrounding Insights cards @@ -36,7 +39,7 @@ const DrugLevelChart = dynamic( interface Glp1TimelineResponse { hasGlp1: boolean; - entries: { medicationName?: string }[]; + medications: { id: string; name: string }[]; } export function Glp1SubstanceCurveSummary({ @@ -45,24 +48,17 @@ export function Glp1SubstanceCurveSummary({ medications: { id: string; name: string; dose: string }[]; }) { const { data } = useQuery({ - queryKey: queryKeys.insightsGlp1Timeline(200), + // Only `medications` is read, so the smallest page of entries will do. + queryKey: queryKeys.insightsGlp1Timeline(1), queryFn: () => - apiGet("/api/insights/glp1-timeline?limit=200"), + apiGet("/api/insights/glp1-timeline?limit=1"), staleTime: 60_000, }); if (!data?.hasGlp1) return null; - // Every timeline entry is from a GLP-1 medication (server filters on - // `treatmentClass: "GLP1"`). Medication names match the comprehensive - // list exactly — both derive from `medication.name` server-side. - const glp1Names = new Set( - data.entries - .map((e) => e.medicationName) - .filter((n): n is string => typeof n === "string" && n.length > 0), - ); - - const glp1Meds = medications.filter((m) => glp1Names.has(m.name)); + const glp1Ids = new Set(data.medications.map((m) => m.id)); + const glp1Meds = medications.filter((m) => glp1Ids.has(m.id)); if (glp1Meds.length === 0) return null; const showNameLabel = glp1Meds.length > 1; diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx index 4555b07c6..7737bd290 100644 --- a/src/components/medications/__tests__/side-effects-section.test.tsx +++ b/src/components/medications/__tests__/side-effects-section.test.tsx @@ -87,8 +87,14 @@ function seedSideEffects( }>, ) { client.setQueryData(queryKeys.medicationSideEffects(medId), { - items: rows, - meta: { total: rows.length }, + pages: [ + { + items: rows, + meta: { total: rows.length }, + from: "2026-01-01T00:00:00.000Z", + }, + ], + pageParams: [null], }); } @@ -131,13 +137,24 @@ async function executeSideEffectsQuery( medicationId: string, ): Promise { const queryKey = queryKeys.medicationSideEffects(medicationId); - const queryFn = client.getQueryCache().find({ queryKey, exact: true }) - ?.options.queryFn; - if (typeof queryFn !== "function") { + const options = client.getQueryCache().find({ queryKey, exact: true }) + ?.options as + | { + queryFn?: unknown; + initialPageParam?: unknown; + getNextPageParam?: unknown; + } + | undefined; + if (typeof options?.queryFn !== "function") { throw new Error("Side-effects query function was not registered"); } try { - await client.fetchQuery({ queryKey, queryFn }); + await client.fetchInfiniteQuery({ + queryKey, + queryFn: options.queryFn as never, + initialPageParam: options.initialPageParam as never, + getNextPageParam: options.getNextPageParam as never, + }); } catch { // The settled error is the state under test. } @@ -420,3 +437,88 @@ describe(" — timeline rows", () => { expect(html).toContain("Mittel"); }); }); + +describe(" — paging", () => { + function sideEffectsResponse( + items: Array<{ id: string; occurredAt: string }>, + ): Response { + return new Response( + JSON.stringify({ + data: { + items: items.map((row) => ({ + ...row, + category: "GI", + entry: "NAUSEA", + severity: 2, + notes: null, + })), + meta: { total: items.length }, + }, + error: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + it("offers a next page after a full one and asks for the entries before it", async () => { + const base = Date.parse("2026-05-20T12:00:00.000Z"); + const firstPage = Array.from({ length: 50 }, (_, i) => ({ + id: `se-${i}`, + occurredAt: new Date(base - i * 60_000).toISOString(), + })); + const oldest = firstPage[firstPage.length - 1]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(sideEffectsResponse(firstPage)) + // The boundary entry comes back once more and is shown once. + .mockResolvedValueOnce( + sideEffectsResponse([ + oldest, + { id: "se-older", occurredAt: "2026-05-01T08:00:00.000Z" }, + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = makeClient(); + render(, client); + await executeSideEffectsQuery(client, "med-paged"); + await waitForQueryStatus(client, "med-paged", "success"); + + const firstHtml = render( + , + client, + ); + expect(firstHtml).toContain(en.medications.sideEffects.loadMore); + + await client + .getQueryCache() + .find({ + queryKey: queryKeys.medicationSideEffects("med-paged"), + exact: true, + }) + ?.fetch(undefined, { meta: { fetchMore: { direction: "forward" } } }); + + const secondUrl = new URL( + String(fetchMock.mock.calls[1]?.[0]), + "http://localhost", + ); + expect(secondUrl.searchParams.get("to")).toBe( + new Date(Date.parse(oldest.occurredAt) + 1).toISOString(), + ); + const firstUrl = new URL( + String(fetchMock.mock.calls[0]?.[0]), + "http://localhost", + ); + expect(secondUrl.searchParams.get("from")).toBe( + firstUrl.searchParams.get("from"), + ); + + const secondHtml = render( + , + client, + ); + // 50 + 1 new, the repeated boundary entry dropped; a short page ends it. + expect(secondHtml).toContain("(51)"); + expect(secondHtml).not.toContain(en.medications.sideEffects.loadMore); + }); +}); diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 7df4d2477..140fb8edb 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -3,7 +3,11 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2, Plus, Trash2 } from "lucide-react"; @@ -110,6 +114,16 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string { .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); } +/** Entries per page of the side-effect list. */ +const SIDE_EFFECTS_PAGE_SIZE = 50; + +interface SideEffectPageParam { + /** The window start, fixed when the first page loaded. */ + from: string; + /** Exclusive upper bound: just after the oldest entry already shown. */ + to: string; +} + export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const { t } = useTranslations(); const { canWriteDomain, canManageDomain } = useRecordCapabilities(); @@ -133,18 +147,40 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const listKey = queryKeys.medicationSideEffects(medicationId); - const { data, isLoading, isError, refetch } = useQuery({ + // The last 30 days, newest first, one page at a time. The window is fixed + // when the first page loads; each further page asks for the entries before + // the oldest one shown (`to` is exclusive, so the boundary instant is + // re-asked one millisecond later and the overlap is dropped by id). + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: listKey, - queryFn: async () => { - // Last 30 days, newest-first. - const to = new Date(); - const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); - const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`; + initialPageParam: null as SideEffectPageParam | null, + queryFn: async ({ pageParam }) => { + const to = pageParam ? new Date(pageParam.to) : new Date(); + const from = pageParam + ? new Date(pageParam.from) + : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); + const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`; const { data: payload } = await apiFetchEnvelope<{ items: SideEffectRow[]; meta: { total: number }; }>(url); - return payload; + return { ...payload, from: from.toISOString() }; + }, + getNextPageParam: (lastPage): SideEffectPageParam | undefined => { + if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined; + const oldest = lastPage.items[lastPage.items.length - 1]; + return { + from: lastPage.from, + to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(), + }; }, staleTime: 30 * 1000, }); @@ -184,7 +220,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { }, }); - const items = data?.items ?? []; + const items = useMemo(() => { + const seen = new Set(); + const out: SideEffectRow[] = []; + for (const page of data?.pages ?? []) { + for (const row of page.items) { + if (seen.has(row.id)) continue; + seen.add(row.id); + out.push(row); + } + } + return out; + }, [data]); const filteredEntries = useMemo( () => entriesByCategory(category), @@ -363,6 +410,20 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { ))} + {hasNextPage ? ( +
    + +
    + ) : null}
    )} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index f13be444c..6f77494fd 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2878,6 +2878,11 @@ export const glp1TimelineResponse = z .describe( "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.", ), + medications: z + .array(z.object({ id: z.string(), name: z.string() })) + .describe( + "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the entry names, to know which medications are GLP-1: a medication whose events are all older than the newest `limit` entries has none in `entries`. Empty when `hasGlp1` is false.", + ), }) .meta({ id: "Glp1TimelineResponse", From 648bb0d9858cb09f279c41f93af9823ee685f464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:10:53 +0200 Subject: [PATCH 4/6] fix(medications): walk intakes to the drug-level horizon, read every symptom in a window, and guard fixed client fetch limits --- .../client-fetch-limit-guard.test.ts | 207 ++++++++++++++++++ .../__tests__/drug-level-chart.test.tsx | 52 +++++ .../medications/drug-level-chart.tsx | 59 ++++- .../__tests__/load-symptom-events.test.ts | 67 ++++++ src/components/symptoms/use-symptoms.ts | 54 ++++- 5 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/client-fetch-limit-guard.test.ts create mode 100644 src/components/symptoms/__tests__/load-symptom-events.test.ts diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts new file mode 100644 index 000000000..fcd8cffa3 --- /dev/null +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -0,0 +1,207 @@ +/** + * No client list or picker with a fixed row cap and nothing to reach past it. + * + * A client read that asks for "the newest N" and renders the answer as the + * whole list loses everything beyond N without a word: the Labs list asked + * for 500 raw rows and grouped them in the browser, so on a long history + * every marker measured only long ago had no row at all. The visit-form lab + * picker was the same class before it, and the GLP-1 curve, the side-effect + * log and the symptom timeline each had a quieter copy. + * + * The rule: every literal `limit=` a client fetch sends either sits + * beside a pager (an `offset` / `cursor` / `pageParam` within a few lines, or + * a `useInfiniteQuery` in the file), or is listed below with the reason the + * cap cannot cut anything off. + * + * What it matches: `limit=` inside a URL string, and the two + * `URLSearchParams` spellings, `.set("limit", "")` and + * `limit: ""`. What it does not: a limit routed through a named + * constant (`limit=${PAGE_SIZE}`), which is a deliberate page size in every + * case today. It walks client code only (`components/`, `hooks/`, and `app/` + * outside `app/api/`); a server-side `take` is not a fetch. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +interface Allowed { + file: string; + limit: number; + reason: string; +} + +const ALLOWLIST: readonly Allowed[] = [ + { + file: "app/labs/page.tsx", + limit: 1, + reason: + "Deep-link resolution: the newest reading under one analyte name, used only to find its marker page.", + }, + { + file: "components/onboarding/first-result-screen.tsx", + limit: 1, + reason: "Asks only whether a type has a reading, and which is newest.", + }, + { + file: "components/insights/glp1-substance-curve-summary.tsx", + limit: 1, + reason: + "Reads only `medications`, which lists every GLP-1 medication whatever the entry cap; the entries are not used.", + }, + { + file: "components/charts/chart-series-request.ts", + limit: 5000, + reason: + "Daily rollup buckets: one row per day, and the widest window (All, 3,650 days, plus a 365-day comparison) is 4,015 days.", + }, + { + file: "components/insights/trend-annotation.tsx", + limit: 5000, + reason: "Daily rollup buckets over a 30-day window: at most 31 rows.", + }, + { + file: "components/insights/coach-panel/attachment-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "components/settings/share-document-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "components/documents/document-link-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "hooks/use-mood-day.ts", + limit: 50, + reason: + "One calendar day of mood entries; fifty check-ins in a day is beyond any cadence the app offers.", + }, +]; + +const LIMIT_PATTERNS: readonly RegExp[] = [ + /[?&]limit=(\d+)/g, + /\.set\(\s*["']limit["']\s*,\s*["'](\d+)["']\s*\)/g, + /\blimit:\s*["'](\d+)["']/g, +]; + +const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/; +const PAGER_LINES = 3; + +function clientFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter( + (p) => + p.startsWith("components/") || + p.startsWith("hooks/") || + (p.startsWith("app/") && !p.startsWith("app/api/")), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)) + .sort(); +} + +interface Hit { + file: string; + line: number; + limit: number; + paged: boolean; +} + +/** Every literal limit in the given source, with whether a pager is beside it. */ +function findLimitHits(file: string, source: string): Hit[] { + const lines = source.split("\n"); + const infinite = /\buseInfiniteQuery\b/.test(source); + const hits: Hit[] = []; + lines.forEach((text, index) => { + // Comments describe old shapes; they send nothing. + const trimmed = text.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) return; + for (const pattern of LIMIT_PATTERNS) { + for (const match of text.matchAll(pattern)) { + const near = lines + .slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1) + .join("\n"); + hits.push({ + file, + line: index + 1, + limit: Number(match[1]), + paged: infinite || PAGER_NEARBY.test(near), + }); + } + } + }); + return hits; +} + +function allHits(): Hit[] { + return clientFiles().flatMap((file) => + findLimitHits(file, readFileSync(join(SRC, file), "utf8")), + ); +} + +function isAllowed(hit: Hit): boolean { + return ALLOWLIST.some((a) => a.file === hit.file && a.limit === hit.limit); +} + +describe("client fetch limits", () => { + it("finds the literal limits it is meant to police", () => { + // An empty sweep would agree with any allowlist. Every allowlisted cap is + // a live hit, so the hit set can never be smaller than the list. + expect(allHits().length).toBeGreaterThanOrEqual(ALLOWLIST.length); + }); + + it("puts every fixed limit beside a pager or on the allowlist", () => { + const offenders = allHits() + .filter((hit) => !hit.paged && !isAllowed(hit)) + .map((hit) => `${hit.file}:${hit.line} limit=${hit.limit}`); + expect( + offenders, + "A client fetch with a fixed limit loses everything past it. Page it (offset / cursor / useInfiniteQuery), move the grouping server-side, or add an allowlist entry that says why the cap cannot cut anything off.", + ).toEqual([]); + }); + + it("keeps no allowlist entry that no longer matches anything", () => { + const hits = allHits(); + const stale = ALLOWLIST.filter( + (a) => !hits.some((h) => h.file === a.file && h.limit === a.limit), + ).map((a) => `${a.file} limit=${a.limit}`); + expect(stale).toEqual([]); + }); + + it("gives every allowlist entry a reason", () => { + for (const entry of ALLOWLIST) { + expect(entry.reason.length, entry.file).toBeGreaterThan(20); + } + }); + + it("flags the shape that dropped markers from the Labs list", () => { + const hits = findLimitHits( + "components/labs/lab-list.tsx", + 'apiGet("/api/labs?limit=500&sortDir=desc")', + ); + expect(hits).toHaveLength(1); + expect(hits[0].paged).toBe(false); + expect(isAllowed(hits[0])).toBe(false); + }); + + it("accepts a limit that travels with an offset", () => { + const hits = findLimitHits( + "components/example.tsx", + "fetch(`/api/x?status=taken&limit=100&offset=${offset}`)", + ); + expect(hits).toHaveLength(1); + expect(hits[0].paged).toBe(true); + }); +}); diff --git a/src/components/medications/__tests__/drug-level-chart.test.tsx b/src/components/medications/__tests__/drug-level-chart.test.tsx index a509dbcd3..9e9b0b673 100644 --- a/src/components/medications/__tests__/drug-level-chart.test.tsx +++ b/src/components/medications/__tests__/drug-level-chart.test.tsx @@ -81,6 +81,7 @@ vi.mock("@/lib/medications/glp1-pk", async () => { import { DrugLevelChart, + loadTakenIntakesSince, parseDoseMg, resolveDoseMg, } from "../drug-level-chart"; @@ -362,3 +363,54 @@ describe("parseDoseMg / resolveDoseMg helpers", () => { expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42); }); }); + +describe("loadTakenIntakesSince", () => { + const DAY = 24 * 60 * 60 * 1000; + // 400 daily taken doses, newest first, the newest one a day ago. + const now = Date.parse("2026-06-01T08:00:00.000Z"); + const all = Array.from({ length: 400 }, (_, i) => ({ + id: `i-${i}`, + takenAt: new Date(now - (i + 1) * DAY).toISOString(), + skipped: false, + scheduledFor: new Date(now - (i + 1) * DAY).toISOString(), + })); + + function pager() { + const urls: string[] = []; + const fetchPage = async (url: string) => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const limit = Number(params.get("limit")); + const offset = Number(params.get("offset")); + return { + events: all.slice(offset, offset + limit), + meta: { total: all.length }, + }; + }; + return { urls, fetchPage }; + } + + it("walks back past a single page to the horizon, and no further", async () => { + const { urls, fetchPage } = pager(); + // 21-day window + 5 half-lives of 7 days = 56 days, then 150 to cross a page. + const since = new Date(now - 150.5 * DAY); + const { events } = await loadTakenIntakesSince("med-1", since, fetchPage); + expect(events).toHaveLength(150); + expect(urls).toHaveLength(2); + for (const url of urls) { + expect(url).toContain("status=taken"); + expect(url).toContain("sortBy=takenAt"); + } + }); + + it("stops at the end of the history", async () => { + const { urls, fetchPage } = pager(); + const { events } = await loadTakenIntakesSince( + "med-1", + new Date(now - 10_000 * DAY), + fetchPage, + ); + expect(events).toHaveLength(400); + expect(urls).toHaveLength(4); + }); +}); diff --git a/src/components/medications/drug-level-chart.tsx b/src/components/medications/drug-level-chart.tsx index 481b65748..86e2fdcd7 100644 --- a/src/components/medications/drug-level-chart.tsx +++ b/src/components/medications/drug-level-chart.tsx @@ -56,6 +56,7 @@ import { } from "@/lib/medications/glp1-pk"; import { findDrugIdByBrand, + GLP1_DRUGS, type Glp1DrugId, } from "@/lib/medications/glp1-knowledge"; import { parseDoseMg } from "@/lib/medications/dose-string"; @@ -74,6 +75,53 @@ const HOURS_PER_DAY = 24; const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY; const WINDOW_HOURS_AFTER = 0; +/** Taken intakes per request while walking back to the model's horizon. */ +const INTAKE_PAGE_SIZE = 100; +/** + * Half-lives before the chart's left edge whose doses still count. After five + * half-lives a dose holds about 3% of its peak, below what the chart can show. + */ +const HORIZON_HALF_LIVES = 5; + +/** + * Every taken intake from `since` on, newest first, walked page by page. The + * curve is a sum over every dose still in the body, so a fixed count of + * recent intakes was wrong in both directions: twenty daily doses of a + * long-acting drug stop well short of steady state, and planned or skipped + * rows used up the count without being doses at all. + */ +export async function loadTakenIntakesSince( + medicationId: string, + since: Date, + fetchPage: (url: string) => Promise<{ + events: IntakeEvent[]; + meta: { total: number }; + }> = (url) => apiGet(url), +): Promise<{ events: IntakeEvent[] }> { + const events: IntakeEvent[] = []; + for (let offset = 0; ; offset += INTAKE_PAGE_SIZE) { + const page = await fetchPage( + `/api/medications/${medicationId}/intake?status=taken&sortBy=takenAt&sortDir=desc&limit=${INTAKE_PAGE_SIZE}&offset=${offset}`, + ); + events.push(...page.events); + const oldest = page.events[page.events.length - 1]; + const reachedHorizon = + !oldest?.takenAt || Date.parse(oldest.takenAt) < since.getTime(); + if ( + reachedHorizon || + page.events.length < INTAKE_PAGE_SIZE || + offset + INTAKE_PAGE_SIZE >= page.meta.total + ) { + break; + } + } + return { + events: events.filter( + (ev) => ev.takenAt && Date.parse(ev.takenAt) >= since.getTime(), + ), + }; +} + interface IntakeEvent { id: string; takenAt: string | null; @@ -147,9 +195,16 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id), queryFn: async () => { try { - return await apiGet<{ events: IntakeEvent[] }>( - `/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`, + const halfLifeHours = + GLP1_DRUGS[drugId!].pharmacology.halfLifeDays * HOURS_PER_DAY; + const since = new Date( + Date.now() - + (WINDOW_HOURS_BEFORE + HORIZON_HALF_LIVES * halfLifeHours) * + 60 * + 60 * + 1000, ); + return await loadTakenIntakesSince(medication.id, since); } catch { return null; } diff --git a/src/components/symptoms/__tests__/load-symptom-events.test.ts b/src/components/symptoms/__tests__/load-symptom-events.test.ts new file mode 100644 index 000000000..4a9098f35 --- /dev/null +++ b/src/components/symptoms/__tests__/load-symptom-events.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { SymptomEventDTO } from "@/lib/symptoms/shared"; +import { loadSymptomEvents, type SymptomEventList } from "../use-symptoms"; + +/** + * A fake of `GET /api/symptoms/events`: newest first, `to` inclusive, at most + * `limit` rows, `hasMore` when the page is full. + */ +function fakeRoute(all: SymptomEventDTO[]) { + const urls: string[] = []; + const fetchPage = async (url: string): Promise => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const to = params.get("to")!; + const limit = Number(params.get("limit")); + const rows = all + .filter((e) => e.occurredAt <= to) + .sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1)) + .slice(0, limit); + return { events: rows, limit, hasMore: rows.length === limit }; + }; + return { urls, fetchPage }; +} + +function event(i: number, occurredAt: string): SymptomEventDTO { + return { id: `e-${i}`, occurredAt } as SymptomEventDTO; +} + +describe("loadSymptomEvents", () => { + it("reads past the first page until the window is exhausted", async () => { + const base = Date.parse("2026-06-01T00:00:00.000Z"); + // 1,200 occurrences an hour apart, two of them sharing a page boundary. + const all = Array.from({ length: 1_200 }, (_, i) => + event(i, new Date(base - i * 3_600_000).toISOString()), + ); + all.push(event(9_999, all[499].occurredAt)); + const { urls, fetchPage } = fakeRoute(all); + + const out = await loadSymptomEvents( + "1900-01-01T00:00:00.000Z", + "2026-06-02T00:00:00.000Z", + "ep-1", + fetchPage, + ); + + expect(out.events).toHaveLength(1_201); + expect(new Set(out.events.map((e) => e.id)).size).toBe(1_201); + expect(out.hasMore).toBe(false); + expect(urls.length).toBeGreaterThan(2); + expect(urls.every((u) => u.includes("episodeId=ep-1"))).toBe(true); + }); + + it("stops after one request when the first page is not full", async () => { + const { urls, fetchPage } = fakeRoute([ + event(1, "2026-05-01T00:00:00.000Z"), + ]); + const out = await loadSymptomEvents( + "2026-04-01T00:00:00.000Z", + "2026-06-01T00:00:00.000Z", + null, + fetchPage, + ); + expect(out.events).toHaveLength(1); + expect(urls).toHaveLength(1); + }); +}); diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index a95ebaed2..9c900f10c 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -55,9 +55,55 @@ export function useSymptomDefinitions(includeHidden = false, enabled = true) { }); } +/** Occurrences per request while walking a window. */ +const SYMPTOM_EVENTS_PAGE_SIZE = 500; + +/** + * Every occurrence inside `[from, to]` (ISO instants), newest first, read page + * by page. The route answers at most one page and says `hasMore`; each + * further page asks up to the oldest instant already read (`to` is + * inclusive, so the boundary instant comes back and is dropped by id). A + * chronic episode can hold more than one page, and a single capped read + * showed only its newest part. + */ +export async function loadSymptomEvents( + from: string, + to: string, + episodeId: string | null, + fetchPage: (url: string) => Promise = (url) => + apiGet(url), +): Promise { + const seen = new Set(); + const events: SymptomEventDTO[] = []; + let upTo = to; + for (;;) { + const params = new URLSearchParams({ + from, + to: upTo, + limit: String(SYMPTOM_EVENTS_PAGE_SIZE), + }); + if (episodeId) params.set("episodeId", episodeId); + const page = await fetchPage(`/api/symptoms/events?${params}`); + let added = 0; + for (const event of page.events) { + if (seen.has(event.id)) continue; + seen.add(event.id); + events.push(event); + added += 1; + } + const oldest = page.events[page.events.length - 1]; + // A page that adds nothing (a full page of one instant) cannot move the + // bound, so it ends the walk rather than repeating forever. + if (!page.hasMore || !oldest || added === 0) { + return { events, limit: SYMPTOM_EVENTS_PAGE_SIZE, hasMore: false }; + } + upTo = oldest.occurredAt; + } +} + /** * Occurrences inside `[from, to]` (ISO instants), optionally only those filed - * against one episode. At most 500, newest first. + * against one episode. Every one of them, newest first. */ export function useSymptomEvents( from: string, @@ -68,11 +114,7 @@ export function useSymptomEvents( return useQuery({ queryKey: queryKeys.symptomEvents(from, to, episodeId), enabled: options.enabled ?? true, - queryFn: () => { - const params = new URLSearchParams({ from, to, limit: "500" }); - if (episodeId) params.set("episodeId", episodeId); - return apiGet(`/api/symptoms/events?${params}`); - }, + queryFn: () => loadSymptomEvents(from, to, episodeId), }); } From eab4d644f7e20a0e2525557c3b9c247915a01bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:12:57 +0200 Subject: [PATCH 5/6] feat(ops): log pending migrations before migrate deploy and document a dump before every update --- Dockerfile | 10 +- docker-entrypoint.sh | 3 + docs/ops/deploy.md | 6 ++ docs/self-hosting/getting-started.md | 39 ++++++++ scripts/pending-migrations.mjs | 95 +++++++++++++++++++ .../container-scripts-ship-guard.test.ts | 30 ++++++ .../pending-migrations-notice.test.ts | 78 +++++++++++++++ 7 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 scripts/pending-migrations.mjs create mode 100644 tests/integration/pending-migrations-notice.test.ts diff --git a/Dockerfile b/Dockerfile index 475e51861..a06be1896 100644 --- a/Dockerfile +++ b/Dockerfile @@ -202,14 +202,20 @@ COPY --from=builder /app/src/generated ./src/generated # `@node-rs/argon2` from the traced runtime tree. Scripts that import the # application graph (`@/…`, the generated Prisma client) run from a source # checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds -# the runbooks and this list in step. Root-owned on purpose: the app user can -# run them but not rewrite them. +# the runbooks and the entrypoint against this list. `pending-migrations.mjs` +# is the entrypoint's own: it logs the pending migration count before +# `migrate deploy`. Root-owned on purpose: the app user can run them but not +# rewrite them. COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs +COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts # Prove the reset CLI's imports resolve in this tree: with every import found # it stops at its own usage line; a missing module throws before reaching it. RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:" +# The boot notice parses, and the `pg` it loads on a real boot resolves here. +RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \ + node --input-type=module -e "await import('pg')" # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3386a8879..8eff71a6b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -85,6 +85,9 @@ done echo "HealthLog: Database is reachable." # ── Run Prisma migrations ──────────────────────────────────── +# Say how many migrations this image is about to apply, with a reminder to +# back up first. Reporting only: it never stops the boot. +node scripts/pending-migrations.mjs || true echo "HealthLog: Running database migrations..." NODE_PATH=/opt/prisma-cli/node_modules \ node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 94f0c047d..1bf418cdc 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -13,6 +13,12 @@ Migrations land **before** the new app container takes traffic. The canonical command sequence on a single-instance host: ```sh +# 0. Dump the database first. A migration is not undone by going back to +# the previous image; the dump is the way back. See "Updates" in +# docs/self-hosting/getting-started.md. +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-before-update-$(date +%F).sql.gz + # 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF, # set it to the new one first. docker compose pull app diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 9967227e6..983a8267f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -361,3 +361,42 @@ For off-host encrypted backups to S3/R2/B2 see [`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the encryption key safely (keep the old key until zero rows remain on it) see [`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md). + +## Updates + +A new image can bring database migrations, and `docker-entrypoint.sh` +applies them on the first start of that image. Most are additive, but some +rewrite data, and going back to the previous image does not undo them. The +only way back from a migration that went wrong is the dump you took before +it. So take one before every pull: + +```bash +# 1. Dump the database (same command as under "Backup and restore"). +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-before-update-$(date +%F).sql.gz + +# 2. Pull the new image and recreate the app container. +docker compose pull app +docker compose up -d app + +# 3. Confirm the running build. +curl -s http://localhost:3000/api/version +``` + +Keep the dump until the new version has run for a few days, and keep your +encryption key with it: a dump without the key cannot be read. + +The start log says what is about to happen before the migrations run: + +```text +HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image. +HealthLog: Running database migrations... +``` + +or `HealthLog: Database schema is up to date.` when the image brings none. +The line is a notice and does not pause the start. If you see a count and +have no dump yet, stop the container (`docker compose stop app`), take the +dump, and start it again; the migrations run on that start. + +For the ordering on a host where you want the migration result before the +new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md). diff --git a/scripts/pending-migrations.mjs b/scripts/pending-migrations.mjs new file mode 100644 index 000000000..387e062dc --- /dev/null +++ b/scripts/pending-migrations.mjs @@ -0,0 +1,95 @@ +/** + * Boot-time notice before `prisma migrate deploy`: how many migrations this + * image is about to apply, and a reminder to have a backup first. + * + * Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`. + * It only reports. A migration that changes data cannot be undone by pulling + * the previous image, so an operator who reads the count in the log still has + * a moment to stop the container and take a `pg_dump`; the entrypoint does + * not wait for that. + * + * Best effort by design: any failure to count (no `_prisma_migrations` table + * yet is not a failure, it means every migration is pending) prints one line + * saying so and exits 0, because a notice is never a reason to keep the app + * from starting. `migrate deploy` right after it is what decides. + */ +import { readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Migration directory names that carry a `migration.sql`, sorted. */ +export function shippedMigrations(migrationsDir) { + return readdirSync(migrationsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => existsSync(join(migrationsDir, name, "migration.sql"))) + .sort(); +} + +/** + * The shipped migrations the database has not finished applying. Reads + * `_prisma_migrations` in the schema the connection string names (Prisma's + * `?schema=` parameter, `public` when absent); a database without that table + * has applied nothing. + */ +export async function pendingMigrations({ connectionString, migrationsDir }) { + const shipped = shippedMigrations(migrationsDir); + const { default: pg } = await import("pg"); + const url = new URL(connectionString); + const schema = url.searchParams.get("schema") || "public"; + url.searchParams.delete("schema"); + const client = new pg.Client({ + connectionString: url.toString(), + connectionTimeoutMillis: 5_000, + statement_timeout: 5_000, + }); + await client.connect(); + try { + const table = await client.query( + "SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t", + [schema], + ); + if (!table.rows[0]?.t) return shipped; + const applied = await client.query( + `SELECT migration_name FROM ${table.rows[0].t} + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`, + ); + const done = new Set(applied.rows.map((row) => row.migration_name)); + return shipped.filter((name) => !done.has(name)); + } finally { + await client.end().catch(() => {}); + } +} + +/** The log line for a count, as the entrypoint prints it. */ +export function pendingNotice(count) { + if (count === 0) return "HealthLog: Database schema is up to date."; + const noun = count === 1 ? "migration" : "migrations"; + return ( + `HealthLog: ${count} database ${noun} pending. ` + + "Back up the database before updating (pg_dump, see the Updates section " + + "of docs/self-hosting/getting-started.md); a migration is not undone by " + + "going back to the previous image." + ); +} + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) return; + try { + const pending = await pendingMigrations({ + connectionString, + migrationsDir: join(process.cwd(), "prisma", "migrations"), + }); + console.log(pendingNotice(pending.length)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.log( + `HealthLog: Could not count pending migrations (${reason}); continuing.`, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts index 83e2e928f..629a62f48 100644 --- a/src/__tests__/container-scripts-ship-guard.test.ts +++ b/src/__tests__/container-scripts-ship-guard.test.ts @@ -69,6 +69,18 @@ function documentedContainerScripts(): string[] { return [...found].sort(); } +/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */ +function entrypointScripts(): string[] { + const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8"); + return [ + ...new Set( + [...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map( + (m) => m[1], + ), + ), + ].sort(); +} + /** Relative import specifiers of a script, resolved against its location. */ function localImports(script: string): string[] { const src = readFileSync(join(ROOT, script), "utf8"); @@ -109,3 +121,21 @@ describe("scripts the runbooks run inside the container ship in the image", () = expect(missing).toEqual([]); }); }); + +describe("scripts the entrypoint runs ship in the image", () => { + it("finds the boot scripts it is meant to check", () => { + // An empty match would agree with any Dockerfile. + expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs"); + }); + + it("copies every boot script and its local imports into the runner stage", () => { + const missing = entrypointScripts() + .flatMap((script) => [script, ...localImports(script)]) + .filter((path) => !copiedIntoRunner(path)); + expect( + missing, + "docker-entrypoint.sh runs these on every boot, but the Dockerfile " + + "runner stage does not COPY them; the container would fail to start.", + ).toEqual([]); + }); +}); diff --git a/tests/integration/pending-migrations-notice.test.ts b/tests/integration/pending-migrations-notice.test.ts new file mode 100644 index 000000000..3631e6612 --- /dev/null +++ b/tests/integration/pending-migrations-notice.test.ts @@ -0,0 +1,78 @@ +/** + * The boot notice `docker-entrypoint.sh` prints before `migrate deploy` + * (`scripts/pending-migrations.mjs`), against the migrated test database. + * + * Every shipped migration is applied here, so the count is zero; a + * migrations directory with one more entry than the database has applied + * stands for a freshly pulled image, and a schema with no migrations table + * for a first boot. + */ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + pendingMigrations, + pendingNotice, + shippedMigrations, +} from "../../scripts/pending-migrations.mjs"; + +const MIGRATIONS = join(process.cwd(), "prisma", "migrations"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true }); +}); + +function connectionString(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set for the integration run"); + return url; +} + +describe("pending-migrations boot notice", () => { + it("counts nothing pending on a database that has applied every migration", async () => { + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: MIGRATIONS, + }); + expect(shippedMigrations(MIGRATIONS).length).toBeGreaterThan(300); + expect(pending).toEqual([]); + expect(pendingNotice(pending.length)).toBe( + "HealthLog: Database schema is up to date.", + ); + }); + + it("counts a migration the image ships and the database has not applied", async () => { + const dir = mkdtempSync(join(tmpdir(), "hl-migrations-")); + tempDirs.push(dir); + cpSync(MIGRATIONS, dir, { recursive: true }); + mkdirSync(join(dir, "9999_not_applied_yet")); + writeFileSync(join(dir, "9999_not_applied_yet", "migration.sql"), ""); + // A directory without migration.sql is not a migration. + mkdirSync(join(dir, "9998_no_sql")); + + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: dir, + }); + expect(pending).toEqual(["9999_not_applied_yet"]); + const notice = pendingNotice(pending.length); + expect(notice).toContain("1 database migration pending"); + expect(notice).toContain("pg_dump"); + }); + + it("treats a schema without a migrations table as a first boot", async () => { + const url = new URL(connectionString()); + url.searchParams.set("schema", "schema_never_migrated"); + const pending = await pendingMigrations({ + connectionString: url.toString(), + migrationsDir: MIGRATIONS, + }); + expect(pending).toEqual(shippedMigrations(MIGRATIONS)); + expect(pendingNotice(pending.length)).toMatch( + /^HealthLog: \d+ database migrations pending\./, + ); + }); +}); From 91ccd154b1a031f4d215ad06c09818ecd4067db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:19:46 +0200 Subject: [PATCH 6/6] test(labs): register the overview route in the sharing and module-gate inventories --- src/__tests__/sharing-surface-guard.test.ts | 9 ++++++++- .../api/__tests__/module-route-gate-inventory.test.ts | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 50035b43b..6d627ac32 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -847,6 +847,10 @@ const DELEGABLE_ROUTES: Record = { domain: "labs", why: "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", }, + "app/api/labs/overview/route.ts": { + domain: "labs", + why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.", + }, "app/api/biomarkers/route.ts": { domain: "labs", why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", @@ -2097,8 +2101,11 @@ const ACTOR_ROUTES: Record = { * v1.42 — the score-history read joins the record list and the manage * literal (a score's daily course, MANAGE like the derived score itself). * 252 -> 254. + * + * v1.42.4 -- the Labs list's per-marker overview joins the record list + * beside `GET /api/labs`, the rows it groups. 255 -> 256. */ -const FROZEN_ENTRY_COUNT = 255; +const FROZEN_ENTRY_COUNT = 256; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 64d8ba407..431e119ca 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -242,6 +242,8 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/workouts/batch/route.ts", "src/app/api/labs/route.ts", "src/app/api/labs/[id]/route.ts", + // The Labs list's per-marker overview reads the same row store. + "src/app/api/labs/overview/route.ts", // v1.18.1 — the lab-result delete-Undo restore endpoint and the // user-scoped Biomarker catalog CRUD share the LabResult data-layer // reasoning: the labs module gates the SURFACES (the Labs page), not the