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:<biomarkerId>`, 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
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:31:27 +01:00
1 parent b1fe1f0597
commit 42b2c72e28
13 files changed
+1336 -36

No files matched your search

+1
View File
@@ -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,
},
});
@@ -34,6 +34,8 @@ function timeline(): TimelineResponse {
standing: [],
series: [],
availableSeries: [],
labSeries: [],
availableLabs: [],
notable: [],
} as unknown as TimelineResponse;
}
@@ -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.
@@ -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" }],
};
}
@@ -150,6 +150,8 @@ function twoMedications(): TimelineResponse {
standing: [],
series: [],
availableSeries: [],
labSeries: [],
availableLabs: [],
notable: [],
};
}
+28
View File
@@ -425,6 +425,34 @@ export type TimelineResponse = z.infer<typeof timelineResponseSchema>;
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:<biomarkerId>` (#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<
+95 -1
View File
@@ -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:<biomarkerId>` (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:<biomarkerId>`, 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:<biomarkerId>`, 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) }),
),
@@ -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<LabLineRow> & Pick<LabLineRow, "id">): 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:<biomarkerId>`, 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();
});
});
+316
View File
@@ -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<TimelineLabPoint, "id" | "date" | "report"> | 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<TimelineLabSeries[]> {
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<string, { id: string; title: string | null }>();
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<string, LabLineRow[]>();
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<TimelineLabMarker[]> {
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,
}));
}
+56 -31
View File
@@ -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<string> = 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:<biomarkerId>`, #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 })),
};
}