mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'feat/v144-report' into release/v1.44.0
This commit is contained in:
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user