From 77619f8aa53a1c5ea664b90e123c6d8217ae00a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:56:16 +0200 Subject: [PATCH] feat(fhir): serve Observations as a time series, code RMSSD and workouts GET /api/fhir/Observation keeps its snapshot answer when no date is sent. A date range (FHIR prefixes eq/gt/ge/lt/le, UTC when no offset) switches it to the time series: every reading in the range plus workouts, newest first, paged in the database through one UNION id query and a count, so a page costs what it holds. Series ids are the row's own (m-, w-) and read back through /Observation/{id}. The series replays the same saved report selection as the snapshot, pairs systolic and diastolic recorded at one instant into the blood-pressure panel, leaves sleep stage segments out, and reads an hourly heart-rate bucket as a mean over its hour. A code parameter narrows either answer; a malformed date is a 400 OperationOutcome. A workout is an activity Observation after the Physical Activity IG: LOINC 73985-4 with the sport as its value and duration (55411-3) and energy (41981-2) as components. RMSSD gets its own registry coding, text-only because LOINC has no RMSSD term, so it never borrows the SDNN code. The capability statement lists the two new parameters. --- docs/api/openapi.yaml | 31 +- src/app/api/fhir/Observation/[id]/route.ts | 19 + src/app/api/fhir/Observation/route.ts | 71 +- .../fhir/__tests__/observation-series.test.ts | 228 ++++++ src/app/api/fhir/metadata/route.ts | 21 +- src/lib/fhir/__tests__/series.test.ts | 375 +++++++++ src/lib/fhir/rest.ts | 47 +- src/lib/fhir/series.ts | 765 ++++++++++++++++++ src/lib/openapi/routes/health-record.ts | 8 +- .../fixtures/expected-measurement-loinc.json | 6 + src/lib/signals/registry.ts | 28 + .../fhir-series-and-medication-effect.test.ts | 362 +++++++++ 12 files changed, 1935 insertions(+), 26 deletions(-) create mode 100644 src/app/api/fhir/__tests__/observation-series.test.ts create mode 100644 src/lib/fhir/__tests__/series.test.ts create mode 100644 src/lib/fhir/series.ts create mode 100644 tests/integration/fhir-series-and-medication-effect.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 356e42699..bcf344eb7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1245,8 +1245,9 @@ paths: summary: FHIR R4 CapabilityStatement (v1.11.0) description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` - interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised - interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)." + interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the + `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a + route. Auth: `fhir:read` scope (cookie sessions also pass)." responses: "200": description: CapabilityStatement (application/fhir+json). @@ -1291,7 +1292,16 @@ paths: - FHIR summary: FHIR R4 Observation search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: - `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`." + `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the + snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` + (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time + series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-` / + `w-` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection + as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the + same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 + Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a + bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 + OperationOutcome." parameters: - in: query name: _count @@ -1301,6 +1311,14 @@ paths: name: _offset schema: type: number + - in: query + name: date + schema: + type: string + - in: query + name: code + schema: + type: string responses: "200": description: searchset Bundle (application/fhir+json). @@ -1463,9 +1481,10 @@ paths: tags: - FHIR summary: FHIR R4 Observation read - description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is - indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` - scope; `userId` is narrowed from auth." + description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id + (`m-` / `w-`, v1.44), the latter read inside the same saved selection the series search applies. An id + belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` + OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id diff --git a/src/app/api/fhir/Observation/[id]/route.ts b/src/app/api/fhir/Observation/[id]/route.ts index 45de4c5e6..1f42faa27 100644 --- a/src/app/api/fhir/Observation/[id]/route.ts +++ b/src/app/api/fhir/Observation/[id]/route.ts @@ -2,6 +2,9 @@ * GET /api/fhir/Observation/{id} — FHIR R4 `read` of one Observation from the * caller's own record. * + * A series id (`m-…` / `w-…`, v1.44) reads that one row inside the same scope + * the series search applies; every other id is a snapshot id. + * * The id space is the export's own (`obs-3`), stable for a given record and * reporting window. A `read` is a filter over the same emitter the search * route pages, so a resource can never differ between the two faces; an id the @@ -16,10 +19,13 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { observationsFromReportData } from "@/lib/fhir/resources"; import { FHIR_READ_SCOPE, + fhirJsonResponse, loadFhirContext, + loadFhirSeriesScope, operationOutcome, readResponse, } from "@/lib/fhir/rest"; +import { readSeriesObservation } from "@/lib/fhir/series"; export const GET = apiHandler( async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { @@ -41,6 +47,19 @@ export const GET = apiHandler( return operationOutcome(429, "throttled", "Rate limit exceeded"); } const { id } = await ctx.params; + if (id.startsWith("m-") || id.startsWith("w-")) { + const { scope, glucoseUnit } = await loadFhirSeriesScope(user.id, null); + annotate({ meta: { resourceId: id, mode: "series" } }); + const observation = await readSeriesObservation({ + userId: user.id, + id, + scope, + glucoseUnit, + }); + return observation + ? fhirJsonResponse(observation) + : operationOutcome(404, "not-found", "No such resource"); + } const context = await loadFhirContext(user.id); annotate({ meta: { resourceId: id } }); return readResponse(observationsFromReportData(context.data), id); diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index ab52fcc54..1988f78c4 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -1,12 +1,19 @@ /** - * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations - * (vitals / activity / lab / survey): one latest reading per type plus the BP - * panel, BMI, glucose, adherence, mood and the wellness composites. + * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations. * - * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in - * `@/lib/fhir/resources` are the single source of the coding, so this face and - * the document export can never describe the same record differently. Offset - * paging via `_count` (clamped ≤200) / `_offset`. + * Two answers, chosen by the request: + * + * - no `date` parameter: the snapshot it has always been — one latest + * reading per type plus the BP panel, BMI, glucose, adherence, mood and the + * wellness composites (`obs-N` ids), offset-paged in memory. + * - with `date` (v1.44): the time series — every reading in the range, plus + * workouts, newest first, paged in the database (`m-…` / `w-…` ids). See + * `@/lib/fhir/series`. + * + * `code` narrows either answer. Read-only. `userId` is narrowed from + * `requireAuth`; both answers read the owner's saved report selection, so the + * series cannot reach a type the snapshot withholds. Paging via `_count` + * (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -18,10 +25,18 @@ import { observationsFromReportData } from "@/lib/fhir/resources"; import { FHIR_READ_SCOPE, loadFhirContext, + loadFhirSeriesScope, operationOutcome, parsePaging, searchsetResponse, } from "@/lib/fhir/rest"; +import { + emitSeriesPage, + matchesCodeTokens, + parseCodeParams, + parseDateParams, + querySeriesPage, +} from "@/lib/fhir/series"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); @@ -41,11 +56,45 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); - const context = await loadFhirContext(user.id); + const params = request.nextUrl.searchParams; + const { count, offset } = parsePaging(params); + const codeValues = params.getAll("code"); + const range = parseDateParams(params.getAll("date")); + if (range && "error" in range) { + return operationOutcome(400, "invalid", range.error); + } - const all = observationsFromReportData(context.data); + if (range) { + const { scope, glucoseUnit } = await loadFhirSeriesScope( + user.id, + parseCodeParams(codeValues), + ); + const { total, refs } = await querySeriesPage({ + userId: user.id, + scope, + range, + count, + offset, + }); + const page = await emitSeriesPage({ + userId: user.id, + refs, + scope, + glucoseUnit, + }); + annotate({ + meta: { mode: "series", total, count, offset, types: scope.types.length }, + }); + return searchsetResponse(request.nextUrl, page, total, count, offset); + } + + const context = await loadFhirContext(user.id); + const snapshot = observationsFromReportData(context.data); + const all = + codeValues.length > 0 + ? snapshot.filter((o) => matchesCodeTokens(o, codeValues)) + : snapshot; const page = all.slice(offset, offset + count); - annotate({ meta: { total: all.length, count, offset } }); + annotate({ meta: { mode: "snapshot", total: all.length, count, offset } }); return searchsetResponse(request.nextUrl, page, all.length, count, offset); }); diff --git a/src/app/api/fhir/__tests__/observation-series.test.ts b/src/app/api/fhir/__tests__/observation-series.test.ts new file mode 100644 index 000000000..25fb76e6f --- /dev/null +++ b/src/app/api/fhir/__tests__/observation-series.test.ts @@ -0,0 +1,228 @@ +/** + * v1.44 — the Observation search's two answers. Without `date` the route + * serves the snapshot exactly as before (the whole-record loader runs, the + * series loader does not); with `date` it serves the paged time series and + * never assembles the whole record. A malformed `date` is a 400, `code` + * narrows the snapshot, and series ids read back through `/{id}`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), + isModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/fhir/rest", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadFhirContext: vi.fn(), + loadFhirSeriesScope: vi.fn(), + }; +}); +vi.mock("@/lib/fhir/resources", () => ({ + observationsFromReportData: vi.fn(), +})); +vi.mock("@/lib/fhir/series", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + querySeriesPage: vi.fn(), + emitSeriesPage: vi.fn(), + readSeriesObservation: vi.fn(), + }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as observationGet } from "../Observation/route"; +import { GET as observationRead } from "../Observation/[id]/route"; +import { + emitSeriesPage, + querySeriesPage, + readSeriesObservation, +} from "@/lib/fhir/series"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { loadFhirContext, loadFhirSeriesScope } from "@/lib/fhir/rest"; +import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; +import { observationsFromReportData } from "@/lib/fhir/resources"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function obs(id: number) { + return { + resourceType: "Observation" as const, + id: `obs-${id}`, + status: "final" as const, + code: { + coding: [ + { system: "http://loinc.org", code: id === 1 ? "29463-7" : "x" }, + ], + }, + subject: { reference: "Patient/patient-1" }, + }; +} + +function req(query = ""): NextRequest { + return new NextRequest(`http://localhost/api/fhir/Observation${query}`); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: Date.now(), + } as never); + // The doctorReport module is ON for the contract assertions below; the + // OFF behaviour is pinned in `module-gate.test.ts`. + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(loadFhirContext).mockResolvedValue({ + data: {} as never, + identity: { insuranceNumber: null }, + germanAtc: false, + records: {}, + }); + vi.mocked(loadFhirSeriesScope).mockResolvedValue({ + scope: { + types: ["WEIGHT"], + pairBloodPressure: false, + glucoseContexts: null, + workouts: true, + }, + glucoseUnit: "mg/dL", + }); +}); + +describe("GET /api/fhir/Observation — snapshot stays the default", () => { + it("serves the snapshot without a date and never touches the series", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([ + obs(1), + obs(2), + ] as never); + const res = await observationGet(req()); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { total: number }; + expect(bundle.total).toBe(2); + expect(loadFhirContext).toHaveBeenCalledTimes(1); + expect(loadFhirSeriesScope).not.toHaveBeenCalled(); + expect(querySeriesPage).not.toHaveBeenCalled(); + }); + + it("narrows the snapshot by code", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([ + obs(1), + obs(2), + ] as never); + const res = await observationGet(req("?code=http://loinc.org|29463-7")); + const bundle = (await res.json()) as { + total: number; + entry: Array<{ resource: { id: string } }>; + }; + expect(bundle.total).toBe(1); + expect(bundle.entry[0].resource.id).toBe("obs-1"); + }); +}); + +describe("GET /api/fhir/Observation?date= — time series", () => { + it("pages in the database and never assembles the whole record", async () => { + vi.mocked(querySeriesPage).mockResolvedValue({ + total: 340, + refs: [{ kind: "m", id: "a" }], + } as never); + vi.mocked(emitSeriesPage).mockResolvedValue([ + { ...obs(9), id: "m-a" }, + ] as never); + const res = await observationGet( + req("?date=ge2026-01-01&date=lt2026-02-01&_count=50&_offset=100"), + ); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + total: number; + link: Array<{ relation: string; url: string }>; + entry: Array<{ fullUrl: string }>; + }; + expect(loadFhirContext).not.toHaveBeenCalled(); + expect(querySeriesPage).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + count: 50, + offset: 100, + range: { + from: new Date("2026-01-01T00:00:00.000Z"), + to: new Date("2026-02-01T00:00:00.000Z"), + }, + }), + ); + expect(bundle.total).toBe(340); + expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/m-a"); + const next = bundle.link.find((l) => l.relation === "next"); + expect(next?.url).toContain("_offset=150"); + expect(next?.url).toContain("date=ge2026-01-01"); + }); + + it("hands the code filter to the scope", async () => { + vi.mocked(querySeriesPage).mockResolvedValue({ total: 0, refs: [] }); + vi.mocked(emitSeriesPage).mockResolvedValue([]); + await observationGet(req("?date=2026&code=80404-7")); + const codes = vi.mocked(loadFhirSeriesScope).mock.calls[0][1]; + expect([...(codes?.types ?? [])]).toEqual(["HEART_RATE_VARIABILITY"]); + }); + + it("answers a malformed date with a 400 OperationOutcome", async () => { + const res = await observationGet(req("?date=last-week")); + expect(res.status).toBe(400); + const body = (await res.json()) as { + resourceType: string; + issue: Array<{ code: string }>; + }; + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("invalid"); + expect(querySeriesPage).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/fhir/Observation/{id} — series ids", () => { + const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + + it("reads a series id through the series scope", async () => { + vi.mocked(readSeriesObservation).mockResolvedValue({ + ...obs(1), + id: "w-x", + } as never); + const res = await observationRead( + new NextRequest("http://localhost/api/fhir/Observation/w-x"), + ctx("w-x"), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as { id: string }).id).toBe("w-x"); + expect(loadFhirContext).not.toHaveBeenCalled(); + }); + + it("answers an unknown series id with not-found", async () => { + vi.mocked(readSeriesObservation).mockResolvedValue(null); + const res = await observationRead( + new NextRequest("http://localhost/api/fhir/Observation/m-nope"), + ctx("m-nope"), + ); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/fhir/metadata/route.ts b/src/app/api/fhir/metadata/route.ts index 2bd4587fb..cb8935d9b 100644 --- a/src/app/api/fhir/metadata/route.ts +++ b/src/app/api/fhir/metadata/route.ts @@ -3,7 +3,8 @@ * * Declares the read-only REST face: the resource types served, the `read` + * `search-type` interactions on each, the search parameters honoured - * (`_count`, `_offset`), the `Patient-everything` operation, and the + * (`_count`, `_offset`; `date` and `code` on Observation), the + * `Patient-everything` operation, and the * `application/fhir+json` format. Static — no per-user data — but still gated * behind the `fhir:read` scope so the whole `/api/fhir` tree answers * uniformly. Read-only: no write interactions are advertised. @@ -22,6 +23,7 @@ import { annotate } from "@/lib/logging/context"; import { FHIR_READ_SCOPE, FHIR_REST_RESOURCE_TYPES, + FHIR_OBSERVATION_SEARCH_PARAMS, FHIR_SEARCH_PARAMS, fhirJsonResponse, } from "@/lib/fhir/rest"; @@ -65,10 +67,19 @@ export const GET = apiHandler(async (request: NextRequest) => { resource: FHIR_REST_RESOURCE_TYPES.map((type) => ({ type, interaction: [{ code: "read" }, { code: "search-type" }], - searchParam: FHIR_SEARCH_PARAMS.map((name) => ({ - name, - type: "number", - })), + searchParam: [ + ...FHIR_SEARCH_PARAMS.map((name) => ({ + name, + type: "number", + })), + // v1.44: the Observation search's time-series range and code filter. + ...(type === "Observation" + ? FHIR_OBSERVATION_SEARCH_PARAMS.map((p) => ({ + name: p.name, + type: p.type, + })) + : []), + ], ...(type === "Patient" ? { operation: [ diff --git a/src/lib/fhir/__tests__/series.test.ts b/src/lib/fhir/__tests__/series.test.ts new file mode 100644 index 000000000..ef832ef0d --- /dev/null +++ b/src/lib/fhir/__tests__/series.test.ts @@ -0,0 +1,375 @@ +/** + * The Observation time series: the `date` and `code` grammar, the scope the + * saved selection allows, and the R4 shape of each emitted resource. + */ +import { describe, expect, it } from "vitest"; + +import { + CALORIES_BURNED_LOINC, + EXERCISE_ACTIVITY_LOINC, + EXERCISE_DURATION_LOINC, + matchesCodeTokens, + measurementSeriesObservation, + parseCodeParams, + parseDateParams, + resolveSeriesScope, + seriesCandidateTypes, + workoutSeriesObservation, +} from "@/lib/fhir/series"; +import { MEASUREMENT_LOINC } from "@/lib/fhir/loinc-map"; +import { isUcumCode } from "@/lib/fhir/ucum"; +import type { FhirObservation } from "@/lib/fhir/types"; +import type { ReportLeafId } from "@/lib/report-selection/catalogue"; + +const iso = (d: Date | null) => d?.toISOString() ?? null; + +describe("parseDateParams", () => { + it("is absent without a date parameter, so the snapshot stays the default", () => { + expect(parseDateParams([])).toBeNull(); + }); + + it("reads prefixes and partial dates as half-open UTC ranges", () => { + const r = parseDateParams(["ge2026-01-01", "lt2026-02-01"]); + expect(r).not.toHaveProperty("error"); + const range = r as { from: Date | null; to: Date | null }; + expect(iso(range.from)).toBe("2026-01-01T00:00:00.000Z"); + expect(iso(range.to)).toBe("2026-02-01T00:00:00.000Z"); + }); + + it("covers a whole month for eq, and the day after for gt and le", () => { + const month = parseDateParams(["2026-02"]) as { from: Date; to: Date }; + expect(iso(month.from)).toBe("2026-02-01T00:00:00.000Z"); + expect(iso(month.to)).toBe("2026-03-01T00:00:00.000Z"); + const gt = parseDateParams(["gt2026-02-10"]) as { from: Date; to: null }; + expect(iso(gt.from)).toBe("2026-02-11T00:00:00.000Z"); + expect(gt.to).toBeNull(); + const le = parseDateParams(["le2026"]) as { from: null; to: Date }; + expect(iso(le.to)).toBe("2027-01-01T00:00:00.000Z"); + }); + + it("honours an offset on a dateTime value", () => { + const r = parseDateParams(["ge2026-03-01T10:00:00+02:00"]) as { + from: Date; + }; + expect(iso(r.from)).toBe("2026-03-01T08:00:00.000Z"); + }); + + it("rejects what it cannot read rather than ignoring it", () => { + expect(parseDateParams(["sa2026-01-01"])).toHaveProperty("error"); + expect(parseDateParams(["2026-02-30"])).toHaveProperty("error"); + expect(parseDateParams(["yesterday"])).toHaveProperty("error"); + }); +}); + +describe("parseCodeParams", () => { + it("maps LOINC codes to their types, with or without a system", () => { + const f = parseCodeParams(["http://loinc.org|80404-7", "29463-7"])!; + expect([...f.types].sort()).toEqual(["HEART_RATE_VARIABILITY", "WEIGHT"]); + expect(f.workouts).toBe(false); + }); + + it("maps any blood-pressure code to the paired panel stream", () => { + const f = parseCodeParams(["8480-6"])!; + expect([...f.types].sort()).toEqual([ + "BLOOD_PRESSURE_DIA", + "BLOOD_PRESSURE_SYS", + ]); + }); + + it("narrows glucose to the contexts its code names", () => { + const fasting = parseCodeParams(["1558-6"])!; + expect([...fasting.types]).toEqual(["BLOOD_GLUCOSE"]); + expect([...fasting.glucoseContexts!]).toEqual(["FASTING"]); + const blood = parseCodeParams(["2339-0"])!; + expect([...blood.glucoseContexts!].sort()).toEqual([ + "BEDTIME", + "NONE", + "RANDOM", + ]); + }); + + it("selects workouts by the exercise-activity code", () => { + expect(parseCodeParams([EXERCISE_ACTIVITY_LOINC])!.workouts).toBe(true); + }); + + it("does not match a LOINC code under a foreign system", () => { + expect( + parseCodeParams(["http://snomed.info/sct|80404-7"])!.types.size, + ).toBe(0); + }); +}); + +describe("resolveSeriesScope", () => { + const gateOf = (leaves: ReportLeafId[]) => ({ + admits: (leaf: ReportLeafId) => leaves.includes(leaf), + }); + + it("carries only what the saved selection admits, never sleep", () => { + const s = resolveSeriesScope( + gateOf(["WEIGHT", "SLEEP_DURATION", "HRV_RMSSD"]), + null, + ); + expect(s.types.sort()).toEqual(["HRV_RMSSD", "WEIGHT"]); + expect(s.workouts).toBe(false); + expect(seriesCandidateTypes()).not.toContain("SLEEP_DURATION"); + }); + + it("pairs blood pressure only when both components are admitted", () => { + expect( + resolveSeriesScope( + gateOf(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]), + null, + ).pairBloodPressure, + ).toBe(true); + expect( + resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null) + .pairBloodPressure, + ).toBe(false); + }); + + it("intersects the code filter with the gate", () => { + const s = resolveSeriesScope( + gateOf(["WEIGHT", "WORKOUTS"]), + parseCodeParams(["80404-7", EXERCISE_ACTIVITY_LOINC]), + ); + expect(s.types).toEqual([]); + expect(s.workouts).toBe(true); + }); +}); + +/** The structural R4 checks the conformance suite applies to every Observation. */ +function assertR4Observation(o: FhirObservation) { + expect(o.resourceType).toBe("Observation"); + expect(o.status).toBe("final"); + expect(o.subject.reference).toBe("Patient/patient-1"); + // effective[x]: exactly one of dateTime / period. + expect(Boolean(o.effectiveDateTime) !== Boolean(o.effectivePeriod)).toBe( + true, + ); + // value[x]: at most one. + const values = [ + o.valueQuantity, + o.valueCodeableConcept, + o.valueString, + o.valueInteger, + ].filter((v) => v !== undefined); + expect(values.length).toBeLessThanOrEqual(1); + // A coded UCUM unit is a parseable one, and system + code travel together. + const quantities = [ + o.valueQuantity, + ...(o.component ?? []).map((c) => c.valueQuantity), + ].filter(Boolean); + for (const q of quantities) { + if (q!.code !== undefined) expect(isUcumCode(q!.code)).toBe(true); + expect(q!.code === undefined).toBe(q!.system === undefined); + } + // vital-signs category promises a LOINC code. + if (o.category?.some((c) => c.coding?.[0]?.code === "vital-signs")) { + const loinc = o.code.coding?.some((c) => c.system === "http://loinc.org"); + const textOnly = !o.code.coding && Boolean(o.code.text); + expect(loinc || textOnly).toBe(true); + } + expect(JSON.parse(JSON.stringify(o))).toEqual(o); +} + +const AT = new Date("2026-03-04T07:30:00.000Z"); + +describe("measurementSeriesObservation", () => { + it("emits one reading per row with its own id and instant", () => { + const o = measurementSeriesObservation( + { + id: "abc", + type: "WEIGHT", + value: 79.45, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.id).toBe("m-abc"); + expect(o.effectiveDateTime).toBe(AT.toISOString()); + expect(o.valueQuantity?.value).toBe(79.45); + expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc); + }); + + it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => { + const o = measurementSeriesObservation( + { + id: "r1", + type: "HRV_RMSSD", + value: 38, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.code.text).toBe("Heart rate variability RMSSD"); + expect(o.code.coding).toBeUndefined(); + expect(o.valueQuantity).toMatchObject({ value: 38, unit: "ms" }); + }); + + it("reads an hourly heart-rate bucket as a mean over its hour", () => { + const o = measurementSeriesObservation( + { + id: "h1", + type: "PULSE", + value: 72, + valueMin: 60, + valueMax: 95, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.effectivePeriod).toEqual({ + start: AT.toISOString(), + end: "2026-03-04T08:30:00.000Z", + }); + expect(o.note?.[0]?.text).toContain("60 to 95"); + }); + + it("pairs a systolic reading with its diastolic partner as the BP panel", () => { + const o = measurementSeriesObservation( + { + id: "s1", + type: "BLOOD_PRESSURE_SYS", + value: 131, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + 84, + )!; + assertR4Observation(o); + expect(o.code.coding?.[0]?.code).toBe("85354-9"); + expect(o.component?.map((c) => c.valueQuantity?.value)).toEqual([131, 84]); + }); + + it("emits a lone component under its own code and outside the vital-signs profile", () => { + const o = measurementSeriesObservation( + { + id: "d1", + type: "BLOOD_PRESSURE_DIA", + value: 84, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.code.coding?.[0]?.code).toBe("8462-4"); + expect(o.category).toBeUndefined(); + }); + + it("codes glucose by context and values it in the owner's unit", () => { + const o = measurementSeriesObservation( + { + id: "g1", + type: "BLOOD_GLUCOSE", + value: 99, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: "FASTING", + }, + "mmol/L", + null, + )!; + assertR4Observation(o); + expect(o.code.coding?.[0]?.code).toBe("1558-6"); + expect(o.valueQuantity?.unit).toBe("mmol/L"); + expect(o.valueQuantity?.value).toBeCloseTo(5.5, 1); + }); + + it("emits nothing for sleep segments", () => { + expect( + measurementSeriesObservation( + { + id: "z", + type: "SLEEP_DURATION", + value: 90, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + ), + ).toBeNull(); + }); +}); + +describe("workoutSeriesObservation", () => { + it("emits an activity Observation per the Physical Activity IG pattern", () => { + const o = workoutSeriesObservation({ + id: "w1", + sportType: "running", + startedAt: AT, + endedAt: new Date("2026-03-04T08:15:30.000Z"), + durationSec: 2730, + totalDistanceM: 8012.5, + totalEnergyKcal: 610, + avgHeartRate: 151, + }); + assertR4Observation(o); + expect(o.id).toBe("w-w1"); + expect(o.category?.[0]?.coding?.[0]?.code).toBe("activity"); + expect(o.code.coding?.[0]?.code).toBe(EXERCISE_ACTIVITY_LOINC); + expect(o.valueCodeableConcept?.coding?.[0]?.code).toBe("running"); + expect(o.effectivePeriod?.end).toBe("2026-03-04T08:15:30.000Z"); + const byCode = Object.fromEntries( + (o.component ?? []).map((c) => [ + c.code.coding?.[0]?.code ?? c.code.text, + c.valueQuantity, + ]), + ); + expect(byCode[EXERCISE_DURATION_LOINC]).toMatchObject({ + value: 45.5, + code: "min", + }); + expect(byCode[CALORIES_BURNED_LOINC]).toMatchObject({ value: 610 }); + expect(byCode.Distance).toMatchObject({ value: 8012.5, unit: "m" }); + }); + + it("leaves out the components a source did not report", () => { + const o = workoutSeriesObservation({ + id: "w2", + sportType: "yoga", + startedAt: AT, + endedAt: AT, + durationSec: 600, + totalDistanceM: null, + totalEnergyKcal: null, + avgHeartRate: null, + }); + expect(o.component).toHaveLength(1); + }); +}); + +describe("matchesCodeTokens", () => { + it("matches a bare code on any system and a system|code pair exactly", () => { + const o = { + code: { coding: [{ system: "http://loinc.org", code: "29463-7" }] }, + } as FhirObservation; + expect(matchesCodeTokens(o, ["29463-7"])).toBe(true); + expect(matchesCodeTokens(o, ["http://loinc.org|29463-7"])).toBe(true); + expect(matchesCodeTokens(o, ["urn:x|29463-7"])).toBe(false); + expect(matchesCodeTokens(o, ["8480-6,29463-7"])).toBe(true); + }); +}); diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index 87ddb0a9c..75e87ecf2 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -24,7 +24,15 @@ import { } from "@/lib/doctor-report-data"; import type { FhirRecordInputs } from "@/lib/fhir/build-bundle"; import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { resolveReportGate } from "@/lib/doctor-report/selection-gate"; +import { + resolveSeriesScope, + seriesGlucoseUnit, + type SeriesCodeFilter, + type SeriesScope, +} from "@/lib/fhir/series"; +import type { GlucoseUnit } from "@/lib/glucose"; import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto"; import type { FhirBundleEntry, @@ -65,6 +73,16 @@ export const FHIR_EVERYTHING_OPERATION = "$everything"; /** Search parameters honoured uniformly across the search routes. */ export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const; +/** + * Further search parameters the Observation search honours (v1.44). `date` + * switches the answer from the snapshot to the time series of every reading + * in the range; `code` narrows either answer to the named codings. + */ +export const FHIR_OBSERVATION_SEARCH_PARAMS = [ + { name: "date", type: "date" }, + { name: "code", type: "token" }, +] as const; + /** Canonical FHIR media type for every response (success or outcome). */ export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8"; @@ -308,3 +326,30 @@ export async function loadFhirContext(userId: string): Promise<{ }, }; } + +/** + * The series face's scope: the same saved selection and module map the + * snapshot replays, narrowed by an optional `code` filter, plus the owner's + * glucose unit. Reads two small rows and the selection — never the + * whole-record aggregate, so a series page costs what its page costs. + * + * Carries the same module backstop as {@link loadFhirContext}. + */ +export async function loadFhirSeriesScope( + userId: string, + codes: SeriesCodeFilter | null, +): Promise<{ scope: SeriesScope; glucoseUnit: GlucoseUnit }> { + if (!(await isModuleEnabled(userId, "doctorReport"))) { + throw new Error( + 'loadFhirSeriesScope called with the "doctorReport" module disabled — ' + + "the calling route is missing its requireModuleEnabled gate", + ); + } + const [selection, moduleMap, glucoseUnit] = await Promise.all([ + resolveSavedSelection(userId), + resolveModuleMap(userId), + seriesGlucoseUnit(userId), + ]); + const gate = resolveReportGate(selection, moduleMap); + return { scope: resolveSeriesScope(gate, codes), glucoseUnit }; +} diff --git a/src/lib/fhir/series.ts b/src/lib/fhir/series.ts new file mode 100644 index 000000000..b7f02fa9f --- /dev/null +++ b/src/lib/fhir/series.ts @@ -0,0 +1,765 @@ +/** + * The time-series face of `GET /api/fhir/Observation`: every recorded reading + * as its own Observation, newest first, paged in the database. + * + * The search route's default answer stays what it always was, a snapshot of + * the record (one latest reading per type plus the derived panels, ids + * `obs-N`). A client opts into the series by naming a `date` range, which is + * what a FHIR client does when it wants readings over time; the snapshot has + * no date range to filter, so the parameter never meant anything there + * before. Additive by construction: no existing request changes its answer. + * + * date=ge2026-01-01&date=lt2026-02-01 readings in January (UTC) + * code=http://loinc.org|80404-7 narrowed to SDNN (also in snapshot mode) + * _count / _offset paging, as on every search route + * + * Series ids are the row's own (`m-` for a measurement, `w-` for a + * workout) so a reading keeps its id across pages and windows, and + * `GET /Observation/{id}` reads it back. + * + * Scope is the snapshot's: the owner's saved report selection and module map + * decide which types may appear (`resolveReportGate`), so the series can + * never reach a type the snapshot would withhold. Memory is bounded by the + * page: one id query (a UNION over measurements and workouts, ordered and + * paged in SQL), one count, and a by-id read of at most `_count` rows. + * + * Every row as recorded. Two sources writing the same weighing are two + * readings here; the snapshot is the de-duplicated view. Sleep is not in the + * series: it is stored as per-stage segments, and a segment is not a night. + */ +import { Prisma } from "@/generated/prisma/client"; +import type { + GlucoseContext, + MeasurementType, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + convertGlucose, + resolveGlucoseUnit, + type GlucoseUnit, +} from "@/lib/glucose"; +import { + BP_DIA_LOINC, + BP_PANEL_LOINC, + BP_SYS_LOINC, + BP_UNIT, + GLUCOSE_LOINC, + HEALTHKIT_CODESYSTEM, + LOINC_SYSTEM, + MEASUREMENT_LOINC, +} from "@/lib/fhir/loinc-map"; +import { ucumQuantity } from "@/lib/fhir/ucum"; +import type { + FhirObservation, + FhirObservationComponent, +} from "@/lib/fhir/types"; +import { + categoryConcept, + codeableFromMapping, + patientRef, +} from "@/lib/fhir/resources/common"; +import type { ReportGate } from "@/lib/doctor-report/selection-gate"; + +// ── date parameter ────────────────────────────────────────────────────── + +/** A half-open instant range; either end may be open. */ +export interface SeriesRange { + from: Date | null; + to: Date | null; +} + +const DATE_RE = + /^(eq|gt|ge|lt|le)?(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/; + +/** + * The instant span one FHIR date value covers: a year, a month, a day, a + * minute or a second. A value without an offset is read as UTC. + */ +function dateSpan(match: RegExpMatchArray): { start: Date; end: Date } | null { + const [, , y, mo, d, h, mi, s, tz] = match; + const year = Number(y); + if (mo === undefined) { + return { + start: new Date(Date.UTC(year, 0, 1)), + end: new Date(Date.UTC(year + 1, 0, 1)), + }; + } + const month = Number(mo) - 1; + if (month < 0 || month > 11) return null; + if (d === undefined) { + return { + start: new Date(Date.UTC(year, month, 1)), + end: new Date(Date.UTC(year, month + 1, 1)), + }; + } + const day = Number(d); + const dayStart = new Date(Date.UTC(year, month, day)); + if (dayStart.getUTCMonth() !== month || dayStart.getUTCDate() !== day) { + return null; + } + if (h === undefined) { + return { start: dayStart, end: new Date(dayStart.getTime() + 86_400_000) }; + } + const iso = `${y}-${mo}-${d}T${h}:${mi}:${s ?? "00"}${tz ?? "Z"}`; + const start = new Date(iso); + if (Number.isNaN(start.getTime())) return null; + return { start, end: new Date(start.getTime() + (s ? 1000 : 60_000)) }; +} + +/** + * Parse every `date` value into one range (their intersection). Returns null + * when no `date` parameter was sent, and an error string for a value the + * server cannot read — a 400, never a silently ignored filter. + */ +export function parseDateParams( + values: readonly string[], +): SeriesRange | { error: string } | null { + if (values.length === 0) return null; + let from: Date | null = null; + let to: Date | null = null; + const later = (a: Date | null, b: Date) => (a && a > b ? a : b); + const earlier = (a: Date | null, b: Date) => (a && a < b ? a : b); + for (const raw of values) { + for (const value of raw.split(",")) { + const match = value.trim().match(DATE_RE); + const span = match ? dateSpan(match) : null; + if (!match || !span) { + return { error: `Unsupported date value: ${value.slice(0, 40)}` }; + } + switch (match[1] ?? "eq") { + case "eq": + from = later(from, span.start); + to = earlier(to, span.end); + break; + case "ge": + from = later(from, span.start); + break; + case "gt": + from = later(from, span.end); + break; + case "le": + to = earlier(to, span.end); + break; + case "lt": + to = earlier(to, span.start); + break; + } + } + } + return { from, to }; +} + +// ── code parameter ────────────────────────────────────────────────────── + +/** Exercise activity — the LOINC a workout Observation is coded with. */ +export const EXERCISE_ACTIVITY_LOINC = "73985-4"; +/** Exercise duration (min). */ +export const EXERCISE_DURATION_LOINC = "55411-3"; +/** Calories burned (kcal). */ +export const CALORIES_BURNED_LOINC = "41981-2"; +/** Local CodeSystem for the canonical workout sport keys. */ +export const WORKOUT_SPORT_CODESYSTEM = + "https://healthlog.dev/fhir/CodeSystem/workout-sport"; + +/** What a set of codes selects from the series. */ +export interface SeriesCodeFilter { + types: Set; + /** Glucose contexts a glucose code narrows to; null keeps every context. */ + glucoseContexts: Set | null; + workouts: boolean; +} + +const GLUCOSE_CONTEXT_OF: Record = { + FASTING: "FASTING", + POSTPRANDIAL: "POSTPRANDIAL", + RANDOM: "RANDOM", + BEDTIME: "BEDTIME", + UNSPECIFIED: "NONE", +}; + +/** + * Resolve `code` tokens (`system|code` or a bare code, comma-separated, any + * number of parameters) to the types they select. Returns null when no + * `code` was sent. An unknown code selects nothing — an empty Bundle, which + * is FHIR's answer to a search that matches nothing. + */ +export function parseCodeParams( + values: readonly string[], +): SeriesCodeFilter | null { + if (values.length === 0) return null; + const filter: SeriesCodeFilter = { + types: new Set(), + glucoseContexts: null, + workouts: false, + }; + let glucoseContexts: Set | null = null; + for (const raw of values) { + for (const token of raw.split(",")) { + const bar = token.indexOf("|"); + const system = bar >= 0 ? token.slice(0, bar) : null; + const code = (bar >= 0 ? token.slice(bar + 1) : token).trim(); + if (!code) continue; + const loinc = system === null || system === "" || system === LOINC_SYSTEM; + const healthkit = system === null || system === HEALTHKIT_CODESYSTEM; + if (loinc && code === EXERCISE_ACTIVITY_LOINC) filter.workouts = true; + if ( + loinc && + (code === BP_PANEL_LOINC || + code === BP_SYS_LOINC || + code === BP_DIA_LOINC) + ) { + filter.types.add("BLOOD_PRESSURE_SYS"); + filter.types.add("BLOOD_PRESSURE_DIA"); + } + if (loinc) { + for (const [ctx, map] of Object.entries(GLUCOSE_LOINC)) { + if (map.loinc !== code) continue; + filter.types.add("BLOOD_GLUCOSE"); + glucoseContexts ??= new Set(); + glucoseContexts.add(GLUCOSE_CONTEXT_OF[ctx]); + } + } + for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) { + if (mapping.loinc !== code) continue; + const isHk = mapping.loinc.startsWith("HKQuantityTypeIdentifier"); + if ((isHk && healthkit) || (!isHk && loinc)) { + filter.types.add(type as MeasurementType); + } + } + } + } + filter.glucoseContexts = glucoseContexts; + return filter; +} + +// ── scope ─────────────────────────────────────────────────────────────── + +/** Types the series can carry: every single-value coding, BP and glucose. Never sleep. */ +export function seriesCandidateTypes(): MeasurementType[] { + const types = Object.keys(MEASUREMENT_LOINC).filter( + (t) => t !== "SLEEP_DURATION", + ) as MeasurementType[]; + return [ + ...types, + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BLOOD_GLUCOSE", + ]; +} + +export interface SeriesScope { + types: MeasurementType[]; + /** Pair diastolic onto systolic (both admitted): the BP panel. */ + pairBloodPressure: boolean; + glucoseContexts: Set | null; + workouts: boolean; +} + +/** The gate's answer, narrowed by an optional code filter. */ +export function resolveSeriesScope( + gate: Pick, + codes: SeriesCodeFilter | null, +): SeriesScope { + const types = seriesCandidateTypes().filter( + (t) => gate.admits(t) && (codes === null || codes.types.has(t)), + ); + return { + types, + pairBloodPressure: + types.includes("BLOOD_PRESSURE_SYS") && + types.includes("BLOOD_PRESSURE_DIA"), + glucoseContexts: codes?.glucoseContexts ?? null, + workouts: gate.admits("WORKOUTS") && (codes === null || codes.workouts), + }; +} + +// ── query ─────────────────────────────────────────────────────────────── + +/** One page of series row references, newest first. */ +interface SeriesRef { + kind: "m" | "w"; + id: string; +} + +function rangeSql(column: string, range: SeriesRange): Prisma.Sql { + const col = Prisma.raw(column); + return Prisma.sql`${ + range.from ? Prisma.sql`AND ${col} >= ${range.from}` : Prisma.empty + } ${range.to ? Prisma.sql`AND ${col} < ${range.to}` : Prisma.empty}`; +} + +/** + * The UNION the page and the count share. Every value is a bound parameter; + * the only spliced text is the closed column names above. + */ +function seriesUnion( + userId: string, + scope: SeriesScope, + range: SeriesRange, +): Prisma.Sql | null { + const parts: Prisma.Sql[] = []; + if (scope.types.length > 0) { + const contexts = scope.glucoseContexts; + const glucoseFilter = + contexts === null + ? Prisma.empty + : Prisma.sql`AND (m."type" <> 'BLOOD_GLUCOSE'::"measurement_type" OR m."glucose_context"::text = ANY(${[ + ...contexts, + ].filter((c) => c !== "NONE")}::text[]) OR (${contexts.has( + "NONE", + )}::boolean AND m."glucose_context" IS NULL))`; + // A diastolic reading that has its systolic partner rides inside the + // systolic row's panel, never as a second entry. + const pairFilter = scope.pairBloodPressure + ? Prisma.sql`AND NOT (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND EXISTS ( + SELECT 1 FROM measurements s + WHERE s."user_id" = m."user_id" + AND s."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" + AND s."measured_at" = m."measured_at" + AND s."deleted_at" IS NULL))` + : Prisma.empty; + parts.push(Prisma.sql` + SELECT 'm' AS kind, m."id" AS id, m."measured_at" AS at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."type"::text = ANY(${scope.types}::text[]) + ${rangeSql('m."measured_at"', range)} + ${glucoseFilter} + ${pairFilter}`); + } + if (scope.workouts) { + parts.push(Prisma.sql` + SELECT 'w' AS kind, w."id" AS id, w."started_at" AS at + FROM workouts w + WHERE w."user_id" = ${userId} + ${rangeSql('w."started_at"', range)}`); + } + if (parts.length === 0) return null; + return Prisma.join(parts, " UNION ALL "); +} + +/** The total match count and one page of references, newest first. */ +export async function querySeriesPage(params: { + userId: string; + scope: SeriesScope; + range: SeriesRange; + count: number; + offset: number; +}): Promise<{ total: number; refs: SeriesRef[] }> { + const { userId, scope, range, count, offset } = params; + const union = seriesUnion(userId, scope, range); + if (!union) return { total: 0, refs: [] }; + const [totals, refs] = await Promise.all([ + prisma.$queryRaw>` + SELECT COUNT(*) AS total FROM (${union}) AS series`, + prisma.$queryRaw` + SELECT kind, id FROM (${union}) AS series + ORDER BY at DESC, id DESC + LIMIT ${count} OFFSET ${offset}`, + ]); + return { total: Number(totals[0]?.total ?? 0), refs }; +} + +// ── emit ──────────────────────────────────────────────────────────────── + +const MEASUREMENT_SELECT = { + id: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + measuredAt: true, + glucoseContext: true, +} as const; + +type SeriesMeasurementRow = { + id: string; + type: MeasurementType; + value: number; + valueMin: number | null; + valueMax: number | null; + measuredAt: Date; + glucoseContext: GlucoseContext | null; +}; + +type SeriesWorkoutRow = { + id: string; + sportType: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + avgHeartRate: number | null; +}; + +const WORKOUT_SELECT = { + id: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, +} as const; + +function bpComponent( + code: string, + display: string, + value: number, +): FhirObservationComponent { + return { + code: { coding: [{ system: LOINC_SYSTEM, code, display }] }, + valueQuantity: ucumQuantity(value, BP_UNIT), + }; +} + +const HOUR_MS = 60 * 60 * 1000; + +/** One measurement row as an Observation; null for a type the series does not code. */ +export function measurementSeriesObservation( + row: SeriesMeasurementRow, + glucoseUnit: GlucoseUnit, + diastolic: number | null, +): FhirObservation | null { + const id = `m-${row.id}`; + const at = row.measuredAt.toISOString(); + if (row.type === "BLOOD_PRESSURE_SYS" || row.type === "BLOOD_PRESSURE_DIA") { + const sys = row.type === "BLOOD_PRESSURE_SYS"; + if (sys && diastolic !== null) { + return { + resourceType: "Observation", + id, + status: "final", + category: [categoryConcept("vital-signs")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: BP_PANEL_LOINC, + display: "Blood pressure panel with all children optional", + }, + ], + text: "Blood pressure", + }, + subject: patientRef, + effectiveDateTime: at, + component: [ + bpComponent(BP_SYS_LOINC, "Systolic blood pressure", row.value), + bpComponent(BP_DIA_LOINC, "Diastolic blood pressure", diastolic), + ], + }; + } + // A lone component (its partner withheld or never recorded) is still a + // reading, coded with its own LOINC. It carries no vital-signs category: + // the R4 blood-pressure profile wants both components, and a category + // that promises the profile would be a claim the resource cannot keep. + return { + resourceType: "Observation", + id, + status: "final", + code: { + coding: [ + sys + ? { + system: LOINC_SYSTEM, + code: BP_SYS_LOINC, + display: "Systolic blood pressure", + } + : { + system: LOINC_SYSTEM, + code: BP_DIA_LOINC, + display: "Diastolic blood pressure", + }, + ], + text: sys ? "Systolic blood pressure" : "Diastolic blood pressure", + }, + subject: patientRef, + effectiveDateTime: at, + valueQuantity: ucumQuantity(row.value, BP_UNIT), + }; + } + if (row.type === "BLOOD_GLUCOSE") { + const map = GLUCOSE_LOINC[row.glucoseContext ?? "UNSPECIFIED"]; + if (!map) return null; + return { + resourceType: "Observation", + id, + status: "final", + category: [categoryConcept("laboratory")], + code: { + coding: [ + { system: LOINC_SYSTEM, code: map.loinc, display: map.display }, + ], + text: map.display, + }, + subject: patientRef, + effectiveDateTime: at, + valueQuantity: ucumQuantity( + convertGlucose(row.value, glucoseUnit), + glucoseUnit, + ), + }; + } + const mapping = MEASUREMENT_LOINC[row.type]; + if (!mapping || row.type === "SLEEP_DURATION") return null; + // An hourly heart-rate bucket (min and max stored beside the mean) is a + // mean over its hour, not a reading at its first instant. + const hourly = row.valueMin !== null && row.valueMax !== null; + return { + resourceType: "Observation", + id, + status: "final", + category: [categoryConcept(mapping.category)], + code: codeableFromMapping(mapping), + subject: patientRef, + ...(hourly + ? { + effectivePeriod: { + start: at, + end: new Date(row.measuredAt.getTime() + HOUR_MS).toISOString(), + }, + note: [ + { + text: `Mean over the hour; range ${row.valueMin} to ${row.valueMax} ${mapping.unit}.`, + }, + ], + } + : { effectiveDateTime: at }), + valueQuantity: ucumQuantity(row.value, mapping.unit), + }; +} + +/** The sport key in words, for `valueCodeableConcept.text`. */ +function sportText(sportType: string): string { + return sportType + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .toLowerCase(); +} + +/** + * A workout as an Observation, after the HL7 Physical Activity IG: the + * session is an `activity` Observation coded 73985-4 (Exercise activity) whose + * value is the kind of exercise, over the session's period, with duration and + * energy as LOINC-coded components. Not a Procedure: R4 reserves Procedure + * for something done to or for the patient by a care process, and a run is a + * measured behaviour. + */ +export function workoutSeriesObservation( + row: SeriesWorkoutRow, +): FhirObservation { + const component: FhirObservationComponent[] = [ + { + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: EXERCISE_DURATION_LOINC, + display: "Exercise duration", + }, + ], + }, + valueQuantity: ucumQuantity( + Math.round((row.durationSec / 60) * 100) / 100, + "min", + ), + }, + ]; + if (row.totalEnergyKcal !== null && row.totalEnergyKcal > 0) { + component.push({ + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: CALORIES_BURNED_LOINC, + display: "Calories burned", + }, + ], + }, + valueQuantity: ucumQuantity(row.totalEnergyKcal, "kcal"), + }); + } + if (row.totalDistanceM !== null && row.totalDistanceM > 0) { + component.push({ + code: { text: "Distance" }, + valueQuantity: ucumQuantity(row.totalDistanceM, "m"), + }); + } + if (row.avgHeartRate !== null && row.avgHeartRate > 0) { + component.push({ + code: { text: "Average heart rate during exercise" }, + valueQuantity: ucumQuantity(row.avgHeartRate, "/min"), + }); + } + return { + resourceType: "Observation", + id: `w-${row.id}`, + status: "final", + category: [categoryConcept("activity")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: EXERCISE_ACTIVITY_LOINC, + display: "Exercise activity", + }, + ], + text: "Exercise activity", + }, + subject: patientRef, + effectivePeriod: { + start: row.startedAt.toISOString(), + end: row.endedAt.toISOString(), + }, + valueCodeableConcept: { + coding: [{ system: WORKOUT_SPORT_CODESYSTEM, code: row.sportType }], + text: sportText(row.sportType), + }, + component, + }; +} + +/** The diastolic value recorded at the same instant as each systolic row. */ +async function diastolicPartners( + userId: string, + systolicAt: Date[], +): Promise> { + if (systolicAt.length === 0) return new Map(); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_PRESSURE_DIA", + deletedAt: null, + measuredAt: { in: systolicAt }, + }, + select: { value: true, measuredAt: true }, + }); + return new Map(rows.map((r) => [r.measuredAt.getTime(), r.value])); +} + +/** Load and emit one page of references, keeping their order. */ +export async function emitSeriesPage(params: { + userId: string; + refs: readonly SeriesRef[]; + scope: SeriesScope; + glucoseUnit: GlucoseUnit; +}): Promise { + const { userId, refs, scope, glucoseUnit } = params; + const mIds = refs.filter((r) => r.kind === "m").map((r) => r.id); + const wIds = refs.filter((r) => r.kind === "w").map((r) => r.id); + const [measurements, workouts] = await Promise.all([ + mIds.length > 0 + ? prisma.measurement.findMany({ + where: { userId, id: { in: mIds }, deletedAt: null }, + select: MEASUREMENT_SELECT, + }) + : Promise.resolve([]), + wIds.length > 0 + ? prisma.workout.findMany({ + where: { userId, id: { in: wIds } }, + select: WORKOUT_SELECT, + }) + : Promise.resolve([]), + ]); + const partners = scope.pairBloodPressure + ? await diastolicPartners( + userId, + measurements + .filter((m) => m.type === "BLOOD_PRESSURE_SYS") + .map((m) => m.measuredAt), + ) + : new Map(); + const byM = new Map(measurements.map((m) => [m.id, m])); + const byW = new Map(workouts.map((w) => [w.id, w])); + const out: FhirObservation[] = []; + for (const ref of refs) { + if (ref.kind === "w") { + const w = byW.get(ref.id); + if (w) out.push(workoutSeriesObservation(w)); + continue; + } + const m = byM.get(ref.id); + if (!m) continue; + const obs = measurementSeriesObservation( + m, + glucoseUnit, + m.type === "BLOOD_PRESSURE_SYS" + ? (partners.get(m.measuredAt.getTime()) ?? null) + : null, + ); + if (obs) out.push(obs); + } + return out; +} + +/** + * Read one series Observation by its id (`m-…` / `w-…`), inside the same + * scope the search applies. Null for an id outside the caller's record or + * scope — the route answers both with the same `not-found`. + */ +export async function readSeriesObservation(params: { + userId: string; + id: string; + scope: SeriesScope; + glucoseUnit: GlucoseUnit; +}): Promise { + const { userId, id, scope, glucoseUnit } = params; + if (id.startsWith("w-")) { + if (!scope.workouts) return null; + const w = await prisma.workout.findFirst({ + where: { userId, id: id.slice(2) }, + select: WORKOUT_SELECT, + }); + return w ? workoutSeriesObservation(w) : null; + } + if (!id.startsWith("m-")) return null; + const m = await prisma.measurement.findFirst({ + where: { userId, id: id.slice(2), deletedAt: null }, + select: MEASUREMENT_SELECT, + }); + if (!m || !scope.types.includes(m.type)) return null; + const partners = + scope.pairBloodPressure && m.type === "BLOOD_PRESSURE_SYS" + ? await diastolicPartners(userId, [m.measuredAt]) + : new Map(); + return measurementSeriesObservation( + m, + glucoseUnit, + partners.get(m.measuredAt.getTime()) ?? null, + ); +} + +/** The owner's display glucose unit, for series values. */ +export async function seriesGlucoseUnit(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { glucoseUnit: true }, + }); + return resolveGlucoseUnit(user?.glucoseUnit ?? null); +} + +/** + * Snapshot-mode `code` filter: keep the Observations whose `code` carries one + * of the tokens (`system|code`, or a bare code matching any system). + */ +export function matchesCodeTokens( + observation: FhirObservation, + values: readonly string[], +): boolean { + const tokens = values.flatMap((v) => v.split(",")).map((t) => t.trim()); + const codings = observation.code.coding ?? []; + return tokens.some((token) => { + if (!token) return false; + const bar = token.indexOf("|"); + const system = bar >= 0 ? token.slice(0, bar) : null; + const code = bar >= 0 ? token.slice(bar + 1) : token; + return codings.some( + (c) => + c.code === code && + (system === null || system === "" || c.system === system), + ); + }); +} diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 43845b213..9673e3aa3 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -353,7 +353,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 CapabilityStatement (v1.11.0)", description: - "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).", + "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).", responses: { "200": { description: "CapabilityStatement (application/fhir+json).", @@ -397,11 +397,13 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 Observation search (v1.11.0)", description: - "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.", + "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-` / `w-` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 OperationOutcome.", requestParams: { query: z.object({ _count: z.coerce.number().optional(), _offset: z.coerce.number().optional(), + date: z.string().optional(), + code: z.string().optional(), }), }, responses: { @@ -557,7 +559,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 Observation read", description: - "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.", + "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id (`m-` / `w-`, v1.44), the latter read inside the same saved selection the series search applies. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.", requestParams: { path: z.object({ id: z.string() }), }, diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index c55d1200c..1c1051344 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -59,6 +59,12 @@ "unit": "ms", "category": "vital-signs" }, + "HRV_RMSSD": { + "loinc": null, + "display": "Heart rate variability RMSSD", + "unit": "ms", + "category": "vital-signs" + }, "ACTIVITY_STEPS": { "loinc": "41950-7", "display": "Number of steps in 24 hour Measured", diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index cf467c8be..cd1eabcac 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -211,6 +211,34 @@ export const SIGNALS: Record = { category: "vital-signs", }, }, + // v1.44 — RMSSD, the heart-rate-variability statistic rings and bands + // report (Oura, WHOOP, Polar, Fitbit, Google Health). Registered for its FHIR + // coding only: every other surface reads it through its own tables today, so + // all surface flags stay off and nothing else derives from this entry. + // LOINC publishes an SDNN term (80404-7, above) and none for RMSSD, so the + // coding is text-only rather than borrowing SDNN's code for a different + // statistic. + HRV_RMSSD: { + key: "HRV_RMSSD", + kind: "measurement", + source: { measurementType: "HRV_RMSSD" }, + displayName: "Heart-rate variability (RMSSD)", + unit: "ms", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: null, + display: "Heart rate variability RMSSD", + unit: "ms", + category: "vital-signs", + }, + }, OXYGEN_SATURATION: { key: "OXYGEN_SATURATION", kind: "measurement", diff --git a/tests/integration/fhir-series-and-medication-effect.test.ts b/tests/integration/fhir-series-and-medication-effect.test.ts new file mode 100644 index 000000000..b2cdc169a --- /dev/null +++ b/tests/integration/fhir-series-and-medication-effect.test.ts @@ -0,0 +1,362 @@ +/** + * v1.44 against a real Postgres: the FHIR Observation time series (the UNION + * page query, its count, the BP pairing, the glucose-context filter, owner and + * soft-delete scoping), the segmented medication effect read end to end, and + * the doctor report's workout and effect blocks. + * + * The effect fixture: an ACE inhibitor started on 2026-03-01 with systolic + * readings of exactly 140 for 60 days before and 130 − 0.1·t after, so the + * fit must recover a level change of −10 mmHg and a slope change of + * −0.7 mmHg/week, and a second medication starting inside the window must be + * listed as a confounder. + */ +import { beforeAll, describe, expect, it } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { + emitSeriesPage, + querySeriesPage, + resolveSeriesScope, +} from "@/lib/fhir/series"; +import { + EFFECT_MEDICATION_INCLUDE, + loadSegmentedEffects, +} from "@/lib/medications/efficacy/load-segmented-effects"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import type { ReportLeafId } from "@/lib/report-selection/catalogue"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const DAY = 86_400_000; +const PIVOT = Date.parse("2026-03-01T00:00:00.000Z"); + +let userId = ""; +let otherId = ""; +let medId = ""; + +const MODULES = { + cycle: false, + mood: false, + sleep: true, + glucose: true, + workouts: true, + recovery: true, + labs: true, + illness: true, + achievements: true, + coach: true, + insights: true, + medications: true, + doctorReport: true, + environment: true, + mcp: true, + inboundDocuments: true, + mentalHealth: true, + nutrients: true, + vaccinations: true, + timeline: true, +} as const; + +const gateOf = (leaves: ReportLeafId[]) => ({ + admits: (leaf: ReportLeafId) => leaves.includes(leaf), +}); + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const user = await prisma.user.create({ + data: { + username: "fhir-series-effect", + email: "fhir-series-effect@example.test", + timezone: "UTC", + }, + }); + userId = user.id; + const other = await prisma.user.create({ + data: { + username: "fhir-series-other", + email: "fhir-series-other@example.test", + timezone: "UTC", + }, + }); + otherId = other.id; + + const rows: Prisma.MeasurementCreateManyInput[] = []; + // Systolic: 140 before, 130 − 0.1·t after (one reading a day at 08:00). + for (let t = -60; t < 60; t++) { + rows.push({ + userId, + type: "BLOOD_PRESSURE_SYS", + value: t < 0 ? 140 : 130 - 0.1 * t, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + t * DAY + 8 * 3_600_000), + }); + } + // Two paired diastolic readings and one lone diastolic. + rows.push( + { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 85, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 10 * DAY + 8 * 3_600_000), + }, + { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 84, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 11 * DAY + 8 * 3_600_000), + }, + { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 80, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 11 * DAY + 20 * 3_600_000), + }, + ); + rows.push( + { + userId, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source: "MANUAL", + glucoseContext: "FASTING", + measuredAt: new Date(PIVOT + 12 * DAY), + }, + { + userId, + type: "BLOOD_GLUCOSE", + value: 130, + unit: "mg/dL", + source: "MANUAL", + glucoseContext: null, + measuredAt: new Date(PIVOT + 12 * DAY + 3_600_000), + }, + { + userId, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 13 * DAY), + deletedAt: new Date(PIVOT + 14 * DAY), + }, + { + userId: otherId, + type: "WEIGHT", + value: 70, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 13 * DAY), + }, + ); + await prisma.measurement.createMany({ data: rows }); + + await prisma.workout.createMany({ + data: [ + { + userId, + source: "MANUAL", + sportType: "running", + externalId: "run-1", + startedAt: new Date(PIVOT + 11 * DAY + 12 * 3_600_000), + endedAt: new Date(PIVOT + 11 * DAY + 13 * 3_600_000), + durationSec: 3600, + totalDistanceM: 10_000, + totalEnergyKcal: 700, + avgHeartRate: 150, + }, + { + userId, + source: "MANUAL", + sportType: "running", + externalId: "run-2", + startedAt: new Date(PIVOT + 20 * DAY), + endedAt: new Date(PIVOT + 20 * DAY + 1_800_000), + durationSec: 1800, + }, + ], + }); + + const med = await prisma.medication.create({ + data: { + userId, + name: "Ramipril", + dose: "5 mg", + treatmentClass: "GENERIC", + atcCode: "C09AA05", + trackIntake: false, + startsOn: new Date(PIVOT), + }, + }); + medId = med.id; + await prisma.medication.create({ + data: { + userId, + name: "Second drug", + dose: "1 mg", + treatmentClass: "GENERIC", + startsOn: new Date(PIVOT + 20 * DAY), + }, + }); +}); + +describe("FHIR Observation series in Postgres", () => { + const range = { + from: new Date(PIVOT + 10 * DAY), + to: new Date(PIVOT + 21 * DAY), + }; + + it("pages newest first, pairs BP, scopes to the owner and drops soft-deleted rows", async () => { + const scope = resolveSeriesScope( + gateOf([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BLOOD_GLUCOSE", + "WEIGHT", + "WORKOUTS", + ]), + null, + ); + const { total, refs } = await querySeriesPage({ + userId, + scope, + range, + count: 200, + offset: 0, + }); + // 11 systolic (days 10..20), 1 lone diastolic, 2 glucose, 2 workouts. + // The two paired diastolic rows ride inside their systolic panels; the + // deleted weight and the other account's weight never appear. + expect(total).toBe(16); + expect(refs).toHaveLength(16); + const page = await emitSeriesPage({ + userId, + refs, + scope, + glucoseUnit: "mg/dL", + }); + expect(page).toHaveLength(16); + // Newest first: the day-20 systolic (08:00, no diastolic partner, so a + // lone 8480-6 reading), then the day-20 run (00:00). + expect(page[0].code.coding?.[0]?.code).toBe("8480-6"); + expect(page[1].id.startsWith("w-")).toBe(true); + const panels = page.filter( + (o) => + o.code.coding?.[0]?.code === "85354-9" && o.component?.length === 2, + ); + expect( + panels.map((p) => p.component?.[1]?.valueQuantity?.value).sort(), + ).toEqual([84, 85]); + expect(page.some((o) => o.code.coding?.[0]?.code === "8462-4")).toBe(true); + expect(page.some((o) => o.code.coding?.[0]?.code === "29463-7")).toBe( + false, + ); + }); + + it("pages with offset and agrees with the count", async () => { + const scope = resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null); + const first = await querySeriesPage({ + userId, + scope, + range, + count: 5, + offset: 0, + }); + const second = await querySeriesPage({ + userId, + scope, + range, + count: 5, + offset: 5, + }); + expect(first.total).toBe(11); + expect(first.refs).toHaveLength(5); + expect(second.refs).toHaveLength(5); + const ids = new Set([...first.refs, ...second.refs].map((r) => r.id)); + expect(ids.size).toBe(10); + }); + + it("narrows glucose to a code's contexts", async () => { + const scope = resolveSeriesScope(gateOf(["BLOOD_GLUCOSE"]), { + types: new Set(["BLOOD_GLUCOSE"]), + glucoseContexts: new Set(["FASTING"]), + workouts: false, + }); + const { total } = await querySeriesPage({ + userId, + scope, + range, + count: 50, + offset: 0, + }); + expect(total).toBe(1); + }); +}); + +describe("segmented medication effect in Postgres", () => { + it("recovers the known level and slope change and lists the confounder", async () => { + const prisma = getPrismaClient(); + const med = await prisma.medication.findFirstOrThrow({ + where: { id: medId }, + include: EFFECT_MEDICATION_INCLUDE, + }); + const effects = await loadSegmentedEffects({ + userId, + med, + targets: ["BLOOD_PRESSURE_SYS"], + timezone: "UTC", + now: new Date(PIVOT + 59 * DAY + 12 * 3_600_000), + }); + const e = effects.get("BLOOD_PRESSURE_SYS")!; + expect(e.present).toBe(true); + expect(e.levelChange!.estimate).toBeCloseTo(-10, 6); + expect(e.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 6); + expect(e.days.before).toBe(60); + expect(e.days.after).toBe(60); + expect(e.adherence.status).toBe("not_tracked"); + expect(e.confounders).toEqual([ + { medicationName: "Second drug", event: "started", date: "2026-03-21" }, + ]); + }); + + it("reaches the doctor report with its workout block", async () => { + const data = await collectDoctorReportData( + userId, + { + start: new Date(PIVOT), + end: new Date(PIVOT + 59 * DAY + 12 * 3_600_000), + days: 60, + }, + selectionFromLeaves([ + "MEDICATION_LIST", + "MEDICATION_EFFECT", + "BLOOD_PRESSURE_SYS", + "WORKOUTS", + ]), + { moduleMap: MODULES as never }, + ); + const effect = data.medicationEffects?.find( + (m) => m.medicationName === "Ramipril", + ); + // Diastolic was not selected, so only the systolic target rides. + expect(effect?.targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]); + expect(effect?.targets[0].effect.present).toBe(true); + expect(data.workouts?.sessions).toBe(2); + expect(data.workouts?.bySport[0]).toMatchObject({ + sportType: "running", + sessions: 2, + durationSec: 5400, + distanceM: 10_000, + distanceSessions: 1, + }); + }); +});