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 && ( + <> + + + + + + )}
    ); }