From 42b2c72e28db248c83edf13e8855cb15ef4e643e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 19:54:02 +0100 Subject: [PATCH] feat(timeline): serve lab markers as value lines The timeline showed a lab report as an event on the axis, but the lines under the lanes could only be measurements. A lab marker can now be named in `values` as `lab:`, beside the measurement types and `MOOD`, so one choice, one request and one stored list carry every line. `GET /api/timeline` answers with `labSeries`, one entry per named marker with its readings in the window as single points (lab values come a few a year, so they are never averaged into buckets), each dated by the record's local day, judged against its own window and linked to the report it was read from when the caller may see documents. `availableLabs` lists every marker the record has a numeric reading for, so a client offers only those. A reading stored in another unit than its marker's is converted through the unit-conversion registry where the pair is known, and otherwise left out and counted in `excluded`, never drawn on a scale it is not in. A marker id that is not in the record yields no line instead of a 422, so a stored choice survives a deleted marker; ids are capped at 24 a request. Refs #1299 --- docs/api/openapi.yaml | 177 ++++++++- e2e/utils/mock-timeline.ts | 4 + src/app/api/timeline/route.ts | 1 + .../timeline/__tests__/lab-day-chip.test.tsx | 2 + .../__tests__/no-raw-code-guard.test.tsx | 2 + .../timeline/__tests__/timeline-fixture.ts | 2 + .../__tests__/timeline-rework.test.tsx | 2 + src/lib/day/contract.ts | 28 ++ src/lib/day/wire-schemas.ts | 96 ++++- src/lib/timeline/__tests__/lab-series.test.ts | 283 +++++++++++++ src/lib/timeline/lab-series.ts | 316 +++++++++++++++ src/lib/timeline/load-timeline.ts | 87 ++-- tests/integration/timeline-lab-series.test.ts | 372 ++++++++++++++++++ 13 files changed, 1336 insertions(+), 36 deletions(-) create mode 100644 src/lib/timeline/__tests__/lab-series.test.ts create mode 100644 src/lib/timeline/lab-series.ts create mode 100644 tests/integration/timeline-lab-series.test.ts 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); + }); +});