feat(timeline): keep gaps in value lines, mark thin means, allow six lines

Every bucket with a reading gets its point; one with fewer than three
readings is drawn hollow. A short gap (one quarter, two months, four
weeks) is bridged with a dashed stroke between the two real points; a
longer one leaves the line open. The selection bar and the value table
for screen readers name each bucket's readings and say "no value" where
there is none. The phone chronicle names each bucket once, at its newest
month. The value menu takes up to six lines. The legend's causality line
is gone with its keys in all seven bundles.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 09:03:42 +02:00
1 parent af982e3812
commit fcc6f40410
21 files changed
+1011 -203

No files matched your search

+3 -2
View File
@@ -26,8 +26,9 @@ an earlier fold computed from part of a day.
- **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with
lanes for life events, conditions, allergies, medications, vaccinations,
visits and procedures, labs and documents, a year, month or week grid and
up to three monthly-mean value lines; a chronicle, newest first, on a
phone. Switching the module on opens a readiness inventory that rates
up to six value lines of quarterly, monthly or weekly means by zoom (a
short gap dashed, a longer one left open, a mean from fewer than three
readings drawn hollow); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates
each lane and links each gap; it stays available as Data coverage.
- **Life events.** Title, kind (family, home, work, loss, other), a start
at day, month or year precision, optional end and note; title and note
+80 -6
View File
@@ -10,8 +10,11 @@
* day.
* 4. A life event is entered through the sheet and posted in the
* contract's shape.
* 5. On a phone the chronicle replaces the chart, says its empty months
* out loud, and a row opens its day.
* 5. The value lines: quarterly means over the years, a missing quarter
* left out, a short gap bridged dashed, a thin quarter hollow, and up
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
*
* The timeline's server routes are mocked with the contract's shapes
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
@@ -22,7 +25,7 @@ import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { mockTimeline } from "./utils/mock-timeline";
import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline";
test.use({ storageState: TIMELINE_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -119,11 +122,10 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1);
await expect(
page.locator('[data-slot="timeline-legend-causality"]'),
).toBeVisible();
// At most three value lines are asked for.
).toHaveCount(0);
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
expect(asked.length).toBeGreaterThan(0);
expect(asked.length).toBeLessThanOrEqual(3);
expect(asked.length).toBeLessThanOrEqual(6);
// The keyboard: one step back and Enter opens that day.
const before = await page
@@ -157,6 +159,69 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]');
await expect(sys).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "quarter");
const point = (t: string) =>
sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`);
// A missing quarter stays missing: no point is drawn for it.
for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0);
// The quarters around Q2 2023 are joined by a dashed bridge; the three
// missing quarters of 2021 are not (one bridge in the whole line).
await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount(
1,
);
await expect(point("2023-01-01")).toHaveCount(1);
await expect(point("2023-07-01")).toHaveCount(1);
// Two readings: drawn hollow; four and more: filled.
await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true");
await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false");
// The value table keeps the missing quarter as a row of its own.
await expect(
page.locator(
'[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]',
),
).toBeAttached();
// Six value lines can be chosen; a seventh cannot.
await page.locator('[data-slot="timeline-values-trigger"]').click();
const options = page.locator('[data-slot="timeline-values-option"]');
await expect(options.first()).toBeVisible();
const count = await options.count();
for (let i = 0; i < count; i++) {
const checked = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"]',
);
if ((await checked.count()) >= 6) break;
const option = options.nth(i);
if ((await option.getAttribute("aria-checked")) === "true") continue;
await option.click();
}
await expect(
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
).toHaveCount(6);
await expect(
page.locator(
'[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]',
),
).toHaveCount(count - 6);
await page.keyboard.press("Escape");
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(6);
await expect(page.locator("svg [data-series]")).toHaveCount(6);
});
async function setTimelineModuleOn(page: Page) {
await page.goto("/settings/modules");
await setTimelineModule(page, true);
@@ -218,6 +283,15 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
await expect(
page.locator('[data-slot="timeline-chronicle-gap"]').first(),
).toBeVisible();
// Quarterly means, each quarter named once, at its newest listed month.
const means = page.locator('[data-slot="timeline-chronicle-means"]');
await expect(means.first()).toBeVisible();
const buckets = await means.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-bucket") ?? ""),
);
expect(buckets.length).toBeGreaterThan(0);
expect(new Set(buckets).size).toBe(buckets.length);
for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/);
await page
.locator(
+115 -33
View File
@@ -46,76 +46,142 @@ function rng(seed: number) {
};
}
function monthly(
from: string,
to: string,
value: (i: number, monthKey: string) => number,
) {
const out: Array<{ t: string; mean: number }> = [];
type Bucket = "quarter" | "month" | "week";
/** The bucket the server picks for a zoom (`timelineBucket`). */
export function bucketFor(zoom: string): Bucket {
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
}
function dayNum(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
}
function dayStr(n: number): string {
return new Date(n * 86_400_000).toISOString().slice(0, 10);
}
/** Bucket starts from the one holding `from` to the one holding `to`. */
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
const out: string[] = [];
if (bucket === "week") {
const n = dayNum(from);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
out.push(dayStr(d));
}
return out;
}
const step = bucket === "quarter" ? 3 : 1;
let [y, m] = from.split("-").map(Number);
m -= (m - 1) % step;
const [ty, tm] = to.split("-").map(Number);
let i = 0;
while (y < ty || (y === ty && m <= tm)) {
const key = `${y}-${String(m).padStart(2, "0")}-01`;
out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 });
i++;
m++;
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
m += step;
if (m > 12) {
m = 1;
m -= 12;
y++;
}
}
return out;
}
function series(dataFrom: string, today: string) {
/**
* Systolic pressure leaves gaps the chart has to show honestly: in the
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
*/
export const SYS_MISSING = new Set([
"2021-01-01",
"2021-04-01",
"2021-07-01",
"2023-04-01",
]);
export const SYS_THIN = "2022-01-01";
function points(
from: string,
to: string,
bucket: Bucket,
value: (i: number, key: string) => number,
skip: (key: string) => boolean = () => false,
) {
const starts = bucketStarts(from, to, bucket);
return starts
.map((key, i) => ({
t: key,
mean: Math.round(value(i / starts.length, key) * 10) / 10,
count: key === SYS_THIN ? 2 : 4 + (i % 9),
}))
.filter((p) => !skip(p.t));
}
function series(dataFrom: string, today: string, bucket: Bucket) {
const r = rng(11);
const months = monthly(dataFrom, today, () => 0).length;
return [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 147 - (18 * i) / months + (r() - 0.5) * 4,
bucket,
(f) => 147 - 18 * f + (r() - 0.5) * 4,
(t) => bucket === "quarter" && SYS_MISSING.has(t),
),
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 92 - (10 * i) / months + (r() - 0.5) * 3,
bucket,
(f) => 92 - 10 * f + (r() - 0.5) * 3,
),
},
{
key: "WEIGHT",
unit: "kg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6,
bucket,
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
),
},
{
key: "RESTING_HEART_RATE",
unit: "bpm",
granularity: "month",
points: monthly(dataFrom, today, (i, k) => {
points: points(dataFrom, today, bucket, (f, k) => {
const month = Number(k.slice(5, 7));
return (
68 -
(5 * i) / months +
Math.sin((month / 12) * 6.28) * 1.2 +
(r() - 0.5) * 1.2
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
);
}),
},
{
key: "PULSE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
},
{
key: "BODY_FAT",
unit: "%",
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
},
{
key: "ACTIVITY_STEPS",
unit: "steps",
points: points(
dataFrom,
today,
bucket,
(f) => 7200 + 900 * f + r() * 400,
),
},
];
}
@@ -350,7 +416,8 @@ export function fullTimeline(today: string) {
},
],
standing: [],
series: series("2019-01-01", today),
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
@@ -468,7 +535,8 @@ export function readyTimeline(today: string) {
},
],
standing: [],
series: series("2024-05-01", today).filter(
bucket: "quarter" as Bucket,
series: series("2024-05-01", today, "quarter").filter(
(s) => s.key !== "RESTING_HEART_RATE",
),
notable: [],
@@ -673,10 +741,24 @@ export async function mockTimeline(
const values = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
const zoom = url.searchParams.get("zoom") ?? "all";
const bucket = bucketFor(zoom);
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
const all =
bucket === body.bucket ? body.series : series(dataFrom, today, bucket);
return json(route, 200, {
...body,
zoom: url.searchParams.get("zoom") ?? "all",
series: body.series.filter((s) => values.includes(s.key)),
zoom,
bucket,
// Any other value the menu offers answers a flat line of its own.
series: values.map(
(key) =>
all.find((s) => s.key === key) ?? {
key,
unit: null,
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
},
),
});
});
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "Zeitleiste",
"subtitle": "Was wann war, neben deinen Werten.",
"legend": "Untereinander heißt gleichzeitig, nicht ursächlich.",
"standing": "Dauerhaft",
"startMissing": "Startdatum fehlt",
"openDay": "{date} öffnen",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "Zeitraum",
"legendEvent": "Ereignis",
"legendMean": "Monatsmittel",
"legendMeanQuarter": "Quartalsmittel",
"legendMeanMonth": "Monatsmittel",
"legendMeanWeek": "Wochenmittel",
"legendThin": "Weniger als 3 Messungen",
"today": "Heute",
"noDataYet": "Noch keine Daten",
"layers": "Ebenen",
@@ -12416,20 +12418,27 @@
"description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier."
},
"values": {
"menuLabel": "Bis zu drei Werte",
"menuLabel": "Bis zu sechs Werte",
"none": "Keine Werte",
"noneInWindow": "Keine Werte in diesem Zeitraum",
"series": {
"BLOOD_PRESSURE_SYS": "Blutdruck sys.",
"BLOOD_PRESSURE_DIA": "Blutdruck dia."
}
},
"noValue": "kein Wert",
"bloodPressure": "Blutdruck",
"readingsOne": "1 Messung",
"readingsFew": "Schnitt aus {count} Messungen",
"readingsOther": "Schnitt aus {count} Messungen"
},
"table": {
"caption": "Einträge der Zeitleiste",
"lane": "Spur",
"entry": "Eintrag",
"start": "Beginn",
"end": "Ende"
"end": "Ende",
"seriesCaption": "Mittelwerte nach Zeitraum",
"period": "Zeitraum"
},
"selection": {
"empty": "Keine Einträge in diesem Monat",
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "Timeline",
"subtitle": "What happened when, beside your values.",
"legend": "Stacked means at the same time, not cause and effect.",
"standing": "Ongoing",
"startMissing": "Start date missing",
"openDay": "Open {date}",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "Period",
"legendEvent": "Event",
"legendMean": "Monthly mean",
"legendMeanQuarter": "Quarterly mean",
"legendMeanMonth": "Monthly mean",
"legendMeanWeek": "Weekly mean",
"legendThin": "Fewer than 3 readings",
"today": "Today",
"noDataYet": "No data yet",
"layers": "Layers",
@@ -12416,20 +12418,27 @@
"description": "Dated entries appear here as soon as there are some."
},
"values": {
"menuLabel": "Up to three values",
"menuLabel": "Up to six values",
"none": "No values",
"noneInWindow": "No values in this range",
"series": {
"BLOOD_PRESSURE_SYS": "Blood pressure sys.",
"BLOOD_PRESSURE_DIA": "Blood pressure dia."
}
},
"noValue": "no value",
"bloodPressure": "Blood pressure",
"readingsOne": "1 reading",
"readingsFew": "mean of {count} readings",
"readingsOther": "mean of {count} readings"
},
"table": {
"caption": "Timeline entries",
"lane": "Lane",
"entry": "Entry",
"start": "Start",
"end": "End"
"end": "End",
"seriesCaption": "Value means by period",
"period": "Period"
},
"selection": {
"empty": "No entries this month",
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "Cronología",
"subtitle": "Qué pasó y cuándo, junto a tus valores.",
"legend": "Uno debajo de otro significa al mismo tiempo, no causa y efecto.",
"standing": "Permanente",
"startMissing": "Falta la fecha de inicio",
"openDay": "Abrir {date}",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "Periodo",
"legendEvent": "Evento",
"legendMean": "Media mensual",
"legendMeanQuarter": "Media trimestral",
"legendMeanMonth": "Media mensual",
"legendMeanWeek": "Media semanal",
"legendThin": "Menos de 3 mediciones",
"today": "Hoy",
"noDataYet": "Aún sin datos",
"layers": "Capas",
@@ -12416,20 +12418,27 @@
"description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna."
},
"values": {
"menuLabel": "Hasta tres valores",
"menuLabel": "Hasta seis valores",
"none": "Sin valores",
"noneInWindow": "Sin valores en este periodo",
"series": {
"BLOOD_PRESSURE_SYS": "Presión sistólica",
"BLOOD_PRESSURE_DIA": "Presión diastólica"
}
},
"noValue": "sin valor",
"bloodPressure": "Presión arterial",
"readingsOne": "1 medición",
"readingsFew": "media de {count} mediciones",
"readingsOther": "media de {count} mediciones"
},
"table": {
"caption": "Entradas de la cronología",
"lane": "Pista",
"entry": "Entrada",
"start": "Inicio",
"end": "Fin"
"end": "Fin",
"seriesCaption": "Medias por periodo",
"period": "Periodo"
},
"selection": {
"empty": "Sin entradas este mes",
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "Chronologie",
"subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.",
"legend": "Superposé signifie simultané, pas cause et effet.",
"standing": "Permanent",
"startMissing": "Date de début manquante",
"openDay": "Ouvrir le {date}",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "Période",
"legendEvent": "Événement",
"legendMean": "Moyenne mensuelle",
"legendMeanQuarter": "Moyenne trimestrielle",
"legendMeanMonth": "Moyenne mensuelle",
"legendMeanWeek": "Moyenne hebdomadaire",
"legendThin": "Moins de 3 mesures",
"today": "Aujourd’hui",
"noDataYet": "Pas encore de données",
"layers": "Calques",
@@ -12416,20 +12418,27 @@
"description": "Les entrées datées apparaîtront ici dès qu’il y en aura."
},
"values": {
"menuLabel": "Jusqu’à trois valeurs",
"menuLabel": "Jusqu’à six valeurs",
"none": "Aucune valeur",
"noneInWindow": "Aucune valeur sur cette période",
"series": {
"BLOOD_PRESSURE_SYS": "Tension systolique",
"BLOOD_PRESSURE_DIA": "Tension diastolique"
}
},
"noValue": "aucune valeur",
"bloodPressure": "Tension artérielle",
"readingsOne": "1 mesure",
"readingsFew": "moyenne de {count} mesures",
"readingsOther": "moyenne de {count} mesures"
},
"table": {
"caption": "Entrées de la chronologie",
"lane": "Piste",
"entry": "Entrée",
"start": "Début",
"end": "Fin"
"end": "Fin",
"seriesCaption": "Moyennes par période",
"period": "Période"
},
"selection": {
"empty": "Aucune entrée ce mois-ci",
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "Cronologia",
"subtitle": "Cosa è successo e quando, accanto ai tuoi valori.",
"legend": "Uno sotto l’altro significa nello stesso momento, non causa ed effetto.",
"standing": "Permanente",
"startMissing": "Data di inizio mancante",
"openDay": "Apri {date}",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "Periodo",
"legendEvent": "Evento",
"legendMean": "Media mensile",
"legendMeanQuarter": "Media trimestrale",
"legendMeanMonth": "Media mensile",
"legendMeanWeek": "Media settimanale",
"legendThin": "Meno di 3 misurazioni",
"today": "Oggi",
"noDataYet": "Ancora nessun dato",
"layers": "Livelli",
@@ -12416,20 +12418,27 @@
"description": "Le voci con data compariranno qui appena ce ne saranno."
},
"values": {
"menuLabel": "Fino a tre valori",
"menuLabel": "Fino a sei valori",
"none": "Nessun valore",
"noneInWindow": "Nessun valore in questo periodo",
"series": {
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
"BLOOD_PRESSURE_DIA": "Pressione diastolica"
}
},
"noValue": "nessun valore",
"bloodPressure": "Pressione arteriosa",
"readingsOne": "1 misurazione",
"readingsFew": "media di {count} misurazioni",
"readingsOther": "media di {count} misurazioni"
},
"table": {
"caption": "Voci della cronologia",
"lane": "Corsia",
"entry": "Voce",
"start": "Inizio",
"end": "Fine"
"end": "Fine",
"seriesCaption": "Medie per periodo",
"period": "Periodo"
},
"selection": {
"empty": "Nessuna voce in questo mese",
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "타임라인",
"subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.",
"legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.",
"standing": "지속",
"startMissing": "시작일 없음",
"openDay": "{date} 열기",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "기간",
"legendEvent": "사건",
"legendMean": "월평균",
"legendMeanQuarter": "분기 평균",
"legendMeanMonth": "월평균",
"legendMeanWeek": "주간 평균",
"legendThin": "측정 3회 미만",
"today": "오늘",
"noDataYet": "아직 데이터 없음",
"layers": "레이어",
@@ -12416,20 +12418,27 @@
"description": "날짜가 있는 기록이 생기면 여기에 나타나요."
},
"values": {
"menuLabel": "최대 세 가지 수치",
"menuLabel": "최대 여섯 가지 수치",
"none": "수치 없음",
"noneInWindow": "이 기간에 수치 없음",
"series": {
"BLOOD_PRESSURE_SYS": "수축기 혈압",
"BLOOD_PRESSURE_DIA": "이완기 혈압"
}
},
"noValue": "값 없음",
"bloodPressure": "혈압",
"readingsOne": "측정 1회",
"readingsFew": "측정 {count}회 평균",
"readingsOther": "측정 {count}회 평균"
},
"table": {
"caption": "타임라인 기록",
"lane": "줄",
"entry": "기록",
"start": "시작",
"end": "끝"
"end": "끝",
"seriesCaption": "기간별 평균",
"period": "기간"
},
"selection": {
"empty": "이번 달 기록 없음",
+14 -5
View File
@@ -12280,7 +12280,6 @@
"timeline": {
"title": "Oś czasu",
"subtitle": "Co i kiedy się działo, obok Twoich wartości.",
"legend": "Jedno pod drugim oznacza ten sam czas, nie przyczynę i skutek.",
"standing": "Stałe",
"startMissing": "Brak daty rozpoczęcia",
"openDay": "Otwórz {date}",
@@ -12402,7 +12401,10 @@
},
"legendSpan": "Okres",
"legendEvent": "Zdarzenie",
"legendMean": "Średnia miesięczna",
"legendMeanQuarter": "Średnia kwartalna",
"legendMeanMonth": "Średnia miesięczna",
"legendMeanWeek": "Średnia tygodniowa",
"legendThin": "Mniej niż 3 pomiary",
"today": "Dziś",
"noDataYet": "Jeszcze brak danych",
"layers": "Warstwy",
@@ -12416,20 +12418,27 @@
"description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą."
},
"values": {
"menuLabel": "Do trzech wartości",
"menuLabel": "Do sześciu wartości",
"none": "Brak wartości",
"noneInWindow": "Brak wartości w tym okresie",
"series": {
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe"
}
},
"noValue": "brak wartości",
"bloodPressure": "Ciśnienie krwi",
"readingsOne": "1 pomiar",
"readingsFew": "średnia z {count} pomiarów",
"readingsOther": "średnia z {count} pomiarów"
},
"table": {
"caption": "Wpisy na osi czasu",
"lane": "Ścieżka",
"entry": "Wpis",
"start": "Początek",
"end": "Koniec"
"end": "Koniec",
"seriesCaption": "Średnie według okresu",
"period": "Okres"
},
"selection": {
"empty": "Brak wpisów w tym miesiącu",
@@ -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>
);
}