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/docs/api/openapi.yaml b/docs/api/openapi.yaml index 363e22769..7a44b2306 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23467,9 +23467,11 @@ paths: - in: query name: values schema: - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an + unknown name, answers 422. type: string - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an + unknown name, answers 422. responses: "200": description: The timeline for the requested zoom and window. @@ -81553,6 +81555,14 @@ components: items: $ref: "#/components/schemas/TimelineStandingItem" description: "Things without a start: shown once as standing." + bucket: + type: string + enum: + - quarter + - month + - week + description: "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` + under two years, `week` for `quarter`." series: type: array items: @@ -81581,6 +81591,7 @@ components: - range - lanes - standing + - bucket - series - notable additionalProperties: false @@ -81695,12 +81706,6 @@ components: anyOf: - type: string - type: "null" - granularity: - type: string - enum: - - month - - week - - day points: type: array items: @@ -81708,17 +81713,25 @@ components: properties: t: type: string - description: A local calendar date, `YYYY-MM-DD`. + description: The bucket's first day. mean: type: number + description: The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a + pulse-like type's its hourly-mean day value). + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Readings behind the mean. Clients draw a point from fewer than three as thin. required: - t - mean + - count additionalProperties: false + description: Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated. required: - key - unit - - granularity - points additionalProperties: false TimelineReadinessEnvelope: 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 bf34146de..608b0d4d2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12284,7 +12284,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", @@ -12406,7 +12405,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", @@ -12420,20 +12422,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 9d38f2574..2f105f9bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12284,7 +12284,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}", @@ -12406,7 +12405,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", @@ -12420,20 +12422,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 4979a7936..648695715 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12284,7 +12284,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}", @@ -12406,7 +12405,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", @@ -12420,20 +12422,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 c2e16bf6e..4e4f8c76b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12284,7 +12284,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}", @@ -12406,7 +12405,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", @@ -12420,20 +12422,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 f4cb777dc..1fdc02ed6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12284,7 +12284,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}", @@ -12406,7 +12405,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", @@ -12420,20 +12422,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 b570216a0..6ba9213ac 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12284,7 +12284,6 @@ "timeline": { "title": "타임라인", "subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.", - "legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.", "standing": "지속", "startMissing": "시작일 없음", "openDay": "{date} 열기", @@ -12406,7 +12405,10 @@ }, "legendSpan": "기간", "legendEvent": "사건", - "legendMean": "월평균", + "legendMeanQuarter": "분기 평균", + "legendMeanMonth": "월평균", + "legendMeanWeek": "주간 평균", + "legendThin": "측정 3회 미만", "today": "오늘", "noDataYet": "아직 데이터 없음", "layers": "레이어", @@ -12420,20 +12422,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 46f29666b..25bd934a2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12284,7 +12284,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}", @@ -12406,7 +12405,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", @@ -12420,20 +12422,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/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index 6180427b2..a14be92d3 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -45,7 +45,7 @@ export const GET = apiHandler(async (request: Request) => { } const seriesKeys = parseSeriesKeys(parsed.data.values); if (seriesKeys === null) { - return apiError("Unknown value series", 422, { + return apiError("Unknown value series, or more than six", 422, { errorCode: "timeline.invalid", }); } 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 && ( + <> + + + + + + )}
    ); } diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index bc3c8863e..ffb3ee430 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -216,6 +216,22 @@ export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const; export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number]; +/** + * The span one value-series point averages. The server picks it per zoom + * (`timelineBucket` in `src/lib/timeline/load-timeline.ts`): quarters for a + * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`. + */ +export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const; + +export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number]; + +/** + * The most value series one request may name. Each series is its own row + * under the lanes, drawn in neutral ink and labelled at the left, so the cap + * is the chart's height, not a palette: six rows add 360 px under the lanes. + */ +export const TIMELINE_MAX_SERIES = 6; + /** * The lanes, top to bottom. An empty lane is not sent. Each lane a module * owns is a surface (`timeline-lane:`) and disappears with it. diff --git a/src/lib/day/daily-stats.ts b/src/lib/day/daily-stats.ts index 0645b57dc..a9bcba033 100644 --- a/src/lib/day/daily-stats.ts +++ b/src/lib/day/daily-stats.ts @@ -30,6 +30,15 @@ import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read"; /** Day key → the day's value, per type. */ export type DailyStats = Map>; +/** A day's value with the readings behind it (the picked source's). */ +export interface DayCell { + value: number; + count: number; +} + +/** Day key → the day's value and reading count, per type. */ +export type DailyCells = Map>; + interface StatRow { type: MeasurementType; source: MeasurementSource; @@ -60,7 +69,22 @@ export async function readLocalDailyStats(args: { tz: string; priorityJson: unknown; }): Promise { - const out: DailyStats = new Map(); + return valuesOnly(await readLocalDailyCells(args)); +} + +/** + * {@link readLocalDailyStats} with each day's reading count beside its + * value: the timeline marks a mean that rests on few readings. + */ +export async function readLocalDailyCells(args: { + userId: string; + types: readonly MeasurementType[]; + from: Date; + to: Date; + tz: string; + priorityJson: unknown; +}): Promise { + const out: DailyCells = new Map(); if (args.types.length === 0 || args.from >= args.to) return out; const hourlyTypes = args.types.filter((t) => HOURLY_MEAN_DAY_TYPES.has(t)); // Every value is parameter-bound; the enum casts are literal SQL. The hour @@ -87,7 +111,17 @@ export async function readLocalDailyStats(args: { AND "measured_at" < ${args.to} GROUP BY 1, 2, 3, 4 `; - return foldDailyStats(rows, args.priorityJson); + return foldDailyCells(rows, args.priorityJson); +} + +function valuesOnly(cells: DailyCells): DailyStats { + const out: DailyStats = new Map(); + for (const [type, days] of cells) { + const perDay = new Map(); + for (const [day, cell] of days) perDay.set(day, cell.value); + out.set(type, perDay); + } + return out; } /** @@ -99,6 +133,14 @@ export function foldDailyStats( rows: readonly StatRow[], priorityJson: unknown, ): DailyStats { + return valuesOnly(foldDailyCells(rows, priorityJson)); +} + +/** {@link foldDailyStats} with the picked source's reading count per day. */ +export function foldDailyCells( + rows: readonly StatRow[], + priorityJson: unknown, +): DailyCells { // type → day → source → hour cells const nested = new Map< MeasurementType, @@ -114,7 +156,7 @@ export function foldDailyStats( cells.push({ total: Number(row.total), count: Number(row.count) }); } - const out: DailyStats = new Map(); + const out: DailyCells = new Map(); for (const [type, days] of nested) { const cumulative = isCumulativeDaySumType(type); const hourly = HOURLY_MEAN_DAY_TYPES.has(type); @@ -141,9 +183,12 @@ export function foldDailyStats( } } const picked = collapseRollupRowsBySource(cells, type, priorityJson); - const perDay = new Map(); + const perDay = new Map(); for (const cell of picked) { - perDay.set(dateOnlyKey(cell.bucketStart), cell.value); + perDay.set(dateOnlyKey(cell.bucketStart), { + value: cell.value, + count: cell.count, + }); } out.set(type, perDay); } diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 3a4b7d88e..0bbc1892f 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -22,6 +22,7 @@ import { LIFE_EVENT_NOTE_MAX, LIFE_EVENT_PRECISIONS, LIFE_EVENT_TITLE_MAX, + TIMELINE_BUCKETS, TIMELINE_ITEM_KINDS, TIMELINE_LANE_KEYS, TIMELINE_READINESS_KEYS, @@ -273,7 +274,7 @@ export const timelineQuerySchema = z.object({ .string() .optional() .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`.", + "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", ), }); @@ -310,8 +311,27 @@ const timelineSeries = z .object({ key: z.string().describe("A `MeasurementType` or `MOOD`."), unit: z.string().nullable(), - granularity: z.enum(["month", "week", "day"]), - points: z.array(z.object({ t: dateKeySchema, mean: z.number() })), + points: z + .array( + z.object({ + t: dateKeySchema.describe("The bucket's first day."), + mean: z + .number() + .describe( + "The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a pulse-like type's its hourly-mean day value).", + ), + count: z + .number() + .int() + .min(1) + .describe( + "Readings behind the mean. Clients draw a point from fewer than three as thin.", + ), + }), + ) + .describe( + "Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated.", + ), }) .meta({ id: "TimelineSeries" }); @@ -334,6 +354,11 @@ export const timelineResponseSchema = z standing: z .array(timelineStanding) .describe("Things without a start: shown once as standing."), + bucket: z + .enum(TIMELINE_BUCKETS) + .describe( + "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`.", + ), series: z.array(timelineSeries), notable: z.array( z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }), diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts index 0e0506328..b9aa6d4c4 100644 --- a/src/lib/timeline/__tests__/series.test.ts +++ b/src/lib/timeline/__tests__/series.test.ts @@ -3,9 +3,9 @@ */ import { describe, expect, it } from "vitest"; -import { overlaps, parseSeriesKeys } from "../load-timeline"; +import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline"; import { coveredWeeks, READINESS_WEEKS } from "../readiness"; -import { bucketKey, foldDays } from "../series"; +import { bucketKey, foldDays, foldMonths } from "../series"; describe("buckets", () => { it("names a bucket by its first local day, weeks from Monday", () => { @@ -13,23 +13,65 @@ describe("buckets", () => { // 2026-03-29 is a Sunday; its week began on Monday the 23rd. expect(bucketKey("2026-03-29", "week")).toBe("2026-03-23"); expect(bucketKey("2026-03-23", "week")).toBe("2026-03-23"); - expect(bucketKey("2026-03-29", "day")).toBe("2026-03-29"); + expect(bucketKey("2026-03-29", "quarter")).toBe("2026-01-01"); + expect(bucketKey("2026-04-01", "quarter")).toBe("2026-04-01"); + expect(bucketKey("2026-12-31", "quarter")).toBe("2026-10-01"); }); - it("weighs each day once in a bucket", () => { + it("weighs each day once in a bucket and counts the readings behind it", () => { const points = foldDays( new Map([ - ["2026-03-01", 100], - ["2026-03-02", 110], - ["2026-04-01", 90], + ["2026-03-01", { value: 100, count: 3 }], + ["2026-03-02", { value: 110, count: 1 }], + ["2026-04-01", { value: 90, count: 1 }], ]), "month", ); expect(points).toEqual([ - { t: "2026-03-01", mean: 105 }, - { t: "2026-04-01", mean: 90 }, + { t: "2026-03-01", mean: 105, count: 4 }, + { t: "2026-04-01", mean: 90, count: 1 }, ]); }); + + it("leaves a bucket without a reading out rather than filling it", () => { + const points = foldDays( + new Map([ + ["2026-02-10", { value: 120, count: 2 }], + ["2026-06-10", { value: 130, count: 2 }], + ["2026-08-10", { value: 125, count: 1 }], + ]), + "month", + ); + expect(points.map((p) => p.t)).toEqual([ + "2026-02-01", + "2026-06-01", + "2026-08-01", + ]); + }); + + it("folds rolled-up months into quarters, each month weighing its days", () => { + expect( + foldMonths( + [ + { t: "2026-01-01", mean: 100, days: 30, count: 30 }, + { t: "2026-02-01", mean: 130, days: 10, count: 12 }, + { t: "2026-04-01", mean: 90, days: 2, count: 2 }, + ], + "quarter", + ), + ).toEqual([ + // (100·30 + 130·10) / 40, not the plain mean of the two months (115). + { t: "2026-01-01", mean: 107.5, count: 42 }, + { t: "2026-04-01", mean: 90, count: 2 }, + ]); + }); + + it("picks the bucket by zoom, quarters only for a long `all`", () => { + expect(timelineBucket("quarter", "2026-01-01", "2026-03-31")).toBe("week"); + expect(timelineBucket("year", "2025-04-01", "2026-03-31")).toBe("month"); + expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month"); + expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter"); + }); }); describe("window", () => { @@ -55,6 +97,19 @@ describe("window", () => { expect(parseSeriesKeys("WEIGHT,MOOD,WEIGHT")).toEqual(["WEIGHT", "MOOD"]); expect(parseSeriesKeys("WEIGHT,DROP TABLE")).toBeNull(); }); + + it("accepts up to six series and refuses a seventh", () => { + const six = [ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "WEIGHT", + "PULSE", + "MOOD", + "BODY_FAT", + ]; + expect(parseSeriesKeys(six.join(","))).toEqual(six); + expect(parseSeriesKeys([...six, "BLOOD_GLUCOSE"].join(","))).toBeNull(); + }); }); describe("readiness weeks", () => { diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index 0b0691b54..bc97b723d 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -18,6 +18,8 @@ import type { MeasurementType } from "@/generated/prisma/enums"; import { DAY_NOTABLE_MAX_SPAN_DAYS, TIMELINE_LANE_KEYS, + TIMELINE_MAX_SERIES, + type TimelineBucket, type TimelineItem, type TimelineQuery, type TimelineResponse, @@ -33,12 +35,8 @@ import { type StandingItem, type TimelineAccess, } from "@/lib/timeline/lanes"; -import { - loadTimelineSeries, - MOOD_SERIES_KEY, - type SeriesGranularity, -} from "@/lib/timeline/series"; -import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series"; +import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; /** The series sent when the request names none. */ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ @@ -47,11 +45,27 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ "WEIGHT", ]; -const ZOOM_GRANULARITY: Readonly> = { - all: "month", - year: "week", - quarter: "day", -}; +/** An `all` window longer than this many days averages quarters. */ +const QUARTER_BUCKET_MIN_DAYS = 2 * 365; + +/** + * The span one series point averages. A point per day or per week over + * years drew a line of fragments wherever readings came in bursts, so the + * bucket grows with the window: weeks in three months (thirteen points), + * months in a year (twelve), quarters once `all` reaches back more than two + * years. An `all` window shorter than that keeps months, so a young record + * does not shrink to a handful of quarters. + */ +export function timelineBucket( + zoom: TimelineZoom, + from: string, + to: string, +): TimelineBucket { + if (zoom === "quarter") return "week"; + if (zoom === "year") return "month"; + const days = daysBetweenDateKeys(from, to); + return days > QUARTER_BUCKET_MIN_DAYS ? "quarter" : "month"; +} const ZOOM_DAYS: Readonly, number>> = { year: 365, @@ -64,7 +78,8 @@ const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( /** * The series keys a `values` parameter names, or null when one is not a - * series this API knows (the route answers 422). + * series this API knows or there are more than {@link TIMELINE_MAX_SERIES} + * (the route answers 422). */ export function parseSeriesKeys(raw: string | undefined): string[] | null { if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES]; @@ -76,7 +91,7 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null { .filter((k) => k.length > 0), ), ]; - if (keys.length > 8) return null; + if (keys.length > TIMELINE_MAX_SERIES) return null; for (const key of keys) { if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null; } @@ -158,6 +173,7 @@ export async function loadTimeline(args: { shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) > from ? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) : from; + const bucket = timelineBucket(query.zoom, from, to); const [series, notable] = await Promise.all([ loadTimelineSeries({ userId: recordId, @@ -165,7 +181,7 @@ export async function loadTimeline(args: { from, to, tz, - granularity: ZOOM_GRANULARITY[query.zoom], + bucket, priorityJson, now, }), @@ -187,6 +203,7 @@ export async function loadTimeline(args: { range: { from, to, dataFrom }, lanes, standing, + bucket, series, notable: notable.map(({ date, kind }) => ({ date, kind })), }; diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index aa51c1024..9dd6a56b7 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -1,76 +1,137 @@ /** - * Value series for the timeline: monthly, weekly or daily means beside the - * lanes (v1.42, #613). + * Value series for the timeline: quarterly, monthly or weekly means beside + * the lanes (v1.42, #613). * - * Three ways in, by zoom: + * The bucket comes with the zoom (`timelineBucket` in `load-timeline.ts`), + * and the source with the window's length: * - * - **all (month)**: the months of the last five years come from the rollup - * tier, MONTH buckets folded from the canonical-source DAY rows. Those - * buckets cut at UTC midnight, so a reading in the first or last hours of - * a month can sit in the neighbouring month. For a monthly mean over + * - **longer than a year** (`all`): the last five years come from the + * rollup tier, MONTH buckets folded from the canonical-source DAY rows + * and, for quarters, folded once more here, each month weighing its days. + * Those buckets cut at UTC midnight, so a reading in the first or last + * hours of a month can sit in the neighbouring month. For a mean over * dozens of days that is a rounding matter, and it is accepted here; the * day view, where it is not, never reads the rollups. The rollups fold - * only five years (`ROLLUP_FOLD_WINDOW_MS`); the months before that come + * only five years (`ROLLUP_FOLD_WINDOW_MS`); the buckets before that come * from one live query over the record's own local days, cached per record * in the analytics bucket, which every measurement write marks stale. - * - **year (week)** and **quarter (day)**: live over the record's local - * days, a year at most. + * - **a year or less** (`year`, `quarter`, a short `all`): live over the + * record's local days. * * Every value is the mean of the bucket's days, each day weighing one (a - * cumulative type's days are its day totals), matching the charts. A series + * cumulative type's days are its day totals, a pulse-like type's day is the + * mean of its hourly means, `daily-stats.ts`), matching the charts. Each + * point carries the readings behind it, so the chart can mark a mean that + * rests on one or two. A bucket without a reading is not sent, and a series * without a point is not sent. `MOOD` is the mood score, read from the - * entries' own dates. + * entries' own dates; its count is the entries. */ import type { MeasurementType } from "@/generated/prisma/enums"; import { caches, cached } from "@/lib/cache/server-cache"; -import { readLocalDailyStats } from "@/lib/day/daily-stats"; -import type { TimelineSeries } from "@/lib/day/contract"; +import { readLocalDailyCells, type DayCell } from "@/lib/day/daily-stats"; +import type { TimelineBucket, TimelineSeries } from "@/lib/day/contract"; import { prisma } from "@/lib/db"; import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read"; import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; import { dateOnlyKey } from "@/lib/tz/date-only"; -import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format"; +import { + daysBetweenDateKeys, + shiftDateKey, + userDayKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; -export type SeriesGranularity = TimelineSeries["granularity"]; +export type SeriesPoint = TimelineSeries["points"][number]; /** - * How long the months before the fold window stay cached. They change only + * How long the buckets before the fold window stay cached. They change only * when an old reading is imported or deleted, and either write marks the * entry stale at once. */ const PRE_FOLD_TTL_MS = 6 * 3_600_000; +/** Windows longer than this read the rollup tier rather than live days. */ +const LIVE_WINDOW_MAX_DAYS = 366; + /** The series key for the mood score. */ export const MOOD_SERIES_KEY = "MOOD"; /** The bucket a local day belongs to, named by its first day. */ -export function bucketKey(day: string, granularity: SeriesGranularity): string { - if (granularity === "day") return day; - if (granularity === "month") return `${day.slice(0, 7)}-01`; +export function bucketKey(day: string, bucket: TimelineBucket): string { + if (bucket === "month") return `${day.slice(0, 7)}-01`; + if (bucket === "quarter") { + const month = Number(day.slice(5, 7)); + const first = month - ((month - 1) % 3); + return `${day.slice(0, 4)}-${String(first).padStart(2, "0")}-01`; + } // Weeks start on Monday. const offset = (weekdayOfDateKey(day) + 6) % 7; return shiftDateKey(day, -offset); } -/** Fold a day series into buckets: the mean of the bucket's days. */ +const round2 = (v: number) => Math.round(v * 100) / 100; + +/** + * Fold a day series into buckets: the mean of the bucket's days, and the + * readings behind them. + */ export function foldDays( - days: ReadonlyMap, - granularity: SeriesGranularity, -): Array<{ t: string; mean: number }> { - const buckets = new Map(); - for (const [day, value] of days) { - const key = bucketKey(day, granularity); - const slot = buckets.get(key) ?? { sum: 0, n: 0 }; - slot.sum += value; + days: ReadonlyMap, + bucket: TimelineBucket, +): SeriesPoint[] { + const buckets = new Map(); + for (const [day, cell] of days) { + const key = bucketKey(day, bucket); + const slot = buckets.get(key) ?? { sum: 0, n: 0, count: 0 }; + slot.sum += cell.value; slot.n += 1; + slot.count += cell.count; buckets.set(key, slot); } return [...buckets] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([t, { sum, n }]) => ({ t, mean: Math.round((sum / n) * 100) / 100 })); + .map(([t, { sum, n, count }]) => ({ t, mean: round2(sum / n), count })); +} + +/** A month from the rollup tier: its day mean, its days, its readings. */ +export interface RolledMonth { + t: string; + mean: number; + days: number; + count: number; +} + +/** + * Fold rolled-up months into `bucket`: each month weighs its days, so a + * quarter's mean is the mean of its days exactly as a live fold makes it. + */ +export function foldMonths( + months: readonly RolledMonth[], + bucket: Exclude, +): SeriesPoint[] { + const buckets = new Map< + string, + { sum: number; days: number; count: number } + >(); + for (const m of months) { + const key = bucketKey(m.t, bucket); + const slot = buckets.get(key) ?? { sum: 0, days: 0, count: 0 }; + slot.sum += m.mean * m.days; + slot.days += m.days; + slot.count += m.count; + buckets.set(key, slot); + } + return [...buckets] + .sort((a, b) => a[0].localeCompare(b[0])) + .filter(([, b]) => b.days > 0 && b.count > 0) + .map(([t, { sum, days, count }]) => ({ + t, + mean: round2(sum / days), + count, + })); } async function latestUnit( @@ -85,24 +146,25 @@ async function latestUnit( return row?.unit ?? null; } -/** Months older than the rollup fold window, live and cached. */ -async function preFoldMonths(args: { +/** Buckets older than the rollup fold window, live and cached. */ +async function preFoldBuckets(args: { userId: string; type: MeasurementType; tz: string; from: Date; boundary: Date; + bucket: TimelineBucket; priorityJson: unknown; -}): Promise> { +}): Promise { if (args.from >= args.boundary) return []; // Keyed under the record's `${userId}|` prefix: every measurement write // marks it stale with the rest of the analytics bucket. - const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`; + const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.bucket}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`; return cached( caches.analytics, key, async () => { - const stats = await readLocalDailyStats({ + const cells = await readLocalDailyCells({ userId: args.userId, types: [args.type], from: args.from, @@ -110,20 +172,20 @@ async function preFoldMonths(args: { tz: args.tz, priorityJson: args.priorityJson, }); - return foldDays(stats.get(args.type) ?? new Map(), "month"); + return foldDays(cells.get(args.type) ?? new Map(), args.bucket); }, undefined, PRE_FOLD_TTL_MS, - ) as Promise>; + ) as Promise; } -async function monthlyFromRollups(args: { +async function monthsFromRollups(args: { userId: string; type: MeasurementType; from: Date; to: Date; priorityJson: unknown; -}): Promise> { +}): Promise { const rows = await readCanonicalRollupBuckets({ userId: args.userId, type: args.type, @@ -142,59 +204,67 @@ async function monthlyFromRollups(args: { : r.mean; return { t: `${dateOnlyKey(r.bucketStart).slice(0, 7)}-01`, - mean: Math.round(value * 100) / 100, + mean: value, + days: Math.max(1, r.days), + count: r.count, }; }); } +/** Whether `[from, to]` is long enough to read the rollup tier. */ +function readsRollups(from: string, to: string): boolean { + const days = daysBetweenDateKeys(from, to); + return days >= LIVE_WINDOW_MAX_DAYS; +} + async function measurementSeries(args: { userId: string; type: MeasurementType; from: string; to: string; tz: string; - granularity: SeriesGranularity; + bucket: TimelineBucket; priorityJson: unknown; now: Date; }): Promise { const start = startOfLocalDayKey(args.from, args.tz); const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); - let points: Array<{ t: string; mean: number }>; - if (args.granularity === "month") { - // The fold boundary rounded up to a month start, so no month is half + let points: SeriesPoint[]; + if (args.bucket !== "week" && readsRollups(args.from, args.to)) { + const bucket = args.bucket; + // The fold boundary rounded up to a bucket start, so no bucket is half // live and half rolled up. const raw = new Date(args.now.getTime() - ROLLUP_FOLD_WINDOW_MS); const boundaryKey = bucketKey( - shiftDateKey(userDayKey(raw, args.tz), 31), - "month", + shiftDateKey(userDayKey(raw, args.tz), bucket === "quarter" ? 92 : 31), + bucket, ); const boundary = startOfLocalDayKey(boundaryKey, args.tz); const [older, recent] = await Promise.all([ - preFoldMonths({ + preFoldBuckets({ userId: args.userId, type: args.type, tz: args.tz, from: start, boundary: boundary < end ? boundary : end, + bucket, priorityJson: args.priorityJson, }), end > boundary - ? monthlyFromRollups({ + ? monthsFromRollups({ userId: args.userId, type: args.type, from: boundary > start ? boundary : start, to: end, priorityJson: args.priorityJson, - }) + }).then((months) => foldMonths(months, bucket)) : Promise.resolve([]), ]); - const merged = new Map(); - for (const p of [...older, ...recent]) merged.set(p.t, p.mean); - points = [...merged] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([t, mean]) => ({ t, mean })); + const merged = new Map(); + for (const p of [...older, ...recent]) merged.set(p.t, p); + points = [...merged.values()].sort((a, b) => a.t.localeCompare(b.t)); } else { - const stats = await readLocalDailyStats({ + const cells = await readLocalDailyCells({ userId: args.userId, types: [args.type], from: start, @@ -202,13 +272,12 @@ async function measurementSeries(args: { tz: args.tz, priorityJson: args.priorityJson, }); - points = foldDays(stats.get(args.type) ?? new Map(), args.granularity); + points = foldDays(cells.get(args.type) ?? new Map(), args.bucket); } if (points.length === 0) return null; return { key: args.type, unit: await latestUnit(args.userId, args.type), - granularity: args.granularity, points, }; } @@ -217,7 +286,7 @@ async function moodSeries(args: { userId: string; from: string; to: string; - granularity: SeriesGranularity; + bucket: TimelineBucket; }): Promise { const rows = await prisma.moodEntry.groupBy({ by: ["date"], @@ -227,17 +296,19 @@ async function moodSeries(args: { date: { gte: args.from, lte: args.to }, }, _avg: { score: true }, + _count: { score: true }, }); - const days = new Map(); + const days = new Map(); for (const row of rows) { - if (row._avg.score !== null) days.set(row.date, row._avg.score); + if (row._avg.score !== null && row._count.score > 0) { + days.set(row.date, { value: row._avg.score, count: row._count.score }); + } } - const points = foldDays(days, args.granularity); + const points = foldDays(days, args.bucket); if (points.length === 0) return null; return { key: MOOD_SERIES_KEY, unit: null, - granularity: args.granularity, points, }; } @@ -248,7 +319,7 @@ export async function loadTimelineSeries(args: { from: string; to: string; tz: string; - granularity: SeriesGranularity; + bucket: TimelineBucket; priorityJson: unknown; now?: Date; }): Promise { diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index b789e60f0..4bbb9a28f 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -874,6 +874,125 @@ describe("timeline", () => { expect(byKey.values.status).toBe("empty"); expect(inventory.verdict).toBe("thin"); }); + + it("averages value series per bucket, counts the readings and leaves gaps out", async () => { + const db = getPrismaClient(); + const owner = await makeUser("tl-values", { timeline: true }); + const reading = (at: string, value: number) => ({ + userId: owner.id, + type: "BLOOD_PRESSURE_SYS" as const, + value, + unit: "mmHg", + measuredAt: new Date(at), + }); + await db.measurement.createMany({ + data: [ + // Two readings on one January day, one on another: three readings, + // two days, each day weighing one. + reading("2026-01-10T07:00:00.000Z", 120), + reading("2026-01-10T19:00:00.000Z", 130), + reading("2026-01-20T07:00:00.000Z", 135), + // Nothing in February. + reading("2026-03-05T07:00:00.000Z", 128), + ], + }); + await signIn(owner.id); + + const { GET } = await import("@/app/api/timeline/route"); + const timeline = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=year&to=2026-03-31&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(timeline.bucket).toBe("month"); + expect(timeline.series).toEqual([ + { + key: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + points: [ + { t: "2026-01-01", mean: 130, count: 3 }, + { t: "2026-03-01", mean: 128, count: 1 }, + ], + }, + ]); + + const weekly = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=quarter&to=2026-03-31&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(weekly.bucket).toBe("week"); + expect(weekly.series[0].points.map((p) => [p.t, p.count])).toEqual([ + ["2026-01-05", 2], + ["2026-01-19", 1], + ["2026-03-02", 1], + ]); + + const six = + "BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD"; + expect( + (await call(GET as Handler, "GET", `/api/timeline?values=${six}`)).status, + ).toBe(200); + expect( + ( + await call( + GET as Handler, + "GET", + `/api/timeline?values=${six},BLOOD_GLUCOSE`, + ) + ).status, + ).toBe(422); + }); + + it("averages quarters over the years, live before the fold window and rolled up after it", async () => { + const db = getPrismaClient(); + const owner = await makeUser("tl-quarters", { timeline: true }); + const reading = (at: string, value: number) => ({ + userId: owner.id, + type: "WEIGHT" as const, + value, + unit: "kg", + measuredAt: new Date(at), + }); + await db.measurement.createMany({ + data: [ + // Before the five-year fold window: read live. + reading("2018-02-10T07:00:00.000Z", 90), + reading("2018-03-10T07:00:00.000Z", 88), + // Inside it: read from the rollups. January has three days, + // February one; the quarter's mean weighs the four days alike. + reading("2024-01-10T07:00:00.000Z", 80), + reading("2024-01-11T07:00:00.000Z", 80), + reading("2024-01-12T07:00:00.000Z", 80), + reading("2024-02-10T07:00:00.000Z", 84), + // Nothing from April 2024 to March 2025. + reading("2025-05-10T07:00:00.000Z", 79), + ], + }); + const { recomputeUserRollups } = + await import("@/lib/rollups/measurement-rollups"); + await recomputeUserRollups(owner.id, { + types: ["WEIGHT"], + from: new Date("2018-01-01T00:00:00.000Z"), + to: new Date(), + }); + await signIn(owner.id); + + const { GET } = await import("@/app/api/timeline/route"); + const timeline = await json( + await call(GET as Handler, "GET", "/api/timeline?zoom=all&values=WEIGHT"), + ); + expect(timeline.bucket).toBe("quarter"); + expect(timeline.series[0].points).toEqual([ + { t: "2018-01-01", mean: 89, count: 2 }, + { t: "2024-01-01", mean: 81, count: 4 }, + { t: "2025-04-01", mean: 79, count: 1 }, + ]); + }); }); describe("performance smoke", () => {