From fcc6f404106efe8816fe6908dc00ffebde070533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 09:03:42 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 5 +- e2e/timeline.spec.ts | 86 +++++++++- e2e/utils/mock-timeline.ts | 148 ++++++++++++++---- messages/de.json | 19 ++- messages/en.json | 19 ++- messages/es.json | 19 ++- messages/fr.json | 19 ++- messages/it.json | 19 ++- messages/ko.json | 19 ++- messages/pl.json | 19 ++- .../__tests__/timeline-components.test.tsx | 93 ++++++++++- .../timeline/__tests__/timeline-fixture.ts | 22 +-- .../__tests__/timeline-geometry.test.ts | 141 +++++++++++++++-- src/components/timeline/selection-bar.tsx | 51 +++--- src/components/timeline/series-format.ts | 131 +++++++++++++++- src/components/timeline/timeline-chart.tsx | 136 +++++++++++++++- .../timeline/timeline-chronicle.tsx | 42 +++-- src/components/timeline/timeline-dates.ts | 58 +++++++ src/components/timeline/timeline-geometry.ts | 110 +++++++++---- src/components/timeline/timeline-menus.tsx | 6 +- src/components/timeline/timeline-view.tsx | 52 ++++-- 21 files changed, 1011 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bdae4fa6..23cd87923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 14723d0b6..8fcdc3bb9 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -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( diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 0159c1a69..f2f2542ce 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -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) => { diff --git a/messages/de.json b/messages/de.json index b7cc77b50..1359c9ab7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index bdea7e6a7..b4bf7793e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 3e9806ff9..1d82142bc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 4ffa3c91a..d04b61760 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 7fb859048..1abc28247 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 3405b2f39..8ea928d80 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "이번 달 기록 없음", diff --git a/messages/pl.json b/messages/pl.json index 7100d719b..8bde6862a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 5340a1fe3..3aef7cb9a 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -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( + 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 = { + 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( 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( + 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("", 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/); }); }); diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 2b466afd7..11e3cf2b1 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -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" }], diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 8b3f36e19..6bd8cc4cf 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -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(); }); }); diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 50d143b44..901e6032e 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -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({ ); })} - {means.length > 0 && ( - + {means.values.length > 0 && ( + {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), + }, + )}`, })} )} diff --git a/src/components/timeline/series-format.ts b/src/components/timeline/series-format.ts index 01315881b..53bb50179 100644 --- a/src/components/timeline/series-format.ts +++ b/src/components/timeline/series-format.ts @@ -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 = 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 { + const out = new Map(); + for (const s of series) { + const points = [...s.points].sort((a, b) => (a.t < b.t ? 1 : -1)); + const taken = new Set(); + 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, ): 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, + 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(" · "); +} diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 1d44cf58e..4f99b953c 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -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} /> + ); } @@ -254,6 +266,7 @@ interface ChartBodyProps { today: string; selected: string | null; dataFrom: string | null; + bucket: TimelineBucket; seriesLabel: (key: string) => string; intl: string; t: ReturnType["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 && ( - )} + {series.points.map((point) => ( + + + {`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`} + + + ))} ))} @@ -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["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 ( + + + + + + {series.map((s) => ( + + ))} + + + + {rows.reverse().map((start) => ( + + + {series.map((s) => { + const point = byKey.get(s.key)?.get(start); + return ( + + ); + })} + + ))} + +
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} + {seriesLabel(s.key)} +
{bucketText(start, bucket, intl, t)} + {point + ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` + : t("timeline.values.noValue")} +
+ ); +} + /** Inline style for a lane colour dot in HTML (chips, chronicle rails). */ export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties { return { background: LANE_COLOR[lane] }; diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index d1cf93668..42ed5aab4 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -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> = { 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(); 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 (
  • {groupLabel(row.group)} - {means.length > 0 && ( - - {formatMonthMeans(means, fmt)} + {bucketMeans && line && ( + + {timeline.bucket === "month" + ? line + : `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`} )}
  • diff --git a/src/components/timeline/timeline-dates.ts b/src/components/timeline/timeline-dates.ts index ac971fffb..9869875c8 100644 --- a/src/components/timeline/timeline-dates.ts +++ b/src/components/timeline/timeline-dates.ts @@ -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), + }; +} diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 4a430a388..3195e221d 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -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 = { - 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> = { + 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 }; } diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index 657aba679..c4878a0d4 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -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, diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index ecca6c373..c87e2c477 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -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} /> - + 0} + /> @@ -436,7 +440,19 @@ export function TimelineView() { ); } -function Legend() { +const LEGEND_MEAN_KEY: Readonly> = { + quarter: "timeline.legendMeanQuarter", + month: "timeline.legendMeanMonth", + week: "timeline.legendMeanWeek", +}; + +function Legend({ + bucket, + hasSeries, +}: { + bucket: TimelineBucket; + hasSeries: boolean; +}) { const { t } = useTranslations(); return (
    {t("timeline.legendEvent")} - - - - {t("timeline.legend")} - + {hasSeries && ( + <> + + + + + + )}
    ); }