Merge branch 'feat/v144-report' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:18:50 +02:00
commit 4e71cf4dc2
58 files changed
+5703 -91

No files matched your search

@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(104);
expect(ALL_LEAF_IDS.length).toBe(106);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
it("places every leaf in exactly one group", () => {
@@ -98,6 +98,8 @@ const LEGIT_IDENTICAL = new Set<string>([
"insights.derived.composite.READINESS.component.hrv",
"labs.catalog.hs-crp",
"measurements.typeHrvRmssd",
// The report's SDNN row, named the way the RMSSD row above is.
"reportSelection.leafHrvSdnn",
"illness.vital.HRV_RMSSD",
"settings.sections.sources.metrics.hrvRmssd",
"measurements.typeVo2Max",
@@ -13,7 +13,7 @@
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
*
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a new
* structured leaf cannot be added to the catalogue without deciding here
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
* the lock, and it is the compiler's, not this suite's.
@@ -291,6 +291,69 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
},
marker: "Semaglutide",
},
MEDICATION_EFFECT: {
kind: "rendered",
data: {
medicationEffects: [
{
medicationName: "Effect-marker drug",
startsOn: "2026-01-02",
targets: [
{
type: "BLOOD_PRESSURE_SYS",
effect: {
present: false,
reason: "insufficient_after",
level: 0.95,
window: null,
days: {
before: 40,
after: 3,
excludedLowAdherence: 0,
excludedNotTaking: 0,
},
adherence: {
status: "tracked",
floorPct: 80,
lookbackDays: 14,
},
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
confounders: [],
},
},
],
},
],
},
marker: "Effect-marker drug",
},
WORKOUTS: {
kind: "rendered",
data: {
workouts: {
sessions: 3,
totalDurationSec: 7200,
sessionsPerWeek: 0.7,
minutesPerWeek: 28,
sportCount: 1,
bySport: [
{
sportType: "rowing",
sessions: 3,
durationSec: 7200,
distanceM: 15000,
distanceSessions: 3,
energyKcal: null,
avgHeartRate: null,
},
],
},
},
marker: "15.0 km",
},
ALLERGIES: {
kind: "rendered",
data: {
@@ -444,7 +507,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
it("reads a plausible catalogue", () => {
// Sanity floor: a degraded import would satisfy every loop below
// vacuously, which is the failure mode a pair guard dies of.
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
[...STRUCTURED_LEAF_IDS].sort(),
);
@@ -30,6 +30,8 @@ const ALLOWED: Record<string, string> = {
"The correlation engine's centred sums and the Welch t-test variances.",
"lib/insights/mood-tag-influence.ts": "Welch t-test variances.",
"lib/insights/mood-pattern-cards.ts": "A pooled variance for a t statistic.",
"lib/stats/segmented-regression.ts":
"The total sum of squares behind R² and the HAC standard errors of a segmented fit; neither is reported as a spread.",
"lib/insights/derived/trajectory.ts":
"The residual standard error of a regression fit (÷ n − 2).",
"lib/insights/derived/sleep-score.ts":
@@ -361,6 +361,16 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason:
"SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)",
},
"lib/doctor-report-pdf/activity-effect-sections.ts": {
lines: 2,
reason:
"SI by policy: the report's workout distance is in km, like every clinician artefact",
},
"components/clinician/activity-effect-sections.tsx": {
lines: 1,
reason:
"SI by policy: the clinician page's workout distance is in km, like the PDF",
},
"components/insights/workout-detail/splits.tsx": {
lines: 1,
reason: "kilometre splits are cut server-side; their pace is per km",
@@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record<string, string> = {
"lib/jobs/workout-insight-generate.ts",
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
"lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts",
// The doctor report's per-sport block (v1.44): sums sessions, so a session
// two sources reported must count once, as it does on the list.
"lib/doctor-report/workouts.ts": "lib/doctor-report/workouts.ts",
// The only indirection in the set. The Coach block is handed rows it does not
// read itself; the snapshot builder fetches them in parallel with every other
// block's data. Recording the owner here rather than assuming the call site
@@ -38,6 +38,8 @@ vi.mock("@/lib/db", () => ({
healthProfileFactRevision: { findMany: vi.fn() },
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
mentalHealthAssessment: { findMany: vi.fn(async () => []) },
// v1.44 — the per-sport workout block.
workout: { findMany: vi.fn(async () => []) },
user: { findUnique: vi.fn(), update: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
@@ -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);
+60 -11
View File
@@ -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);
});
@@ -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<typeof import("@/lib/fhir/rest")>();
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<typeof import("@/lib/fhir/series")>();
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);
});
});
+16 -5
View File
@@ -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: [
@@ -0,0 +1,192 @@
/**
* The two leaves the v1.44 report added, on the clinician page: the per-sport
* workout summary and the medication effect around each start. Content and
* order follow the PDF blocks in `doctor-report-pdf/activity-effect-sections`,
* and both read the same aggregated payload.
*
* A pure server component: no client hooks, no markdown — every value renders
* as escaped React text.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { reportStatUnit } from "@/lib/doctor-report/stat-display";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
import { reportMeasurementLabelKey } from "@/lib/report-selection/catalogue";
import { reportSportLabel } from "@/lib/doctor-report/sport-label";
import {
LeafSection,
StatRow,
type LeafScope,
type Translate,
} from "./report-sections";
const REASON_KEYS = {
no_start: "doctorReport.medicationEffect.reasonNoStart",
insufficient_before: "doctorReport.medicationEffect.reasonBefore",
insufficient_after: "doctorReport.medicationEffect.reasonAfter",
fit_failed: "doctorReport.medicationEffect.reasonFit",
} as const;
const CONFOUNDER_KEYS = {
started: "medications.efficacy.segmented.confounderStarted",
stopped: "medications.efficacy.segmented.confounderStopped",
dose_change: "medications.efficacy.segmented.confounderDoseChange",
paused: "medications.efficacy.segmented.confounderPaused",
resumed: "medications.efficacy.segmented.confounderResumed",
} as const;
export function WorkoutsSection({
t,
report,
scope,
fmtDigits,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDigits: (n: number, digits: number) => string;
}) {
const w = report.workouts ?? null;
return (
<LeafSection
t={t}
scope={scope}
leaves={["WORKOUTS"]}
title={t("doctorReport.workouts.title")}
empty={w === null || w.sessions === 0}
>
{w ? (
<>
<p className="text-muted-foreground mb-2 text-xs">
{t("doctorReport.workouts.summary", {
sessions: w.sessions,
hours: fmtDigits(w.totalDurationSec / 3600, 1),
perWeek: fmtDigits(w.sessionsPerWeek, 1),
minutesPerWeek: fmtDigits(w.minutesPerWeek, 0),
})}
</p>
{w.bySport.map((s) => (
<StatRow
key={s.sportType}
label={`${reportSportLabel(t, s.sportType)} (${s.sessions})`}
value={[
`${fmtDigits(s.durationSec / 3600, 1)} h`,
s.distanceM !== null
? `${fmtDigits(s.distanceM / 1000, 1)} km`
: null,
s.energyKcal !== null
? `${fmtDigits(s.energyKcal, 0)} kcal`
: null,
s.avgHeartRate !== null
? `${fmtDigits(s.avgHeartRate, 0)} bpm`
: null,
]
.filter(Boolean)
.join(", ")}
/>
))}
{w.sportCount > w.bySport.length ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("doctorReport.workouts.moreSports", {
count: w.sportCount - w.bySport.length,
})}
</p>
) : null}
</>
) : null}
</LeafSection>
);
}
export function MedicationEffectSection({
t,
report,
scope,
fmtDigits,
fmtDay,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDigits: (n: number, digits: number) => string;
/** A stated calendar day (YYYY-MM-DD) in the owner's date order. */
fmtDay: (day: string) => string;
}) {
const effects = report.medicationEffects ?? [];
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
return (
<LeafSection
t={t}
scope={scope}
leaves={["MEDICATION_EFFECT"]}
title={t("doctorReport.medicationEffect.title")}
empty={effects.length === 0}
>
{effects.flatMap((med) =>
med.targets.map((target) => {
const e = target.effect;
const label = `${med.medicationName}: ${t(
reportMeasurementLabelKey(target.type as MeasurementType),
)}`;
const unit = reportStatUnit(target.type, glucoseUnit, t);
if (!e.present || !e.levelChange || !e.slopeChangePerWeek) {
return (
<StatRow
key={`${med.medicationName}:${target.type}`}
label={label}
value={t(REASON_KEYS[e.reason ?? "fit_failed"])}
/>
);
}
const level = formatEstimate(e.levelChange, fmtDigits);
const slope = formatEstimate(e.slopeChangePerWeek, fmtDigits);
return (
<div
key={`${med.medicationName}:${target.type}`}
className="border-border/40 border-b py-2 last:border-0"
>
<p className="text-foreground text-sm font-medium">{label}</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.level", {
...level,
unit,
})}
</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.slope", {
...slope,
unit,
})}
</p>
<p className="text-muted-foreground text-xs">
{t("doctorReport.medicationEffect.days", {
before: e.days.before,
after: e.days.after,
excluded:
e.days.excludedLowAdherence + e.days.excludedNotTaking,
})}
</p>
{e.confounders.map((c) => (
<p
key={`${c.medicationName}:${c.event}:${c.date}`}
className="text-muted-foreground text-xs"
>
{t(CONFOUNDER_KEYS[c.event], {
name: c.medicationName,
date: fmtDay(c.date),
})}
</p>
))}
</div>
);
}),
)}
{effects.length > 0 ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("doctorReport.medicationEffect.method")}
</p>
) : null}
</LeafSection>
);
}
@@ -52,6 +52,10 @@ import {
VisitsSection,
} from "./history-sections";
import { DoseLogSection, Glp1Section } from "./therapy-sections";
import {
MedicationEffectSection,
WorkoutsSection,
} from "./activity-effect-sections";
import {
CycleSection,
FamilyHistorySection,
@@ -219,6 +223,13 @@ export function ClinicianView({
</Section>
) : null}
<WorkoutsSection
t={t}
report={report}
scope={scope}
fmtDigits={fmt.number}
/>
{/* ── glucose and labs ─────────────────────────────────── */}
<GlucoseSection
t={t}
@@ -241,6 +252,13 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
/>
<MedicationEffectSection
t={t}
report={report}
scope={scope}
fmtDigits={fmt.number}
fmtDay={(day) => fmt.dateShortSmartCalendar(day)}
/>
<Glp1Section
t={t}
report={report}
+2 -2
View File
@@ -26,11 +26,11 @@ import {
} from "@/lib/doctor-report/stat-display";
import type { FractionDigitsFormat } from "@/lib/format/recorded-number";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isReportLeafId,
isStructuredLeafId,
REPORT_GROUPS,
reportMeasurementLabelKey,
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
@@ -248,7 +248,7 @@ export function MeasurementGroups({
return (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
label={t(reportMeasurementLabelKey(type))}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest, digits),
avg: show(type, stat!.avg, digits),
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import enMessages from "../../../../../../messages/en.json";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
import { ChangeAtStartSummary } from "../change-at-start-summary";
function est(estimate: number, se: number) {
const ciLow = estimate - 2 * se;
const ciHigh = estimate + 2 * se;
return {
estimate,
ciLow,
ciHigh,
standardError: se,
includesZero: ciLow <= 0 && ciHigh >= 0,
};
}
const present: EfficacySegmented = {
present: true,
level: 0.95,
window: { from: "2026-01-01", to: "2026-05-30", endReason: "dose_change" },
days: {
before: 80,
after: 70,
excludedLowAdherence: 6,
excludedNotTaking: 0,
},
adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 },
levelChange: est(-7.34, 0.6),
slopeChangePerWeek: est(-0.26, 0.16),
preSlopePerWeek: est(0.05, 0.1),
fit: {
n: 150,
df: 146,
rSquared: 0.6,
residualAutocorrelation: 0.3,
hacLag: 4,
},
confounders: [
{ medicationName: "Example B", event: "started", date: "2026-02-10" },
],
};
function render(segmented: EfficacySegmented) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en" initialMessages={enMessages as never}>
<ChangeAtStartSummary
segmented={segmented}
unit="mmHg"
minDays={14}
minSpanDays={21}
/>
</I18nProvider>,
);
}
describe("ChangeAtStartSummary", () => {
it("states the level and trend change with their ranges in plain words", () => {
const html = render(present);
expect(html).toContain("the level moved by -7.3 mmHg");
expect(html).toContain("95 % range -8.5 to -6.1");
expect(html).toContain("changed by -0.3 mmHg per week");
// The slope interval spans zero, so the no-change reading is said aloud.
expect(html).toContain("also consistent with no change");
expect(html).toContain("Based on 80 days before and 70 days after");
expect(html).toContain("6 days after the start were left out");
expect(html).toContain("describes the starting dose");
expect(html).toContain("Example B started on");
expect(html).not.toMatch(/work(s|ing)|effective|successful/i);
});
it("names the missing floor instead of a number", () => {
const html = render({
...present,
present: false,
reason: "insufficient_before",
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
});
expect(html).toContain("at least 14 days spread over 21 days before");
expect(html).not.toContain("level moved");
});
});
@@ -0,0 +1,155 @@
"use client";
/**
* The segmented-regression read-out on a Wirkung target card: the level
* change and the change in weekly trend at the start, each with its 95 %
* range, in plain sentences, followed by what the fit rests on (days used,
* days left out and why) and the other medication changes it cannot separate
* from this one. Renders the server DTO; recomputes nothing.
*/
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
const CONFOUNDER_KEYS = {
started: "medications.efficacy.segmented.confounderStarted",
stopped: "medications.efficacy.segmented.confounderStopped",
dose_change: "medications.efficacy.segmented.confounderDoseChange",
paused: "medications.efficacy.segmented.confounderPaused",
resumed: "medications.efficacy.segmented.confounderResumed",
} as const;
const NEED_KEYS = {
no_start: "medications.efficacy.segmented.needStart",
insufficient_before: "medications.efficacy.segmented.needBefore",
insufficient_after: "medications.efficacy.segmented.needAfter",
fit_failed: "medications.efficacy.segmented.fitFailed",
} as const;
export function ChangeAtStartSummary({
segmented,
unit,
minDays,
minSpanDays,
}: {
segmented: EfficacySegmented;
unit: string | null;
minDays: number;
minSpanDays: number;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const num = (v: number, d: number) => fmt.number(v, d);
const u = unit ?? "";
if (
!segmented.present ||
!segmented.levelChange ||
!segmented.slopeChangePerWeek ||
!segmented.preSlopePerWeek ||
!segmented.window
) {
return (
<div
className="border-border/60 rounded-lg border border-dashed p-3"
data-slot="medication-wirkung-segmented-empty"
>
<p className="text-muted-foreground text-sm">
{t(NEED_KEYS[segmented.reason ?? "fit_failed"], {
days: minDays,
span: minSpanDays,
})}
</p>
</div>
);
}
const level = formatEstimate(segmented.levelChange, num);
const slope = formatEstimate(segmented.slopeChangePerWeek, num);
const pre = formatEstimate(segmented.preSlopePerWeek, num);
const anyZero =
segmented.levelChange.includesZero ||
segmented.slopeChangePerWeek.includesZero;
return (
<div
className="bg-muted/40 space-y-2 rounded-lg p-3"
data-slot="medication-wirkung-segmented"
>
<p className="text-foreground text-sm font-medium">
{t("medications.efficacy.segmented.title")}
</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.level", { ...level, unit: u })}
</p>
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.slope", { ...slope, unit: u })}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.preSlope", {
value: pre.value,
unit: u,
})}
</p>
{anyZero ? (
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.includesZero")}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.basis", {
before: segmented.days.before,
after: segmented.days.after,
from: fmt.dateShortSmartCalendar(segmented.window.from),
to: fmt.dateShortSmartCalendar(segmented.window.to),
})}
</p>
{segmented.days.excludedLowAdherence > 0 ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.excludedAdherence", {
count: segmented.days.excludedLowAdherence,
floor: segmented.adherence.floorPct,
lookback: segmented.adherence.lookbackDays,
})}
</p>
) : null}
{segmented.days.excludedNotTaking > 0 ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.excludedNotTaking", {
count: segmented.days.excludedNotTaking,
})}
</p>
) : null}
{segmented.adherence.status === "not_tracked" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.notTracked")}
</p>
) : null}
{segmented.window.endReason === "dose_change" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.endDoseChange")}
</p>
) : null}
{segmented.confounders.length > 0 ? (
<div data-slot="medication-wirkung-confounders">
<p className="text-foreground text-sm">
{t("medications.efficacy.segmented.confoundersTitle")}
</p>
<ul className="text-foreground list-disc pl-4 text-sm">
{segmented.confounders.map((c) => (
<li key={`${c.medicationName}:${c.event}:${c.date}`}>
{t(CONFOUNDER_KEYS[c.event], {
name: c.medicationName,
date: fmt.dateShortSmartCalendar(c.date),
})}
</li>
))}
</ul>
</div>
) : null}
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.segmented.method")}
</p>
</div>
);
}
@@ -43,6 +43,11 @@ import type {
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
import { EmptyState } from "@/components/ui/empty-state";
import {
SEGMENTED_MIN_DAYS_PER_SIDE,
SEGMENTED_MIN_SPAN_DAYS,
} from "@/lib/medications/efficacy/segmented-effect";
import { ChangeAtStartSummary } from "./change-at-start-summary";
// v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own
// `<Card>` as a bare 220px `ResponsiveContainer` with no chrome of its own.
@@ -310,6 +315,15 @@ function TargetCard({
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
{target.segmented ? (
<ChangeAtStartSummary
segmented={target.segmented}
unit={target.unit}
minDays={SEGMENTED_MIN_DAYS_PER_SIDE}
minSpanDays={SEGMENTED_MIN_SPAN_DAYS}
/>
) : null}
{target.levelShift?.present &&
target.levelShift.nearStart &&
target.levelShift.at ? (
@@ -191,11 +191,11 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "vitals")).toBe("0/7");
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
expect(groupChip(html, "medications")).toBe("0/5");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/15");
expect(groupChip(html, "activity")).toBe("0/11");
expect(groupChip(html, "activity")).toBe("0/12");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
expect(groupChip(html, "mobility")).toBe("0/11");
expect(groupChip(html, "environment")).toBe("0/7");
+2
View File
@@ -36,6 +36,7 @@ import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-fi
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section";
import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section";
import { buildWorkoutsSection } from "./doctor-report-pdf/activity-effect-sections";
import { buildClinicalRecordsNotesSection } from "./doctor-report-pdf/clinical-records-notes-section";
import { buildReportFooter } from "./doctor-report-pdf/footer-section";
import {
@@ -294,6 +295,7 @@ export function buildDoctorReportPdfDocument(
}
cursor = buildHeaderProfileSection(context, cursor);
cursor = buildMeasurementsChartsSection(context, cursor);
cursor = buildWorkoutsSection(context, cursor);
cursor = buildMedicationMoodWellnessSection(context, cursor);
cursor = buildClinicalRecordsNotesSection(context, cursor);
buildReportFooter(context, cursor);
@@ -0,0 +1,312 @@
/**
* The v1.44 report blocks in the rendered PDF: sleep, both heart-rate
* variability statistics under their own labels, steps, the per-sport workout
* block and the medication effect, plus the per-sport fold behind the workout
* block. Text is read back out of the real PDF bytes.
*/
import { describe, expect, it } from "vitest";
import { PDFParse } from "pdf-parse";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { summariseReportWorkouts } from "@/lib/doctor-report/workouts";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
const NOW = new Date("2026-05-03T12:00:00.000Z");
async function textOf(bytes: Uint8Array): Promise<string> {
const parser = new PDFParse({ data: bytes });
try {
return (await parser.getText()).text;
} finally {
await parser.destroy();
}
}
function series(values: number[]) {
return values.map((value, i) => ({
value,
measuredAt: new Date(
Date.parse("2026-03-01T06:00:00.000Z") + i * 86_400_000,
).toISOString(),
}));
}
function stat(values: number[]) {
return {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
}
const EFFECT: EfficacySegmented = {
present: true,
level: 0.95,
window: { from: "2025-12-02", to: "2026-04-10", endReason: "dose_change" },
days: {
before: 84,
after: 61,
excludedLowAdherence: 5,
excludedNotTaking: 0,
},
adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 },
levelChange: {
estimate: -7.34,
ciLow: -8.52,
ciHigh: -6.15,
standardError: 0.6,
includesZero: false,
},
slopeChangePerWeek: {
estimate: -0.26,
ciLow: -0.58,
ciHigh: 0.07,
standardError: 0.16,
includesZero: true,
},
preSlopePerWeek: {
estimate: 0.1,
ciLow: -0.1,
ciHigh: 0.3,
standardError: 0.1,
includesZero: true,
},
fit: {
n: 145,
df: 141,
rSquared: 0.55,
residualAutocorrelation: 0.31,
hacLag: 4,
},
confounders: [
{ medicationName: "Other drug", event: "started", date: "2026-02-14" },
],
};
function data(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
const sleep = [400, 430, 455, 470, 410];
const sdnn = [42.5, 45.25, 40.125, 47];
const rmssd = [31, 35.5, 29, 33];
const steps = [8000, 9500, 7200, 10100];
return {
period: {
days: 90,
since: "2026-02-02T00:00:00.000Z",
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: null,
dateOfBirth: null,
gender: null,
heightCm: null,
},
practiceName: null,
measurements: {
SLEEP_DURATION: series(sleep),
HEART_RATE_VARIABILITY: series(sdnn),
HRV_RMSSD: series(rmssd),
ACTIVITY_STEPS: series(steps),
},
stats: {
SLEEP_DURATION: stat(sleep),
HEART_RATE_VARIABILITY: stat(sdnn),
HRV_RMSSD: stat(rmssd),
ACTIVITY_STEPS: stat(steps),
},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], { now: NOW }),
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
medications: [],
mood: null,
workouts: {
sessions: 9,
totalDurationSec: 9 * 3600,
sessionsPerWeek: 0.7,
minutesPerWeek: 42,
sportCount: 2,
bySport: [
{
sportType: "running",
sessions: 6,
durationSec: 6 * 3600,
distanceM: 54_300,
distanceSessions: 6,
energyKcal: 3900,
avgHeartRate: 148.5,
},
{
sportType: "yoga",
sessions: 3,
durationSec: 3 * 3600,
distanceM: null,
distanceSessions: 0,
energyKcal: null,
avgHeartRate: null,
},
],
},
medicationEffects: [
{
medicationName: "Example ACE inhibitor",
startsOn: "2026-03-02",
targets: [{ type: "BLOOD_PRESSURE_SYS", effect: EFFECT }],
},
],
...overrides,
} as DoctorReportData;
}
function render(d: DoctorReportData) {
return renderDoctorReportPdfBytes(d, {
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("en").t,
locale: "en",
now: NOW,
});
}
describe("doctor report v1.44 sections", () => {
it("labels SDNN and RMSSD as separate rows and keeps recorded precision", async () => {
const text = await textOf(render(data()));
expect(text).toContain("HRV (SDNN)");
expect(text).toContain("HRV (RMSSD)");
// #1228: the row keeps the digits the readings carry, up to three
// significant ones (40.125 ms reads 40.1, never 40).
expect(text).toContain("40.1");
expect(text).not.toMatch(/\b40 ms/);
expect(text).toContain("Sleep");
expect(text).toContain("Steps");
});
it("charts the wearable series at half width in the report's units", async () => {
const text = await textOf(render(data()));
// The sleep chart's max label is in hours (470 min = 7.8 h), never minutes.
expect(text).toContain("7.8 h");
expect(text).not.toContain("470.0 h");
});
it("prints the workout block per sport with weekly figures", async () => {
const text = await textOf(render(data()));
expect(text).toContain("Workouts");
expect(text).toContain("9 sessions, 9.0 h in total");
expect(text).toContain("0.7 sessions and 42 minutes per week");
expect(text).toContain("Running");
expect(text).toContain("54.3 km");
expect(text).toContain("149 bpm");
expect(text).toContain("Yoga");
});
it("prints the medication effect with intervals, exclusions and confounders", async () => {
// Notes wrap at the page edge; read them as one run of words.
const text = (await textOf(render(data()))).replace(/\s+/g, " ");
expect(text).toContain("Change around each medication start");
expect(text).toContain("Example ACE inhibitor");
expect(text).toContain("-7.3 mmHg");
expect(text).toContain("(-8.5 to -6.2)");
expect(text).toContain("-0.3 mmHg/week");
expect(text).toContain("84 / 61");
expect(text).toContain("5 days after the start left out");
expect(text).toContain("Other drug started on");
expect(text).toContain("not a proven effect");
});
it("names the missing floor instead of printing numbers", async () => {
const text = await textOf(
render(
data({
medicationEffects: [
{
medicationName: "Example ACE inhibitor",
startsOn: null,
targets: [
{
type: "BLOOD_PRESSURE_SYS",
effect: {
...EFFECT,
present: false,
reason: "no_start",
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
},
},
],
},
],
}),
),
);
expect(text).toContain("No start date recorded");
expect(text).not.toContain("-7.3 mmHg");
});
it("omits both blocks when the aggregator handed none over", async () => {
const text = await textOf(
render(data({ workouts: null, medicationEffects: null })),
);
expect(text).not.toContain("Workouts");
expect(text).not.toContain("Change around each medication start");
});
});
describe("summariseReportWorkouts", () => {
it("sums per sport, longest total first, and averages over the window's weeks", () => {
const w = summariseReportWorkouts(
[
{
sportType: "running",
durationSec: 1800,
totalDistanceM: 5000,
totalEnergyKcal: 300,
avgHeartRate: 150,
},
{
sportType: "running",
durationSec: 2400,
totalDistanceM: null,
totalEnergyKcal: 400,
avgHeartRate: 140,
},
{
sportType: "cycling",
durationSec: 7200,
totalDistanceM: 40_000,
totalEnergyKcal: null,
avgHeartRate: null,
},
],
14,
)!;
expect(w.sessions).toBe(3);
expect(w.totalDurationSec).toBe(11_400);
expect(w.sessionsPerWeek).toBe(1.5);
expect(w.minutesPerWeek).toBe(95);
expect(w.bySport.map((s) => s.sportType)).toEqual(["cycling", "running"]);
const running = w.bySport[1];
expect(running).toEqual({
sportType: "running",
sessions: 2,
durationSec: 4200,
distanceM: 5000,
distanceSessions: 1,
energyKcal: 700,
avgHeartRate: 145,
});
expect(w.bySport[0].avgHeartRate).toBeNull();
});
it("returns null without sessions", () => {
expect(summariseReportWorkouts([], 30)).toBeNull();
});
});
@@ -0,0 +1,287 @@
/**
* Two compact PDF blocks added with the v1.44 report: the per-sport workout
* summary and the medication effect around each start.
*
* Both render only what the aggregator handed over (the selection and the
* modules are applied there), and both are tables of a few rows: a workout
* block is one row per sport, never a session log, and the effect block is
* one row per medication and target with its caveats as short notes under it.
*/
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import type { MeasurementType } from "@/generated/prisma/client";
import { formatEstimate } from "@/lib/medications/efficacy/segmented-format";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
import { reportSportLabel } from "../doctor-report/sport-label";
import {
pdfCursorState,
type DoctorReportPdfCursorState,
type DoctorReportPdfRenderContext,
} from "./render-context";
const TABLE_STYLES = {
theme: "grid" as const,
styles: {
fontSize: 8.5,
cellPadding: 2.2,
textColor: [30, 30, 30] as [number, number, number],
lineColor: [200, 200, 200] as [number, number, number],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245] as [number, number, number],
textColor: [30, 30, 30] as [number, number, number],
fontStyle: "bold" as const,
},
alternateRowStyles: {
fillColor: [252, 252, 252] as [number, number, number],
},
};
function lastTableY(doc: jsPDF): number {
return (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY;
}
function heading(
context: DoctorReportPdfRenderContext,
y: number,
text: string,
keepWith: number,
): number {
const { doc, margin, ensureSpace } = context;
const top = ensureSpace(y, 6 + keepWith);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(text, margin, top);
return top + 5;
}
function note(
context: DoctorReportPdfRenderContext,
y: number,
text: string,
): number {
const { doc, margin, pageWidth, ensureSpace } = context;
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
doc.setTextColor(90, 90, 90);
let cursor = y;
for (const line of doc.splitTextToSize(text, pageWidth - 2 * margin)) {
cursor = ensureSpace(cursor, 4);
doc.text(line, margin, cursor);
cursor += 3.8;
}
doc.setTextColor(30, 30, 30);
return cursor;
}
/** The per-sport workout summary. */
export function buildWorkoutsSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
const { doc, data, t, num, margin, tableBottomMargin } = context;
const w = data.workouts;
if (!w || w.sessions === 0) return state;
let y = heading(context, state.y, t("doctorReport.workouts.title"), 24);
y = note(
context,
y,
t("doctorReport.workouts.summary", {
sessions: w.sessions,
hours: num(w.totalDurationSec / 3600, 1),
perWeek: num(w.sessionsPerWeek, 1),
minutesPerWeek: num(w.minutesPerWeek, 0),
}),
);
y += 1;
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.workouts.colSport"),
t("doctorReport.workouts.colSessions"),
t("doctorReport.workouts.colTime"),
t("doctorReport.workouts.colDistance"),
t("doctorReport.workouts.colEnergy"),
t("doctorReport.workouts.colHeartRate"),
],
],
body: w.bySport.map((s) => [
reportSportLabel(t, s.sportType),
String(s.sessions),
`${num(s.durationSec / 3600, 1)} h`,
s.distanceM !== null
? s.distanceSessions < s.sessions
? `${num(s.distanceM / 1000, 1)} km (${s.distanceSessions}/${s.sessions})`
: `${num(s.distanceM / 1000, 1)} km`
: "",
s.energyKcal !== null ? `${num(s.energyKcal, 0)} kcal` : "",
s.avgHeartRate !== null ? `${num(s.avgHeartRate, 0)} bpm` : "",
]),
...TABLE_STYLES,
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y = lastTableY(doc) + 3;
if (w.sportCount > w.bySport.length) {
y = note(
context,
y,
t("doctorReport.workouts.moreSports", {
count: w.sportCount - w.bySport.length,
}),
);
}
return pdfCursorState(doc, y + 5);
}
const EFFECT_REASON_KEYS: Record<
NonNullable<EfficacySegmented["reason"]>,
string
> = {
no_start: "doctorReport.medicationEffect.reasonNoStart",
insufficient_before: "doctorReport.medicationEffect.reasonBefore",
insufficient_after: "doctorReport.medicationEffect.reasonAfter",
fit_failed: "doctorReport.medicationEffect.reasonFit",
};
const CONFOUNDER_KEYS = {
started: "medications.efficacy.segmented.confounderStarted",
stopped: "medications.efficacy.segmented.confounderStopped",
dose_change: "medications.efficacy.segmented.confounderDoseChange",
paused: "medications.efficacy.segmented.confounderPaused",
resumed: "medications.efficacy.segmented.confounderResumed",
} as const;
/** A stated calendar day printed in the report's date order. */
function dayLabel(
fmtDate: DoctorReportPdfRenderContext["fmtDate"],
day: string,
): string {
return fmtDate(dateOnlyAtNoonUtc(day).toISOString());
}
/** The segmented-regression effect, one row per medication and target. */
export function buildMedicationEffectSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
const { doc, data, t, num, fmtDate, margin, tableBottomMargin, unitFor } =
context;
const effects = data.medicationEffects;
if (!effects || effects.length === 0) return state;
const numAt = (v: number, d: number) => num(v, d);
const rows: string[][] = [];
const notes: string[] = [];
for (const med of effects) {
for (const target of med.targets) {
const e = target.effect;
const label = t(
reportMeasurementLabelKey(target.type as MeasurementType),
);
const unit = unitFor(target.type);
const name = `${med.medicationName}\n${label}`;
const start = med.startsOn ? dayLabel(fmtDate, med.startsOn) : "";
if (!e.present || !e.levelChange || !e.slopeChangePerWeek || !e.window) {
rows.push([
name,
start,
t(EFFECT_REASON_KEYS[e.reason ?? "fit_failed"]),
"",
"",
]);
continue;
}
const level = formatEstimate(e.levelChange, numAt);
const slope = formatEstimate(e.slopeChangePerWeek, numAt);
rows.push([
name,
start,
`${level.value} ${unit}\n(${level.low} ${t("doctorReport.medicationEffect.to")} ${level.high})`,
`${slope.value} ${unit}/${t("doctorReport.medicationEffect.week")}\n(${slope.low} ${t("doctorReport.medicationEffect.to")} ${slope.high})`,
`${e.days.before} / ${e.days.after}`,
]);
const extras: string[] = [];
if (e.days.excludedLowAdherence > 0) {
extras.push(
t("doctorReport.medicationEffect.excludedAdherence", {
count: e.days.excludedLowAdherence,
floor: e.adherence.floorPct,
}),
);
}
if (e.days.excludedNotTaking > 0) {
extras.push(
t("doctorReport.medicationEffect.excludedNotTaking", {
count: e.days.excludedNotTaking,
}),
);
}
if (e.adherence.status === "not_tracked") {
extras.push(t("doctorReport.medicationEffect.notTracked"));
}
if (e.window.endReason === "dose_change") {
extras.push(
t("doctorReport.medicationEffect.endDoseChange", {
date: dayLabel(fmtDate, e.window.to),
}),
);
}
for (const c of e.confounders) {
extras.push(
t(CONFOUNDER_KEYS[c.event], {
name: c.medicationName,
date: dayLabel(fmtDate, c.date),
}),
);
}
if (extras.length > 0) {
notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`);
}
}
}
if (rows.length === 0) return state;
let y = heading(
context,
state.y,
t("doctorReport.medicationEffect.title"),
24,
);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.medicationEffect.colMedication"),
t("doctorReport.medicationEffect.colStart"),
t("doctorReport.medicationEffect.colLevel"),
t("doctorReport.medicationEffect.colSlope"),
t("doctorReport.medicationEffect.colDays"),
],
],
body: rows,
...TABLE_STYLES,
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y = lastTableY(doc) + 3;
for (const line of notes) y = note(context, y, line);
y = note(context, y + 1, t("doctorReport.medicationEffect.method"));
return pdfCursorState(doc, y + 5);
}
+16 -2
View File
@@ -3,7 +3,7 @@ import {
type DoctorReportData,
} from "../doctor-report-data";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
import type {
DoctorReportNumberFormatter,
DoctorReportTranslator,
@@ -35,6 +35,20 @@ export const SPARKLINE_TYPES = [
"PULSE",
] as const;
/**
* The wearable series charted at half width, two to a row, below the
* full-width vitals. Each still needs its leaf selected and two points in the
* window. The points are the report's own series: one per night for sleep,
* day buckets for heart-rate variability once the window or the row count
* passes the raw limit, and the daily totals steps arrive as.
*/
export const COMPACT_SPARKLINE_TYPES = [
"SLEEP_DURATION",
"HEART_RATE_VARIABILITY",
"HRV_RMSSD",
"ACTIVITY_STEPS",
] as const satisfies readonly MeasurementType[];
/** First-half vs second-half mean → "↑" / "↓" / "→". */
export function trendArrow(values: number[]): "↑" | "↓" | "→" {
if (values.length < 2) return "→";
@@ -95,7 +109,7 @@ export function buildClinicalSummaryLines(
);
lines.push(
t("doctorReport.summaryTrend", {
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
label: t(reportMeasurementLabelKey(type as MeasurementType)),
latest: num(stat.latest, 1),
arrow,
}),
@@ -11,10 +11,11 @@ import {
reportStatValue,
} from "../doctor-report/stat-display";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import { reportMeasurementLabelKey } from "../report-selection/catalogue";
import {
getBmiClassificationKey,
getBpClassificationKey,
COMPACT_SPARKLINE_TYPES,
SPARKLINE_TYPES,
} from "./clinical-summary";
import {
@@ -252,7 +253,7 @@ export function buildMeasurementsChartsSection(
const show = (v: number) =>
num(reportStatValue(type, v, glucoseUnit), digits);
groupRows.push([
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
t(reportMeasurementLabelKey(type as MeasurementType)),
`${show(s.latest)} ${unit}`.trim(),
`${show(s.avg)} ${unit}`.trim(),
show(s.min),
@@ -433,7 +434,13 @@ export function buildMeasurementsChartsSection(
const chartTypes = SPARKLINE_TYPES.filter(
(type) => (data.measurements[type]?.length ?? 0) >= 2,
);
if (chartTypes.length > 0) {
// v1.44 — the wearable series (sleep, both HRV statistics, steps) draw at
// half width, two to a row: a clinician reads their direction, and four
// full-width charts would push the medication blocks a page further.
const compactTypes = COMPACT_SPARKLINE_TYPES.filter(
(type) => (data.measurements[type]?.length ?? 0) >= 2,
);
if (chartTypes.length > 0 || compactTypes.length > 0) {
// Keep the heading with at least the first chart.
y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4);
doc.setFontSize(12);
@@ -446,9 +453,7 @@ export function buildMeasurementsChartsSection(
// A chart never tears across a page boundary — break before drawing
// it whole.
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
const label = t(
MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "",
);
const label = t(reportMeasurementLabelKey(type as MeasurementType));
y = drawSparkline(doc, {
x: margin,
y,
@@ -461,6 +466,32 @@ export function buildMeasurementsChartsSection(
});
y += 4;
}
const columnGap = 6;
const halfWidth = (pageWidth - 2 * margin - columnGap) / 2;
for (let i = 0; i < compactTypes.length; i += 2) {
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
let rowBottom = y;
for (const [column, type] of compactTypes.slice(i, i + 2).entries()) {
// Plotted in the unit the table prints (sleep in hours, not the
// stored minutes), so the min/max labels match the row above.
const points = (data.measurements[type] ?? []).map((p) => ({
measuredAt: p.measuredAt,
value: reportStatValue(type, p.value, glucoseUnit),
}));
const bottom = drawSparkline(doc, {
x: margin + column * (halfWidth + columnGap),
y,
width: halfWidth,
label: t(reportMeasurementLabelKey(type)),
points,
num,
unit: unitFor(type),
dateShort: (iso) => dateShort(iso),
});
rowBottom = Math.max(rowBottom, bottom);
}
y = rowBottom + 4;
}
y += 2;
}
}
@@ -9,6 +9,7 @@ import {
type DoctorReportPdfRenderContext,
} from "./render-context";
import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "../medications/glp1-side-effect-tags";
import { buildMedicationEffectSection } from "./activity-effect-sections";
const MOOD_LABEL_KEYS: Record<number, string> = {
1: "doctorReport.moodAwful",
@@ -140,6 +141,9 @@ export function buildMedicationMoodWellnessSection(
.finalY + 8;
}
// v1.44 — the effect around each medication's start, beside its adherence.
y = buildMedicationEffectSection(context, pdfCursorState(doc, y)).y;
// v1.4.25 — GLP-1 therapy section. Only renders when the data
// aggregator emitted a non-null `glp1` block (user has at least one
// active GLP-1 medication and the compliance toggle is on). Lists
+51
View File
@@ -15,6 +15,7 @@ import type { GlucoseUnit } from "@/lib/glucose";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { ModuleKey } from "@/lib/modules/gate";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
import type {
ComplianceSchedule,
@@ -497,6 +498,56 @@ export interface DoctorReportData {
note: string | null;
noteUnreadable: boolean;
} | null;
/**
* Workouts over the window, summed per sport (v1.44). Null when the
* WORKOUTS leaf was not selected, the `workouts` module is off, or no
* session fell inside the window. Optional so older fixtures typecheck.
*/
workouts?: DoctorReportWorkouts | null;
/**
* The segmented-regression effect of each listed medication on its target
* metric(s), around the medication's start (v1.44). Computed without AI from
* the same day values and dose ledger the efficacy tab reads. A target rides
* only when its own measurement leaf is admitted too, so the block never
* carries a figure derived from a series the owner withheld. Null when the
* MEDICATION_EFFECT leaf was not selected or nothing qualified.
*/
medicationEffects?: DoctorReportMedicationEffect[] | null;
}
/** The report's workout block: totals plus one row per sport. */
export interface DoctorReportWorkouts {
sessions: number;
totalDurationSec: number;
/** Sessions and minutes divided by the window's weeks (days / 7). */
sessionsPerWeek: number;
minutesPerWeek: number;
/** Distinct sports in the window; `bySport` lists at most twelve. */
sportCount: number;
bySport: Array<{
/** The canonical sport key; the renderer names it in the reader's language. */
sportType: string;
sessions: number;
durationSec: number;
/** Summed over the sessions that reported a distance (`distanceSessions`). */
distanceM: number | null;
distanceSessions: number;
energyKcal: number | null;
/** Mean of the per-session average heart rates that were reported. */
avgHeartRate: number | null;
}>;
}
/** One medication's effect block in the report. */
export interface DoctorReportMedicationEffect {
medicationName: string;
/** YYYY-MM-DD, or null when the medication carries no start date. */
startsOn: string | null;
targets: Array<{
/** The target MeasurementType; the renderer labels it and picks the unit. */
type: string;
effect: EfficacySegmented;
}>;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -257,6 +257,17 @@ function seed() {
practitioner: { name: "Sample practice" },
},
];
rows.workout = [
{
sportType: "running",
startedAt: new Date("2026-02-03T07:00:00.000Z"),
durationSec: 1800,
source: "MANUAL",
totalDistanceM: 5000,
totalEnergyKcal: 300,
avgHeartRate: 140,
},
];
rows.familyHistoryEntry = [
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
];
@@ -0,0 +1,127 @@
/**
* The report's medication-effect block reads only what its gate admits: a
* target whose own measurement leaf is withheld never comes back as an
* effect, the reads stop at the report's end, and the block is bounded.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { findMany, resolveTargets, loadEffects } = vi.hoisted(() => ({
findMany: vi.fn(),
resolveTargets: vi.fn(),
loadEffects: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany } } }));
vi.mock("@/lib/medications/efficacy/build-efficacy", () => ({
resolveEffectiveTargets: resolveTargets,
}));
vi.mock("@/lib/medications/efficacy/load-segmented-effects", () => ({
EFFECT_MEDICATION_INCLUDE: {},
loadSegmentedEffects: loadEffects,
}));
import {
loadReportMedicationEffects,
REPORT_EFFECT_MEDICATION_CAP,
} from "../medication-effects";
const END = new Date("2026-05-01T00:00:00.000Z");
function med(id: string) {
return {
id,
name: `Drug ${id}`,
treatmentClass: "ANTIHYPERTENSIVE",
atcCode: null,
startsOn: new Date("2026-02-01T00:00:00.000Z"),
};
}
beforeEach(() => {
findMany.mockReset();
resolveTargets.mockReset();
loadEffects.mockReset();
resolveTargets.mockResolvedValue({
tier: "atc",
cls: "x",
targets: [
{ kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" },
{ kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" },
{ kind: "lab", analyte: "Potassium", label: "Potassium" },
],
});
loadEffects.mockImplementation(
async ({ targets }: { targets: string[] }) =>
new Map(targets.map((t) => [t, { present: false, reason: "no_start" }])),
);
});
describe("loadReportMedicationEffects", () => {
it("keeps only metric targets whose own leaf is admitted, and stops at the report end", async () => {
findMany.mockResolvedValue([med("a")]);
const out = await loadReportMedicationEffects({
userId: "u1",
medicationIds: ["a"],
admitsMeasurement: (type) => type === "BLOOD_PRESSURE_SYS",
timezone: "Europe/Berlin",
end: END,
});
expect(out).toHaveLength(1);
expect(out![0].targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]);
expect(out![0].startsOn).toBe("2026-02-01");
expect(loadEffects).toHaveBeenCalledWith(
expect.objectContaining({
targets: ["BLOOD_PRESSURE_SYS"],
now: END,
timezone: "Europe/Berlin",
}),
);
// Owner-scoped, one-shot medications excluded at the query.
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "u1", id: { in: ["a"] }, oneShot: false },
}),
);
});
it("drops a medication none of whose targets is admitted, and answers null when nothing remains", async () => {
findMany.mockResolvedValue([med("a")]);
const out = await loadReportMedicationEffects({
userId: "u1",
medicationIds: ["a"],
admitsMeasurement: () => false,
timezone: "UTC",
end: END,
});
expect(out).toBeNull();
expect(loadEffects).not.toHaveBeenCalled();
});
it("reads nothing without medications", async () => {
const out = await loadReportMedicationEffects({
userId: "u1",
medicationIds: [],
admitsMeasurement: () => true,
timezone: "UTC",
end: END,
});
expect(out).toBeNull();
expect(findMany).not.toHaveBeenCalled();
});
it("caps the number of medications it computes", async () => {
const ids = Array.from({ length: 12 }, (_, i) => `m${i}`);
findMany.mockResolvedValue(ids.map(med));
const out = await loadReportMedicationEffects({
userId: "u1",
medicationIds: ids,
admitsMeasurement: () => true,
timezone: "UTC",
end: END,
});
expect(out).toHaveLength(REPORT_EFFECT_MEDICATION_CAP);
expect(loadEffects).toHaveBeenCalledTimes(REPORT_EFFECT_MEDICATION_CAP);
// In the report's own list order.
expect(out![0].medicationName).toBe("Drug m0");
});
});
+30
View File
@@ -65,6 +65,8 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import { loadReportWorkouts } from "./workouts";
import { loadReportMedicationEffects } from "./medication-effects";
import { userDayKey, validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
loadAllergies,
@@ -92,6 +94,9 @@ const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
const DENSE_STAT_TYPES = [
"PULSE",
"HEART_RATE_VARIABILITY",
// RMSSD arrives per night from most rings and bands, but some write it per
// five-minute window; the row cap below keeps the sparse case raw.
"HRV_RMSSD",
"OXYGEN_SATURATION",
] as const satisfies readonly MeasurementType[];
@@ -114,6 +119,7 @@ const MEDICATION_LEAVES = [
"MEDICATION_LIST",
"MEDICATION_ADMINISTRATIONS",
"MEDICATION_COMPLIANCE",
"MEDICATION_EFFECT",
"GLP1_THERAPY",
] as const;
@@ -644,6 +650,8 @@ export async function collectDoctorReportData(
// Each remaining structured block: read only when its leaf was chosen.
const [
workouts,
medicationEffects,
cycle,
labResults,
illnessEpisodes,
@@ -655,6 +663,26 @@ export async function collectDoctorReportData(
anamnesis,
emergency,
] = await Promise.all([
gate.admits("WORKOUTS")
? loadReportWorkouts({
userId,
start,
end,
days,
sourcePriorityJson: userProfile?.sourcePriorityJson ?? null,
})
: Promise.resolve(null),
// The effect around each listed medication's start. Its own leaf AND the
// target's measurement leaf must both admit it; the loader asks the gate.
gate.admits("MEDICATION_EFFECT")
? loadReportMedicationEffects({
userId,
medicationIds: medications.map((m) => m.id),
admitsMeasurement: (type) => gate.admits(type),
timezone: reportTz,
end,
})
: Promise.resolve(null),
gate.admits("CYCLE")
? buildCycleExportSummary(userId, userDayKey(end, reportTz))
: Promise.resolve(null),
@@ -765,5 +793,7 @@ export async function collectDoctorReportData(
familyHistory,
anamnesis,
emergency,
workouts,
medicationEffects,
};
}
@@ -0,0 +1,74 @@
/**
* The medication-effect block of the doctor report: for each listed
* medication, the segmented-regression effect on its target metric(s) around
* its start, from the same loader the efficacy tab reads.
*
* Bounded: at most {@link REPORT_EFFECT_MEDICATION_CAP} medications, each a
* handful of reads (targets, intake events, confounders, one per-day SQL fold
* per target), whatever the window length. Every read stops at the report's
* end, so a report pinned in the past never uses later data.
*
* A target rides only when the gate admits its own measurement leaf: a
* deselected blood-pressure series must not come back as a blood-pressure
* effect. Lab targets are left out (too sparse for a daily series).
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { dayKeyOfDate } from "@/lib/medications/course-window";
import { resolveEffectiveTargets } from "@/lib/medications/efficacy/build-efficacy";
import {
EFFECT_MEDICATION_INCLUDE,
loadSegmentedEffects,
} from "@/lib/medications/efficacy/load-segmented-effects";
import type { DoctorReportMedicationEffect } from "@/lib/doctor-report-types";
/** Medications the block computes at most, in the report's list order. */
export const REPORT_EFFECT_MEDICATION_CAP = 8;
export async function loadReportMedicationEffects(params: {
userId: string;
medicationIds: readonly string[];
admitsMeasurement: (type: MeasurementType) => boolean;
timezone: string;
end: Date;
}): Promise<DoctorReportMedicationEffect[] | null> {
const { userId, medicationIds, admitsMeasurement, timezone, end } = params;
if (medicationIds.length === 0) return null;
const meds = await prisma.medication.findMany({
where: { userId, id: { in: [...medicationIds] }, oneShot: false },
include: EFFECT_MEDICATION_INCLUDE,
});
const order = new Map(medicationIds.map((id, i) => [id, i]));
meds.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
const out: DoctorReportMedicationEffect[] = [];
for (const med of meds) {
if (out.length >= REPORT_EFFECT_MEDICATION_CAP) break;
const resolved = await resolveEffectiveTargets({
id: med.id,
name: med.name,
treatmentClass: med.treatmentClass,
atcCode: med.atcCode ?? null,
});
const metricTargets = resolved.targets
.flatMap((t) => (t.kind === "metric" ? [t.measurementType] : []))
.filter((type) => admitsMeasurement(type));
if (metricTargets.length === 0) continue;
const effects = await loadSegmentedEffects({
userId,
med,
targets: metricTargets,
timezone,
now: end,
});
out.push({
medicationName: med.name,
startsOn: med.startsOn ? dayKeyOfDate(med.startsOn) : null,
targets: metricTargets.flatMap((type) => {
const effect = effects.get(type);
return effect ? [{ type, effect }] : [];
}),
});
}
return out.length > 0 ? out : null;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* A workout sport's name in the report's language, shared by the PDF and the
* clinician view. A sport key the catalogue has no sentence for prints as its
* own words ("stairClimber" → "stair climber"), never as a raw key.
*/
import { translateOrNull } from "@/lib/i18n/translate-code";
export function reportSportLabel(
t: (key: string, params?: Record<string, string | number>) => string,
sportType: string,
): string {
return (
translateOrNull(t, `insights.workouts.sport.${sportType}`) ??
sportType
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.toLowerCase()
);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* The workouts block of the doctor report: sessions over the window, summed
* per sport.
*
* A clinician reads training volume, not a session log, so the block is a
* per-sport summary (sessions, total time, distance, energy, mean of the
* session heart-rate averages) plus the window's weekly figures. Workouts are
* a sparse table — a handful a week even for a daily athlete — so the rows are
* read narrow (no metadata, no samples, no route) and collapsed through the
* canonical-workout picker first, the same rule the workout list applies, so a
* session two sources reported counts once.
*/
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import type { DoctorReportWorkouts } from "@/lib/doctor-report-types";
/**
* Sport rows the block lists at most, longest total time first. The totals
* above the table still count every session, and `sportCount` says how many
* sports there were.
*/
export const REPORT_WORKOUT_SPORT_CAP = 12;
export async function loadReportWorkouts(params: {
userId: string;
start: Date;
end: Date;
days: number;
sourcePriorityJson: unknown;
}): Promise<DoctorReportWorkouts | null> {
const { userId, start, end, days, sourcePriorityJson } = params;
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: start, lte: end } },
orderBy: { startedAt: "asc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
source: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
},
});
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
return summariseReportWorkouts(canonical, days);
}
/** Pure fold, exported for tests. */
export function summariseReportWorkouts(
rows: ReadonlyArray<{
sportType: string;
durationSec: number;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
}>,
days: number,
): DoctorReportWorkouts | null {
if (rows.length === 0) return null;
const bySport = new Map<
string,
{
sessions: number;
durationSec: number;
distanceM: number;
distanceSessions: number;
energyKcal: number;
energySessions: number;
hrSum: number;
hrSessions: number;
}
>();
let totalDurationSec = 0;
for (const r of rows) {
totalDurationSec += r.durationSec;
const s = bySport.get(r.sportType) ?? {
sessions: 0,
durationSec: 0,
distanceM: 0,
distanceSessions: 0,
energyKcal: 0,
energySessions: 0,
hrSum: 0,
hrSessions: 0,
};
s.sessions += 1;
s.durationSec += r.durationSec;
if (r.totalDistanceM !== null && r.totalDistanceM > 0) {
s.distanceM += r.totalDistanceM;
s.distanceSessions += 1;
}
if (r.totalEnergyKcal !== null && r.totalEnergyKcal > 0) {
s.energyKcal += r.totalEnergyKcal;
s.energySessions += 1;
}
if (r.avgHeartRate !== null && r.avgHeartRate > 0) {
s.hrSum += r.avgHeartRate;
s.hrSessions += 1;
}
bySport.set(r.sportType, s);
}
const weeks = Math.max(days, 1) / 7;
return {
sessions: rows.length,
totalDurationSec,
sessionsPerWeek: rows.length / weeks,
minutesPerWeek: totalDurationSec / 60 / weeks,
sportCount: bySport.size,
bySport: [...bySport.entries()]
.sort((a, b) => b[1].durationSec - a[1].durationSec)
.slice(0, REPORT_WORKOUT_SPORT_CAP)
.map(([sportType, s]) => ({
sportType,
sessions: s.sessions,
durationSec: s.durationSec,
// A sum over the sessions that reported it, and how many did: a
// partial distance never reads as the sport's whole distance.
distanceM: s.distanceSessions > 0 ? s.distanceM : null,
distanceSessions: s.distanceSessions,
energyKcal: s.energySessions > 0 ? s.energyKcal : null,
avgHeartRate: s.hrSessions > 0 ? s.hrSum / s.hrSessions : null,
})),
};
}
+375
View File
@@ -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);
});
});
+46 -1
View File
@@ -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 };
}
+765
View File
@@ -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-<id>` for a measurement, `w-<id>` 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<MeasurementType>;
/** Glucose contexts a glucose code narrows to; null keeps every context. */
glucoseContexts: Set<GlucoseContext | "NONE"> | null;
workouts: boolean;
}
const GLUCOSE_CONTEXT_OF: Record<string, GlucoseContext | "NONE"> = {
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<GlucoseContext | "NONE"> | 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<GlucoseContext | "NONE"> | null;
workouts: boolean;
}
/** The gate's answer, narrowed by an optional code filter. */
export function resolveSeriesScope(
gate: Pick<ReportGate, "admits">,
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<Array<{ total: bigint | number }>>`
SELECT COUNT(*) AS total FROM (${union}) AS series`,
prisma.$queryRaw<SeriesRef[]>`
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<Map<number, number>> {
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<FhirObservation[]> {
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<number, number>();
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<FhirObservation | null> {
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<number, number>();
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<GlucoseUnit> {
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),
);
});
}
@@ -0,0 +1,208 @@
/**
* The medication-effect assembly around the segmented regression: the window,
* the adherence and not-taking exclusions, the dose-change cut, the confounder
* list and the minimum-data floor. The regression itself is pinned to
* reference values in `src/lib/stats/__tests__/segmented-regression.test.ts`.
*/
import { describe, expect, it } from "vitest";
import {
buildSegmentedEffect,
notTakingPredicate,
type SegmentedEffectInput,
} from "@/lib/medications/efficacy/segmented-effect";
const DAY = 24 * 60 * 60 * 1000;
const PIVOT = "2026-03-01";
function dayAt(offset: number): string {
return new Date(Date.parse(`${PIVOT}T00:00:00Z`) + offset * DAY)
.toISOString()
.slice(0, 10);
}
/** 140 before, a clean −10 shift and −0.7/week (−0.1/day) slope after. */
function series(fromOffset: number, toOffset: number) {
const out: { day: string; value: number }[] = [];
for (let t = fromOffset; t <= toOffset; t++) {
out.push({
day: dayAt(t),
value: t < 0 ? 140 : 130 - 0.1 * t,
});
}
return out;
}
function input(over: Partial<SegmentedEffectInput> = {}): SegmentedEffectInput {
return {
dayValues: series(-60, 59),
pivotDay: PIVOT,
lastDay: dayAt(59),
adherenceDays: null,
notTakingOn: () => false,
ownDoseChangeDays: [],
otherMedicationEvents: [],
...over,
};
}
describe("buildSegmentedEffect", () => {
it("recovers the known level and weekly slope change", () => {
const r = buildSegmentedEffect(input());
expect(r.present).toBe(true);
expect(r.levelChange!.estimate).toBeCloseTo(-10, 9);
expect(r.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 9);
expect(r.preSlopePerWeek!.estimate).toBeCloseTo(0, 9);
expect(r.days).toEqual({
before: 60,
after: 60,
excludedLowAdherence: 0,
excludedNotTaking: 0,
});
expect(r.window).toEqual({
from: dayAt(-90),
to: dayAt(59),
endReason: "today",
});
expect(r.adherence.status).toBe("not_tracked");
expect(r.level).toBe(0.95);
});
it("caps the post window at 180 days", () => {
const r = buildSegmentedEffect(
input({ dayValues: series(-30, 300), lastDay: dayAt(300) }),
);
expect(r.window).toEqual({
from: dayAt(-90),
to: dayAt(179),
endReason: "window",
});
expect(r.days.after).toBe(180);
});
it("drops values older than the 90-day pre window", () => {
const r = buildSegmentedEffect(input({ dayValues: series(-200, 59) }));
expect(r.days.before).toBe(90);
});
it("ends the window the day before the first own dose change", () => {
const r = buildSegmentedEffect(
input({ ownDoseChangeDays: [PIVOT, dayAt(40)] }),
);
expect(r.window?.to).toBe(dayAt(39));
expect(r.window?.endReason).toBe("dose_change");
expect(r.days.after).toBe(40);
});
it("leaves out post-start days whose trailing 14-day adherence is below 80 %", () => {
// One dose a day; every dose from day 20 to day 29 missed.
const adherenceDays = Array.from({ length: 60 }, (_, t) => ({
day: dayAt(t),
taken: t >= 20 && t < 30 ? 0 : 1,
missed: t >= 20 && t < 30 ? 1 : 0,
}));
const r = buildSegmentedEffect(input({ adherenceDays }));
expect(r.adherence.status).toBe("tracked");
// Day d is low when misses in [d-13, d] exceed 2 (3/14 = 78.6 %): from
// day 22 (3 misses) through day 29+13-2 = day 40 (3 misses left).
expect(r.days.excludedLowAdherence).toBe(19);
expect(r.days.after).toBe(60 - 19);
// The fit on the remaining days still recovers the clean effect.
expect(r.levelChange!.estimate).toBeCloseTo(-10, 9);
});
it("treats a lookback without any expected dose as unknown, not low", () => {
const r = buildSegmentedEffect(input({ adherenceDays: [] }));
expect(r.days.excludedLowAdherence).toBe(0);
expect(r.present).toBe(true);
});
it("leaves out days the medication was not being taken", () => {
const r = buildSegmentedEffect(
input({ notTakingOn: (d) => d >= dayAt(10) && d < dayAt(20) }),
);
expect(r.days.excludedNotTaking).toBe(10);
});
it("needs 14 days on each side", () => {
const before = buildSegmentedEffect(input({ dayValues: series(-13, 59) }));
expect(before.present).toBe(false);
expect(before.reason).toBe("insufficient_before");
expect(before.levelChange).toBeNull();
const after = buildSegmentedEffect(
input({ dayValues: series(-60, 12), lastDay: dayAt(12) }),
);
expect(after.reason).toBe("insufficient_after");
});
it("needs each side to span 21 days, not just hold 14 values", () => {
// 14 consecutive days right before the start: enough values, too short.
const r = buildSegmentedEffect(input({ dayValues: series(-14, 59) }));
expect(r.days.before).toBe(14);
expect(r.reason).toBe("insufficient_before");
});
it("answers no_start without a start date", () => {
const r = buildSegmentedEffect(input({ pivotDay: null }));
expect(r).toMatchObject({ present: false, reason: "no_start" });
expect(r.window).toBeNull();
});
it("lists only the other medications' changes inside the window, oldest first", () => {
const r = buildSegmentedEffect(
input({
otherMedicationEvents: [
{ medicationName: "B", event: "dose_change", date: dayAt(30) },
{ medicationName: "A", event: "started", date: dayAt(-20) },
{ medicationName: "C", event: "stopped", date: dayAt(-120) },
],
}),
);
expect(r.confounders).toEqual([
{ medicationName: "A", event: "started", date: dayAt(-20) },
{ medicationName: "B", event: "dose_change", date: dayAt(30) },
]);
});
it("flags an interval that spans zero", () => {
const flat = series(-60, 59).map((p, i) => ({
day: p.day,
value: 120 + (i % 2 === 0 ? 3 : -3),
}));
const r = buildSegmentedEffect(input({ dayValues: flat }));
expect(r.present).toBe(true);
expect(r.levelChange!.includesZero).toBe(true);
});
});
describe("notTakingPredicate", () => {
it("covers a pause up to the resume day and the gaps between courses", () => {
const pred = notTakingPredicate(
{
pauseEras: [
{
pausedAt: new Date("2026-03-10T08:00:00Z"),
resumedAt: new Date("2026-03-15T08:00:00Z"),
},
],
courses: [
{
startsOn: new Date("2026-03-01T00:00:00Z"),
endsOn: new Date("2026-03-31T00:00:00Z"),
},
{
startsOn: new Date("2026-05-01T00:00:00Z"),
endsOn: null,
},
],
} as never,
"UTC",
);
expect(pred("2026-03-09")).toBe(false);
expect(pred("2026-03-10")).toBe(true);
expect(pred("2026-03-14")).toBe(true);
expect(pred("2026-03-15")).toBe(false);
expect(pred("2026-04-10")).toBe(true);
expect(pred("2026-05-02")).toBe(false);
});
});
+33 -10
View File
@@ -24,7 +24,6 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import {
buildComplianceMedicationContext,
COURSES_COMPLIANCE_SELECT,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
@@ -40,6 +39,11 @@ import {
type MedTargetTier,
} from "@/lib/medications/med-target-map";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import {
EFFECT_MEDICATION_INCLUDE,
loadSegmentedEffects,
} from "@/lib/medications/efficacy/load-segmented-effects";
import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -93,6 +97,12 @@ export interface EfficacyTargetView {
beforeAfter: EfficacyBeforeAfter;
/** Metric targets only; null for lab targets (changepoint reads metrics). */
levelShift: EfficacyLevelShift | null;
/**
* The segmented-regression effect (level + slope change with 95 %
* intervals, adherence-adjusted). Metric targets only; null for a lab,
* whose readings are too sparse for a daily time series.
*/
segmented: EfficacySegmented | null;
}
export interface EfficacyMarkers {
@@ -294,6 +304,7 @@ async function buildMetricTarget(
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift,
segmented: null,
};
}
@@ -343,6 +354,7 @@ async function buildLabTarget(
series,
beforeAfter: beforeAfterFromSeries(straddle, pivotMs),
levelShift: null,
segmented: null,
};
}
@@ -351,7 +363,7 @@ async function buildLabTarget(
* override wins (tier "override"); otherwise the derived ATC/name resolution.
* Returns the tier + class provenance for the DTO alongside the target list.
*/
async function resolveEffectiveTargets(med: {
export async function resolveEffectiveTargets(med: {
id: string;
name: string;
treatmentClass: string;
@@ -417,14 +429,8 @@ export async function buildMedicationEfficacy(
): Promise<MedicationEfficacyDTO | null> {
const med = await prisma.medication.findFirst({
where: { id: medicationId, userId },
include: {
schedules: true,
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
// The courses ride along, so a gap between two expects nothing (#1024).
include: EFFECT_MEDICATION_INCLUDE,
});
if (!med) return null;
@@ -570,6 +576,23 @@ export async function buildMedicationEfficacy(
}
}
// The segmented effect, per metric target, around `startsOn` only: the
// first-reading fallback has no "before" by definition.
const segmented = await loadSegmentedEffects({
userId,
med,
targets: targets
.filter((t) => t.kind === "metric")
.map((t) => t.key as MeasurementType),
timezone,
now,
});
for (const view of targets) {
if (view.kind === "metric") {
view.segmented = segmented.get(view.key as MeasurementType) ?? null;
}
}
// Adherence lane — the cadence-aware per-day rate from the SAME ledger the
// compliance surfaces read; never recomputed here.
const fetchFrom = new Date(
@@ -0,0 +1,247 @@
/**
* The database half of the segmented medication effect: one intake-event
* read, one confounder read and one per-day SQL fold per target, handed to the
* pure assembly in `./segmented-effect.ts`.
*/
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
COURSES_COMPLIANCE_SELECT,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { dayKeyOfDate } from "@/lib/medications/course-window";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { plausibleMetricRange } from "@/lib/measurements/value-domain";
import { userDayKey } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import {
buildSegmentedEffect,
notTakingPredicate,
SEGMENTED_POST_WINDOW_DAYS,
SEGMENTED_PRE_WINDOW_DAYS,
type EfficacyConfounder,
type EfficacySegmented,
type SegmentedEffectInput,
} from "./segmented-effect";
const DAY_MS = 24 * 60 * 60 * 1000;
/** The medication shape the effect needs: the efficacy builder's include. */
export const EFFECT_MEDICATION_INCLUDE = {
schedules: true,
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
courses: COURSES_COMPLIANCE_SELECT,
doseChanges: { orderBy: { effectiveFrom: "asc" } },
} satisfies Prisma.MedicationInclude;
export type EffectMedication = Prisma.MedicationGetPayload<{
include: typeof EFFECT_MEDICATION_INCLUDE;
}>;
/** Other medications' starts, stops, dose changes and pauses inside a window. */
async function loadConfounders(
userId: string,
medicationId: string,
windowFrom: Date,
windowTo: Date,
timezone: string,
): Promise<EfficacyConfounder[]> {
const inWindow = { gte: windowFrom, lte: windowTo };
const others = await prisma.medication.findMany({
where: {
userId,
id: { not: medicationId },
OR: [
{ startsOn: inWindow },
{ endsOn: inWindow },
{ doseChanges: { some: { effectiveFrom: inWindow } } },
{ pauseEras: { some: { pausedAt: inWindow } } },
{ pauseEras: { some: { resumedAt: inWindow } } },
],
},
select: {
name: true,
startsOn: true,
endsOn: true,
doseChanges: {
where: { effectiveFrom: inWindow },
select: { effectiveFrom: true },
},
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
take: 50,
});
const out: EfficacyConfounder[] = [];
const within = (d: Date | null): d is Date =>
d !== null &&
d.getTime() >= windowFrom.getTime() &&
d.getTime() <= windowTo.getTime();
for (const m of others) {
if (within(m.startsOn)) {
out.push({
medicationName: m.name,
event: "started",
date: dayKeyOfDate(m.startsOn),
});
}
if (within(m.endsOn)) {
out.push({
medicationName: m.name,
event: "stopped",
date: dayKeyOfDate(m.endsOn),
});
}
for (const dc of m.doseChanges) {
out.push({
medicationName: m.name,
event: "dose_change",
date: userDayKey(dc.effectiveFrom, timezone),
});
}
for (const p of m.pauseEras) {
if (within(p.pausedAt)) {
out.push({
medicationName: m.name,
event: "paused",
date: userDayKey(p.pausedAt, timezone),
});
}
if (within(p.resumedAt)) {
out.push({
medicationName: m.name,
event: "resumed",
date: userDayKey(p.resumedAt, timezone),
});
}
}
}
return out;
}
/**
* Load and fit the segmented effect for each metric target of one medication.
* `now` bounds every read: a report pinned in the past passes its own end so
* nothing after it is used. Reads: one intake-event query, one confounder
* query, and one per-day SQL fold per target.
*/
export async function loadSegmentedEffects(params: {
userId: string;
med: EffectMedication;
targets: readonly MeasurementType[];
timezone: string;
now: Date;
}): Promise<Map<MeasurementType, EfficacySegmented>> {
const { userId, med, targets, timezone, now } = params;
const out = new Map<MeasurementType, EfficacySegmented>();
if (targets.length === 0) return out;
const pivotDay = med.startsOn ? dayKeyOfDate(med.startsOn) : null;
const lastDay = userDayKey(now, timezone);
if (pivotDay === null || pivotDay > lastDay) {
const empty = buildSegmentedEffect({
dayValues: [],
pivotDay: null,
lastDay,
adherenceDays: null,
notTakingOn: () => false,
ownDoseChangeDays: [],
otherMedicationEvents: [],
});
for (const t of targets) out.set(t, empty);
return out;
}
// A day of margin either side: the SQL folds on local days, the window is
// cut on day keys afterwards.
const pivotMs = dayKeyAsUtcMidnight(pivotDay).getTime();
const since = new Date(pivotMs - (SEGMENTED_PRE_WINDOW_DAYS + 1) * DAY_MS);
const until = new Date(
Math.min(
now.getTime(),
pivotMs + (SEGMENTED_POST_WINDOW_DAYS + 1) * DAY_MS,
),
);
let adherenceDays: SegmentedEffectInput["adherenceDays"] = null;
if (med.trackIntake) {
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: med.id,
userId,
deletedAt: null,
scheduledFor: { gte: since, lte: until },
},
orderBy: { scheduledFor: "desc" },
select: {
takenAt: true,
skipped: true,
scheduledFor: true,
autoMissed: true,
attributionSource: true,
},
});
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
timezone,
);
const bundle = buildMedicationComplianceBundle(
events,
dueSchedules(med),
ctx,
until,
);
adherenceDays = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
timezone,
).map((d) => ({
day: userDayKey(d.date, timezone),
taken: d.taken,
missed: d.missed,
}));
}
const confounders = await loadConfounders(
userId,
med.id,
since,
until,
timezone,
);
const notTakingOn = notTakingPredicate(med, timezone);
const ownDoseChangeDays = med.doseChanges.map((dc) =>
userDayKey(dc.effectiveFrom, timezone),
);
for (const target of targets) {
const rows = await readDayAggregates({
userId,
type: target,
since,
until,
timeZone: timezone,
valueRange: plausibleMetricRange(target),
});
out.set(
target,
buildSegmentedEffect({
dayValues: rows.map((r) => ({
day: r.day,
value: r.dayMean ?? r.sum / r.n,
})),
pivotDay,
lastDay,
adherenceDays,
notTakingOn,
ownDoseChangeDays,
otherMedicationEvents: confounders,
}),
);
}
return out;
}
@@ -0,0 +1,345 @@
/**
* The medication effect as an interrupted time series: the target metric's
* daily values over a window around the start, fitted with a segmented
* regression (level change + slope change, Newey–West intervals).
*
* Adherence-adjusted by exclusion. After the start, a day is left out when the
* doses expected over the trailing fourteen days were taken less than 80 % of
* the time, and when the medication was paused or between courses that day.
* The adherence comes from the cadence-aware dose ledger the compliance
* surfaces read (`dailyComplianceRatesFromLedger`), never a recount. With
* intake tracking off nothing is excluded and the DTO says so.
*
* The window is ninety days before the start and up to 180 after it, cut short
* at the medication's own first dose change (the estimate then describes the
* starting dose, not a blend of two). Other medications that started, stopped,
* changed dose or paused inside the window are listed as caveats: the model
* cannot tell their effect from this one.
*
* Descriptive and association-only, like the rest of the efficacy DTO: there
* is no verdict field. The renderer phrases the numbers and the caveats; it
* never decides whether a medication "works".
*
* Pure: the database reads live in `./load-segmented-effects.ts`, so the
* client renderer can import the constants and types from here. Computed on
* request, without AI.
*/
import { dayKeyOfDate } from "@/lib/medications/course-window";
import { userDayKey } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import {
fitSegmentedRegression,
type SegmentedEstimate,
} from "@/lib/stats/segmented-regression";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days of target data the window reaches back over before the start. */
export const SEGMENTED_PRE_WINDOW_DAYS = 90;
/** Days after the start the window reaches forward over, at most. */
export const SEGMENTED_POST_WINDOW_DAYS = 180;
/** Days with a value required on each side of the start. */
export const SEGMENTED_MIN_DAYS_PER_SIDE = 14;
/** Calendar span (first to last value) required on each side. */
export const SEGMENTED_MIN_SPAN_DAYS = 21;
/** Trailing window the per-day adherence is judged over. */
export const ADHERENCE_LOOKBACK_DAYS = 14;
/** Adherence below this share leaves a post-start day out. */
export const ADHERENCE_FLOOR = 0.8;
/** Confidence level of every interval. */
export const SEGMENTED_LEVEL = 0.95;
/** An estimate in display terms: the unit per day for a level, per week for a slope. */
export interface EfficacyEstimate {
estimate: number;
ciLow: number;
ciHigh: number;
standardError: number;
/** True when the interval spans zero (the data is also consistent with no change). */
includesZero: boolean;
}
export type EfficacyConfounderEvent =
"started" | "stopped" | "dose_change" | "paused" | "resumed";
export interface EfficacyConfounder {
medicationName: string;
event: EfficacyConfounderEvent;
/** YYYY-MM-DD. */
date: string;
}
export interface EfficacySegmented {
present: boolean;
reason?:
"no_start" | "insufficient_before" | "insufficient_after" | "fit_failed";
/** Confidence level of the intervals (0.95). */
level: number;
/** The window actually fitted (YYYY-MM-DD, inclusive). */
window: {
from: string;
to: string;
endReason: "today" | "window" | "dose_change";
} | null;
/** Days with a value used on each side, and the days left out after the start. */
days: {
before: number;
after: number;
excludedLowAdherence: number;
excludedNotTaking: number;
};
adherence: {
/** "not_tracked" when the medication logs no intake, so nothing was excluded. */
status: "tracked" | "not_tracked";
floorPct: number;
lookbackDays: number;
};
/** Immediate shift at the start, in the target's unit. */
levelChange: EfficacyEstimate | null;
/** Change in the weekly trend after the start, unit per week. */
slopeChangePerWeek: EfficacyEstimate | null;
/** The weekly trend before the start, unit per week. */
preSlopePerWeek: EfficacyEstimate | null;
fit: {
n: number;
df: number;
rSquared: number;
residualAutocorrelation: number;
hacLag: number;
} | null;
/** Other medication changes inside the window, oldest first. */
confounders: EfficacyConfounder[];
}
function dayIndex(day: string): number {
return Math.round(dayKeyAsUtcMidnight(day).getTime() / DAY_MS);
}
function dayKeyOfIndex(index: number): string {
return dayKeyOfDate(new Date(index * DAY_MS));
}
function scaled(e: SegmentedEstimate, factor: number): EfficacyEstimate {
const ciLow = e.ciLow * factor;
const ciHigh = e.ciHigh * factor;
return {
estimate: e.estimate * factor,
ciLow,
ciHigh,
standardError: e.standardError * factor,
includesZero: ciLow <= 0 && ciHigh >= 0,
};
}
/** The input to the pure assembly — every day already a local day key. */
export interface SegmentedEffectInput {
/** One value per local day (any order). */
dayValues: ReadonlyArray<{ day: string; value: number }>;
/** The start day, or null when the medication carries no start date. */
pivotDay: string | null;
/** The last day the window may reach (today, or a report's last day). */
lastDay: string;
/** Per-day dose tallies from the ledger; null when intake is not tracked. */
adherenceDays: ReadonlyArray<{
day: string;
taken: number;
missed: number;
}> | null;
/** Whether the medication was not being taken on a day (paused, between courses). */
notTakingOn: (day: string) => boolean;
/** The medication's own dose changes (YYYY-MM-DD). */
ownDoseChangeDays: readonly string[];
/** Other medications' changes (YYYY-MM-DD), any order. */
otherMedicationEvents: readonly EfficacyConfounder[];
}
const EMPTY_DAYS = {
before: 0,
after: 0,
excludedLowAdherence: 0,
excludedNotTaking: 0,
};
/**
* Assemble the segmented effect from already-resolved day data. Pure, so the
* window, exclusion and minimum-data rules are testable without a database.
*/
export function buildSegmentedEffect(
input: SegmentedEffectInput,
): EfficacySegmented {
const adherence = {
status: input.adherenceDays === null ? "not_tracked" : "tracked",
floorPct: Math.round(ADHERENCE_FLOOR * 100),
lookbackDays: ADHERENCE_LOOKBACK_DAYS,
} as const;
const absent = (
reason: NonNullable<EfficacySegmented["reason"]>,
window: EfficacySegmented["window"] = null,
days = EMPTY_DAYS,
confounders: EfficacyConfounder[] = [],
): EfficacySegmented => ({
present: false,
reason,
level: SEGMENTED_LEVEL,
window,
days,
adherence,
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
confounders,
});
if (input.pivotDay === null) return absent("no_start");
const pivot = dayIndex(input.pivotDay);
const from = pivot - SEGMENTED_PRE_WINDOW_DAYS;
let to = Math.min(
dayIndex(input.lastDay),
pivot + SEGMENTED_POST_WINDOW_DAYS - 1,
);
let endReason: "today" | "window" | "dose_change" =
to === dayIndex(input.lastDay) ? "today" : "window";
// The first own dose change after the start ends the window the day
// before it: one estimate per dose. A dose recorded on the start day is
// the starting dose, not a change.
const firstChange = input.ownDoseChangeDays
.map(dayIndex)
.filter((d) => d > pivot)
.sort((a, b) => a - b)[0];
if (firstChange !== undefined && firstChange - 1 < to) {
to = firstChange - 1;
endReason = "dose_change";
}
const window = {
from: dayKeyOfIndex(from),
to: dayKeyOfIndex(to),
endReason,
};
const confounders = input.otherMedicationEvents
.filter((c) => {
const d = dayIndex(c.date);
return d >= from && d <= to;
})
.slice()
.sort((a, b) => a.date.localeCompare(b.date));
// Per-day adherence, judged over the trailing lookback.
const adherenceByDay = new Map<number, { taken: number; missed: number }>();
for (const a of input.adherenceDays ?? []) {
adherenceByDay.set(dayIndex(a.day), { taken: a.taken, missed: a.missed });
}
const lowAdherenceOn = (d: number): boolean => {
if (input.adherenceDays === null) return false;
let taken = 0;
let missed = 0;
for (let k = d - ADHERENCE_LOOKBACK_DAYS + 1; k <= d; k++) {
const a = adherenceByDay.get(k);
if (!a) continue;
taken += a.taken;
missed += a.missed;
}
// No dose expected in the lookback: adherence is unknown, not low.
if (taken + missed === 0) return false;
return taken / (taken + missed) < ADHERENCE_FLOOR;
};
const points: { t: number; y: number }[] = [];
const days = { ...EMPTY_DAYS };
const sorted = input.dayValues
.map((v) => ({ d: dayIndex(v.day), day: v.day, value: v.value }))
.filter((v) => v.d >= from && v.d <= to && Number.isFinite(v.value))
.sort((a, b) => a.d - b.d);
let beforeFirst: number | null = null;
let beforeLast: number | null = null;
let afterFirst: number | null = null;
let afterLast: number | null = null;
for (const v of sorted) {
const t = v.d - pivot;
if (t >= 0) {
if (input.notTakingOn(v.day)) {
days.excludedNotTaking += 1;
continue;
}
if (lowAdherenceOn(v.d)) {
days.excludedLowAdherence += 1;
continue;
}
days.after += 1;
afterFirst ??= t;
afterLast = t;
} else {
days.before += 1;
beforeFirst ??= t;
beforeLast = t;
}
points.push({ t, y: v.value });
}
const span = (a: number | null, b: number | null) =>
a === null || b === null ? 0 : b - a + 1;
if (
days.before < SEGMENTED_MIN_DAYS_PER_SIDE ||
span(beforeFirst, beforeLast) < SEGMENTED_MIN_SPAN_DAYS
) {
return absent("insufficient_before", window, days, confounders);
}
if (
days.after < SEGMENTED_MIN_DAYS_PER_SIDE ||
span(afterFirst, afterLast) < SEGMENTED_MIN_SPAN_DAYS
) {
return absent("insufficient_after", window, days, confounders);
}
const fit = fitSegmentedRegression(points, { level: SEGMENTED_LEVEL });
if (!fit) return absent("fit_failed", window, days, confounders);
return {
present: true,
level: SEGMENTED_LEVEL,
window,
days,
adherence,
levelChange: scaled(fit.levelChange, 1),
slopeChangePerWeek: scaled(fit.slopeChange, 7),
preSlopePerWeek: scaled(fit.preSlope, 7),
fit: {
n: fit.n,
df: fit.df,
rSquared: fit.rSquared,
residualAutocorrelation: fit.residualAutocorrelation,
hacLag: fit.hacLag,
},
confounders,
};
}
/** Day keys on which a medication was not being taken (paused, between courses). */
export function notTakingPredicate(
med: {
pauseEras: ReadonlyArray<{ pausedAt: Date; resumedAt: Date | null }>;
courses: ReadonlyArray<{ startsOn: Date; endsOn: Date | null }>;
},
timezone: string,
): (day: string) => boolean {
const pauses = med.pauseEras.map((p) => ({
from: userDayKey(p.pausedAt, timezone),
to: p.resumedAt ? userDayKey(p.resumedAt, timezone) : null,
}));
const courses = med.courses.map((c) => ({
from: dayKeyOfDate(c.startsOn),
to: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
}));
return (day: string) => {
// Paused from the pause day up to (not including) the resume day.
if (pauses.some((p) => day >= p.from && (p.to === null || day < p.to))) {
return true;
}
if (courses.length === 0) return false;
return !courses.some(
(c) => day >= c.from && (c.to === null || day <= c.to),
);
};
}
@@ -0,0 +1,38 @@
/**
* How a segmented-effect estimate is printed, shared by the efficacy tab, the
* PDF report and the clinician view so all three show the same figure.
*
* The precision follows the uncertainty: one significant digit of the standard
* error decides the decimals (an SE of 0.6 mmHg prints one decimal, 0.02 kg
* prints two). More digits than that would claim a precision the data does not
* have; fewer would hide a real interval. Capped at three decimals.
*/
import type { EfficacyEstimate } from "./segmented-effect";
export function estimateDecimals(e: Pick<EfficacyEstimate, "standardError">) {
const se = Math.abs(e.standardError);
if (!Number.isFinite(se) || se === 0) return 1;
return Math.min(3, Math.max(0, -Math.floor(Math.log10(se))));
}
/**
* The three numbers of an estimate as display strings, the point estimate
* signed. `num` is the caller's locale formatter.
*/
export function formatEstimate(
e: EfficacyEstimate,
num: (value: number, decimals: number) => string,
): { value: string; low: string; high: string } {
const decimals = estimateDecimals(e);
// A value that rounds to zero prints unsigned, never "-0.0".
const signed = (v: number) => {
const text = num(v, decimals);
const rounded = Number(v.toFixed(decimals));
return rounded > 0 ? `+${text}` : rounded === 0 ? num(0, decimals) : text;
};
return {
value: signed(e.estimate),
low: num(e.ciLow, decimals),
high: num(e.ciHigh, decimals),
};
}
+5 -3
View File
@@ -353,7 +353,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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-<id>` / `w-<id>` 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<ZodOpenApiObject["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.",
"Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id (`m-<id>` / `w-<id>`, 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() }),
},
@@ -32,6 +32,7 @@ import {
leafLabelKey,
LEAF_MODULE,
MEASUREMENT_LEAF_GROUP,
reportMeasurementLabelKey,
} from "../catalogue";
import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template";
@@ -42,6 +43,15 @@ const EN = JSON.parse(
),
) as Record<string, unknown>;
function labelOf(key: string): unknown {
let node: unknown = EN;
for (const part of key.split(".")) {
if (typeof node !== "object" || node === null) return undefined;
node = (node as Record<string, unknown>)[part];
}
return node;
}
function resolves(key: string): boolean {
let node: unknown = EN;
for (const part of key.split(".")) {
@@ -54,8 +64,8 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(86);
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(ALL_LEAF_IDS).toHaveLength(104);
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
expect(ALL_LEAF_IDS).toHaveLength(106);
});
it("resolves a label for every leaf", () => {
@@ -205,6 +215,15 @@ describe("heart-rate variability in the report", () => {
// own cardio leaf beside SDNN, never folded into it, and like SDNN no
// module gates it: a report with Recovery off must not lose the account's
// only HRV series for the estimator its device reports.
it("names the statistic on both rows, so the two never read as one series", () => {
const sdnn = reportMeasurementLabelKey("HEART_RATE_VARIABILITY");
const rmssd = reportMeasurementLabelKey("HRV_RMSSD");
expect(sdnn).not.toBe(rmssd);
expect(leafLabelKey("HEART_RATE_VARIABILITY")).toBe(sdnn);
expect(labelOf(sdnn)).toBe("HRV (SDNN)");
expect(labelOf(rmssd)).toBe("HRV (RMSSD)");
});
it("files SDNN and RMSSD as separate, ungated cardio leaves", () => {
for (const type of ["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const) {
expect(MEASUREMENT_LEAF_GROUP[type]).toBe("cardio");
+35 -4
View File
@@ -5,8 +5,8 @@
* A LEAF is the smallest releasable unit. There are exactly two kinds: every
* `MeasurementType` enum member, and the structured sections of the report
* payload that are not measurement series (identity, insurance, the glucose
* panel, labs, the three medication blocks, GLP-1, allergies, illness
* episodes, family history, mood, cycle).
* panel, labs, the medication blocks, GLP-1, the medication effect, workouts,
* allergies, illness episodes, family history, mood, cycle).
*
* One catalogue drives the selection panel, the aggregator, the PDF, the
* clinician share view and the guard tests, so a control can never again exist
@@ -44,7 +44,9 @@ export type StructuredLeafId =
| "MEDICATION_LIST"
| "MEDICATION_ADMINISTRATIONS"
| "MEDICATION_COMPLIANCE"
| "MEDICATION_EFFECT"
| "GLP1_THERAPY"
| "WORKOUTS"
| "ALLERGIES"
| "ILLNESS_EPISODES"
| "VISITS"
@@ -213,7 +215,9 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
MEDICATION_LIST: "medications",
MEDICATION_ADMINISTRATIONS: "medications",
MEDICATION_COMPLIANCE: "medications",
MEDICATION_EFFECT: "medications",
GLP1_THERAPY: "medications",
WORKOUTS: "activity",
ALLERGIES: "history",
ILLNESS_EPISODES: "history",
VISITS: "history",
@@ -225,7 +229,7 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
ANAMNESIS: "sensitive",
};
/** i18n label keys for the 18 structured leaves. */
/** i18n label keys for the structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
EMERGENCY: "reportSelection.leafEmergency",
@@ -235,7 +239,9 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
MEDICATION_LIST: "reportSelection.leafMedicationList",
MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
MEDICATION_EFFECT: "reportSelection.leafMedicationEffect",
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
WORKOUTS: "reportSelection.leafWorkouts",
ALLERGIES: "reportSelection.leafAllergies",
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
VISITS: "reportSelection.leafVisits",
@@ -294,6 +300,8 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
STRAIN_SCORE: "workouts",
DAY_STRAIN: "workouts",
WORKOUT_STRAIN: "workouts",
// The per-sport workout block.
WORKOUTS: "workouts",
// Readiness signals.
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "recovery",
@@ -339,6 +347,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
MEDICATION_LIST: "medications",
MEDICATION_ADMINISTRATIONS: "medications",
MEDICATION_COMPLIANCE: "medications",
MEDICATION_EFFECT: "medications",
GLP1_THERAPY: "medications",
// The immunization log is its own opt-out module: the leaf hides when the
// `vaccinations` module is off, and the deselected leaf hides regardless of
@@ -433,11 +442,33 @@ export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
: MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
);
/**
* Measurement labels the report artefacts print differently from the app.
* Heart-rate variability comes as two statistics that are not
* interchangeable (SDNN from Apple Health and Withings, RMSSD from Oura,
* WHOOP, Polar and Fitbit); the app's generic "Heart-rate variability" label
* on the SDNN series sits beside "HRV (RMSSD)" on a clinician's page, so the
* report names the statistic on both.
*/
const REPORT_MEASUREMENT_LABEL_OVERRIDES: Partial<
Record<MeasurementType, string>
> = {
HEART_RATE_VARIABILITY: "reportSelection.leafHrvSdnn",
};
/** The i18n label key a report artefact prints a measurement type under. */
export function reportMeasurementLabelKey(type: MeasurementType): string {
return (
REPORT_MEASUREMENT_LABEL_OVERRIDES[type] ??
MEASUREMENT_TYPE_LABEL_KEYS[type]
);
}
/** The i18n label key for any leaf. */
export function leafLabelKey(leaf: ReportLeafId): string {
return isStructuredLeafId(leaf)
? STRUCTURED_LEAF_LABEL_KEYS[leaf]
: MEASUREMENT_TYPE_LABEL_KEYS[leaf];
: reportMeasurementLabelKey(leaf);
}
/** The group a leaf belongs to. */
+20 -6
View File
@@ -16,13 +16,21 @@ import { type ReportLeafId } from "./catalogue";
/**
* Identity and insurer (the cover page a practice files the document under),
* the three classic vitals, weight and BMI, the glucose series and its panel,
* labs, the drug list and adherence, allergies, past illness episodes, the
* surgical history and the immunization record.
* labs, the drug list, adherence and the effect around each start, sleep,
* both heart-rate-variability statistics, daily steps and the workout summary,
* allergies, past illness episodes, the surgical history and the immunization
* record.
*
* Deliberately not in it: everything a wearable produces, gait and mobility,
* environment exposure, the per-dose administration ledger (a clinician asks
* for adherence, not for every logged dose), the GLP-1 block, and the whole
* fenced tier.
* The wearable picks (v1.44) are the four a general visit reads: sleep
* duration, SDNN and RMSSD as two labelled rows, steps and the per-sport
* workout block. Each is one table row or one compact block, and each still
* rides its module: an account with sleep or workouts switched off gets none
* of it.
*
* Deliberately not in it: the rest of what a wearable produces, gait and
* mobility, environment exposure, the per-dose administration ledger (a
* clinician asks for adherence, not for every logged dose), the GLP-1 block,
* and the whole fenced tier.
*/
export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"PATIENT_IDENTITY",
@@ -38,6 +46,12 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"LAB_RESULTS",
"MEDICATION_LIST",
"MEDICATION_COMPLIANCE",
"MEDICATION_EFFECT",
"SLEEP_DURATION",
"HEART_RATE_VARIABILITY",
"HRV_RMSSD",
"ACTIVITY_STEPS",
"WORKOUTS",
"ALLERGIES",
"ILLNESS_EPISODES",
"SURGICAL_HISTORY",
@@ -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",
+28
View File
@@ -211,6 +211,34 @@ export const SIGNALS: Record<string, SignalDefinition> = {
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",
@@ -0,0 +1,114 @@
/**
* Exact statistics for the segmented (interrupted time series) regression.
*
* The reference values for the noisy fixtures were computed independently
* with statsmodels 0.14 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags":
* L, "use_correction": True}, use_t=True)`, L = floor(4·(n/100)^(2/9))) and
* scipy's `stats.t`. The fixture generator below is the same deterministic
* formula the reference script used, so a drift in the estimator, the HAC
* meat, the small-sample factor or the t quantile fails here.
*/
import { describe, expect, it } from "vitest";
import {
fitSegmentedRegression,
neweyWestLag,
studentTCdf,
studentTQuantile,
type SegmentedPoint,
} from "@/lib/stats/segmented-regression";
/** y = 140 − 0.05·t − 8·D − 0.1·t·D + deterministic wobble. */
function fixture(
nPre: number,
nPost: number,
opts: { gaps?: boolean; noise?: boolean } = {},
): SegmentedPoint[] {
const out: SegmentedPoint[] = [];
for (let t = -nPre; t < nPost; t++) {
if (opts.gaps && ((t % 5) + 5) % 5 === 2) continue;
// Python's `%` is a floored modulo; JS `%` truncates. Match Python.
const pyMod = (((0.45 * t * t) % 7) + 7) % 7;
const noise =
opts.noise === false ? 0 : 3 * Math.sin(1.7 * t) + 2 * Math.cos(pyMod);
const d = t >= 0 ? 1 : 0;
out.push({ t, y: 140 - 0.05 * t - 8 * d - 0.1 * t * d + noise });
}
return out;
}
describe("studentT", () => {
it("matches scipy quantiles", () => {
expect(studentTQuantile(0.975, 10)).toBeCloseTo(2.228138851986274, 9);
expect(studentTQuantile(0.975, 86)).toBeCloseTo(1.98793420623902, 9);
expect(studentTQuantile(0.95, 3)).toBeCloseTo(2.3533634348018233, 9);
});
it("matches the scipy CDF", () => {
expect(studentTCdf(-2.1, 7)).toBeCloseTo(0.036935598106461326, 10);
expect(studentTCdf(0, 5)).toBeCloseTo(0.5, 12);
});
});
describe("fitSegmentedRegression", () => {
it("recovers a noise-free level and slope change exactly, with zero-width intervals", () => {
const fit = fitSegmentedRegression(fixture(30, 30, { noise: false }));
expect(fit).not.toBeNull();
expect(fit!.intercept.estimate).toBeCloseTo(140, 9);
expect(fit!.preSlope.estimate).toBeCloseTo(-0.05, 9);
expect(fit!.levelChange.estimate).toBeCloseTo(-8, 9);
expect(fit!.slopeChange.estimate).toBeCloseTo(-0.1, 9);
expect(fit!.levelChange.standardError).toBeLessThan(1e-9);
expect(fit!.rSquared).toBeCloseTo(1, 12);
});
it("matches statsmodels HAC on a contiguous 90-day series", () => {
const fit = fitSegmentedRegression(fixture(40, 50))!;
expect(fit.n).toBe(90);
expect(fit.df).toBe(86);
expect(fit.hacLag).toBe(3);
expect(fit.intercept.estimate).toBeCloseTo(139.46950523140714, 9);
expect(fit.preSlope.estimate).toBeCloseTo(-0.09725785860840647, 10);
expect(fit.levelChange.estimate).toBeCloseTo(-7.336815685253282, 9);
expect(fit.slopeChange.estimate).toBeCloseTo(-0.03684505010294312, 10);
expect(fit.intercept.standardError).toBeCloseTo(0.3824128525477469, 9);
expect(fit.preSlope.standardError).toBeCloseTo(0.018189958785228614, 10);
expect(fit.levelChange.standardError).toBeCloseTo(0.5960977099276101, 9);
expect(fit.slopeChange.standardError).toBeCloseTo(0.02336733639963861, 10);
expect(fit.levelChange.ciLow).toBeCloseTo(-8.521818713079124, 8);
expect(fit.levelChange.ciHigh).toBeCloseTo(-6.151812657427441, 8);
expect(fit.slopeChange.ciLow).toBeCloseTo(-0.08329777744047885, 9);
expect(fit.slopeChange.ciHigh).toBeCloseTo(0.009607677234592618, 9);
expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10);
});
it("matches statsmodels HAC on a series with gaps", () => {
const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }))!;
expect(fit.n).toBe(48);
expect(fit.hacLag).toBe(3);
expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9);
expect(fit.levelChange.standardError).toBeCloseTo(0.9608527406825199, 9);
expect(fit.levelChange.ciLow).toBeCloseTo(-8.059729606822106, 8);
expect(fit.levelChange.ciHigh).toBeCloseTo(-4.18678669204816, 8);
expect(fit.slopeChange.estimate).toBeCloseTo(-0.11857109294626766, 10);
expect(fit.slopeChange.ciLow).toBeCloseTo(-0.23733292721328714, 9);
expect(fit.slopeChange.ciHigh).toBeCloseTo(0.0001907413207517944, 9);
expect(fit.rSquared).toBeCloseTo(0.8247246446176286, 10);
});
it("returns null when one side of the change has no points", () => {
expect(
fitSegmentedRegression(fixture(0, 30).filter((p) => p.t >= 0)),
).toBeNull();
});
it("returns null without residual degrees of freedom", () => {
expect(fitSegmentedRegression(fixture(2, 2, { noise: false }))).toBeNull();
});
it("uses the automatic Newey–West lag", () => {
expect(neweyWestLag(90)).toBe(3);
expect(neweyWestLag(30)).toBe(3);
expect(neweyWestLag(10)).toBe(2);
});
});
+285
View File
@@ -0,0 +1,285 @@
/**
* Segmented regression for an interrupted time series.
*
* One change point, known in advance (a medication start), four parameters:
*
* y(t) = b0 + b1·t + b2·D(t) + b3·t·D(t) + e(t)
*
* where `t` is the time in days relative to the change point and `D(t)` is 1
* at and after it, 0 before. `b1` is the trend before the change, `b2` the
* immediate shift in level, `b3` the change in slope. Fitted by ordinary
* least squares.
*
* Daily health series are autocorrelated: today's blood pressure says a lot
* about tomorrow's. Plain OLS standard errors ignore that and come out too
* narrow, so the covariance here is the Newey–West estimator (Bartlett
* kernel) with the usual automatic lag `floor(4·(n/100)^(2/9))` and the
* small-sample factor n/(n−k). Intervals use Student's t with n−k degrees of
* freedom. This matches `statsmodels` OLS `cov_type="HAC"` with
* `use_correction=True` and `use_t=True`; the unit tests pin it to values
* computed there.
*
* Observations are taken in the order given, which for a daily series with
* gaps treats a gap as adjacent days. That is a known approximation of the
* lag structure, not of the coefficients.
*
* Pure. No I/O, no rounding: callers round for display.
*/
/** One observation: time in days relative to the change point, and a value. */
export interface SegmentedPoint {
t: number;
y: number;
}
/** A coefficient with its HAC standard error and two-sided interval. */
export interface SegmentedEstimate {
estimate: number;
standardError: number;
ciLow: number;
ciHigh: number;
}
export interface SegmentedFit {
/** Observations used. */
n: number;
/** Residual degrees of freedom, n − 4. */
df: number;
/** Confidence level of every interval, e.g. 0.95. */
level: number;
/** Newey–West truncation lag that was applied. */
hacLag: number;
intercept: SegmentedEstimate;
preSlope: SegmentedEstimate;
levelChange: SegmentedEstimate;
slopeChange: SegmentedEstimate;
/** Coefficient of determination of the fit. */
rSquared: number;
/** Lag-1 autocorrelation of the residuals (a reader's sanity check). */
residualAutocorrelation: number;
}
const K = 4;
/** Automatic Newey–West lag: floor(4·(n/100)^(2/9)). */
export function neweyWestLag(n: number): number {
return Math.floor(4 * Math.pow(n / 100, 2 / 9));
}
/** Invert a small symmetric positive-definite matrix (Gauss–Jordan). */
function invert(matrix: number[][]): number[][] | null {
const size = matrix.length;
const a = matrix.map((row, i) => [
...row,
...Array.from({ length: size }, (_, j) => (i === j ? 1 : 0)),
]);
for (let col = 0; col < size; col++) {
let pivot = col;
for (let r = col + 1; r < size; r++) {
if (Math.abs(a[r][col]) > Math.abs(a[pivot][col])) pivot = r;
}
if (Math.abs(a[pivot][col]) < 1e-12) return null;
[a[col], a[pivot]] = [a[pivot], a[col]];
const p = a[col][col];
for (let j = 0; j < 2 * size; j++) a[col][j] /= p;
for (let r = 0; r < size; r++) {
if (r === col) continue;
const f = a[r][col];
if (f === 0) continue;
for (let j = 0; j < 2 * size; j++) a[r][j] -= f * a[col][j];
}
}
return a.map((row) => row.slice(size));
}
function designRow(p: SegmentedPoint): number[] {
const d = p.t >= 0 ? 1 : 0;
return [1, p.t, d, p.t * d];
}
/**
* Fit the four-parameter segmented model. Returns null when the design is
* singular (no points on one side, or every point at one instant) or when
* there are no residual degrees of freedom.
*/
export function fitSegmentedRegression(
points: readonly SegmentedPoint[],
options: { level?: number; hacLag?: number } = {},
): SegmentedFit | null {
const level = options.level ?? 0.95;
const n = points.length;
const df = n - K;
if (df < 1) return null;
const X = points.map(designRow);
const y = points.map((p) => p.y);
const xtx = Array.from({ length: K }, () => new Array<number>(K).fill(0));
const xty = new Array<number>(K).fill(0);
for (let i = 0; i < n; i++) {
for (let r = 0; r < K; r++) {
xty[r] += X[i][r] * y[i];
for (let c = 0; c < K; c++) xtx[r][c] += X[i][r] * X[i][c];
}
}
const bread = invert(xtx);
if (!bread) return null;
const beta = bread.map((row) => row.reduce((s, v, c) => s + v * xty[c], 0));
const residuals = y.map(
(yi, i) => yi - X[i].reduce((s, v, c) => s + v * beta[c], 0),
);
// Meat: Σ e_t² x_t x_tᵀ + Σ_l w_l Σ_t e_t e_{t−l} (x_t x_{t−l}ᵀ + x_{t−l} x_tᵀ)
const lag = Math.max(0, Math.min(options.hacLag ?? neweyWestLag(n), n - 1));
const meat = Array.from({ length: K }, () => new Array<number>(K).fill(0));
for (let l = 0; l <= lag; l++) {
const w = l === 0 ? 1 : 1 - l / (lag + 1);
for (let t = l; t < n; t++) {
const ee = residuals[t] * residuals[t - l] * w;
for (let r = 0; r < K; r++) {
for (let c = 0; c < K; c++) {
meat[r][c] +=
l === 0
? ee * X[t][r] * X[t][c]
: ee * (X[t][r] * X[t - l][c] + X[t - l][r] * X[t][c]);
}
}
}
}
const correction = n / df;
const cov = Array.from({ length: K }, (_, r) =>
Array.from({ length: K }, (_, c) => {
let s = 0;
for (let i = 0; i < K; i++) {
for (let j = 0; j < K; j++) s += bread[r][i] * meat[i][j] * bread[j][c];
}
return s * correction;
}),
);
const tCrit = studentTQuantile(1 - (1 - level) / 2, df);
const estimateAt = (i: number): SegmentedEstimate => {
const se = Math.sqrt(Math.max(0, cov[i][i]));
return {
estimate: beta[i],
standardError: se,
ciLow: beta[i] - tCrit * se,
ciHigh: beta[i] + tCrit * se,
};
};
const meanY = y.reduce((s, v) => s + v, 0) / n;
let ssr = 0;
let sst = 0;
for (let i = 0; i < n; i++) {
ssr += residuals[i] ** 2;
sst += (y[i] - meanY) ** 2;
}
let acNum = 0;
for (let i = 1; i < n; i++) acNum += residuals[i] * residuals[i - 1];
return {
n,
df,
level,
hacLag: lag,
intercept: estimateAt(0),
preSlope: estimateAt(1),
levelChange: estimateAt(2),
slopeChange: estimateAt(3),
rSquared: sst > 0 ? 1 - ssr / sst : 1,
residualAutocorrelation: ssr > 0 ? acNum / ssr : 0,
};
}
// ── Student's t ────────────────────────────────────────────────────────
/** ln Γ(x), Lanczos approximation (g = 7, n = 9). */
function logGamma(x: number): number {
const c = [
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
];
if (x < 0.5) {
return Math.log(Math.PI / Math.sin(Math.PI * x)) - logGamma(1 - x);
}
const z = x - 1;
let a = c[0];
const t = z + 7.5;
for (let i = 1; i < 9; i++) a += c[i] / (z + i);
return (
0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(a)
);
}
/** Continued fraction for the incomplete beta (Numerical Recipes betacf). */
function betaContinuedFraction(x: number, a: number, b: number): number {
const tiny = 1e-300;
let c = 1;
let d = 1 - ((a + b) * x) / (a + 1);
if (Math.abs(d) < tiny) d = tiny;
d = 1 / d;
let h = d;
for (let m = 1; m <= 300; m++) {
const m2 = 2 * m;
let aa = (m * (b - m) * x) / ((a + m2 - 1) * (a + m2));
d = 1 + aa * d;
if (Math.abs(d) < tiny) d = tiny;
c = 1 + aa / c;
if (Math.abs(c) < tiny) c = tiny;
d = 1 / d;
h *= d * c;
aa = (-(a + m) * (a + b + m) * x) / ((a + m2) * (a + m2 + 1));
d = 1 + aa * d;
if (Math.abs(d) < tiny) d = tiny;
c = 1 + aa / c;
if (Math.abs(c) < tiny) c = tiny;
d = 1 / d;
const delta = d * c;
h *= delta;
if (Math.abs(delta - 1) < 1e-15) break;
}
return h;
}
/** Regularised incomplete beta I_x(a, b). */
function incompleteBeta(x: number, a: number, b: number): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const front = Math.exp(
logGamma(a + b) -
logGamma(a) -
logGamma(b) +
a * Math.log(x) +
b * Math.log(1 - x),
);
if (x < (a + 1) / (a + b + 2)) {
return (front * betaContinuedFraction(x, a, b)) / a;
}
return 1 - (front * betaContinuedFraction(1 - x, b, a)) / b;
}
/** Student's t cumulative distribution function. */
export function studentTCdf(t: number, df: number): number {
const x = df / (df + t * t);
const tail = 0.5 * incompleteBeta(x, df / 2, 0.5);
return t >= 0 ? 1 - tail : tail;
}
/** Student's t quantile, by bisection on the CDF (monotone, so it converges). */
export function studentTQuantile(p: number, df: number): number {
if (p <= 0) return Number.NEGATIVE_INFINITY;
if (p >= 1) return Number.POSITIVE_INFINITY;
if (p === 0.5) return 0;
let lo = -1e3;
let hi = 1e3;
for (let i = 0; i < 200; i++) {
const mid = (lo + hi) / 2;
if (studentTCdf(mid, df) < p) lo = mid;
else hi = mid;
if (hi - lo < 1e-12) break;
}
return (lo + hi) / 2;
}
@@ -81,6 +81,81 @@ const levelShiftSchema = z
})
.nullable();
const estimateSchema = z
.object({
estimate: z.number(),
ciLow: z.number(),
ciHigh: z.number(),
standardError: z.number(),
includesZero: z.boolean(),
})
.nullable();
/**
* The segmented-regression (interrupted time series) effect: the immediate
* level change and the change in weekly trend at the start, each with a 95 %
* interval (Newey–West), fitted over up to 90 days before and 180 after with
* low-adherence and not-taking days after the start left out. Descriptive
* only; `present: false` carries the reason the data floor was not met.
*/
const segmentedSchema = z
.object({
present: z.boolean(),
reason: z
.enum([
"no_start",
"insufficient_before",
"insufficient_after",
"fit_failed",
])
.optional(),
level: z.number(),
window: z
.object({
from: z.string(),
to: z.string(),
endReason: z.enum(["today", "window", "dose_change"]),
})
.nullable(),
days: z.object({
before: z.number().int(),
after: z.number().int(),
excludedLowAdherence: z.number().int(),
excludedNotTaking: z.number().int(),
}),
adherence: z.object({
status: z.enum(["tracked", "not_tracked"]),
floorPct: z.number().int(),
lookbackDays: z.number().int(),
}),
levelChange: estimateSchema,
slopeChangePerWeek: estimateSchema,
preSlopePerWeek: estimateSchema,
fit: z
.object({
n: z.number().int(),
df: z.number().int(),
rSquared: z.number(),
residualAutocorrelation: z.number(),
hacLag: z.number().int(),
})
.nullable(),
confounders: z.array(
z.object({
medicationName: z.string(),
event: z.enum([
"started",
"stopped",
"dose_change",
"paused",
"resumed",
]),
date: z.string(),
}),
),
})
.nullable();
const targetViewSchema = z.object({
kind: z.enum(["metric", "lab"]),
key: z.string(),
@@ -91,6 +166,7 @@ const targetViewSchema = z.object({
series: z.array(seriesPointSchema),
beforeAfter: beforeAfterSchema,
levelShift: levelShiftSchema,
segmented: segmentedSchema,
});
export const medicationEfficacyResponseSchema = z.object({