mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(fhir): serve Observations as a time series, code RMSSD and workouts
GET /api/fhir/Observation keeps its snapshot answer when no date is
sent. A date range (FHIR prefixes eq/gt/ge/lt/le, UTC when no offset)
switches it to the time series: every reading in the range plus
workouts, newest first, paged in the database through one UNION id
query and a count, so a page costs what it holds. Series ids are the
row's own (m-<id>, w-<id>) and read back through /Observation/{id}.
The series replays the same saved report selection as the snapshot,
pairs systolic and diastolic recorded at one instant into the
blood-pressure panel, leaves sleep stage segments out, and reads an
hourly heart-rate bucket as a mean over its hour. A code parameter
narrows either answer; a malformed date is a 400 OperationOutcome.
A workout is an activity Observation after the Physical Activity IG:
LOINC 73985-4 with the sport as its value and duration (55411-3) and
energy (41981-2) as components. RMSSD gets its own registry coding,
text-only because LOINC has no RMSSD term, so it never borrows the SDNN
code. The capability statement lists the two new parameters.
This commit is contained in:
1 parent
9e2a0681b2
commit
77619f8aa5
12 files changed
+1935
-26
No files matched your search
+25
-6
@@ -1245,8 +1245,9 @@ paths:
|
||||
summary: FHIR R4 CapabilityStatement (v1.11.0)
|
||||
description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient,
|
||||
Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type`
|
||||
interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised
|
||||
interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)."
|
||||
interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the
|
||||
`Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a
|
||||
route. Auth: `fhir:read` scope (cookie sessions also pass)."
|
||||
responses:
|
||||
"200":
|
||||
description: CapabilityStatement (application/fhir+json).
|
||||
@@ -1291,7 +1292,16 @@ paths:
|
||||
- FHIR
|
||||
summary: FHIR R4 Observation search (v1.11.0)
|
||||
description: "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth:
|
||||
`fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`."
|
||||
`fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the
|
||||
snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date`
|
||||
(v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time
|
||||
series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-<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."
|
||||
parameters:
|
||||
- in: query
|
||||
name: _count
|
||||
@@ -1301,6 +1311,14 @@ paths:
|
||||
name: _offset
|
||||
schema:
|
||||
type: number
|
||||
- in: query
|
||||
name: date
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: code
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: searchset Bundle (application/fhir+json).
|
||||
@@ -1463,9 +1481,10 @@ paths:
|
||||
tags:
|
||||
- FHIR
|
||||
summary: FHIR R4 Observation read
|
||||
description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is
|
||||
indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
|
||||
scope; `userId` is narrowed from auth."
|
||||
description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id
|
||||
(`m-<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."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
|
||||
@@ -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,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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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() }),
|
||||
},
|
||||
|
||||
@@ -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,362 @@
|
||||
/**
|
||||
* v1.44 against a real Postgres: the FHIR Observation time series (the UNION
|
||||
* page query, its count, the BP pairing, the glucose-context filter, owner and
|
||||
* soft-delete scoping), the segmented medication effect read end to end, and
|
||||
* the doctor report's workout and effect blocks.
|
||||
*
|
||||
* The effect fixture: an ACE inhibitor started on 2026-03-01 with systolic
|
||||
* readings of exactly 140 for 60 days before and 130 − 0.1·t after, so the
|
||||
* fit must recover a level change of −10 mmHg and a slope change of
|
||||
* −0.7 mmHg/week, and a second medication starting inside the window must be
|
||||
* listed as a confounder.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
emitSeriesPage,
|
||||
querySeriesPage,
|
||||
resolveSeriesScope,
|
||||
} from "@/lib/fhir/series";
|
||||
import {
|
||||
EFFECT_MEDICATION_INCLUDE,
|
||||
loadSegmentedEffects,
|
||||
} from "@/lib/medications/efficacy/load-segmented-effects";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const PIVOT = Date.parse("2026-03-01T00:00:00.000Z");
|
||||
|
||||
let userId = "";
|
||||
let otherId = "";
|
||||
let medId = "";
|
||||
|
||||
const MODULES = {
|
||||
cycle: false,
|
||||
mood: false,
|
||||
sleep: true,
|
||||
glucose: true,
|
||||
workouts: true,
|
||||
recovery: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
environment: true,
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
vaccinations: true,
|
||||
timeline: true,
|
||||
} as const;
|
||||
|
||||
const gateOf = (leaves: ReportLeafId[]) => ({
|
||||
admits: (leaf: ReportLeafId) => leaves.includes(leaf),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "fhir-series-effect",
|
||||
email: "fhir-series-effect@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
const other = await prisma.user.create({
|
||||
data: {
|
||||
username: "fhir-series-other",
|
||||
email: "fhir-series-other@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
otherId = other.id;
|
||||
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
// Systolic: 140 before, 130 − 0.1·t after (one reading a day at 08:00).
|
||||
for (let t = -60; t < 60; t++) {
|
||||
rows.push({
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: t < 0 ? 140 : 130 - 0.1 * t,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + t * DAY + 8 * 3_600_000),
|
||||
});
|
||||
}
|
||||
// Two paired diastolic readings and one lone diastolic.
|
||||
rows.push(
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 85,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 10 * DAY + 8 * 3_600_000),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 84,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 11 * DAY + 8 * 3_600_000),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 80,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 11 * DAY + 20 * 3_600_000),
|
||||
},
|
||||
);
|
||||
rows.push(
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
glucoseContext: "FASTING",
|
||||
measuredAt: new Date(PIVOT + 12 * DAY),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 130,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
glucoseContext: null,
|
||||
measuredAt: new Date(PIVOT + 12 * DAY + 3_600_000),
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 13 * DAY),
|
||||
deletedAt: new Date(PIVOT + 14 * DAY),
|
||||
},
|
||||
{
|
||||
userId: otherId,
|
||||
type: "WEIGHT",
|
||||
value: 70,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(PIVOT + 13 * DAY),
|
||||
},
|
||||
);
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
|
||||
await prisma.workout.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
source: "MANUAL",
|
||||
sportType: "running",
|
||||
externalId: "run-1",
|
||||
startedAt: new Date(PIVOT + 11 * DAY + 12 * 3_600_000),
|
||||
endedAt: new Date(PIVOT + 11 * DAY + 13 * 3_600_000),
|
||||
durationSec: 3600,
|
||||
totalDistanceM: 10_000,
|
||||
totalEnergyKcal: 700,
|
||||
avgHeartRate: 150,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
source: "MANUAL",
|
||||
sportType: "running",
|
||||
externalId: "run-2",
|
||||
startedAt: new Date(PIVOT + 20 * DAY),
|
||||
endedAt: new Date(PIVOT + 20 * DAY + 1_800_000),
|
||||
durationSec: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
atcCode: "C09AA05",
|
||||
trackIntake: false,
|
||||
startsOn: new Date(PIVOT),
|
||||
},
|
||||
});
|
||||
medId = med.id;
|
||||
await prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Second drug",
|
||||
dose: "1 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
startsOn: new Date(PIVOT + 20 * DAY),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("FHIR Observation series in Postgres", () => {
|
||||
const range = {
|
||||
from: new Date(PIVOT + 10 * DAY),
|
||||
to: new Date(PIVOT + 21 * DAY),
|
||||
};
|
||||
|
||||
it("pages newest first, pairs BP, scopes to the owner and drops soft-deleted rows", async () => {
|
||||
const scope = resolveSeriesScope(
|
||||
gateOf([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"BLOOD_GLUCOSE",
|
||||
"WEIGHT",
|
||||
"WORKOUTS",
|
||||
]),
|
||||
null,
|
||||
);
|
||||
const { total, refs } = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 200,
|
||||
offset: 0,
|
||||
});
|
||||
// 11 systolic (days 10..20), 1 lone diastolic, 2 glucose, 2 workouts.
|
||||
// The two paired diastolic rows ride inside their systolic panels; the
|
||||
// deleted weight and the other account's weight never appear.
|
||||
expect(total).toBe(16);
|
||||
expect(refs).toHaveLength(16);
|
||||
const page = await emitSeriesPage({
|
||||
userId,
|
||||
refs,
|
||||
scope,
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(page).toHaveLength(16);
|
||||
// Newest first: the day-20 systolic (08:00, no diastolic partner, so a
|
||||
// lone 8480-6 reading), then the day-20 run (00:00).
|
||||
expect(page[0].code.coding?.[0]?.code).toBe("8480-6");
|
||||
expect(page[1].id.startsWith("w-")).toBe(true);
|
||||
const panels = page.filter(
|
||||
(o) =>
|
||||
o.code.coding?.[0]?.code === "85354-9" && o.component?.length === 2,
|
||||
);
|
||||
expect(
|
||||
panels.map((p) => p.component?.[1]?.valueQuantity?.value).sort(),
|
||||
).toEqual([84, 85]);
|
||||
expect(page.some((o) => o.code.coding?.[0]?.code === "8462-4")).toBe(true);
|
||||
expect(page.some((o) => o.code.coding?.[0]?.code === "29463-7")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("pages with offset and agrees with the count", async () => {
|
||||
const scope = resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null);
|
||||
const first = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 5,
|
||||
offset: 0,
|
||||
});
|
||||
const second = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 5,
|
||||
offset: 5,
|
||||
});
|
||||
expect(first.total).toBe(11);
|
||||
expect(first.refs).toHaveLength(5);
|
||||
expect(second.refs).toHaveLength(5);
|
||||
const ids = new Set([...first.refs, ...second.refs].map((r) => r.id));
|
||||
expect(ids.size).toBe(10);
|
||||
});
|
||||
|
||||
it("narrows glucose to a code's contexts", async () => {
|
||||
const scope = resolveSeriesScope(gateOf(["BLOOD_GLUCOSE"]), {
|
||||
types: new Set(["BLOOD_GLUCOSE"]),
|
||||
glucoseContexts: new Set(["FASTING"]),
|
||||
workouts: false,
|
||||
});
|
||||
const { total } = await querySeriesPage({
|
||||
userId,
|
||||
scope,
|
||||
range,
|
||||
count: 50,
|
||||
offset: 0,
|
||||
});
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmented medication effect in Postgres", () => {
|
||||
it("recovers the known level and slope change and lists the confounder", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.findFirstOrThrow({
|
||||
where: { id: medId },
|
||||
include: EFFECT_MEDICATION_INCLUDE,
|
||||
});
|
||||
const effects = await loadSegmentedEffects({
|
||||
userId,
|
||||
med,
|
||||
targets: ["BLOOD_PRESSURE_SYS"],
|
||||
timezone: "UTC",
|
||||
now: new Date(PIVOT + 59 * DAY + 12 * 3_600_000),
|
||||
});
|
||||
const e = effects.get("BLOOD_PRESSURE_SYS")!;
|
||||
expect(e.present).toBe(true);
|
||||
expect(e.levelChange!.estimate).toBeCloseTo(-10, 6);
|
||||
expect(e.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 6);
|
||||
expect(e.days.before).toBe(60);
|
||||
expect(e.days.after).toBe(60);
|
||||
expect(e.adherence.status).toBe("not_tracked");
|
||||
expect(e.confounders).toEqual([
|
||||
{ medicationName: "Second drug", event: "started", date: "2026-03-21" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reaches the doctor report with its workout block", async () => {
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
{
|
||||
start: new Date(PIVOT),
|
||||
end: new Date(PIVOT + 59 * DAY + 12 * 3_600_000),
|
||||
days: 60,
|
||||
},
|
||||
selectionFromLeaves([
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_EFFECT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"WORKOUTS",
|
||||
]),
|
||||
{ moduleMap: MODULES as never },
|
||||
);
|
||||
const effect = data.medicationEffects?.find(
|
||||
(m) => m.medicationName === "Ramipril",
|
||||
);
|
||||
// Diastolic was not selected, so only the systolic target rides.
|
||||
expect(effect?.targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]);
|
||||
expect(effect?.targets[0].effect.present).toBe(true);
|
||||
expect(data.workouts?.sessions).toBe(2);
|
||||
expect(data.workouts?.bySport[0]).toMatchObject({
|
||||
sportType: "running",
|
||||
sessions: 2,
|
||||
durationSec: 5400,
|
||||
distanceM: 10_000,
|
||||
distanceSessions: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user