{chips.length > 0 && (
@@ -314,7 +326,7 @@ export function TimelineChronicle({
);
}
const { entry } = row;
- const { title, meta } = entryText(entry);
+ const { title, meta } = entryText(entry, { t, tCount, words });
const Icon =
entry.kind === "notable"
? Sparkles
@@ -367,6 +379,12 @@ export function TimelineChronicle({
{meta}
)}
+
>
);
diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts
index 2bb2b49f4..f6213f0ed 100644
--- a/src/components/timeline/timeline-geometry.ts
+++ b/src/components/timeline/timeline-geometry.ts
@@ -20,6 +20,7 @@ import type {
TimelineBucket,
TimelineItem,
TimelineItemKind,
+ TimelineLabSeries,
TimelineLane,
TimelineLaneKey,
TimelineSeries,
@@ -27,6 +28,13 @@ import type {
} from "@/lib/day/contract";
import { itemLine, type ItemWordsFn } from "./item-words";
+import {
+ SERIES_HEIGHT,
+ layoutLabSeries,
+ layoutSeries,
+ type LabSeriesLayout,
+ type SeriesLayout,
+} from "./series-geometry";
import {
doseSegments,
medicationGroups,
@@ -36,8 +44,6 @@ import {
} from "./medication-rows";
import {
addMonths,
- bucketAfter,
- bucketIndex,
dayKey,
dayNumber,
endOfMonth,
@@ -57,8 +63,6 @@ export const ROW_HEIGHT = 22;
export const LANE_PAD_BOTTOM = 18;
/** Gap between the last lane and the first value line. */
export const SERIES_GAP = 18;
-/** Height of one value line, label included. */
-export const SERIES_HEIGHT = 60;
/** Label font size in the lanes, px. */
export const LABEL_FONT_PX = 11;
/** Estimated advance of one label character at 11 px, on the wide side. */
@@ -1051,143 +1055,6 @@ function placeLabels(
return labels;
}
-/* ─── Value lines ─────────────────────────────────────────────────────────── */
-
-export interface SeriesPointLayout {
- /** The bucket's first day. */
- t: string;
- mean: number;
- /** Readings behind the mean. */
- count: number;
- x: number;
- y: number;
- /** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
- thin: boolean;
-}
-
-export interface SeriesLayout {
- key: string;
- unit: string | null;
- top: number;
- height: number;
- /** SVG path data of the solid runs; empty when no two points touch. */
- path: string;
- /** SVG path data of the dashed bridges over short gaps; may be empty. */
- bridges: string;
- /** The most recent mean inside the window, or null. */
- latest: number | null;
- points: SeriesPointLayout[];
-}
-
-/** A bucket mean from fewer readings than this is drawn hollow. */
-export const THIN_BUCKET_COUNT = 3;
-
-/**
- * The most missing buckets a dashed bridge spans. Beyond it the line stops
- * and starts again, so a long silence reads as one and not as a trend.
- *
- * The rule is one length, about six months between the two points a bridge
- * joins, never more, and never more than a third of what the zoom shows:
- * one missing quarter (the points are six months apart), two missing months
- * in a year (three months apart, a sixth of the window) and four missing
- * weeks in three months (five weeks apart, the window's third). A longer
- * bridge would draw a slope through a stretch where nothing was measured.
- */
-export const MAX_BRIDGED_GAP: Readonly
> = {
- quarter: 1,
- month: 2,
- week: 4,
- // Days come only with a chosen range under six weeks. Three missing days
- // (points four days apart) bridge a weekend away or a forgotten cuff;
- // a longer silence in a window that short is a break worth seeing, and
- // the bridge stays well under a third of even a two-week range.
- day: 3,
-};
-
-/**
- * One neutral line per series, scaled to its own range inside the window.
- * A point sits in the middle of its bucket, and every bucket with a reading
- * gets its point, so one month with no neighbours stays visible. Missing
- * buckets stay missing: a short gap is bridged by a dashed stroke
- * ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
- * between them, a longer one breaks the line.
- */
-export function layoutSeries(
- series: readonly TimelineSeries[],
- top: number,
- window: TimeWindow,
- scale: Scale,
- bucket: TimelineBucket,
-): SeriesLayout[] {
- return series.map((s, index) => {
- const rowTop = top + index * SERIES_HEIGHT + 6;
- const height = SERIES_HEIGHT - 16;
- // A bucket that starts before the window still belongs to it while any
- // of its days lie inside.
- const inside = s.points
- .filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
- .sort((a, b) => (a.t < b.t ? -1 : 1));
- if (inside.length === 0) {
- return {
- key: s.key,
- unit: s.unit,
- top: rowTop,
- height,
- path: "",
- bridges: "",
- latest: null,
- points: [],
- };
- }
- let lo = Math.min(...inside.map((p) => p.mean));
- let hi = Math.max(...inside.map((p) => p.mean));
- const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
- lo -= pad;
- hi += pad;
- const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
- const points = inside.map((p) => {
- const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
- const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
- return {
- t: p.t,
- mean: p.mean,
- count: p.count,
- x,
- y: y(p.mean),
- thin: p.count < THIN_BUCKET_COUNT,
- };
- });
- // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
- const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
- let path = "";
- let bridges = "";
- for (let i = 1; i < points.length; i++) {
- const prev = points[i - 1];
- const cur = points[i];
- const missing =
- bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
- if (missing === 0) {
- // Continue the run, or open a new one at the previous point.
- path += path.endsWith(xy(prev))
- ? `L${xy(cur)}`
- : `M${xy(prev)}L${xy(cur)}`;
- } else if (missing <= MAX_BRIDGED_GAP[bucket]) {
- bridges += `M${xy(prev)}L${xy(cur)}`;
- }
- }
- return {
- key: s.key,
- unit: s.unit,
- top: rowTop,
- height,
- path,
- bridges,
- latest: inside[inside.length - 1].mean,
- points,
- };
- });
-}
-
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
export interface TimelineLayout {
@@ -1197,6 +1064,8 @@ export interface TimelineLayout {
lanes: LaneLayout[];
seriesTop: number;
series: SeriesLayout[];
+ /** The lab lines, below the value lines (#1299). */
+ labSeries: LabSeriesLayout[];
}
/** Width of the lane-name column at a given chart width. */
@@ -1209,6 +1078,8 @@ export function layoutTimeline(input: {
window: TimeWindow;
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
+ /** Lab lines, drawn under the value lines; none by default. */
+ labSeries?: readonly TimelineLabSeries[];
bucket: TimelineBucket;
words: ItemWordsFn;
startMissing: string;
@@ -1231,7 +1102,9 @@ export function layoutTimeline(input: {
lanes.push(layout);
top += layout.height;
}
- const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
+ const labInput = input.labSeries ?? [];
+ const seriesTop =
+ top + (input.series.length + labInput.length > 0 ? SERIES_GAP : 0);
const series = layoutSeries(
input.series,
seriesTop,
@@ -1239,8 +1112,23 @@ export function layoutTimeline(input: {
scale,
input.bucket,
);
- const height = seriesTop + series.length * SERIES_HEIGHT + 4;
- return { width: input.width, height, scale, lanes, seriesTop, series };
+ const labSeries = layoutLabSeries(
+ labInput,
+ seriesTop + series.length * SERIES_HEIGHT,
+ input.window,
+ scale,
+ );
+ const height =
+ seriesTop + (series.length + labSeries.length) * SERIES_HEIGHT + 4;
+ return {
+ width: input.width,
+ height,
+ scale,
+ lanes,
+ seriesTop,
+ series,
+ labSeries,
+ };
}
/* ─── Interaction ─────────────────────────────────────────────────────────── */
@@ -1364,6 +1252,7 @@ export function latestDataDate(
timeline: {
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
+ labSeries?: readonly TimelineLabSeries[];
},
today: string,
window: TimeWindow,
@@ -1391,5 +1280,9 @@ export function latestDataDate(
for (const series of timeline.series) {
for (const point of series.points) consider(point.t);
}
+ // A lab reading counts on its own day.
+ for (const series of timeline.labSeries ?? []) {
+ for (const point of series.points) consider(point.date);
+ }
return best;
}
diff --git a/src/components/timeline/timeline-legend.tsx b/src/components/timeline/timeline-legend.tsx
new file mode 100644
index 000000000..ef1b57ad8
--- /dev/null
+++ b/src/components/timeline/timeline-legend.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+/**
+ * The timeline's legend (v1.42, #613; lab marks #1299).
+ */
+import type { TimelineBucket, TimelineLabSeries } from "@/lib/day/contract";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { TIMELINE_LEGEND_MEAN_KEY } from "./label-keys";
+import { LabExcludedNote } from "./lab-lines";
+
+/**
+ * What the marks mean. Under the selection bar and set off from it by a
+ * hairline. With value lines, the two marks a line can carry besides its
+ * points are explained by an example of each: the dashed stroke over a short
+ * gap without measurements, and the hollow point of a mean resting on fewer
+ * than three.
+ */
+export function TimelineLegend({
+ bucket,
+ hasSeries,
+ labSeries = [],
+ label = (key) => key,
+}: {
+ bucket: TimelineBucket;
+ hasSeries: boolean;
+ /**
+ * The lab lines drawn: their three marks are explained, and what a line
+ * left out is said here, where the marks are (#1299).
+ */
+ labSeries?: readonly TimelineLabSeries[];
+ label?: (key: string) => string;
+}) {
+ const { t } = useTranslations();
+ const hasLabs = labSeries.length > 0;
+ return (
+
+
+
+ {t("timeline.legendSpan")}
+
+
+
+ {t("timeline.legendEvent")}
+
+ {hasSeries && (
+ <>
+
+
+ {t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
+
+
+
+ {t("timeline.legendGap")}
+
+
+
+ {t("timeline.legendThin")}
+
+ >
+ )}
+ {hasLabs && (
+ <>
+
+
+ {t("timeline.legendLabValue")}
+
+
+
+ {t("timeline.legendLabOutside")}
+
+
+
+ {t("timeline.legendLabRange")}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx
index e33d583de..a20abefac 100644
--- a/src/components/timeline/timeline-menus.tsx
+++ b/src/components/timeline/timeline-menus.tsx
@@ -7,7 +7,9 @@
*
* The value menu is one compact button, "Values (3)". The chosen lines are
* not listed again beside it: each one is a named row under the lanes, and
- * the menu marks it with its colour.
+ * the menu marks it with its colour. Below the measurements it offers the
+ * lab markers the record has values for (#1299), under the groups the labs
+ * list sorts them into; a lab line is chosen and stored like any other.
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -16,44 +18,94 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import type { TimelineLaneKey } from "@/lib/day/contract";
+import {
+ timelineLabMarkerId,
+ type TimelineLaneKey,
+ type TimelineResponse,
+} from "@/lib/day/contract";
+import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
+/** Lab markers under one group of the labs list (null: no group). */
+export interface LabOptionGroup {
+ panel: string | null;
+ keys: readonly string[];
+}
+
export function ValueSeriesMenu({
options,
+ labGroups = [],
selected,
label,
color,
onChange,
+ iconOnly = false,
}: {
options: readonly string[];
+ /** The record's lab markers, grouped as the labs list groups them. */
+ labGroups?: readonly LabOptionGroup[];
selected: readonly string[];
label: (key: string) => string;
/** A chosen line's colour, shown as a dot beside its name. */
color: (key: string) => string;
onChange: (next: string[]) => void;
+ /** The phone toolbar's square button, named by its label. */
+ iconOnly?: boolean;
}) {
const { t } = useTranslations();
+ const option = (key: string) => {
+ const checked = selected.includes(key);
+ return (
+ event.preventDefault()}
+ onCheckedChange={(next) =>
+ onChange(
+ next ? [...selected, key] : selected.filter((k) => k !== key),
+ )
+ }
+ >
+ {/* The dot is the line's colour on the chart; an unchosen
+ value has no line yet, so its place stays empty. */}
+
+ {label(key)}
+
+ );
+ };
+ const trigger = t("timeline.values.trigger", { count: selected.length });
+ // A group is named once there is more than one, or when it has a name.
+ const namedGroups =
+ labGroups.length > 1 || labGroups.some((g) => g.panel !== null);
return (
{t("timeline.values.menuTitle")}
- {options.map((key) => {
- const checked = selected.includes(key);
- return (
- event.preventDefault()}
- onCheckedChange={(next) =>
- onChange(
- next ? [...selected, key] : selected.filter((k) => k !== key),
- )
- }
- >
- {/* The dot is the line's colour on the chart; an unchosen
- value has no line yet, so its place stays empty. */}
-
- {label(key)}
-
- );
- })}
+ {options.map(option)}
+ {labGroups.length > 0 && (
+ <>
+
+
+ {t("timeline.labs.menuTitle")}
+
+ {labGroups.map((group) => (
+
+ {namedGroups && (
+
+ {group.panel ?? t("timeline.labs.otherPanel")}
+
+ )}
+ {group.keys.map(option)}
+
+ ))}
+ >
+ )}
);
@@ -147,3 +194,102 @@ export function LayersMenu({
);
}
+
+/**
+ * The lab markers of the menu, grouped as the labs list groups them: by the
+ * marker's group, named groups first, each in the reader's alphabetical
+ * order, markers inside a group likewise.
+ */
+export function labOptionGroups(
+ markers: ReadonlyArray<{ key: string; name: string; panel: string | null }>,
+ intl: string,
+): LabOptionGroup[] {
+ const collator = new Intl.Collator(intl, { sensitivity: "base" });
+ const groups = new Map();
+ const seen = new Set();
+ for (const marker of markers) {
+ if (seen.has(marker.key)) continue;
+ seen.add(marker.key);
+ const panel = marker.panel?.trim() || null;
+ groups.set(panel, [...(groups.get(panel) ?? []), marker]);
+ }
+ return [...groups]
+ .sort(([a], [b]) =>
+ a === null ? 1 : b === null ? -1 : collator.compare(a, b),
+ )
+ .map(([panel, list]) => ({
+ panel,
+ keys: [...list]
+ .sort((a, b) => collator.compare(a.name, b.name))
+ .map((m) => m.key),
+ }));
+}
+
+/** Each lab marker's name by its key, from what the server sent. */
+export function labMarkerNames(
+ timeline: Pick | undefined,
+): Map {
+ return new Map(
+ [...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map(
+ (m) => [m.key, m.name],
+ ),
+ );
+}
+
+/**
+ * The values menu as the page holds it: every series the record has, the
+ * chosen ones kept even when they have nothing yet, in `rank`'s order and
+ * then the record's; then the record's lab markers by group. It counts and
+ * toggles the lines it can name, so a stored marker the record no longer
+ * has drops out with the next change.
+ */
+export function TimelineValuesMenu({
+ timeline,
+ values,
+ allowed,
+ rank,
+ labsOn,
+ label,
+ color,
+ onChange,
+ iconOnly,
+}: {
+ timeline: TimelineResponse | undefined;
+ values: readonly string[];
+ allowed: (key: string) => boolean;
+ rank: readonly string[];
+ labsOn: boolean;
+ label: (key: string) => string;
+ color: (key: string) => string;
+ onChange: (next: string[]) => void;
+ iconOnly: boolean;
+}) {
+ const { locale } = useTranslations();
+ const options = [
+ ...new Set([
+ ...(timeline?.availableSeries ?? []).filter(allowed),
+ ...values.filter((key) => timelineLabMarkerId(key) === null),
+ ]),
+ ].sort((a, b) => rank.indexOf(a) - rank.indexOf(b));
+ const markers = new Map(
+ [...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map(
+ (m) => [m.key, m],
+ ),
+ );
+ const labGroups = labsOn
+ ? labOptionGroups([...markers.values()], resolveIntlLocale(locale))
+ : [];
+ return (
+ timelineLabMarkerId(key) === null || markers.has(key),
+ )}
+ label={label}
+ color={color}
+ onChange={onChange}
+ iconOnly={iconOnly}
+ />
+ );
+}
diff --git a/src/components/timeline/timeline-tables.tsx b/src/components/timeline/timeline-tables.tsx
new file mode 100644
index 000000000..93bbecce8
--- /dev/null
+++ b/src/components/timeline/timeline-tables.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+/**
+ * The timeline's chart as tables, for a screen reader (v1.42, #613): the
+ * lane entries with their dates, and the value lines' means per bucket.
+ * Both are visually hidden; the phone chronicle is the visible list form.
+ * The lab lines' table sits with the lab lines (`lab-lines.tsx`).
+ */
+import type {
+ TimelineBucket,
+ TimelineLaneKey,
+ TimelineResponse,
+} from "@/lib/day/contract";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { itemLine, useItemWords } from "./item-words";
+import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
+import { medicationListItems } from "./medication-rows";
+import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
+import {
+ bucketAfter,
+ bucketStart,
+ dayNumber,
+ formatAtPrecision,
+ formatBucket,
+} from "./timeline-dates";
+import { isSpan } from "./timeline-geometry";
+
+/**
+ * The chart as a table, for a screen reader: every entry with its lane and
+ * dates. Visually hidden; the phone chronicle is the visible list form.
+ */
+export function TimelineTable({
+ timeline,
+ hiddenLanes,
+ intl,
+}: {
+ timeline: TimelineResponse;
+ hiddenLanes: ReadonlySet;
+ intl: string;
+}) {
+ const { t } = useTranslations();
+ const words = useItemWords();
+ const rows = timeline.lanes
+ .filter((lane) => !hiddenLanes.has(lane.key))
+ .flatMap((lane) =>
+ (lane.key === "medications"
+ ? medicationListItems(lane.items)
+ : lane.items
+ ).map((item) => ({ lane: lane.key, item })),
+ )
+ .sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
+ return (
+ // The table is hidden by a wrapper, never by `sr-only` on the table
+ // itself: `overflow: hidden` does not apply to a table box, so a
+ // visually hidden table still laid out its rows at full height under
+ // the page and made `` scroll past its last card into nothing.
+
+
+ {t("timeline.table.caption")}
+
+
+ | {t("timeline.table.lane")} |
+ {t("timeline.table.entry")} |
+ {t("timeline.table.start")} |
+ {t("timeline.table.end")} |
+
+
+
+ {rows.map(({ lane, item }) => (
+
+ | {t(TIMELINE_LANE_LABEL_KEY[lane])} |
+ {itemLine(item, words, t("timeline.startUnknown"))} |
+ {formatAtPrecision(item.start, item.precision, intl)} |
+
+ {!item.open && isSpan(item) && item.end
+ ? formatAtPrecision(item.end, item.precision, intl)
+ : ""}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
+export function bucketText(
+ start: string,
+ bucket: TimelineBucket,
+ intl: string,
+ t: ReturnType["t"],
+): string {
+ const label = formatBucket(start, bucket, intl);
+ return typeof label === "string"
+ ? label
+ : t("timeline.selection.range", label);
+}
+
+/**
+ * The value lines as a table, for a screen reader: one row per bucket from
+ * the first to the last with a reading in the window, one column per
+ * series. A bucket without a reading says so; nothing is filled in.
+ */
+export function SeriesTable({
+ timeline,
+ window,
+ seriesLabel,
+ fmt,
+ intl,
+}: {
+ timeline: TimelineResponse;
+ window: { from: string; to: string };
+ seriesLabel: (key: string) => string;
+ fmt: SeriesValueFormat;
+ intl: string;
+}) {
+ const { t, tCount } = useTranslations();
+ const { bucket, series } = timeline;
+ const inWindow = (start: string) =>
+ start <= window.to && bucketAfter(start, bucket) > window.from;
+ const starts = series
+ .flatMap((s) => s.points.map((p) => p.t))
+ .filter(inWindow)
+ .sort();
+ if (starts.length === 0) return null;
+ const rows: string[] = [];
+ for (
+ let start = bucketStart(starts[0], bucket);
+ start <= starts[starts.length - 1];
+ start = bucketAfter(start, bucket)
+ ) {
+ rows.push(start);
+ }
+ const byKey = new Map(
+ series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
+ );
+ return (
+ // The table is hidden by a wrapper, never by `sr-only` on the table
+ // itself: `overflow: hidden` does not apply to a table box, so a
+ // visually hidden table still laid out its rows at full height under
+ // the page and made `` scroll past its last card into nothing.
+
+
+ {t("timeline.table.seriesCaption")}
+
+
+ | {t("timeline.table.period")} |
+ {series.map((s) => (
+
+ {seriesLabel(s.key)}
+ |
+ ))}
+
+
+
+ {rows.reverse().map((start) => (
+
+ | {bucketText(start, bucket, intl, t)} |
+ {series.map((s) => {
+ const point = byKey.get(s.key)?.get(start);
+ return (
+
+ {point
+ ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
+ : t("timeline.values.noValue")}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx
index ac8a9c97b..229f4da63 100644
--- a/src/components/timeline/timeline-view.tsx
+++ b/src/components/timeline/timeline-view.tsx
@@ -14,6 +14,10 @@
* and Back closes it. An incoming `?day=` selects that day and zooms to
* three months, so a day's "In the timeline" lands on itself.
*
+ * Lab markers the record has values for are offered beside the measurements
+ * (#1299): a chosen marker is stored and requested like any other line, as
+ * `lab:`, and drawn as its single readings, never as means.
+ *
* At 768 px and up the chart; below it the chronicle, which is also the
* list form of the chart. The readiness card sits on top while a lane is
* thin and the person has not closed it; empty lanes are never drawn.
@@ -35,8 +39,8 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
+ timelineLabMarkerId,
type LifeEventDTO,
- type TimelineBucket,
type TimelineLaneKey,
type TimelineResponse,
type TimelineZoom,
@@ -49,9 +53,10 @@ import type { ModuleKey } from "@/lib/modules/registry";
import type { ChronicleGrouping } from "./chronicle-model";
import {
TIMELINE_BLOOD_PRESSURE_SERIES_KEY,
- TIMELINE_LEGEND_MEAN_KEY,
TIMELINE_ZOOM_LABEL_KEY,
} from "./label-keys";
+import { LabExcludedNote } from "./lab-lines";
+import { TimelineLegend } from "./timeline-legend";
import { LifeEventSheet } from "./life-event-sheet";
import { ReadinessCard } from "./readiness-card";
import {
@@ -78,7 +83,11 @@ import {
type TimelineRange,
} from "./timeline-url";
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
-import { LayersMenu, ValueSeriesMenu } from "./timeline-menus";
+import {
+ LayersMenu,
+ TimelineValuesMenu,
+ labMarkerNames,
+} from "./timeline-menus";
import {
isBoolean,
isStringArray,
@@ -140,13 +149,56 @@ function timelineIsEmpty(data: TimelineResponse): boolean {
return (
data.lanes.every((l) => l.items.length === 0) &&
data.standing.length === 0 &&
- data.series.every((s) => s.points.length === 0)
+ data.series.every((s) => s.points.length === 0) &&
+ data.labSeries.every((s) => s.points.length === 0)
);
}
const VALUES_KEY = "healthlog.timeline.values";
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
+/**
+ * The value lines asked for: the stored choice, minus anything unknown and
+ * minus a line whose module is off, and one colour per chosen line.
+ */
+function useValueLines(
+ modules: Partial> | undefined,
+) {
+ const [storedValues, setStoredValues] = useDevicePreference(
+ VALUES_KEY,
+ DEFAULT_VALUES,
+ isStringArray,
+ );
+ // What is asked for: the stored choice, minus anything unknown and minus
+ // a line whose module is off. The server narrows it further to what the
+ // caller may see.
+ const allowed = useCallback(
+ (key: string) => {
+ // A lab line belongs to the labs module; whether the record has the
+ // marker is the server's to say.
+ if (timelineLabMarkerId(key) !== null) return modules?.labs !== false;
+ if (!KNOWN_SERIES.has(key)) return false;
+ const owner: ModuleKey | null | undefined =
+ VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
+ return !owner || modules?.[owner] !== false;
+ },
+ [modules],
+ );
+ const values = useMemo(
+ () => storedValues.filter(allowed),
+ [storedValues, allowed],
+ );
+ // One colour per chosen line, shared by the chart, the selection bar, the
+ // menu and the phone chronicle; contested colours go by the menu's order.
+ const seriesColors = useMemo(
+ () => assignSeriesColors(values, SERIES_RANK),
+ [values],
+ );
+ const seriesColor = (key: string) =>
+ seriesColors.get(key) ?? SERIES_PALETTE[0];
+ return { values, allowed, setStoredValues, seriesColor };
+}
+
export function TimelineView() {
const { t } = useTranslations();
const { user } = useAuth();
@@ -183,11 +235,6 @@ export function TimelineView() {
const [lifeEventOpen, setLifeEventOpen] = useState(false);
const [editing, setEditing] = useState(null);
- const [storedValues, setStoredValues] = useDevicePreference(
- VALUES_KEY,
- DEFAULT_VALUES,
- isStringArray,
- );
const [hiddenList, setHiddenList] = useDevicePreference(
HIDDEN_LANES_KEY,
[],
@@ -200,31 +247,9 @@ export function TimelineView() {
);
const modules = user?.modules;
- // What is asked for: the stored choice, minus anything unknown and minus
- // a line whose module is off. The server narrows it further to what the
- // caller may see.
- const allowed = useCallback(
- (key: string) => {
- if (!KNOWN_SERIES.has(key)) return false;
- const owner: ModuleKey | null | undefined =
- VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
- return !owner || modules?.[owner] !== false;
- },
- [modules],
- );
- const values = useMemo(
- () => storedValues.filter(allowed),
- [storedValues, allowed],
- );
- // One colour per chosen line, shared by the chart, the selection bar, the
- // menu and the phone chronicle; contested colours go by the menu's order.
- const seriesColors = useMemo(
- () => assignSeriesColors(values, SERIES_RANK),
- [values],
- );
+ const { values, allowed, setStoredValues, seriesColor } =
+ useValueLines(modules);
const seriesFormat = useSeriesValueFormat();
- const seriesColor = (key: string) =>
- seriesColors.get(key) ?? SERIES_PALETTE[0];
const hidden = useMemo(
() => new Set(hiddenList as TimelineLaneKey[]),
[hiddenList],
@@ -334,14 +359,17 @@ export function TimelineView() {
setLifeEventOpen(true);
}
+ const labNames = labMarkerNames(data);
// Blood pressure reads as its own name here; the list labels ("Sys",
- // "Dia") lean on a column header the chart does not have.
+ // "Dia") lean on a column header the chart does not have. A lab marker
+ // reads by its own name, as on the labs list.
const seriesLabel = (key: string) =>
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
- : t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
+ : (labNames.get(key) ??
+ t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
const shownWindow = data
? windowFor(
@@ -367,12 +395,19 @@ export function TimelineView() {
today,
data?.range.dataFrom ?? null,
);
- // The selector offers every series the record has, the chosen ones kept
- // even when they have nothing yet, in the selector's order and then the
- // record's.
- const valueOptions = [
- ...new Set([...(data?.availableSeries ?? []).filter(allowed), ...values]),
- ].sort((a, b) => SERIES_RANK.indexOf(a) - SERIES_RANK.indexOf(b));
+ const valuesMenu = (iconOnly: boolean) => (
+
+ );
const presentLanes = (data?.lanes ?? [])
.filter((l) => l.items.length > 0)
.map((l) => l.key)
@@ -532,13 +567,7 @@ export function TimelineView() {
onApply={(next) => navigate("range", next)}
/>
-
+ {valuesMenu(false)}
{layers(false)}
@@ -571,6 +600,8 @@ export function TimelineView() {