Merge the timeline buckets, gaps and series limit into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 09:04:11 +02:00
commit f6f0969da9
30 files changed
+1477 -308

No files matched your search

@@ -16,6 +16,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { ReadinessCard } from "../readiness-card";
import { ReadinessInventory, VerdictMeter } from "../readiness-inventory";
import { SelectionBar } from "../selection-bar";
import { TimelineChart } from "../timeline-chart";
import { TimelineChronicle } from "../timeline-chronicle";
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
@@ -121,25 +122,109 @@ describe("chronicle", () => {
);
expect(html).toContain("129/82 mmHg · 82,6 kg");
});
it("names a quarter once, at its newest month, with its span", () => {
const timeline = {
...fullTimeline(),
bucket: "quarter" as const,
series: [
{
key: "WEIGHT",
unit: "kg",
points: [{ t: "2026-01-01", mean: 82.6, count: 9 }],
},
],
};
const html = render(
<TimelineChronicle
timeline={timeline}
today={TODAY}
grouping="month"
selected={null}
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>,
);
expect(html.match(/data-slot="timeline-chronicle-means"/g)).toHaveLength(1);
expect(html).toContain("Jan bis März 2026: 82,6 kg");
});
});
const LABELS: Record<string, string> = {
BLOOD_PRESSURE_SYS: "Blutdruck sys.",
BLOOD_PRESSURE_DIA: "Blutdruck dia.",
WEIGHT: "Gewicht",
};
const seriesLabel = (key: string) => LABELS[key] ?? key;
describe("selection bar", () => {
it("lists the month's entries and offers the selected day", () => {
const html = render(
const bar = (selected: string) =>
render(
<SelectionBar
timeline={fullTimeline()}
selected="2026-01-03"
selected={selected}
today={TODAY}
seriesLabel={seriesLabel}
onOpenDay={() => undefined}
/>,
);
it("lists the month's entries and offers the selected day", () => {
const html = bar("2026-01-03");
expect(html).toContain("Januar 2026");
expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan.");
expect(html).toContain("Vitamin D (Winter) · laufend");
expect(html).not.toContain("Bluthochdruck");
expect(html).toContain("3. Jan. öffnen");
expect(html).toContain('data-date="2026-01-03"');
expect(html).toContain("Ø 129/82 mmHg · 82,6 kg");
});
it("names the bucket's means with the readings behind each", () => {
expect(bar("2026-01-03")).toContain(
"Ø Januar 2026: Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
);
expect(bar("2026-07-15")).toContain(
"Ø Juli 2026: Blutdruck sys. 131 mmHg (1 Messung) · Blutdruck dia. kein Wert · Gewicht kein Wert",
);
});
it("says a missing month has no value instead of interpolating one", () => {
const html = bar("2026-02-10");
expect(html).toContain(
"Ø Februar 2026: Blutdruck sys. kein Wert · Blutdruck dia. kein Wert · Gewicht kein Wert",
);
// January's 129 and March's 133 would make 131 between them.
expect(html).not.toMatch(/\d mmHg/);
});
});
describe("value table for a screen reader", () => {
it("lists every month between the first and the last reading, gaps as gaps", () => {
const html = render(
<TimelineChart
timeline={fullTimeline()}
window={{ from: "2025-10-01", to: "2026-10-31" }}
zoom="year"
today={TODAY}
selected={null}
hiddenLanes={new Set()}
seriesLabel={seriesLabel}
onSelect={() => undefined}
onOpenDay={() => undefined}
/>,
);
const table = html.slice(html.indexOf('data-slot="timeline-series-table"'));
// October 2025 to July 2026: ten months, newest first.
expect(table.match(/data-bucket="/g)).toHaveLength(10);
const row = (bucket: string) => {
const start = table.indexOf(`data-bucket="${bucket}"`);
return table.slice(start, table.indexOf("</tr>", start));
};
expect(row("2026-02-01")).toContain("Februar 2026");
expect(row("2026-02-01").match(/kein Wert/g)).toHaveLength(3);
expect(row("2026-01-01")).toContain("129 mmHg, Schnitt aus 4 Messungen");
expect(row("2026-07-01")).toContain("131 mmHg, 1 Messung");
expect(row("2026-05-01")).not.toMatch(/mmHg|kg/);
});
});
@@ -235,31 +235,31 @@ export function fullTimeline(): TimelineResponse {
},
],
standing: [],
bucket: "month",
series: [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
granularity: "month",
points: [
{ t: "2025-10-01", mean: 127 },
{ t: "2025-11-01", mean: 128 },
{ t: "2025-12-01", mean: 130 },
{ t: "2026-01-01", mean: 129 },
// Nothing for February to June.
{ t: "2026-07-01", mean: 131 },
{ t: "2025-10-01", mean: 127, count: 6 },
{ t: "2025-11-01", mean: 128, count: 5 },
{ t: "2025-12-01", mean: 130, count: 4 },
{ t: "2026-01-01", mean: 129, count: 4 },
// Nothing in February: one missing month, bridged.
{ t: "2026-03-01", mean: 133, count: 2 },
// Nothing from April to June: three missing months, broken.
{ t: "2026-07-01", mean: 131, count: 1 },
],
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
granularity: "month",
points: [{ t: "2026-01-01", mean: 82 }],
points: [{ t: "2026-01-01", mean: 82, count: 4 }],
},
{
key: "WEIGHT",
unit: "kg",
granularity: "month",
points: [{ t: "2026-01-01", mean: 82.6 }],
points: [{ t: "2026-01-01", mean: 82.6, count: 3 }],
},
],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
@@ -40,6 +40,7 @@ function layoutAt(width: number): TimelineLayout {
window: windowFor("all", TODAY, null, tl.range),
lanes: tl.lanes,
series: tl.series,
bucket: tl.bucket,
startMissing: "Startdatum fehlt",
today: TODAY,
});
@@ -345,30 +346,136 @@ describe("layoutTimeline", () => {
});
describe("layoutSeries", () => {
it("breaks the line across missing months instead of bridging them", () => {
const window = { from: "2025-10-01", to: "2026-10-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
window,
scale,
);
expect(bp.path.match(/M/g)).toHaveLength(2);
const window = { from: "2025-10-01", to: "2026-10-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
window,
scale,
"month",
);
it("keeps every month with a reading as a point, and invents none", () => {
expect(bp.points.map((p) => p.t)).toEqual([
"2025-10-01",
"2025-11-01",
"2025-12-01",
"2026-01-01",
"2026-03-01",
"2026-07-01",
]);
expect(bp.points.map((p) => p.mean)).toEqual([
127, 128, 130, 129, 133, 131,
]);
expect(bp.latest).toBe(131);
});
it("keeps an empty series as a row without a line", () => {
const window = { from: "2019-01-01", to: "2019-12-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
it("draws the run solid, bridges one missing month dashed, and breaks at three", () => {
const [oct, , , jan, mar, jul] = bp.points;
const at = (p: { x: number; y: number }) =>
`${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
// One solid run, October to January.
expect(bp.path.match(/M/g)).toHaveLength(1);
expect(bp.path.startsWith(`M${at(oct)}`)).toBe(true);
expect(bp.path.endsWith(`L${at(jan)}`)).toBe(true);
// February is missing: one dashed stroke from January to March, with no
// vertex between them.
expect(bp.bridges).toBe(`M${at(jan)}L${at(mar)}`);
// April to June are missing: July joins nothing.
expect(bp.path).not.toContain(at(jul));
expect(bp.bridges).not.toContain(at(jul));
});
it("draws a bucket of fewer than three readings hollow", () => {
expect(bp.points.map((p) => [p.t, p.thin])).toEqual([
["2025-10-01", false],
["2025-11-01", false],
["2025-12-01", false],
["2026-01-01", false],
["2026-03-01", true],
["2026-07-01", true],
]);
});
it("bridges at most one missing quarter and four missing weeks", () => {
const quarter = (points: Array<[string, number]>) =>
layoutSeries(
[
{
key: "WEIGHT",
unit: "kg",
points: points.map(([t, mean]) => ({ t, mean, count: 5 })),
},
],
0,
{ from: "2020-01-01", to: "2026-12-31" },
createScale({ from: "2020-01-01", to: "2026-12-31" }, 164, 1200),
"quarter",
)[0];
// Q1, then Q3: one missing quarter, bridged.
expect(
quarter([
["2024-01-01", 80],
["2024-07-01", 81],
]).bridges,
).not.toBe("");
// Q1, then Q4: two missing quarters, broken; each point still drawn.
const broken = quarter([
["2024-01-01", 80],
["2024-10-01", 81],
]);
expect(broken.bridges).toBe("");
expect(broken.path).toBe("");
expect(broken.points).toHaveLength(2);
const weeks = (second: string) =>
layoutSeries(
[
{
key: "WEIGHT",
unit: "kg",
points: [
{ t: "2026-01-05", mean: 80, count: 3 },
{ t: second, mean: 81, count: 3 },
],
},
],
0,
{ from: "2026-01-01", to: "2026-03-31" },
createScale({ from: "2026-01-01", to: "2026-03-31" }, 164, 1200),
"week",
)[0];
// Mondays: four missing weeks bridged, five broken.
expect(weeks("2026-02-09").bridges).not.toBe("");
expect(weeks("2026-02-16").bridges).toBe("");
});
it("keeps a lone point visible", () => {
const [weight] = layoutSeries(
fullTimeline().series.slice(2, 3),
0,
window,
scale,
"month",
);
expect(bp.path).toBe("");
expect(bp.latest).toBeNull();
expect(weight.path).toBe("");
expect(weight.bridges).toBe("");
expect(weight.points).toHaveLength(1);
});
it("keeps an empty series as a row without a line", () => {
const early = { from: "2019-01-01", to: "2019-12-31" };
const [empty] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
early,
createScale(early, 164, 1200),
"month",
);
expect(empty.path).toBe("");
expect(empty.points).toEqual([]);
expect(empty.latest).toBeNull();
});
});
+26 -25
View File
@@ -2,8 +2,10 @@
/**
* The bar under the chart (v1.42, #613): the selected month, every entry
* that touches it as a chip (each one a way into its day), the month's
* means of the chosen values, and "Open 3 Jan." for the selected day.
* that touches it as a chip (each one a way into its day), the means of the
* chosen values in the bucket that holds the selected day (with the readings
* behind each, and "no value" where there is none), and "Open 3 Jan." for
* the selected day.
*
* It is also where a label the chart had to leave out is read in full, and
* where each keyboard step on the chart is announced (`aria-live`).
@@ -23,45 +25,31 @@ import {
startOfMonth,
} from "./timeline-dates";
import { itemsInMonth } from "./timeline-geometry";
import { laneDotStyle } from "./timeline-chart";
import { formatMonthMeans } from "./series-format";
import { bucketText, laneDotStyle } from "./timeline-chart";
import { bucketValues, formatLabelledMeans } from "./series-format";
export interface SelectionBarProps {
timeline: TimelineResponse;
selected: string;
today: string;
seriesLabel: (key: string) => string;
onOpenDay: (date: string) => void;
onEditLifeEvent?: (id: string) => void;
}
/** The month means of each series for the month of `date`. */
export function monthMeans(
series: TimelineResponse["series"],
date: string,
): Array<{ key: string; mean: number; unit: string | null }> {
const month = monthOf(date);
const out: Array<{ key: string; mean: number; unit: string | null }> = [];
for (const s of series) {
const inMonth = s.points.filter((p) => monthOf(p.t) === month);
if (inMonth.length === 0) continue;
const mean = inMonth.reduce((sum, p) => sum + p.mean, 0) / inMonth.length;
out.push({ key: s.key, mean, unit: s.unit });
}
return out;
}
export function SelectionBar({
timeline,
selected,
today,
seriesLabel,
onOpenDay,
onEditLifeEvent,
}: SelectionBarProps) {
const { t, locale } = useTranslations();
const { t, tCount, locale } = useTranslations();
const fmt = useFormatters();
const intl = resolveIntlLocale(locale);
const entries = itemsInMonth(timeline.lanes, selected, today);
const means = monthMeans(timeline.series, selected);
const means = bucketValues(timeline.series, timeline.bucket, selected);
const monthFrom = startOfMonth(selected);
const monthTo = endOfMonth(selected);
@@ -134,10 +122,23 @@ export function SelectionBar({
</button>
);
})}
{means.length > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">
{means.values.length > 0 && (
<span
className="text-muted-foreground text-xs tabular-nums"
data-slot="timeline-selection-means"
data-bucket={means.start}
>
{t("timeline.selection.mean", {
values: formatMonthMeans(means, fmt),
values: `${bucketText(means.start, timeline.bucket, intl, t)}: ${formatLabelledMeans(
means.values,
fmt,
{
label: seriesLabel,
bloodPressure: t("timeline.values.bloodPressure"),
noValue: t("timeline.values.noValue"),
readings: (n) => tCount("timeline.values.readings", n),
},
)}`,
})}
</span>
)}
+124 -7
View File
@@ -3,8 +3,11 @@
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
* server's, already in the person's display unit.
*/
import type { TimelineBucket, TimelineResponse } from "@/lib/day/contract";
import type { Formatters } from "@/lib/format-locale";
import { addMonths, bucketAfter, bucketStart } from "./timeline-dates";
const WHOLE_NUMBER_KEYS: ReadonlySet<string> = new Set([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
@@ -43,18 +46,92 @@ export function formatSeriesValue(
return out.unit ? `${out.value} ${out.unit}` : out.value;
}
/** One series in one bucket: its mean, or nothing when it has no reading. */
export interface BucketValue {
key: string;
unit: string | null;
mean: number | null;
count: number | null;
}
/**
* The month means in one short line: systolic and diastolic fold into
* "129/82 mmHg", the rest follow as "82,6 kg".
* Every series in the bucket that holds `date`. A series without a reading
* in that bucket is listed with `mean: null`: the gap stays a gap.
*/
export function formatMonthMeans(
means: ReadonlyArray<{ key: string; mean: number; unit: string | null }>,
export function bucketValues(
series: TimelineResponse["series"],
bucket: TimelineBucket,
date: string,
): { start: string; values: BucketValue[] } {
const start = bucketStart(date, bucket);
return {
start,
values: series.map((s) => {
const point = s.points.find((p) => p.t === start);
return {
key: s.key,
unit: s.unit,
mean: point?.mean ?? null,
count: point?.count ?? null,
};
}),
};
}
/**
* The bucket means each chronicle month shows (`months` newest first, as the
* chronicle lists them). A bucket is named once, at the newest listed month
* it overlaps: a quarter rides on its latest month with entries, never on
* all three. Where two buckets of one series meet at one month (weeks), the
* newer stands. Only buckets with a reading appear; a month without one
* shows nothing rather than a number.
*/
export function chronicleMeans(
series: TimelineResponse["series"],
bucket: TimelineBucket,
months: readonly string[],
): Map<string, { start: string; values: BucketValue[] }> {
const out = new Map<string, { start: string; values: BucketValue[] }>();
for (const s of series) {
const points = [...s.points].sort((a, b) => (a.t < b.t ? 1 : -1));
const taken = new Set<string>();
for (const p of points) {
const end = bucketAfter(p.t, bucket);
const month = months.find(
(m) => m < end && addMonths(m, 1) > p.t && !taken.has(m),
);
if (!month) continue;
taken.add(month);
const slot = out.get(month) ?? { start: p.t, values: [] };
if (p.t > slot.start) slot.start = p.t;
slot.values.push({
key: s.key,
unit: s.unit,
mean: p.mean,
count: p.count,
});
out.set(month, slot);
}
}
return out;
}
/**
* The means in one short line: systolic and diastolic fold into
* "129/82 mmHg", the rest follow as "82,6 kg". Series without a value are
* left out; the line is for a glance, the selection bar names the gaps.
*/
export function formatMeans(
means: readonly BucketValue[],
fmt: Pick<Formatters, "number">,
): string {
const sys = means.find((m) => m.key === "BLOOD_PRESSURE_SYS");
const dia = means.find((m) => m.key === "BLOOD_PRESSURE_DIA");
const present = means.filter(
(m): m is BucketValue & { mean: number } => m.mean !== null,
);
const sys = present.find((m) => m.key === "BLOOD_PRESSURE_SYS");
const dia = present.find((m) => m.key === "BLOOD_PRESSURE_DIA");
const parts: string[] = [];
for (const m of means) {
for (const m of present) {
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
parts.push(
@@ -66,3 +143,43 @@ export function formatMonthMeans(
}
return parts.join(" · ");
}
/**
* The means of one bucket with their names, readings and gaps, for the
* selection bar: "Blood pressure 129/82 mmHg (mean of 4 readings) · Weight
* no value". Systolic and diastolic fold when both have a value.
*/
export function formatLabelledMeans(
means: readonly BucketValue[],
fmt: Pick<Formatters, "number">,
words: {
label: (key: string) => string;
bloodPressure: string;
noValue: string;
readings: (count: number) => string;
},
): string {
const sys = means.find(
(m) => m.key === "BLOOD_PRESSURE_SYS" && m.mean !== null,
);
const dia = means.find(
(m) => m.key === "BLOOD_PRESSURE_DIA" && m.mean !== null,
);
const fold = sys && dia;
const parts: string[] = [];
for (const m of means) {
if (fold && m.key === "BLOOD_PRESSURE_DIA") continue;
if (fold && m.key === "BLOOD_PRESSURE_SYS") {
parts.push(
`${words.bloodPressure} ${fmt.number(sys.mean!, 0)}/${fmt.number(dia.mean!, 0)}${sys.unit ? ` ${sys.unit}` : ""} (${words.readings(sys.count ?? 0)})`,
);
continue;
}
parts.push(
m.mean === null
? `${words.label(m.key)} ${words.noValue}`
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, fmt)} (${words.readings(m.count ?? 0)})`,
);
}
return parts.join(" · ");
}
+129 -7
View File
@@ -43,6 +43,7 @@ import {
} from "lucide-react";
import type {
TimelineBucket,
TimelineLaneKey,
TimelineResponse,
TimelineZoom,
@@ -51,7 +52,10 @@ import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
bucketAfter,
bucketStart,
formatAtPrecision,
formatBucket,
formatMonthShort,
formatMonthYear,
dayNumber,
@@ -145,11 +149,12 @@ export function TimelineChart({
window,
lanes,
series: timeline.series,
bucket: timeline.bucket,
startMissing: t("timeline.startMissing"),
today,
})
: null,
[width, window, lanes, timeline.series, t, today],
[width, window, lanes, timeline.series, timeline.bucket, t, today],
);
const floor = timeline.range.dataFrom ?? window.from;
@@ -228,6 +233,7 @@ export function TimelineChart({
today={today}
selected={selected}
dataFrom={timeline.range.dataFrom}
bucket={timeline.bucket}
seriesLabel={seriesLabel}
intl={intl}
t={t}
@@ -243,6 +249,12 @@ export function TimelineChart({
hiddenLanes={hiddenLanes}
intl={intl}
/>
<SeriesTable
timeline={timeline}
window={window}
seriesLabel={seriesLabel}
intl={intl}
/>
</div>
);
}
@@ -254,6 +266,7 @@ interface ChartBodyProps {
today: string;
selected: string | null;
dataFrom: string | null;
bucket: TimelineBucket;
seriesLabel: (key: string) => string;
intl: string;
t: ReturnType<typeof useTranslations>["t"];
@@ -267,11 +280,13 @@ function ChartBody({
today,
selected,
dataFrom,
bucket,
seriesLabel,
intl,
t,
fmt,
}: ChartBodyProps) {
const { tCount } = useTranslations();
const { scale, height, width } = layout;
const todayInWindow = today >= window.from && today <= window.to;
const todayX = scale.xMid(today);
@@ -461,14 +476,37 @@ function ChartBody({
strokeLinecap="round"
/>
)}
{series.points.length === 1 && (
<circle
cx={series.points[0].x}
cy={series.points[0].y}
r={2.5}
fill="var(--foreground)"
{series.bridges && (
<path
d={series.bridges}
data-slot="timeline-series-bridge"
fill="none"
stroke="var(--foreground)"
strokeWidth={1.2}
strokeOpacity={0.6}
strokeDasharray="3 3"
strokeLinecap="round"
/>
)}
{series.points.map((point) => (
<circle
key={point.t}
data-slot="timeline-series-point"
data-t={point.t}
data-count={point.count}
data-thin={point.thin ? "true" : "false"}
cx={point.x}
cy={point.y}
r={point.thin ? 2.75 : 2.5}
fill={point.thin ? "var(--card)" : "var(--foreground)"}
stroke="var(--foreground)"
strokeWidth={point.thin ? 1.4 : 0}
>
<title>
{`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
</title>
</circle>
))}
</g>
))}
@@ -761,6 +799,90 @@ function TimelineTable({
);
}
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
export function bucketText(
start: string,
bucket: TimelineBucket,
intl: string,
t: ReturnType<typeof useTranslations>["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.
*/
function SeriesTable({
timeline,
window,
seriesLabel,
intl,
}: {
timeline: TimelineResponse;
window: { from: string; to: string };
seriesLabel: (key: string) => string;
intl: string;
}) {
const { t, tCount } = useTranslations();
const fmt = useFormatters();
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 (
<table className="sr-only" data-slot="timeline-series-table">
<caption>{t("timeline.table.seriesCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.period")}</th>
{series.map((s) => (
<th key={s.key} scope="col">
{seriesLabel(s.key)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.reverse().map((start) => (
<tr key={start} data-bucket={start}>
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
{series.map((s) => {
const point = byKey.get(s.key)?.get(start);
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
: t("timeline.values.noValue")}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
return { background: LANE_COLOR[lane] };
+29 -13
View File
@@ -2,9 +2,10 @@
/**
* The timeline on a phone (v1.42, #613): an "Ongoing" block that folds away,
* then the chronicle, newest first, with the month's means beside each
* month, thin rails beside the rows an episode or a course spans, and empty
* stretches named rather than skipped. Built from `chronicle-model.ts`.
* then the chronicle, newest first, with the bucket means beside the month
* headers (a quarter named once, at its newest month, with its span), thin
* rails beside the rows an episode or a course spans, and empty stretches
* named rather than skipped. Built from `chronicle-model.ts`.
*
* A dated row opens its day (the same `?day=` layer the chart hands off to);
* a life event opens its edit sheet instead, because the event is the
@@ -47,9 +48,8 @@ import {
formatMonthYear,
} from "./timeline-dates";
import { LANE_COLOR } from "./timeline-geometry";
import { laneDotStyle } from "./timeline-chart";
import { monthMeans } from "./selection-bar";
import { formatMonthMeans } from "./series-format";
import { bucketText, laneDotStyle } from "./timeline-chart";
import { chronicleMeans, formatMeans } from "./series-format";
const KIND_ICON: Partial<Record<TimelineItemKind, LucideIcon>> = {
lifeEvent: Flag,
@@ -141,6 +141,16 @@ export function TimelineChronicle({
const [standingOpen, setStandingOpen] = useState(true);
const chips = standingChips(timeline);
const rows = buildChronicle(timeline, today, grouping);
// The means ride on the month headers, each bucket on the newest month it
// overlaps; the year view lists none.
const means =
grouping === "month"
? chronicleMeans(
timeline.series,
timeline.bucket,
rows.flatMap((r) => (r.type === "header" ? [r.group] : [])),
)
: new Map<string, never>();
const groupLabel = (group: string) =>
grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4);
@@ -244,10 +254,10 @@ export function TimelineChronicle({
);
}
if (row.type === "header") {
const means =
grouping === "month"
? monthMeans(timeline.series, row.group)
: [];
const bucketMeans = means.get(row.group);
const line = bucketMeans
? formatMeans(bucketMeans.values, fmt)
: "";
return (
<li
key={`h-${row.group}`}
@@ -259,9 +269,15 @@ export function TimelineChronicle({
<h3 className="text-sm font-semibold">
{groupLabel(row.group)}
</h3>
{means.length > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">
{formatMonthMeans(means, fmt)}
{bucketMeans && line && (
<span
className="text-muted-foreground text-xs tabular-nums"
data-slot="timeline-chronicle-means"
data-bucket={bucketMeans.start}
>
{timeline.bucket === "month"
? line
: `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`}
</span>
)}
</li>
+58
View File
@@ -10,6 +10,8 @@
* Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a
* date never moves by a day for a reader east or west of Greenwich.
*/
import type { TimelineBucket } from "@/lib/day/contract";
const MS_PER_DAY = 86_400_000;
/** Days since 1970-01-01 for a `YYYY-MM-DD` key. */
@@ -55,6 +57,38 @@ export function addMonths(key: string, months: number): string {
return target.toISOString().slice(0, 10);
}
/* ─── Series buckets ──────────────────────────────────────────────────────── */
/** The first day of the bucket `key` falls in. Weeks start on Monday. */
export function bucketStart(key: string, bucket: TimelineBucket): string {
if (bucket === "month") return startOfMonth(key);
if (bucket === "quarter") {
const month = Number(key.slice(5, 7));
const first = month - ((month - 1) % 3);
return `${key.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
}
const day = dayNumber(key);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
return dayKey(day - ((((day - 4) % 7) + 7) % 7));
}
/** The first day after the bucket that starts on `start`. */
export function bucketAfter(start: string, bucket: TimelineBucket): string {
if (bucket === "month") return addMonths(start, 1);
if (bucket === "quarter") return addMonths(start, 3);
return dayKey(dayNumber(start) + 7);
}
/**
* A running number for the bucket that starts on `start`: neighbouring
* buckets differ by one, so a difference counts the buckets between.
*/
export function bucketIndex(start: string, bucket: TimelineBucket): number {
if (bucket === "week") return Math.floor((dayNumber(start) - 4) / 7);
const months = Number(start.slice(0, 4)) * 12 + Number(start.slice(5, 7)) - 1;
return bucket === "quarter" ? Math.floor(months / 3) : months;
}
/** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */
export function monthsBetween(from: string, to: string): string[] {
const out: string[] = [];
@@ -148,3 +182,27 @@ export function formatAtPrecision(
if (precision === "MONTH") return formatMonthYearShort(key, intlLocale);
return formatDayMonthYear(key, intlLocale);
}
/**
* How a series bucket reads: "Januar 2026" for a month, otherwise its first
* and last day for the caller's "{from} to {to}" ("Jan to März 2026",
* "5. Jan. to 11. Jan. 2026").
*/
export function formatBucket(
start: string,
bucket: TimelineBucket,
intlLocale: string,
): string | { from: string; to: string } {
if (bucket === "month") return formatMonthYear(start, intlLocale);
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
if (bucket === "quarter") {
return {
from: formatMonthShort(start, intlLocale),
to: formatMonthYearShort(last, intlLocale),
};
}
return {
from: formatDayMonth(start, intlLocale),
to: formatDayMonthYear(last, intlLocale),
};
}
+82 -28
View File
@@ -17,6 +17,7 @@
* month in full.
*/
import type {
TimelineBucket,
TimelineItem,
TimelineItemKind,
TimelineLane,
@@ -27,6 +28,8 @@ import type {
import {
addMonths,
bucketAfter,
bucketIndex,
dayKey,
dayNumber,
endOfMonth,
@@ -688,42 +691,74 @@ function placeLabels(
/* ─── 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; empty when the window holds no point. */
/** 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: Array<{ t: string; mean: number; x: number; y: number }>;
points: SeriesPointLayout[];
}
const BUCKET_DAYS: Record<TimelineSeries["granularity"], number> = {
month: 30,
week: 7,
day: 1,
/** 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<Record<TimelineBucket, number>> = {
quarter: 1,
month: 2,
week: 4,
};
/**
* One neutral line per series, scaled to its own range inside the window.
* A point sits in the middle of its bucket. Missing buckets break the line
* rather than bridging the gap: a straight stroke across eight empty months
* would claim values nobody measured.
* 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;
const bucket = BUCKET_DAYS[s.granularity];
// 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 >= startOfMonthIf(window.from, s) && p.t <= window.to)
.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 {
@@ -732,6 +767,7 @@ export function layoutSeries(
top: rowTop,
height,
path: "",
bridges: "",
latest: null,
points: [],
};
@@ -742,18 +778,34 @@ export function layoutSeries(
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const placed = inside.map((p) => {
const mid = dayNumber(p.t) + bucket / 2;
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, x, y: y(p.mean) };
return {
t: p.t,
mean: p.mean,
count: p.count,
x,
y: y(p.mean),
thin: p.count < THIN_BUCKET_COUNT,
};
});
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
let path = "";
let prevDay: number | null = null;
for (const p of placed) {
const day = dayNumber(p.t);
const broken = prevDay === null || day - prevDay > bucket * 1.6;
path += `${broken ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
prevDay = day;
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,
@@ -761,18 +813,13 @@ export function layoutSeries(
top: rowTop,
height,
path,
bridges,
latest: inside[inside.length - 1].mean,
points: placed,
points,
};
});
}
function startOfMonthIf(from: string, s: TimelineSeries): string {
// A month bucket keyed on the 1st still belongs to a window that starts
// mid-month.
return s.granularity === "month" ? startOfMonth(from) : from;
}
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
export interface TimelineLayout {
@@ -794,6 +841,7 @@ export function layoutTimeline(input: {
window: TimeWindow;
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
bucket: TimelineBucket;
startMissing: string;
today: string;
}): TimelineLayout {
@@ -814,7 +862,13 @@ export function layoutTimeline(input: {
top += layout.height;
}
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
const series = layoutSeries(input.series, seriesTop, input.window, scale);
const series = layoutSeries(
input.series,
seriesTop,
input.window,
scale,
input.bucket,
);
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
return { width: input.width, height, scale, lanes, seriesTop, series };
}
+3 -3
View File
@@ -2,7 +2,7 @@
/**
* The two menus above the chart (v1.42, #613): which value lines run under
* the lanes (at most three), and which lanes show, with the way back into
* the lanes (at most six, the server's cap), and which lanes show, with the way back into
* the readiness inventory ("Data coverage").
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -17,11 +17,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { TimelineLaneKey } from "@/lib/day/contract";
import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
/** The most value lines the chart runs under its lanes. */
export const MAX_VALUE_SERIES = 3;
export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES;
export function ValueSeriesMenu({
options,
+40 -12
View File
@@ -3,9 +3,8 @@
/**
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
* medications, vaccinations, visits, documents and the person's own life
* events in lanes, up to three neutral value lines below them, all on one
* time axis. "Stacked means at the same time, not cause and effect" is said
* once, in the legend, and nothing on the page draws a connection.
* events in lanes, up to six neutral value lines below them, all on one
* time axis. Nothing on the page draws a connection between them.
*
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
* Enter on the chart, a double click, a chip, a chronicle row) pushes the
@@ -35,6 +34,7 @@ import {
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
type LifeEventDTO,
type TimelineBucket,
type TimelineLaneKey,
type TimelineZoom,
} from "@/lib/day/contract";
@@ -385,10 +385,14 @@ export function TimelineView() {
timeline={data}
selected={effectiveSelected}
today={today}
seriesLabel={seriesLabel}
onOpenDay={openDay}
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
/>
<Legend />
<Legend
bucket={data.bucket}
hasSeries={data.series.length > 0}
/>
</CardContent>
</Card>
</div>
@@ -436,7 +440,19 @@ export function TimelineView() {
);
}
function Legend() {
const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
};
function Legend({
bucket,
hasSeries,
}: {
bucket: TimelineBucket;
hasSeries: boolean;
}) {
const { t } = useTranslations();
return (
<div
@@ -457,13 +473,25 @@ function Legend() {
/>
{t("timeline.legendEvent")}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t("timeline.legendMean")}
</span>
<span className="sm:ml-auto" data-slot="timeline-legend-causality">
{t("timeline.legend")}
</span>
{hasSeries && (
<>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t(LEGEND_MEAN_KEY[bucket])}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="border-foreground bg-card size-2 rounded-full border"
aria-hidden="true"
/>
{t("timeline.legendThin")}
</span>
</>
)}
</div>
);
}