Files
HealthLog/src/components/timeline/lab-lines.tsx
T
Marc-André Bombeck b8a983bdec feat(timeline): draw lab markers as lines under the values
With the server naming lab markers as value lines, the page offers them:
the values menu lists the markers the record has numeric readings for,
under the groups the labs list sorts them into, and a choice is stored
and requested like any measurement line. The phone toolbar gains the same
menu as an icon button, since the chronicle shows the chosen lines too.

A lab row sits under the value lines in their rhythm, on its own scale.
Each reading is its own point on the day of the draw, joined in time
order by a thin stroke and never averaged. The reference range is painted
behind the points as the labs page paints it, a faint band with dashed
edges, and the scale holds it so the band is always in view. A reading
outside its range is a triangle pointing the way it left and is worded as
outside wherever it is written out, so colour is never the only sign.

A point reads on its own by pointer, keyboard and tap: the day, the value
with every digit it was stored with, the verdict, the range and the
report it was read from (a link once pinned). The selection bar lists
the bucket's readings with their report, a lab day's row in the phone
chronicle lists that day's readings, a screen-reader table lists them
all, and a marker with readings in a unit that does not convert says how
many were left out. Nothing else on the timeline changes.

To make room without growing the large timeline files, the legend, the
screen-reader tables and the value-line geometry move into modules of
their own, and the chart, the chronicle, the point cards and the page
each hand a part to a helper; the size baselines are written back lower.

Refs #1299
2026-10-29 20:31:36 +01:00

541 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Lab markers on the timeline (#1299): their rows under the value lines,
* their readings in the selection bar and the phone chronicle, and their
* table for a screen reader.
*
* A lab line is its readings, one point each on the day of the draw, never a
* mean. Each row has its own scale, which holds the reference range as well
* as the points, and the range is painted behind them as the labs page
* paints it: a faint band with dashed edges, in the row's own colour. A
* reading outside its range is a triangle pointing the way it left (up above
* the range, down below it) and says so in words wherever it is written out,
* so the colour is never the only sign. The numbers read through the labs
* page's own formatter, so a reading keeps every digit it was stored with.
*/
import { useCallback } from "react";
import Link from "next/link";
import { useLabNumber } from "@/components/labs/use-lab-format";
import { InlineAction } from "@/components/ui/inline-action";
import type {
TimelineBucket,
TimelineLabPoint,
TimelineLabSeries,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import type { ChronicleEntry } from "./chronicle-model";
import { TIMELINE_LAB_RANGE_KEY } from "./label-keys";
import {
bucketAfter,
dayKey,
dayNumber,
formatDayMonth,
formatDayMonthYear,
} from "./timeline-dates";
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
export type LabValueText = (
value: number,
sourceValue: number | null,
unit: string,
) => string;
export function useLabValueText(): LabValueText {
const labNumber = useLabNumber();
return useCallback(
(value, sourceValue, unit) =>
`${labNumber(value, sourceValue)} ${unit}`.trim(),
[labNumber],
);
}
/** "below range" / "above range", or null for a reading in range. */
export function useLabVerdict(): (
status: TimelineLabPoint["rangeStatus"],
) => string | null {
const { t } = useTranslations();
return useCallback(
(status) =>
status === "below" || status === "above"
? t(TIMELINE_LAB_RANGE_KEY[status])
: null,
[t],
);
}
/** "Reference 30–400 ng/mL", or null without a range. */
export function useLabReference(): (
point: Pick<TimelineLabPoint, "referenceLow" | "referenceHigh">,
unit: string,
) => string | null {
const { t } = useTranslations();
const labNumber = useLabNumber();
return useCallback(
(point, unit) => {
const range = formatReferenceRange(
point.referenceLow,
point.referenceHigh,
(v) => labNumber(v),
);
return range
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
: null;
},
[t, labNumber],
);
}
/** The readings of every lab line whose day lies in `[from, to]`. */
export function labReadingsIn(
labSeries: readonly TimelineLabSeries[],
from: string,
to: string,
): Array<{ key: string; unit: string; point: TimelineLabPoint }> {
return labSeries.flatMap((s) =>
s.points
.filter((p) => p.date >= from && p.date <= to)
.map((point) => ({ key: s.key, unit: s.unit, point })),
);
}
/* ─── The rows on the chart ───────────────────────────────────────────────── */
/** Where a row's name and latest value start, as on the value lines. */
const TEXT_X = 16;
/**
* A reading's mark: a dot in range (or without one), a triangle pointing up
* above it and down below it. Every mark takes the row's `currentColor`.
*/
export function LabPointGlyph({
x,
y,
status,
}: {
x: number;
y: number;
status: TimelineLabPoint["rangeStatus"];
}) {
if (status === "above" || status === "below") {
const d =
status === "above"
? `M${x} ${y - 4.5}L${x + 4.5} ${y + 3.5}L${x - 4.5} ${y + 3.5}Z`
: `M${x} ${y + 4.5}L${x + 4.5} ${y - 3.5}L${x - 4.5} ${y - 3.5}Z`;
return (
<path
d={d}
data-slot="timeline-lab-point"
data-status={status}
fill="currentColor"
stroke="var(--card)"
strokeWidth={1.2}
strokeLinejoin="round"
/>
);
}
return (
<circle
data-slot="timeline-lab-point"
data-status={status}
cx={x}
cy={y}
r={3.25}
fill="currentColor"
stroke="var(--card)"
strokeWidth={1.2}
/>
);
}
export function LabLines({
layout,
selectedBucket,
bucket,
seriesLabel,
seriesColor,
valueText,
}: {
layout: Pick<
TimelineLayout,
"labSeries" | "series" | "seriesTop" | "scale" | "width"
>;
/** The first day of the selected bucket: its readings are ringed. */
selectedBucket: string | null;
bucket: TimelineBucket;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
valueText: LabValueText;
}) {
const { t } = useTranslations();
const { scale, width } = layout;
const rows = layout.labSeries;
const selectedTo = selectedBucket
? dayKey(dayNumber(bucketAfter(selectedBucket, bucket)) - 1)
: null;
const nameLines = (key: string) =>
seriesNameLines(seriesLabel(key), scale.x0 - 12 - TEXT_X, 13);
return (
<>
{/* The hairline above the rows, when no value line drew it already. */}
{layout.series.length === 0 && rows.length > 0 && (
<line
x1={0}
x2={width}
y1={layout.seriesTop - 18}
y2={layout.seriesTop - 18}
stroke="var(--border)"
/>
)}
{rows.map((row) => {
const color = seriesColor(row.key);
const lines = nameLines(row.key);
return (
<g
key={row.key}
data-lab-series={row.key}
data-color={color}
color={color}
>
{row.band && (
<g data-slot="timeline-lab-band">
{row.band.top !== null && row.band.bottom !== null && (
<rect
x={scale.x0}
y={row.band.top}
width={Math.max(0, scale.x1 - scale.x0)}
height={Math.max(0, row.band.bottom - row.band.top)}
fill="currentColor"
fillOpacity={0.1}
data-slot="timeline-lab-band-fill"
/>
)}
{[row.band.top, row.band.bottom].map((edge, i) =>
edge === null ? null : (
<line
key={i}
data-slot="timeline-lab-band-edge"
x1={scale.x0}
x2={scale.x1}
y1={edge}
y2={edge}
stroke="currentColor"
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
),
)}
</g>
)}
<line
data-slot="timeline-series-swatch"
x1={1.5}
x2={10.5}
y1={row.top + 8}
y2={row.top + 8}
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
<text
data-slot="timeline-series-name"
x={TEXT_X}
y={row.top + 12}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{lines.map((line, i) => (
<tspan key={i} x={TEXT_X} dy={i === 0 ? 0 : 15}>
{line}
</tspan>
))}
</text>
<text
x={TEXT_X}
y={row.top + 30 + (lines.length - 1) * 15}
fontSize={12}
fill="var(--muted-foreground)"
>
{row.latest === null
? t("timeline.values.noneInWindow")
: valueText(row.latest.value, row.latest.sourceValue, row.unit)}
</text>
{row.path && (
<path
d={row.path}
data-slot="timeline-lab-line"
fill="none"
stroke="currentColor"
strokeWidth={1.2}
strokeOpacity={0.6}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{row.points.map((point) => (
<g
key={point.id}
data-slot="timeline-lab-mark"
data-id={point.id}
data-date={point.date}
>
<LabPointGlyph
x={point.x}
y={point.y}
status={point.rangeStatus}
/>
</g>
))}
{selectedBucket !== null &&
selectedTo !== null &&
row.points
.filter(
(point) =>
point.date >= selectedBucket && point.date <= selectedTo,
)
.map((point) => (
<circle
key={`selected-${point.id}`}
data-slot="timeline-series-selected"
cx={point.x}
cy={point.y}
r={6}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
/>
))}
</g>
);
})}
</>
);
}
/* ─── Written out ─────────────────────────────────────────────────────────── */
/**
* Lab readings as a line of their own under the means: "● Ferritin 18 ng/mL,
* below range, 12 Feb." with the report it came from as a link. Each reading
* is one unit that wraps only between units, like a means line.
*/
export function LabReadingsLine({
readings,
label,
seriesColor,
valueText,
showDate,
links = true,
intl,
}: {
readings: ReturnType<typeof labReadingsIn>;
label: (key: string) => string;
seriesColor: (key: string) => string;
valueText: LabValueText;
/** The day beside each reading: where a line covers more than one day. */
showDate: boolean;
/**
* The report as a link. Off inside a row that is itself a button (a link
* cannot sit inside one); the day the row opens names the report.
*/
links?: boolean;
intl: string;
}) {
const { t } = useTranslations();
const verdict = useLabVerdict();
return readings.map(({ key, unit, point }, i) => {
const words = [
`${label(key)} ${valueText(point.value, point.sourceValue, unit)}`,
verdict(point.rangeStatus),
showDate ? formatDayMonth(point.date, intl) : null,
]
.filter(Boolean)
.join(", ");
return (
<span
key={`${key}:${point.id}`}
data-slot="timeline-lab-reading"
data-series={key}
data-status={point.rangeStatus}
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
>
{i > 0 ? <span className="sr-only">, </span> : null}
<span
data-slot="timeline-series-dot"
className="size-2 shrink-0 rounded-full"
style={{ background: seriesColor(key) }}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{words}</span>
{links && point.report ? (
<InlineAction asChild tone="muted" size="xs">
<Link
href={point.report.href}
data-slot="timeline-lab-report"
onClick={(event) => event.stopPropagation()}
>
{point.report.title
? t("timeline.labs.report", { title: point.report.title })
: t("timeline.labs.reportUntitled")}
</Link>
</InlineAction>
) : null}
</span>
);
});
}
/**
* A lab day's row in the phone chronicle lists the chosen lab lines'
* readings of that day under its names. The row is a button that opens the
* day, so the report is named there, not linked here.
*/
export function ChronicleLabReadings({
entry,
labSeries,
seriesColor,
className,
}: {
entry: ChronicleEntry;
labSeries: readonly TimelineLabSeries[];
seriesColor: (key: string) => string;
/** The row's means-line classes, so both read alike. */
className: string;
}) {
const { locale } = useTranslations();
const valueText = useLabValueText();
if (
entry.kind !== "item" ||
entry.item.kind !== "labDay" ||
entry.role === "end"
) {
return null;
}
const readings = labReadingsIn(labSeries, entry.date, entry.date);
if (readings.length === 0) return null;
return (
<span className={className} data-slot="timeline-chronicle-labs">
<LabReadingsLine
readings={readings}
label={(key) => labSeries.find((s) => s.key === key)?.name ?? key}
seriesColor={seriesColor}
valueText={valueText}
showDate={false}
links={false}
intl={resolveIntlLocale(locale)}
/>
</span>
);
}
/**
* A lab line with readings left out because their unit does not convert into
* the marker's says so, once, under the chart (and over the phone list):
* nothing is dropped without a word.
*/
export function LabExcludedNote({
labSeries,
label,
block = false,
}: {
labSeries: readonly TimelineLabSeries[];
label: (key: string) => string;
/** A line of its own (the phone list), not runs inside the legend. */
block?: boolean;
}) {
const { tCount } = useTranslations();
const lines = labSeries.filter((s) => s.excluded > 0);
if (lines.length === 0) return null;
const notes = lines.map((s) => (
<span
key={s.key}
data-slot="timeline-lab-excluded"
data-series={s.key}
className="inline-flex items-center gap-1.5"
>
{tCount("timeline.labs.excluded", s.excluded, { name: label(s.key) })}
</span>
));
return block ? (
<div
className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs"
data-slot="timeline-mobile-lab-note"
>
{notes}
</div>
) : (
notes
);
}
/**
* The lab lines as a table, for a screen reader: one row per reading, its
* marker, day, value, verdict, range and report. Visually hidden by a
* wrapper, as the other chart tables are.
*/
export function LabSeriesTable({
labSeries,
window,
label,
valueText,
intl,
}: {
labSeries: readonly TimelineLabSeries[];
window: { from: string; to: string };
label: (key: string) => string;
valueText: LabValueText;
intl: string;
}) {
const { t } = useTranslations();
const verdict = useLabVerdict();
const reference = useLabReference();
const rows = labReadingsIn(labSeries, window.from, window.to).sort((a, b) =>
a.point.date < b.point.date ? 1 : -1,
);
if (rows.length === 0) return null;
return (
<div className="sr-only" data-slot="timeline-lab-table-frame">
<table data-slot="timeline-lab-table">
<caption>{t("timeline.table.labsCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.marker")}</th>
<th scope="col">{t("timeline.table.date")}</th>
<th scope="col">{t("timeline.table.value")}</th>
</tr>
</thead>
<tbody>
{rows.map(({ key, unit, point }) => (
<tr key={point.id}>
<th scope="row">{label(key)}</th>
<td>{formatDayMonthYear(point.date, intl)}</td>
<td>
{[
valueText(point.value, point.sourceValue, unit),
verdict(point.rangeStatus),
reference(point, unit),
point.report
? point.report.title
? t("timeline.labs.report", {
title: point.report.title,
})
: t("timeline.labs.reportUntitled")
: null,
]
.filter(Boolean)
.join(", ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}