diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f31a99c8e..b4f2a97c7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26131,11 +26131,17 @@ paths: - in: query name: values schema: - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts - once, so the list is bounded by the names that exist; an unknown name answers 422. + description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the + names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike + names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is + left out of `labSeries` without an error, so a stored choice never breaks the page. type: string - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts - once, so the list is bounded by the names that exist; an unknown name answers 422. + description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the + names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike + names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left + out of `labSeries` without an error, so a stored choice never breaks the page. responses: "200": description: The timeline for the requested zoom and window. @@ -92030,6 +92036,18 @@ components: description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`." + labSeries: + type: array + items: + $ref: "#/components/schemas/TimelineLabSeries" + description: The lab markers named in `values`, in the order named, each with its single readings in the window. A + marker named but not found in the record is left out. + availableLabs: + type: array + items: + $ref: "#/components/schemas/TimelineLabMarker" + description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. + Each may be named in `values` by its `key`. notable: type: array items: @@ -92057,6 +92075,8 @@ components: - bucket - series - availableSeries + - labSeries + - availableLabs - notable additionalProperties: false TimelineItem: @@ -92209,6 +92229,155 @@ components: - unit - points additionalProperties: false + TimelineLabSeries: + type: object + properties: + key: + type: string + description: "`lab:`, as named in `values`." + biomarkerId: + type: string + name: + type: string + description: The marker's name, as the labs list shows it. + panel: + type: + - string + - "null" + unit: + type: string + description: The marker's own unit; every point is in it. + band: + anyOf: + - type: object + properties: + low: + type: + - number + - "null" + high: + type: + - number + - "null" + required: + - low + - high + additionalProperties: false + - type: "null" + description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest + reading in the window. Null when neither is known." + points: + type: array + items: + $ref: "#/components/schemas/TimelineLabPoint" + description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A + day with two readings has two points." + excluded: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` + rather than drawn on a scale they are not in. + required: + - key + - biomarkerId + - name + - panel + - unit + - band + - points + - excluded + additionalProperties: false + TimelineLabPoint: + type: object + properties: + id: + type: string + description: The lab result's id. + date: + type: string + description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. + value: + type: number + description: In the series' `unit`. + sourceValue: + description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision + `value` is shown with. Null for a reading stored as stated. + type: + - number + - "null" + rangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. + referenceLow: + description: "The window this reading is judged against, in the series' unit: the one its report printed, else the + marker's own." + type: + - number + - "null" + referenceHigh: + type: + - number + - "null" + report: + anyOf: + - type: object + properties: + id: + type: string + title: + description: The document's title, else its file name. + type: + - string + - "null" + href: + type: string + required: + - id + - title + - href + additionalProperties: false + - type: "null" + description: The document the reading was read from, when one is linked and the caller may see documents. + required: + - id + - date + - value + - sourceValue + - rangeStatus + - referenceLow + - referenceHigh + - report + additionalProperties: false + TimelineLabMarker: + type: object + properties: + key: + type: string + description: "`lab:`, to name in `values`." + biomarkerId: + type: string + name: + type: string + panel: + description: The marker's group, as the labs list shows it. + type: + - string + - "null" + unit: + type: string + required: + - key + - biomarkerId + - name + - panel + - unit + additionalProperties: false TimelineReadinessEnvelope: type: object properties: diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 512b90c8f..0d6e4f284 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -512,6 +512,8 @@ export function fullTimeline(today: string) { bucket: "quarter" as Bucket, series: series("2019-01-01", today, "quarter"), availableSeries: FULL_AVAILABLE, + labSeries: [], + availableLabs: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } @@ -637,6 +639,8 @@ export function readyTimeline(today: string) { (s) => s.key !== "RESTING_HEART_RATE", ), availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], + labSeries: [], + availableLabs: [], notable: [], }; } diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index 6180427b2..8adf3ee06 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -69,6 +69,7 @@ export const GET = apiHandler(async (request: Request) => { lanes: timeline.lanes.length, items: timeline.lanes.reduce((n, l) => n + l.items.length, 0), series: timeline.series.length, + labSeries: timeline.labSeries.length, notable: timeline.notable.length, }, }); diff --git a/src/components/timeline/__tests__/lab-day-chip.test.tsx b/src/components/timeline/__tests__/lab-day-chip.test.tsx index e6d5b6c4f..b145566ef 100644 --- a/src/components/timeline/__tests__/lab-day-chip.test.tsx +++ b/src/components/timeline/__tests__/lab-day-chip.test.tsx @@ -34,6 +34,8 @@ function timeline(): TimelineResponse { standing: [], series: [], availableSeries: [], + labSeries: [], + availableLabs: [], notable: [], } as unknown as TimelineResponse; } diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx index 0859365b1..8934caa50 100644 --- a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -258,6 +258,8 @@ function everyKind(): TimelineResponse { standing: [], series: [], availableSeries: [], + labSeries: [], + availableLabs: [], notable: [], }; // The fixture itself must reach every kind, or the guard proves less. diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 79bf546b1..70762d50f 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -284,6 +284,8 @@ export function fullTimeline(): TimelineResponse { }, ], availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], + labSeries: [], + availableLabs: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx index 45bac0cae..80ac9f356 100644 --- a/src/components/timeline/__tests__/timeline-rework.test.tsx +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -150,6 +150,8 @@ function twoMedications(): TimelineResponse { standing: [], series: [], availableSeries: [], + labSeries: [], + availableLabs: [], notable: [], }; } diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index 387b6f64f..97651415e 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -425,6 +425,34 @@ export type TimelineResponse = z.infer; export type TimelineLane = TimelineResponse["lanes"][number]; export type TimelineItem = TimelineLane["items"][number]; export type TimelineSeries = TimelineResponse["series"][number]; +export type TimelineLabSeries = TimelineResponse["labSeries"][number]; +export type TimelineLabPoint = TimelineLabSeries["points"][number]; +export type TimelineLabMarker = TimelineResponse["availableLabs"][number]; + +/** + * A lab marker's line is named in `values` as `lab:` (#1299), + * beside the measurement types and `MOOD`, so one choice, one request and + * one stored list carry every line under the lanes. + */ +export const TIMELINE_LAB_KEY_PREFIX = "lab:"; + +/** + * The most lab markers one request names. Measurement names are a closed + * set and bound themselves; marker ids are not. + */ +export const TIMELINE_LAB_SERIES_MAX = 24; + +/** The marker id a `values` entry names, or null when it names no marker. */ +export function timelineLabMarkerId(key: string): string | null { + if (!key.startsWith(TIMELINE_LAB_KEY_PREFIX)) return null; + const id = key.slice(TIMELINE_LAB_KEY_PREFIX.length); + return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null; +} + +/** The `values` entry for a lab marker. */ +export function timelineLabKey(biomarkerId: string): string { + return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`; +} /** `GET /api/timeline/readiness` → `data`. */ export type TimelineReadinessResponse = z.infer< diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 637327877..4a5c892c3 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -370,7 +370,7 @@ export const timelineQuerySchema = z .string() .optional() .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422.", + "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left out of `labSeries` without an error, so a stored choice never breaks the page.", ), }) .refine((q) => q.zoom !== "range" || (q.from && q.to), { @@ -466,6 +466,90 @@ const timelineSeries = z }) .meta({ id: "TimelineSeries" }); +const labRangeStatus = z + .enum(["in-range", "below", "above", "unknown"]) + .describe( + "The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.", + ); + +const timelineLabPoint = z + .object({ + id: z.string().describe("The lab result's id."), + date: dateKeySchema.describe( + "The record's local day of the draw, the day `GET /api/day/{date}` lists it under.", + ), + value: z.number().describe("In the series' `unit`."), + sourceValue: z + .number() + .nullable() + .describe( + "The figure the report printed, when `value` was converted into the series' unit; it sets the precision `value` is shown with. Null for a reading stored as stated.", + ), + rangeStatus: labRangeStatus, + referenceLow: z + .number() + .nullable() + .describe( + "The window this reading is judged against, in the series' unit: the one its report printed, else the marker's own.", + ), + referenceHigh: z.number().nullable(), + report: z + .object({ + id: z.string(), + title: z + .string() + .nullable() + .describe("The document's title, else its file name."), + href: z.string(), + }) + .nullable() + .describe( + "The document the reading was read from, when one is linked and the caller may see documents.", + ), + }) + .meta({ id: "TimelineLabPoint" }); + +const timelineLabSeries = z + .object({ + key: z.string().describe("`lab:`, as named in `values`."), + biomarkerId: z.string(), + name: z.string().describe("The marker's name, as the labs list shows it."), + panel: z.string().nullable(), + unit: z.string().describe("The marker's own unit; every point is in it."), + band: z + .object({ low: z.number().nullable(), high: z.number().nullable() }) + .nullable() + .describe( + "The reference range to draw behind the points: the marker's own, else the window printed with its newest reading in the window. Null when neither is known.", + ), + points: z + .array(timelineLabPoint) + .describe( + "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A day with two readings has two points.", + ), + excluded: z + .number() + .int() + .min(0) + .describe( + "Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` rather than drawn on a scale they are not in.", + ), + }) + .meta({ id: "TimelineLabSeries" }); + +const timelineLabMarker = z + .object({ + key: z.string().describe("`lab:`, to name in `values`."), + biomarkerId: z.string(), + name: z.string(), + panel: z + .string() + .nullable() + .describe("The marker's group, as the labs list shows it."), + unit: z.string(), + }) + .meta({ id: "TimelineLabMarker" }); + export const timelineResponseSchema = z .object({ zoom: z.enum(TIMELINE_ZOOMS), @@ -496,6 +580,16 @@ export const timelineResponseSchema = z .describe( "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`.", ), + labSeries: z + .array(timelineLabSeries) + .describe( + "The lab markers named in `values`, in the order named, each with its single readings in the window. A marker named but not found in the record is left out.", + ), + availableLabs: z + .array(timelineLabMarker) + .describe( + "Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`.", + ), notable: z.array( z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }), ), diff --git a/src/lib/timeline/__tests__/lab-series.test.ts b/src/lib/timeline/__tests__/lab-series.test.ts new file mode 100644 index 000000000..1a29fdc5f --- /dev/null +++ b/src/lib/timeline/__tests__/lab-series.test.ts @@ -0,0 +1,283 @@ +/** + * Lab markers as lines on the timeline (#1299): the shape of one marker's + * line, and the `values` entries that name one. + * + * A lab line is its readings, one point each, never a mean; a reading in a + * unit that converts is drawn converted, one that does not is counted and + * left out; each point is judged against its own window and the band behind + * the line is the marker's own range. All data invented. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { + TIMELINE_LAB_SERIES_MAX, + timelineLabMarkerId, +} from "@/lib/day/contract"; + +import { parseSeriesKeys } from "../load-timeline"; +import { + shapeLabSeries, + type LabLineMarker, + type LabLineRow, +} from "../lab-series"; + +const FERRITIN: LabLineMarker = { + id: "bm1", + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + panel: "Iron", + analyteKey: "ferritin", +}; + +const GLUCOSE: LabLineMarker = { + id: "bm2", + name: "Fasting glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 99, + panel: null, + analyteKey: "fasting-glucose", +}; + +function row(over: Partial & Pick): LabLineRow { + return { + analyte: "Ferritin", + panel: null, + value: 100, + unit: "ng/mL", + sourceValue: null, + sourceUnit: null, + referenceLow: null, + referenceHigh: null, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + biomarkerId: "bm1", + takenAt: new Date("2025-03-10T07:30:00.000Z"), + report: null, + ...over, + }; +} + +const WINDOW = { from: "2025-01-01", to: "2025-12-31", tz: "Europe/Berlin" }; + +describe("a lab marker's line", () => { + it("is its readings, one point each, oldest first, never a mean", () => { + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "r3", value: 52, takenAt: new Date("2025-11-02T08:00:00Z") }), + row({ id: "r1", value: 41, takenAt: new Date("2025-02-03T08:00:00Z") }), + // Two draws on one day stay two points. + row({ + id: "r2a", + value: 44, + takenAt: new Date("2025-02-20T07:00:00Z"), + }), + row({ + id: "r2b", + value: 46, + takenAt: new Date("2025-02-20T15:00:00Z"), + }), + ], + WINDOW, + ); + expect(line.key).toBe("lab:bm1"); + expect(line.unit).toBe("ng/mL"); + expect(line.points.map((p) => [p.id, p.date, p.value])).toEqual([ + ["r1", "2025-02-03", 41], + ["r2a", "2025-02-20", 44], + ["r2b", "2025-02-20", 46], + ["r3", "2025-11-02", 52], + ]); + expect(line.excluded).toBe(0); + }); + + it("dates a reading by the record's local day, as the day view lists it", () => { + // 23:30 UTC on 31 Dec is already 1 Jan in Berlin. + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "late", takenAt: new Date("2024-12-31T23:30:00Z") }), + row({ id: "out", takenAt: new Date("2025-12-31T23:30:00Z") }), + ], + WINDOW, + ); + expect(line.points.map((p) => [p.id, p.date])).toEqual([ + ["late", "2025-01-01"], + ]); + }); + + it("judges each reading against its own printed window, else the marker's", () => { + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "low", value: 12 }), + row({ + id: "printed", + value: 420, + takenAt: new Date("2025-04-01T08:00:00Z"), + sourceReferenceLow: 20, + sourceReferenceHigh: 500, + }), + row({ + id: "high", + value: 450, + takenAt: new Date("2025-05-01T08:00:00Z"), + }), + ], + WINDOW, + ); + expect( + line.points.map((p) => [ + p.id, + p.rangeStatus, + p.referenceLow, + p.referenceHigh, + ]), + ).toEqual([ + ["low", "below", 30, 400], + // In range against the window its own report printed. + ["printed", "in-range", 20, 500], + ["high", "above", 30, 400], + ]); + // The band behind the line is the marker's own range. + expect(line.band).toEqual({ low: 30, high: 400 }); + }); + + it("falls back to the newest reading's window when the marker has no range", () => { + const line = shapeLabSeries( + { ...FERRITIN, lowerBound: null, upperBound: null }, + [ + row({ id: "a", sourceReferenceLow: 10, sourceReferenceHigh: 300 }), + row({ + id: "b", + takenAt: new Date("2025-06-01T08:00:00Z"), + sourceReferenceLow: 15, + sourceReferenceHigh: 150, + }), + ], + WINDOW, + ); + expect(line.band).toEqual({ low: 15, high: 150 }); + const bare = shapeLabSeries( + { ...FERRITIN, lowerBound: null, upperBound: null }, + [row({ id: "a" })], + WINDOW, + ); + expect(bare.band).toBeNull(); + expect(bare.points[0].rangeStatus).toBe("unknown"); + }); + + it("converts a reading in another unit that converts, window and all", () => { + const line = shapeLabSeries( + GLUCOSE, + [ + row({ + id: "mmol", + analyte: "Fasting glucose", + biomarkerId: "bm2", + value: 5.5, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 5.5, + }), + // The same unit spelled another way is the same unit. + row({ + id: "spelled", + analyte: "Fasting glucose", + biomarkerId: "bm2", + value: 92, + unit: "mg/dl", + takenAt: new Date("2025-06-01T08:00:00Z"), + }), + ], + WINDOW, + ); + const [mmol, spelled] = line.points; + expect(mmol.value).toBeCloseTo(99.09, 1); + // The figure as stored sets the precision the converted one shows with. + expect(mmol.sourceValue).toBe(5.5); + expect(mmol.referenceLow).toBeCloseTo(70.26, 1); + expect(mmol.referenceHigh).toBeCloseTo(99.09, 1); + expect(mmol.rangeStatus).toBe("in-range"); + expect(spelled).toMatchObject({ value: 92, sourceValue: null }); + expect(line.excluded).toBe(0); + }); + + it("leaves out and counts a reading whose unit does not convert", () => { + // Ferritin has no molar conversion: pmol/L is never drawn on a ng/mL scale. + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "ok", value: 80 }), + row({ + id: "molar", + value: 180, + unit: "pmol/L", + takenAt: new Date("2025-06-01T08:00:00Z"), + }), + ], + WINDOW, + ); + expect(line.points.map((p) => p.id)).toEqual(["ok"]); + expect(line.excluded).toBe(1); + }); + + it("links the report a reading was read from", () => { + const line = shapeLabSeries( + FERRITIN, + [ + row({ id: "doc", report: { id: "d1", title: "Lab report March" } }), + row({ id: "none", takenAt: new Date("2025-04-01T08:00:00Z") }), + ], + WINDOW, + ); + expect(line.points[0].report).toEqual({ + id: "d1", + title: "Lab report March", + href: "/documents?doc=d1", + }); + expect(line.points[1].report).toBeNull(); + }); + + it("has no points, and says so, when the window holds no reading", () => { + const line = shapeLabSeries(FERRITIN, [], WINDOW); + expect(line.points).toEqual([]); + expect(line.excluded).toBe(0); + expect(line.band).toEqual({ low: 30, high: 400 }); + }); +}); + +describe("a lab marker in `values`", () => { + it("is named `lab:`, nothing else", () => { + expect(timelineLabMarkerId("lab:ckx1abc")).toBe("ckx1abc"); + expect(timelineLabMarkerId("WEIGHT")).toBeNull(); + expect(timelineLabMarkerId("lab:")).toBeNull(); + expect(timelineLabMarkerId("lab:a,b")).toBeNull(); + expect(timelineLabMarkerId("lab:../x")).toBeNull(); + }); + + it("rides beside measurement names, and an unknown name still refuses", () => { + expect(parseSeriesKeys("WEIGHT,lab:bm1,lab:bm1,MOOD")).toEqual([ + "WEIGHT", + "lab:bm1", + "MOOD", + ]); + expect(parseSeriesKeys("lab:bm1,NOPE")).toBeNull(); + expect(parseSeriesKeys("lab:../etc")).toBeNull(); + }); + + it("is capped, because marker ids are not a closed set", () => { + const many = (n: number) => + Array.from({ length: n }, (_, i) => `lab:m${i}`).join(","); + expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX))).toHaveLength( + TIMELINE_LAB_SERIES_MAX, + ); + expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX + 1))).toBeNull(); + }); +}); diff --git a/src/lib/timeline/lab-series.ts b/src/lib/timeline/lab-series.ts new file mode 100644 index 000000000..d48838a13 --- /dev/null +++ b/src/lib/timeline/lab-series.ts @@ -0,0 +1,316 @@ +/** + * Lab markers as lines on the timeline (#1299). + * + * A measurement line is a mean per bucket; a lab marker is a handful of + * readings a year, and a mean of them would invent a value nobody measured. + * So a lab line is its readings, one point each, on the day of the draw. The + * day is the record's local day of `takenAt`, the same cut the labs lane and + * the day view make, so a point opens the day that lists it. + * + * Only markers linked to a `Biomarker` are offered: every write links one, + * the backfill links the rest, and the labs list itself shows an unlinked + * group as not yet linked. A marker is read through `resolveLabFields`, the + * resolver every lab surface shares, so its name, unit, panel and the window + * each reading is judged against are the labs page's own. + * + * Units. A reading stored since v1.40 is in its marker's unit by + * construction. Older ones were linked by the backfill under the unit of the + * group's newest reading, and a marker's unit can be edited after its + * readings were written, so a row's own `unit` can still differ from the + * marker's. Such a row is converted through the unit-conversion registry + * when the pair is known (its printed window with it) and otherwise left out + * and counted in `excluded`: it is never drawn on a scale it is not in. + */ +import { + classifyAgainstEffectiveRange, + resolveEffectiveReferenceRange, +} from "@/lib/labs/reference-range"; +import { resolveLabFields, type LabFieldRow } from "@/lib/labs/serialise"; +import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { + convertLabRange, + convertLabValue, +} from "@/lib/labs/unit-conversion/convert"; +import { + timelineLabKey, + type TimelineLabMarker, + type TimelineLabPoint, + type TimelineLabSeries, +} from "@/lib/day/contract"; +import { prisma } from "@/lib/db"; +import { listTargetsBySource } from "@/lib/links/link-service"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +import { laneVisible, type TimelineAccess } from "./lanes"; + +/** The marker columns a lab line needs. */ +export interface LabLineMarker { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + analyteKey: string | null; +} + +/** One numeric reading of a marker, with the report it came from. */ +export interface LabLineRow extends LabFieldRow { + id: string; + value: number; + sourceValue: number | null; + takenAt: Date; + report: { id: string; title: string | null } | null; +} + +/** A reading in the marker's unit, or null when its unit does not convert. */ +function inMarkerUnit( + row: LabLineRow, + marker: LabLineMarker, +): Omit | null { + if (sameLabUnit(row.unit, marker.unit)) { + const fields = resolveLabFields(row, marker); + return { + value: row.value, + sourceValue: row.sourceValue, + rangeStatus: classifyAgainstEffectiveRange( + row.value, + fields.effectiveRange, + ), + referenceLow: fields.referenceLow, + referenceHigh: fields.referenceHigh, + }; + } + const converted = convertLabValue({ + analyteKey: marker.analyteKey, + value: row.value, + from: row.unit, + to: marker.unit, + }); + if (!converted.ok) return null; + // The printed window is in the row's unit; it moves with the value or is + // dropped (the marker's own band then judges the reading). + const printed = convertLabRange({ + analyteKey: marker.analyteKey, + low: row.sourceReferenceLow, + high: row.sourceReferenceHigh, + from: row.unit, + to: marker.unit, + }); + const effective = resolveEffectiveReferenceRange( + marker.lowerBound, + marker.upperBound, + { + sourceReferenceLow: printed.ok ? printed.low : null, + sourceReferenceHigh: printed.ok ? printed.high : null, + sourceReferenceText: null, + sourceUnit: row.unit, + }, + ); + return { + value: converted.value, + // The figure as stored sets the precision the converted one is shown with. + sourceValue: row.value, + rangeStatus: classifyAgainstEffectiveRange(converted.value, effective), + referenceLow: effective.low, + referenceHigh: effective.high, + }; +} + +/** + * One marker's line over `[from, to]` from its numeric readings (any order): + * a point per reading inside the window, oldest first, the readings whose + * unit does not convert counted, and the band to draw behind them. + */ +export function shapeLabSeries( + marker: LabLineMarker, + rows: readonly LabLineRow[], + window: { from: string; to: string; tz: string }, +): TimelineLabSeries { + const drawn: Array<{ point: TimelineLabPoint; at: number }> = []; + let excluded = 0; + for (const row of rows) { + const date = userDayKey(row.takenAt, window.tz); + if (date < window.from || date > window.to) continue; + const reading = inMarkerUnit(row, marker); + if (!reading) { + excluded += 1; + continue; + } + drawn.push({ + point: { + id: row.id, + date, + ...reading, + report: row.report + ? { + id: row.report.id, + title: row.report.title, + href: `/documents?doc=${row.report.id}`, + } + : null, + }, + at: row.takenAt.getTime(), + }); + } + const points = drawn + .sort((a, b) => a.at - b.at || (a.point.id < b.point.id ? -1 : 1)) + .map((d) => d.point); + const newest = points.at(-1); + const band = + marker.lowerBound !== null || marker.upperBound !== null + ? { low: marker.lowerBound, high: marker.upperBound } + : newest && + (newest.referenceLow !== null || newest.referenceHigh !== null) + ? { low: newest.referenceLow, high: newest.referenceHigh } + : null; + return { + key: timelineLabKey(marker.id), + biomarkerId: marker.id, + name: marker.name, + panel: marker.panel, + unit: marker.unit, + band, + points, + excluded, + }; +} + +const MARKER_SELECT = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + analyteKey: true, +} as const; + +/** + * The lines of the markers `ids` names, in that order, over `[from, to]`. + * Every read is narrowed to the record: an id of another record's marker + * finds nothing and is left out, as is a marker deleted since it was chosen. + * The report behind a reading is read only when the caller may see + * documents. + */ +export async function loadLabSeries(args: { + recordId: string; + ids: readonly string[]; + from: string; + to: string; + tz: string; + access: TimelineAccess; +}): Promise { + const { recordId, ids, from, to, tz, access } = args; + if (ids.length === 0 || !laneVisible("labs", access)) return []; + const markers = await prisma.biomarker.findMany({ + where: { userId: recordId, id: { in: [...ids] } }, + select: MARKER_SELECT, + }); + if (markers.length === 0) return []; + const rows = await prisma.labResult.findMany({ + where: { + userId: recordId, + biomarkerId: { in: markers.map((m) => m.id) }, + deletedAt: null, + value: { not: null }, + takenAt: { + gte: startOfLocalDayKey(from, tz), + lt: startOfLocalDayKey(shiftDateKey(to, 1), tz), + }, + }, + select: { + id: true, + biomarkerId: true, + analyte: true, + panel: true, + value: true, + unit: true, + sourceValue: true, + sourceUnit: true, + referenceLow: true, + referenceHigh: true, + sourceReferenceLow: true, + sourceReferenceHigh: true, + sourceReferenceText: true, + takenAt: true, + }, + orderBy: [{ takenAt: "asc" }, { id: "asc" }], + }); + // A reading read from two documents names the first one linked. The link + // service drops a deleted document and narrows every read to the record. + const reportOf = new Map(); + if (rows.length > 0 && laneVisible("documents", access)) { + const linked = await listTargetsBySource(prisma, { + userId: recordId, + sourceKind: "labResult", + sourceIds: rows.map((r) => r.id), + targetKind: "document", + }); + for (const [labResultId, [first]] of linked) { + if (!first) continue; + // A document with neither title nor file name is labelled by its id. + reportOf.set(labResultId, { + id: first.id, + title: first.label === first.id ? null : first.label, + }); + } + } + const byMarker = new Map(); + for (const row of rows) { + if (row.biomarkerId === null || row.value === null) continue; + const list = byMarker.get(row.biomarkerId) ?? []; + list.push({ + ...row, + value: row.value, + report: reportOf.get(row.id) ?? null, + }); + byMarker.set(row.biomarkerId, list); + } + const found = new Map(markers.map((m) => [m.id, m])); + return ids.flatMap((id) => { + const marker = found.get(id); + return marker + ? [shapeLabSeries(marker, byMarker.get(id) ?? [], { from, to, tz })] + : []; + }); +} + +/** + * Every marker the record has a numeric reading for, whatever the window, + * by name. A marker with only qualitative readings ("negative") has nothing + * to draw and is not offered. + */ +export async function loadAvailableLabMarkers(args: { + recordId: string; + access: TimelineAccess; +}): Promise { + const { recordId, access } = args; + if (!laneVisible("labs", access)) return []; + const groups = await prisma.labResult.groupBy({ + by: ["biomarkerId"], + where: { + userId: recordId, + deletedAt: null, + value: { not: null }, + biomarkerId: { not: null }, + }, + }); + const ids = groups + .map((g) => g.biomarkerId) + .filter((id): id is string => id !== null); + if (ids.length === 0) return []; + const markers = await prisma.biomarker.findMany({ + where: { userId: recordId, id: { in: ids } }, + select: { id: true, name: true, panel: true, unit: true }, + orderBy: [{ name: "asc" }, { id: "asc" }], + }); + return markers.map((m) => ({ + key: timelineLabKey(m.id), + biomarkerId: m.id, + name: m.name, + panel: m.panel, + unit: m.unit, + })); +} diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index 2493257a4..4e07ee67b 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -18,7 +18,9 @@ import type { MeasurementType } from "@/generated/prisma/enums"; import { DAY_NOTABLE_MAX_SPAN_DAYS, + TIMELINE_LAB_SERIES_MAX, TIMELINE_LANE_KEYS, + timelineLabMarkerId, type TimelineBucket, type TimelineItem, type TimelineQuery, @@ -36,6 +38,10 @@ import { type StandingItem, type TimelineAccess, } from "@/lib/timeline/lanes"; +import { + loadAvailableLabMarkers, + loadLabSeries, +} from "@/lib/timeline/lab-series"; import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; @@ -107,8 +113,12 @@ const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( /** * The series keys a `values` parameter names, or null when one is not a * series this API knows (the route answers 422). There is no cap on the - * count: a name given twice counts once, so the list never grows past the - * names that exist, and each series is its own row under the lanes. + * measurement names: a name given twice counts once, so the list never + * grows past the names that exist, and each series is its own row under the + * lanes. A lab marker (`lab:`, #1299) is checked for its shape + * only: whether the record has it is the loader's question, and the answer + * to a stale one is no line rather than an error. Marker ids are not a + * closed set, so their count is capped. */ export function parseSeriesKeys(raw: string | undefined): string[] | null { if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES]; @@ -120,10 +130,15 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null { .filter((k) => k.length > 0), ), ]; + let labs = 0; for (const key of keys) { + if (timelineLabMarkerId(key) !== null) { + labs += 1; + continue; + } if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null; } - return keys; + return labs > TIMELINE_LAB_SERIES_MAX ? null : keys; } /** @@ -246,11 +261,16 @@ export async function loadTimeline(args: { standing.push(...reads[i].standing); }); + const labIds = args.seriesKeys + .map(timelineLabMarkerId) + .filter((id): id is string => id !== null); const seriesKeys = args.seriesKeys.filter((key) => - key === MOOD_SERIES_KEY - ? access.modules.mood !== false && access.domainVisible("mind") - : measurementsVisible && - measurementTypeVisible(key as MeasurementType, access.modules), + timelineLabMarkerId(key) !== null + ? false + : key === MOOD_SERIES_KEY + ? access.modules.mood !== false && access.domainVisible("mind") + : measurementsVisible && + measurementTypeVisible(key as MeasurementType, access.modules), ); const priorityJson = measurementsVisible ? await loadUserSourcePriority(recordId) @@ -260,30 +280,33 @@ export async function loadTimeline(args: { ? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) : from; const bucket = timelineBucket(query.zoom, from, to); - const [series, notable, availableSeries] = await Promise.all([ - loadTimelineSeries({ - userId: recordId, - keys: seriesKeys, - from, - to, - tz, - bucket, - priorityJson, - now, - }), - measurementsVisible - ? loadNotableRange({ - userId: recordId, - from: notableFrom, - to, - tz, - priorityJson, - typeVisible: (t) => measurementTypeVisible(t, access.modules), - gaps: false, - }) - : Promise.resolve([]), - loadAvailableSeries({ recordId, access }), - ]); + const [series, notable, availableSeries, labSeries, availableLabs] = + await Promise.all([ + loadTimelineSeries({ + userId: recordId, + keys: seriesKeys, + from, + to, + tz, + bucket, + priorityJson, + now, + }), + measurementsVisible + ? loadNotableRange({ + userId: recordId, + from: notableFrom, + to, + tz, + priorityJson, + typeVisible: (t) => measurementTypeVisible(t, access.modules), + gaps: false, + }) + : Promise.resolve([]), + loadAvailableSeries({ recordId, access }), + loadLabSeries({ recordId, ids: labIds, from, to, tz, access }), + loadAvailableLabMarkers({ recordId, access }), + ]); return { zoom: query.zoom, @@ -293,6 +316,8 @@ export async function loadTimeline(args: { bucket, series, availableSeries, + labSeries, + availableLabs, notable: notable.map(({ date, kind }) => ({ date, kind })), }; } diff --git a/tests/integration/timeline-lab-series.test.ts b/tests/integration/timeline-lab-series.test.ts new file mode 100644 index 000000000..9b6f54278 --- /dev/null +++ b/tests/integration/timeline-lab-series.test.ts @@ -0,0 +1,372 @@ +/** + * Lab markers as lines on the timeline (#1299), driven through + * `GET /api/timeline` against real Postgres. + * + * What the unit tests cannot show: that the record's own markers are offered + * and nobody else's, that a marker id from another record finds nothing, + * that a reading is dated by the record's local day and linked to its + * report, that a switched-off labs or documents module reads neither, that a + * grant scoped to some sections is refused, and that the route admits a cookie session and + * refuses a narrow Bearer token (the fail-closed default: the timeline names + * no scope). All data invented. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { hashToken } from "@/lib/auth/hmac"; +import type { TimelineResponse } from "@/lib/day/contract"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +let counter = 0; + +const DAY_MS = 86_400_000; + +/** + * The calendar day `n` days before today, so every reading stays inside + * the windows below whenever the suite runs. + */ +function daysAgo(n: number): string { + return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10); +} + +/** The window every reading lies in. */ +const WINDOW = () => `zoom=range&from=${daysAgo(1000)}&to=${daysAgo(1)}`; + +/** The reading days the record's local zone puts them on. */ +const DAYS = { + low: () => daysAgo(900), + lateNight: () => shiftDateKey(daysAgo(500), 1), + recent: () => daysAgo(200), +}; + +const MODULES = { timeline: true, labs: true, inboundDocuments: true }; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `tl-lab-${suffix}`, + email: `tl-lab-${suffix}@example.test`, + displayName: `TL ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + modulePreferencesJson: MODULES, + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function mintToken(userId: string, permissions: string[]) { + const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId, + name: "timeline-lab-test", + tokenHash: hashToken(raw), + permissions, + }, + }); + return raw; +} + +async function shareWith( + ownerId: string, + delegateId: string, + scope: Array<"labs" | "measurements" | "documents"> | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegateId }); + const session = await signIn(delegateId); + await switchSessionTo(session.id, ownerId); +} + +async function getTimeline(query: string): Promise { + const { GET } = await import("@/app/api/timeline/route"); + return (GET as Handler)( + new NextRequest(`http://localhost/api/timeline?${query}`, { + method: "GET", + }), + { params: Promise.resolve({}) }, + ); +} + +async function data(response: Response): Promise { + expect(response.status).toBe(200); + return ((await response.json()) as { data: TimelineResponse }).data; +} + +/** A record with ferritin over three years, one reading linked to its report. */ +async function seedFerritin(userId: string) { + const db = getPrismaClient(); + const marker = await db.biomarker.create({ + data: { + userId, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + panel: "Iron", + analyteKey: "ferritin", + }, + }); + const at = (iso: string, value: number, unit = "ng/mL") => + db.labResult.create({ + data: { + userId, + biomarkerId: marker.id, + analyte: "Ferritin", + value, + unit, + takenAt: new Date(iso), + }, + }); + const first = await at(`${daysAgo(900)}T12:00:00.000Z`, 18); + // 23:30 UTC is already the next day in Berlin, summer or winter. + await at(`${daysAgo(500)}T23:30:00.000Z`, 85); + await at(`${daysAgo(200)}T12:00:00.000Z`, 120); + // A reading stored in a unit that does not convert into ng/mL. + await at(`${daysAgo(199)}T12:00:00.000Z`, 260, "pmol/L"); + // A qualitative reading has nothing to draw. + await db.labResult.create({ + data: { + userId, + biomarkerId: marker.id, + analyte: "Ferritin", + valueText: "haemolysed", + unit: "", + takenAt: new Date(`${daysAgo(198)}T12:00:00.000Z`), + }, + }); + const document = await db.inboundDocument.create({ + data: { + userId, + kind: "LAB_RESULT", + title: "Lab report February", + filename: "labs.pdf", + mimeType: "application/pdf", + byteSize: 1, + contentEncrypted: new Uint8Array([1]), + contentCodec: "binary2", + }, + }); + await db.labResultDocumentLink.create({ + data: { userId, labResultId: first.id, documentId: document.id }, + }); + // A marker with only a qualitative reading is not offered. + const urine = await db.biomarker.create({ + data: { userId, name: "Urine protein", unit: "" }, + }); + await db.labResult.create({ + data: { + userId, + biomarkerId: urine.id, + analyte: "Urine protein", + valueText: "negative", + unit: "", + takenAt: new Date(`${daysAgo(200)}T12:00:00.000Z`), + }, + }); + return { marker, document }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("lab lines on the timeline", () => { + it("offers the record's markers and draws a chosen one as its readings", async () => { + const owner = await makeUser("owner"); + const { marker, document } = await seedFerritin(owner.id); + await signIn(owner.id); + + const timeline = await data( + await getTimeline(`${WINDOW()}&values=WEIGHT,lab:${marker.id}`), + ); + expect(timeline.availableLabs).toEqual([ + { + key: `lab:${marker.id}`, + biomarkerId: marker.id, + name: "Ferritin", + panel: "Iron", + unit: "ng/mL", + }, + ]); + expect(timeline.labSeries).toHaveLength(1); + const [line] = timeline.labSeries; + expect(line).toMatchObject({ + key: `lab:${marker.id}`, + name: "Ferritin", + unit: "ng/mL", + band: { low: 30, high: 400 }, + excluded: 1, + }); + expect( + line.points.map((p) => [p.date, p.value, p.rangeStatus, p.report]), + ).toEqual([ + [ + DAYS.low(), + 18, + "below", + { + id: document.id, + title: "Lab report February", + href: `/documents?doc=${document.id}`, + }, + ], + [DAYS.lateNight(), 85, "in-range", null], + [DAYS.recent(), 120, "in-range", null], + ]); + // The measurement lines are not touched by a lab entry beside them. + expect(timeline.series).toEqual([]); + + // The window decides which readings are sent; the marker stays a line. + const quarter = await data( + await getTimeline( + `zoom=range&from=${daysAgo(530)}&to=${daysAgo(470)}&values=lab:${marker.id}`, + ), + ); + expect(quarter.labSeries[0].points.map((p) => p.date)).toEqual([ + DAYS.lateNight(), + ]); + const empty = await data( + await getTimeline( + `zoom=range&from=${daysAgo(1300)}&to=${daysAgo(1200)}&values=lab:${marker.id}`, + ), + ); + expect(empty.labSeries[0]).toMatchObject({ points: [], excluded: 0 }); + }); + + it("finds nothing under another record's marker id, and does not refuse", async () => { + const owner = await makeUser("owner"); + const other = await makeUser("other"); + const { marker: theirs } = await seedFerritin(other.id); + await signIn(owner.id); + + const timeline = await data( + await getTimeline(`zoom=all&values=lab:${theirs.id},lab:gone123`), + ); + expect(timeline.labSeries).toEqual([]); + expect(timeline.availableLabs).toEqual([]); + + // A list of ids is capped, and a malformed one is refused. + const many = Array.from({ length: 25 }, (_, i) => `lab:m${i}`).join(","); + expect((await getTimeline(`values=${many}`)).status).toBe(422); + expect((await getTimeline(`values=lab:..%2Fx`)).status).toBe(422); + }); + + it("refuses a grant scoped to some sections, and shares the lines on a whole-record grant", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + + const noLabs = await makeUser("no-labs"); + await shareWith(owner.id, noLabs.id, ["measurements"]); + // The timeline is admitted on a whole-record grant only. + expect((await getTimeline(`zoom=all&values=lab:${marker.id}`)).status).toBe( + 403, + ); + + const whole = await makeUser("whole"); + await shareWith(owner.id, whole.id, null); + const shared = await data( + await getTimeline(`${WINDOW()}&values=lab:${marker.id}`), + ); + expect(shared.labSeries[0].points).toHaveLength(3); + expect(shared.availableLabs.map((m) => m.name)).toEqual(["Ferritin"]); + }); + + it("names no report with documents off, and draws no lab line with labs off", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { modulePreferencesJson: { ...MODULES, inboundDocuments: false } }, + }); + await signIn(owner.id); + const noDocs = await data( + await getTimeline(`${WINDOW()}&values=lab:${marker.id}`), + ); + expect(noDocs.labSeries[0].points.map((p) => p.report)).toEqual([ + null, + null, + null, + ]); + + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { modulePreferencesJson: { ...MODULES, labs: false } }, + }); + const timeline = await data( + await getTimeline(`zoom=all&values=lab:${marker.id}`), + ); + expect(timeline.labSeries).toEqual([]); + expect(timeline.availableLabs).toEqual([]); + }); + + it("admits a cookie session and a full token, refuses a narrow token and nobody", async () => { + const owner = await makeUser("owner"); + const { marker } = await seedFerritin(owner.id); + const query = `zoom=all&values=lab:${marker.id}`; + + expect((await getTimeline(query)).status).toBe(401); + + const narrow = await mintToken(owner.id, ["fhir:read"]); + headerJar.set("authorization", `Bearer ${narrow}`); + expect((await getTimeline(query)).status).toBe(403); + + const full = await mintToken(owner.id, ["*"]); + headerJar.set("authorization", `Bearer ${full}`); + const timeline = await data(await getTimeline(query)); + expect(timeline.labSeries[0].points.length).toBeGreaterThan(0); + }); +});