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

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

No files matched your search

+3 -2
View File
@@ -26,8 +26,9 @@ an earlier fold computed from part of a day.
- **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with
lanes for life events, conditions, allergies, medications, vaccinations,
visits and procedures, labs and documents, a year, month or week grid and
up to three monthly-mean value lines; a chronicle, newest first, on a
phone. Switching the module on opens a readiness inventory that rates
up to six value lines of quarterly, monthly or weekly means by zoom (a
short gap dashed, a longer one left open, a mean from fewer than three
readings drawn hollow); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates
each lane and links each gap; it stays available as Data coverage.
- **Life events.** Title, kind (family, home, work, loss, other), a start
at day, month or year precision, optional end and note; title and note
+23 -10
View File
@@ -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:
+80 -6
View File
@@ -10,8 +10,11 @@
* day.
* 4. A life event is entered through the sheet and posted in the
* contract's shape.
* 5. On a phone the chronicle replaces the chart, says its empty months
* out loud, and a row opens its day.
* 5. The value lines: quarterly means over the years, a missing quarter
* left out, a short gap bridged dashed, a thin quarter hollow, and up
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
*
* The timeline's server routes are mocked with the contract's shapes
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
@@ -22,7 +25,7 @@ import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { mockTimeline } from "./utils/mock-timeline";
import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline";
test.use({ storageState: TIMELINE_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -119,11 +122,10 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1);
await expect(
page.locator('[data-slot="timeline-legend-causality"]'),
).toBeVisible();
// At most three value lines are asked for.
).toHaveCount(0);
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
expect(asked.length).toBeGreaterThan(0);
expect(asked.length).toBeLessThanOrEqual(3);
expect(asked.length).toBeLessThanOrEqual(6);
// The keyboard: one step back and Enter opens that day.
const before = await page
@@ -157,6 +159,69 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]');
await expect(sys).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "quarter");
const point = (t: string) =>
sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`);
// A missing quarter stays missing: no point is drawn for it.
for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0);
// The quarters around Q2 2023 are joined by a dashed bridge; the three
// missing quarters of 2021 are not (one bridge in the whole line).
await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount(
1,
);
await expect(point("2023-01-01")).toHaveCount(1);
await expect(point("2023-07-01")).toHaveCount(1);
// Two readings: drawn hollow; four and more: filled.
await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true");
await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false");
// The value table keeps the missing quarter as a row of its own.
await expect(
page.locator(
'[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]',
),
).toBeAttached();
// Six value lines can be chosen; a seventh cannot.
await page.locator('[data-slot="timeline-values-trigger"]').click();
const options = page.locator('[data-slot="timeline-values-option"]');
await expect(options.first()).toBeVisible();
const count = await options.count();
for (let i = 0; i < count; i++) {
const checked = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"]',
);
if ((await checked.count()) >= 6) break;
const option = options.nth(i);
if ((await option.getAttribute("aria-checked")) === "true") continue;
await option.click();
}
await expect(
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
).toHaveCount(6);
await expect(
page.locator(
'[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]',
),
).toHaveCount(count - 6);
await page.keyboard.press("Escape");
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(6);
await expect(page.locator("svg [data-series]")).toHaveCount(6);
});
async function setTimelineModuleOn(page: Page) {
await page.goto("/settings/modules");
await setTimelineModule(page, true);
@@ -218,6 +283,15 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
await expect(
page.locator('[data-slot="timeline-chronicle-gap"]').first(),
).toBeVisible();
// Quarterly means, each quarter named once, at its newest listed month.
const means = page.locator('[data-slot="timeline-chronicle-means"]');
await expect(means.first()).toBeVisible();
const buckets = await means.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-bucket") ?? ""),
);
expect(buckets.length).toBeGreaterThan(0);
expect(new Set(buckets).size).toBe(buckets.length);
for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/);
await page
.locator(
+115 -33
View File
@@ -46,76 +46,142 @@ function rng(seed: number) {
};
}
function monthly(
from: string,
to: string,
value: (i: number, monthKey: string) => number,
) {
const out: Array<{ t: string; mean: number }> = [];
type Bucket = "quarter" | "month" | "week";
/** The bucket the server picks for a zoom (`timelineBucket`). */
export function bucketFor(zoom: string): Bucket {
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
}
function dayNum(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
}
function dayStr(n: number): string {
return new Date(n * 86_400_000).toISOString().slice(0, 10);
}
/** Bucket starts from the one holding `from` to the one holding `to`. */
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
const out: string[] = [];
if (bucket === "week") {
const n = dayNum(from);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
out.push(dayStr(d));
}
return out;
}
const step = bucket === "quarter" ? 3 : 1;
let [y, m] = from.split("-").map(Number);
m -= (m - 1) % step;
const [ty, tm] = to.split("-").map(Number);
let i = 0;
while (y < ty || (y === ty && m <= tm)) {
const key = `${y}-${String(m).padStart(2, "0")}-01`;
out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 });
i++;
m++;
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
m += step;
if (m > 12) {
m = 1;
m -= 12;
y++;
}
}
return out;
}
function series(dataFrom: string, today: string) {
/**
* Systolic pressure leaves gaps the chart has to show honestly: in the
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
*/
export const SYS_MISSING = new Set([
"2021-01-01",
"2021-04-01",
"2021-07-01",
"2023-04-01",
]);
export const SYS_THIN = "2022-01-01";
function points(
from: string,
to: string,
bucket: Bucket,
value: (i: number, key: string) => number,
skip: (key: string) => boolean = () => false,
) {
const starts = bucketStarts(from, to, bucket);
return starts
.map((key, i) => ({
t: key,
mean: Math.round(value(i / starts.length, key) * 10) / 10,
count: key === SYS_THIN ? 2 : 4 + (i % 9),
}))
.filter((p) => !skip(p.t));
}
function series(dataFrom: string, today: string, bucket: Bucket) {
const r = rng(11);
const months = monthly(dataFrom, today, () => 0).length;
return [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 147 - (18 * i) / months + (r() - 0.5) * 4,
bucket,
(f) => 147 - 18 * f + (r() - 0.5) * 4,
(t) => bucket === "quarter" && SYS_MISSING.has(t),
),
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 92 - (10 * i) / months + (r() - 0.5) * 3,
bucket,
(f) => 92 - 10 * f + (r() - 0.5) * 3,
),
},
{
key: "WEIGHT",
unit: "kg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6,
bucket,
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
),
},
{
key: "RESTING_HEART_RATE",
unit: "bpm",
granularity: "month",
points: monthly(dataFrom, today, (i, k) => {
points: points(dataFrom, today, bucket, (f, k) => {
const month = Number(k.slice(5, 7));
return (
68 -
(5 * i) / months +
Math.sin((month / 12) * 6.28) * 1.2 +
(r() - 0.5) * 1.2
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
);
}),
},
{
key: "PULSE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
},
{
key: "BODY_FAT",
unit: "%",
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
},
{
key: "ACTIVITY_STEPS",
unit: "steps",
points: points(
dataFrom,
today,
bucket,
(f) => 7200 + 900 * f + r() * 400,
),
},
];
}
@@ -350,7 +416,8 @@ export function fullTimeline(today: string) {
},
],
standing: [],
series: series("2019-01-01", today),
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
@@ -468,7 +535,8 @@ export function readyTimeline(today: string) {
},
],
standing: [],
series: series("2024-05-01", today).filter(
bucket: "quarter" as Bucket,
series: series("2024-05-01", today, "quarter").filter(
(s) => s.key !== "RESTING_HEART_RATE",
),
notable: [],
@@ -673,10 +741,24 @@ export async function mockTimeline(
const values = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
const zoom = url.searchParams.get("zoom") ?? "all";
const bucket = bucketFor(zoom);
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
const all =
bucket === body.bucket ? body.series : series(dataFrom, today, bucket);
return json(route, 200, {
...body,
zoom: url.searchParams.get("zoom") ?? "all",
series: body.series.filter((s) => values.includes(s.key)),
zoom,
bucket,
// Any other value the menu offers answers a flat line of its own.
series: values.map(
(key) =>
all.find((s) => s.key === key) ?? {
key,
unit: null,
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
},
),
});
});
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {
+14 -5
View File
@@ -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",
+14 -5
View File
@@ -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",
+14 -5
View File
@@ -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",
+14 -5
View File
@@ -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",
+14 -5
View File
@@ -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",
+14 -5
View File
@@ -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": "이번 달 기록 없음",
+14 -5
View File
@@ -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",
+1 -1
View File
@@ -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",
});
}
@@ -16,6 +16,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { ReadinessCard } from "../readiness-card";
import { ReadinessInventory, VerdictMeter } from "../readiness-inventory";
import { SelectionBar } from "../selection-bar";
import { TimelineChart } from "../timeline-chart";
import { TimelineChronicle } from "../timeline-chronicle";
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
@@ -121,25 +122,109 @@ describe("chronicle", () => {
);
expect(html).toContain("129/82 mmHg · 82,6 kg");
});
it("names a quarter once, at its newest month, with its span", () => {
const timeline = {
...fullTimeline(),
bucket: "quarter" as const,
series: [
{
key: "WEIGHT",
unit: "kg",
points: [{ t: "2026-01-01", mean: 82.6, count: 9 }],
},
],
};
const html = render(
<TimelineChronicle
timeline={timeline}
today={TODAY}
grouping="month"
selected={null}
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>,
);
expect(html.match(/data-slot="timeline-chronicle-means"/g)).toHaveLength(1);
expect(html).toContain("Jan bis März 2026: 82,6 kg");
});
});
const LABELS: Record<string, string> = {
BLOOD_PRESSURE_SYS: "Blutdruck sys.",
BLOOD_PRESSURE_DIA: "Blutdruck dia.",
WEIGHT: "Gewicht",
};
const seriesLabel = (key: string) => LABELS[key] ?? key;
describe("selection bar", () => {
it("lists the month's entries and offers the selected day", () => {
const html = render(
const bar = (selected: string) =>
render(
<SelectionBar
timeline={fullTimeline()}
selected="2026-01-03"
selected={selected}
today={TODAY}
seriesLabel={seriesLabel}
onOpenDay={() => undefined}
/>,
);
it("lists the month's entries and offers the selected day", () => {
const html = bar("2026-01-03");
expect(html).toContain("Januar 2026");
expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan.");
expect(html).toContain("Vitamin D (Winter) · laufend");
expect(html).not.toContain("Bluthochdruck");
expect(html).toContain("3. Jan. öffnen");
expect(html).toContain('data-date="2026-01-03"');
expect(html).toContain("Ø 129/82 mmHg · 82,6 kg");
});
it("names the bucket's means with the readings behind each", () => {
expect(bar("2026-01-03")).toContain(
"Ø Januar 2026: Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
);
expect(bar("2026-07-15")).toContain(
"Ø Juli 2026: Blutdruck sys. 131 mmHg (1 Messung) · Blutdruck dia. kein Wert · Gewicht kein Wert",
);
});
it("says a missing month has no value instead of interpolating one", () => {
const html = bar("2026-02-10");
expect(html).toContain(
"Ø Februar 2026: Blutdruck sys. kein Wert · Blutdruck dia. kein Wert · Gewicht kein Wert",
);
// January's 129 and March's 133 would make 131 between them.
expect(html).not.toMatch(/\d mmHg/);
});
});
describe("value table for a screen reader", () => {
it("lists every month between the first and the last reading, gaps as gaps", () => {
const html = render(
<TimelineChart
timeline={fullTimeline()}
window={{ from: "2025-10-01", to: "2026-10-31" }}
zoom="year"
today={TODAY}
selected={null}
hiddenLanes={new Set()}
seriesLabel={seriesLabel}
onSelect={() => undefined}
onOpenDay={() => undefined}
/>,
);
const table = html.slice(html.indexOf('data-slot="timeline-series-table"'));
// October 2025 to July 2026: ten months, newest first.
expect(table.match(/data-bucket="/g)).toHaveLength(10);
const row = (bucket: string) => {
const start = table.indexOf(`data-bucket="${bucket}"`);
return table.slice(start, table.indexOf("</tr>", start));
};
expect(row("2026-02-01")).toContain("Februar 2026");
expect(row("2026-02-01").match(/kein Wert/g)).toHaveLength(3);
expect(row("2026-01-01")).toContain("129 mmHg, Schnitt aus 4 Messungen");
expect(row("2026-07-01")).toContain("131 mmHg, 1 Messung");
expect(row("2026-05-01")).not.toMatch(/mmHg|kg/);
});
});
@@ -235,31 +235,31 @@ export function fullTimeline(): TimelineResponse {
},
],
standing: [],
bucket: "month",
series: [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
granularity: "month",
points: [
{ t: "2025-10-01", mean: 127 },
{ t: "2025-11-01", mean: 128 },
{ t: "2025-12-01", mean: 130 },
{ t: "2026-01-01", mean: 129 },
// Nothing for February to June.
{ t: "2026-07-01", mean: 131 },
{ t: "2025-10-01", mean: 127, count: 6 },
{ t: "2025-11-01", mean: 128, count: 5 },
{ t: "2025-12-01", mean: 130, count: 4 },
{ t: "2026-01-01", mean: 129, count: 4 },
// Nothing in February: one missing month, bridged.
{ t: "2026-03-01", mean: 133, count: 2 },
// Nothing from April to June: three missing months, broken.
{ t: "2026-07-01", mean: 131, count: 1 },
],
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
granularity: "month",
points: [{ t: "2026-01-01", mean: 82 }],
points: [{ t: "2026-01-01", mean: 82, count: 4 }],
},
{
key: "WEIGHT",
unit: "kg",
granularity: "month",
points: [{ t: "2026-01-01", mean: 82.6 }],
points: [{ t: "2026-01-01", mean: 82.6, count: 3 }],
},
],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
@@ -40,6 +40,7 @@ function layoutAt(width: number): TimelineLayout {
window: windowFor("all", TODAY, null, tl.range),
lanes: tl.lanes,
series: tl.series,
bucket: tl.bucket,
startMissing: "Startdatum fehlt",
today: TODAY,
});
@@ -345,30 +346,136 @@ describe("layoutTimeline", () => {
});
describe("layoutSeries", () => {
it("breaks the line across missing months instead of bridging them", () => {
const window = { from: "2025-10-01", to: "2026-10-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
window,
scale,
);
expect(bp.path.match(/M/g)).toHaveLength(2);
const window = { from: "2025-10-01", to: "2026-10-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
window,
scale,
"month",
);
it("keeps every month with a reading as a point, and invents none", () => {
expect(bp.points.map((p) => p.t)).toEqual([
"2025-10-01",
"2025-11-01",
"2025-12-01",
"2026-01-01",
"2026-03-01",
"2026-07-01",
]);
expect(bp.points.map((p) => p.mean)).toEqual([
127, 128, 130, 129, 133, 131,
]);
expect(bp.latest).toBe(131);
});
it("keeps an empty series as a row without a line", () => {
const window = { from: "2019-01-01", to: "2019-12-31" };
const scale = createScale(window, 164, 1200);
const [bp] = layoutSeries(
fullTimeline().series.slice(0, 1),
it("draws the run solid, bridges one missing month dashed, and breaks at three", () => {
const [oct, , , jan, mar, jul] = bp.points;
const at = (p: { x: number; y: number }) =>
`${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
// One solid run, October to January.
expect(bp.path.match(/M/g)).toHaveLength(1);
expect(bp.path.startsWith(`M${at(oct)}`)).toBe(true);
expect(bp.path.endsWith(`L${at(jan)}`)).toBe(true);
// February is missing: one dashed stroke from January to March, with no
// vertex between them.
expect(bp.bridges).toBe(`M${at(jan)}L${at(mar)}`);
// April to June are missing: July joins nothing.
expect(bp.path).not.toContain(at(jul));
expect(bp.bridges).not.toContain(at(jul));
});
it("draws a bucket of fewer than three readings hollow", () => {
expect(bp.points.map((p) => [p.t, p.thin])).toEqual([
["2025-10-01", false],
["2025-11-01", false],
["2025-12-01", false],
["2026-01-01", false],
["2026-03-01", true],
["2026-07-01", true],
]);
});
it("bridges at most one missing quarter and four missing weeks", () => {
const quarter = (points: Array<[string, number]>) =>
layoutSeries(
[
{
key: "WEIGHT",
unit: "kg",
points: points.map(([t, mean]) => ({ t, mean, count: 5 })),
},
],
0,
{ from: "2020-01-01", to: "2026-12-31" },
createScale({ from: "2020-01-01", to: "2026-12-31" }, 164, 1200),
"quarter",
)[0];
// Q1, then Q3: one missing quarter, bridged.
expect(
quarter([
["2024-01-01", 80],
["2024-07-01", 81],
]).bridges,
).not.toBe("");
// Q1, then Q4: two missing quarters, broken; each point still drawn.
const broken = quarter([
["2024-01-01", 80],
["2024-10-01", 81],
]);
expect(broken.bridges).toBe("");
expect(broken.path).toBe("");
expect(broken.points).toHaveLength(2);
const weeks = (second: string) =>
layoutSeries(
[
{
key: "WEIGHT",
unit: "kg",
points: [
{ t: "2026-01-05", mean: 80, count: 3 },
{ t: second, mean: 81, count: 3 },
],
},
],
0,
{ from: "2026-01-01", to: "2026-03-31" },
createScale({ from: "2026-01-01", to: "2026-03-31" }, 164, 1200),
"week",
)[0];
// Mondays: four missing weeks bridged, five broken.
expect(weeks("2026-02-09").bridges).not.toBe("");
expect(weeks("2026-02-16").bridges).toBe("");
});
it("keeps a lone point visible", () => {
const [weight] = layoutSeries(
fullTimeline().series.slice(2, 3),
0,
window,
scale,
"month",
);
expect(bp.path).toBe("");
expect(bp.latest).toBeNull();
expect(weight.path).toBe("");
expect(weight.bridges).toBe("");
expect(weight.points).toHaveLength(1);
});
it("keeps an empty series as a row without a line", () => {
const early = { from: "2019-01-01", to: "2019-12-31" };
const [empty] = layoutSeries(
fullTimeline().series.slice(0, 1),
0,
early,
createScale(early, 164, 1200),
"month",
);
expect(empty.path).toBe("");
expect(empty.points).toEqual([]);
expect(empty.latest).toBeNull();
});
});
+26 -25
View File
@@ -2,8 +2,10 @@
/**
* The bar under the chart (v1.42, #613): the selected month, every entry
* that touches it as a chip (each one a way into its day), the month's
* means of the chosen values, and "Open 3 Jan." for the selected day.
* that touches it as a chip (each one a way into its day), the means of the
* chosen values in the bucket that holds the selected day (with the readings
* behind each, and "no value" where there is none), and "Open 3 Jan." for
* the selected day.
*
* It is also where a label the chart had to leave out is read in full, and
* where each keyboard step on the chart is announced (`aria-live`).
@@ -23,45 +25,31 @@ import {
startOfMonth,
} from "./timeline-dates";
import { itemsInMonth } from "./timeline-geometry";
import { laneDotStyle } from "./timeline-chart";
import { formatMonthMeans } from "./series-format";
import { bucketText, laneDotStyle } from "./timeline-chart";
import { bucketValues, formatLabelledMeans } from "./series-format";
export interface SelectionBarProps {
timeline: TimelineResponse;
selected: string;
today: string;
seriesLabel: (key: string) => string;
onOpenDay: (date: string) => void;
onEditLifeEvent?: (id: string) => void;
}
/** The month means of each series for the month of `date`. */
export function monthMeans(
series: TimelineResponse["series"],
date: string,
): Array<{ key: string; mean: number; unit: string | null }> {
const month = monthOf(date);
const out: Array<{ key: string; mean: number; unit: string | null }> = [];
for (const s of series) {
const inMonth = s.points.filter((p) => monthOf(p.t) === month);
if (inMonth.length === 0) continue;
const mean = inMonth.reduce((sum, p) => sum + p.mean, 0) / inMonth.length;
out.push({ key: s.key, mean, unit: s.unit });
}
return out;
}
export function SelectionBar({
timeline,
selected,
today,
seriesLabel,
onOpenDay,
onEditLifeEvent,
}: SelectionBarProps) {
const { t, locale } = useTranslations();
const { t, tCount, locale } = useTranslations();
const fmt = useFormatters();
const intl = resolveIntlLocale(locale);
const entries = itemsInMonth(timeline.lanes, selected, today);
const means = monthMeans(timeline.series, selected);
const means = bucketValues(timeline.series, timeline.bucket, selected);
const monthFrom = startOfMonth(selected);
const monthTo = endOfMonth(selected);
@@ -134,10 +122,23 @@ export function SelectionBar({
</button>
);
})}
{means.length > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">
{means.values.length > 0 && (
<span
className="text-muted-foreground text-xs tabular-nums"
data-slot="timeline-selection-means"
data-bucket={means.start}
>
{t("timeline.selection.mean", {
values: formatMonthMeans(means, fmt),
values: `${bucketText(means.start, timeline.bucket, intl, t)}: ${formatLabelledMeans(
means.values,
fmt,
{
label: seriesLabel,
bloodPressure: t("timeline.values.bloodPressure"),
noValue: t("timeline.values.noValue"),
readings: (n) => tCount("timeline.values.readings", n),
},
)}`,
})}
</span>
)}
+124 -7
View File
@@ -3,8 +3,11 @@
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
* server's, already in the person's display unit.
*/
import type { TimelineBucket, TimelineResponse } from "@/lib/day/contract";
import type { Formatters } from "@/lib/format-locale";
import { addMonths, bucketAfter, bucketStart } from "./timeline-dates";
const WHOLE_NUMBER_KEYS: ReadonlySet<string> = new Set([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
@@ -43,18 +46,92 @@ export function formatSeriesValue(
return out.unit ? `${out.value} ${out.unit}` : out.value;
}
/** One series in one bucket: its mean, or nothing when it has no reading. */
export interface BucketValue {
key: string;
unit: string | null;
mean: number | null;
count: number | null;
}
/**
* The month means in one short line: systolic and diastolic fold into
* "129/82 mmHg", the rest follow as "82,6 kg".
* Every series in the bucket that holds `date`. A series without a reading
* in that bucket is listed with `mean: null`: the gap stays a gap.
*/
export function formatMonthMeans(
means: ReadonlyArray<{ key: string; mean: number; unit: string | null }>,
export function bucketValues(
series: TimelineResponse["series"],
bucket: TimelineBucket,
date: string,
): { start: string; values: BucketValue[] } {
const start = bucketStart(date, bucket);
return {
start,
values: series.map((s) => {
const point = s.points.find((p) => p.t === start);
return {
key: s.key,
unit: s.unit,
mean: point?.mean ?? null,
count: point?.count ?? null,
};
}),
};
}
/**
* The bucket means each chronicle month shows (`months` newest first, as the
* chronicle lists them). A bucket is named once, at the newest listed month
* it overlaps: a quarter rides on its latest month with entries, never on
* all three. Where two buckets of one series meet at one month (weeks), the
* newer stands. Only buckets with a reading appear; a month without one
* shows nothing rather than a number.
*/
export function chronicleMeans(
series: TimelineResponse["series"],
bucket: TimelineBucket,
months: readonly string[],
): Map<string, { start: string; values: BucketValue[] }> {
const out = new Map<string, { start: string; values: BucketValue[] }>();
for (const s of series) {
const points = [...s.points].sort((a, b) => (a.t < b.t ? 1 : -1));
const taken = new Set<string>();
for (const p of points) {
const end = bucketAfter(p.t, bucket);
const month = months.find(
(m) => m < end && addMonths(m, 1) > p.t && !taken.has(m),
);
if (!month) continue;
taken.add(month);
const slot = out.get(month) ?? { start: p.t, values: [] };
if (p.t > slot.start) slot.start = p.t;
slot.values.push({
key: s.key,
unit: s.unit,
mean: p.mean,
count: p.count,
});
out.set(month, slot);
}
}
return out;
}
/**
* The means in one short line: systolic and diastolic fold into
* "129/82 mmHg", the rest follow as "82,6 kg". Series without a value are
* left out; the line is for a glance, the selection bar names the gaps.
*/
export function formatMeans(
means: readonly BucketValue[],
fmt: Pick<Formatters, "number">,
): string {
const sys = means.find((m) => m.key === "BLOOD_PRESSURE_SYS");
const dia = means.find((m) => m.key === "BLOOD_PRESSURE_DIA");
const present = means.filter(
(m): m is BucketValue & { mean: number } => m.mean !== null,
);
const sys = present.find((m) => m.key === "BLOOD_PRESSURE_SYS");
const dia = present.find((m) => m.key === "BLOOD_PRESSURE_DIA");
const parts: string[] = [];
for (const m of means) {
for (const m of present) {
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
parts.push(
@@ -66,3 +143,43 @@ export function formatMonthMeans(
}
return parts.join(" · ");
}
/**
* The means of one bucket with their names, readings and gaps, for the
* selection bar: "Blood pressure 129/82 mmHg (mean of 4 readings) · Weight
* no value". Systolic and diastolic fold when both have a value.
*/
export function formatLabelledMeans(
means: readonly BucketValue[],
fmt: Pick<Formatters, "number">,
words: {
label: (key: string) => string;
bloodPressure: string;
noValue: string;
readings: (count: number) => string;
},
): string {
const sys = means.find(
(m) => m.key === "BLOOD_PRESSURE_SYS" && m.mean !== null,
);
const dia = means.find(
(m) => m.key === "BLOOD_PRESSURE_DIA" && m.mean !== null,
);
const fold = sys && dia;
const parts: string[] = [];
for (const m of means) {
if (fold && m.key === "BLOOD_PRESSURE_DIA") continue;
if (fold && m.key === "BLOOD_PRESSURE_SYS") {
parts.push(
`${words.bloodPressure} ${fmt.number(sys.mean!, 0)}/${fmt.number(dia.mean!, 0)}${sys.unit ? ` ${sys.unit}` : ""} (${words.readings(sys.count ?? 0)})`,
);
continue;
}
parts.push(
m.mean === null
? `${words.label(m.key)} ${words.noValue}`
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, fmt)} (${words.readings(m.count ?? 0)})`,
);
}
return parts.join(" · ");
}
+129 -7
View File
@@ -43,6 +43,7 @@ import {
} from "lucide-react";
import type {
TimelineBucket,
TimelineLaneKey,
TimelineResponse,
TimelineZoom,
@@ -51,7 +52,10 @@ import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
bucketAfter,
bucketStart,
formatAtPrecision,
formatBucket,
formatMonthShort,
formatMonthYear,
dayNumber,
@@ -145,11 +149,12 @@ export function TimelineChart({
window,
lanes,
series: timeline.series,
bucket: timeline.bucket,
startMissing: t("timeline.startMissing"),
today,
})
: null,
[width, window, lanes, timeline.series, t, today],
[width, window, lanes, timeline.series, timeline.bucket, t, today],
);
const floor = timeline.range.dataFrom ?? window.from;
@@ -228,6 +233,7 @@ export function TimelineChart({
today={today}
selected={selected}
dataFrom={timeline.range.dataFrom}
bucket={timeline.bucket}
seriesLabel={seriesLabel}
intl={intl}
t={t}
@@ -243,6 +249,12 @@ export function TimelineChart({
hiddenLanes={hiddenLanes}
intl={intl}
/>
<SeriesTable
timeline={timeline}
window={window}
seriesLabel={seriesLabel}
intl={intl}
/>
</div>
);
}
@@ -254,6 +266,7 @@ interface ChartBodyProps {
today: string;
selected: string | null;
dataFrom: string | null;
bucket: TimelineBucket;
seriesLabel: (key: string) => string;
intl: string;
t: ReturnType<typeof useTranslations>["t"];
@@ -267,11 +280,13 @@ function ChartBody({
today,
selected,
dataFrom,
bucket,
seriesLabel,
intl,
t,
fmt,
}: ChartBodyProps) {
const { tCount } = useTranslations();
const { scale, height, width } = layout;
const todayInWindow = today >= window.from && today <= window.to;
const todayX = scale.xMid(today);
@@ -461,14 +476,37 @@ function ChartBody({
strokeLinecap="round"
/>
)}
{series.points.length === 1 && (
<circle
cx={series.points[0].x}
cy={series.points[0].y}
r={2.5}
fill="var(--foreground)"
{series.bridges && (
<path
d={series.bridges}
data-slot="timeline-series-bridge"
fill="none"
stroke="var(--foreground)"
strokeWidth={1.2}
strokeOpacity={0.6}
strokeDasharray="3 3"
strokeLinecap="round"
/>
)}
{series.points.map((point) => (
<circle
key={point.t}
data-slot="timeline-series-point"
data-t={point.t}
data-count={point.count}
data-thin={point.thin ? "true" : "false"}
cx={point.x}
cy={point.y}
r={point.thin ? 2.75 : 2.5}
fill={point.thin ? "var(--card)" : "var(--foreground)"}
stroke="var(--foreground)"
strokeWidth={point.thin ? 1.4 : 0}
>
<title>
{`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
</title>
</circle>
))}
</g>
))}
@@ -761,6 +799,90 @@ function TimelineTable({
);
}
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
export function bucketText(
start: string,
bucket: TimelineBucket,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
): string {
const label = formatBucket(start, bucket, intl);
return typeof label === "string"
? label
: t("timeline.selection.range", label);
}
/**
* The value lines as a table, for a screen reader: one row per bucket from
* the first to the last with a reading in the window, one column per
* series. A bucket without a reading says so; nothing is filled in.
*/
function SeriesTable({
timeline,
window,
seriesLabel,
intl,
}: {
timeline: TimelineResponse;
window: { from: string; to: string };
seriesLabel: (key: string) => string;
intl: string;
}) {
const { t, tCount } = useTranslations();
const fmt = useFormatters();
const { bucket, series } = timeline;
const inWindow = (start: string) =>
start <= window.to && bucketAfter(start, bucket) > window.from;
const starts = series
.flatMap((s) => s.points.map((p) => p.t))
.filter(inWindow)
.sort();
if (starts.length === 0) return null;
const rows: string[] = [];
for (
let start = bucketStart(starts[0], bucket);
start <= starts[starts.length - 1];
start = bucketAfter(start, bucket)
) {
rows.push(start);
}
const byKey = new Map(
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
);
return (
<table className="sr-only" data-slot="timeline-series-table">
<caption>{t("timeline.table.seriesCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.period")}</th>
{series.map((s) => (
<th key={s.key} scope="col">
{seriesLabel(s.key)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.reverse().map((start) => (
<tr key={start} data-bucket={start}>
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
{series.map((s) => {
const point = byKey.get(s.key)?.get(start);
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
: t("timeline.values.noValue")}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
return { background: LANE_COLOR[lane] };
+29 -13
View File
@@ -2,9 +2,10 @@
/**
* The timeline on a phone (v1.42, #613): an "Ongoing" block that folds away,
* then the chronicle, newest first, with the month's means beside each
* month, thin rails beside the rows an episode or a course spans, and empty
* stretches named rather than skipped. Built from `chronicle-model.ts`.
* then the chronicle, newest first, with the bucket means beside the month
* headers (a quarter named once, at its newest month, with its span), thin
* rails beside the rows an episode or a course spans, and empty stretches
* named rather than skipped. Built from `chronicle-model.ts`.
*
* A dated row opens its day (the same `?day=` layer the chart hands off to);
* a life event opens its edit sheet instead, because the event is the
@@ -47,9 +48,8 @@ import {
formatMonthYear,
} from "./timeline-dates";
import { LANE_COLOR } from "./timeline-geometry";
import { laneDotStyle } from "./timeline-chart";
import { monthMeans } from "./selection-bar";
import { formatMonthMeans } from "./series-format";
import { bucketText, laneDotStyle } from "./timeline-chart";
import { chronicleMeans, formatMeans } from "./series-format";
const KIND_ICON: Partial<Record<TimelineItemKind, LucideIcon>> = {
lifeEvent: Flag,
@@ -141,6 +141,16 @@ export function TimelineChronicle({
const [standingOpen, setStandingOpen] = useState(true);
const chips = standingChips(timeline);
const rows = buildChronicle(timeline, today, grouping);
// The means ride on the month headers, each bucket on the newest month it
// overlaps; the year view lists none.
const means =
grouping === "month"
? chronicleMeans(
timeline.series,
timeline.bucket,
rows.flatMap((r) => (r.type === "header" ? [r.group] : [])),
)
: new Map<string, never>();
const groupLabel = (group: string) =>
grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4);
@@ -244,10 +254,10 @@ export function TimelineChronicle({
);
}
if (row.type === "header") {
const means =
grouping === "month"
? monthMeans(timeline.series, row.group)
: [];
const bucketMeans = means.get(row.group);
const line = bucketMeans
? formatMeans(bucketMeans.values, fmt)
: "";
return (
<li
key={`h-${row.group}`}
@@ -259,9 +269,15 @@ export function TimelineChronicle({
<h3 className="text-sm font-semibold">
{groupLabel(row.group)}
</h3>
{means.length > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">
{formatMonthMeans(means, fmt)}
{bucketMeans && line && (
<span
className="text-muted-foreground text-xs tabular-nums"
data-slot="timeline-chronicle-means"
data-bucket={bucketMeans.start}
>
{timeline.bucket === "month"
? line
: `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`}
</span>
)}
</li>
+58
View File
@@ -10,6 +10,8 @@
* Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a
* date never moves by a day for a reader east or west of Greenwich.
*/
import type { TimelineBucket } from "@/lib/day/contract";
const MS_PER_DAY = 86_400_000;
/** Days since 1970-01-01 for a `YYYY-MM-DD` key. */
@@ -55,6 +57,38 @@ export function addMonths(key: string, months: number): string {
return target.toISOString().slice(0, 10);
}
/* ─── Series buckets ──────────────────────────────────────────────────────── */
/** The first day of the bucket `key` falls in. Weeks start on Monday. */
export function bucketStart(key: string, bucket: TimelineBucket): string {
if (bucket === "month") return startOfMonth(key);
if (bucket === "quarter") {
const month = Number(key.slice(5, 7));
const first = month - ((month - 1) % 3);
return `${key.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
}
const day = dayNumber(key);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
return dayKey(day - ((((day - 4) % 7) + 7) % 7));
}
/** The first day after the bucket that starts on `start`. */
export function bucketAfter(start: string, bucket: TimelineBucket): string {
if (bucket === "month") return addMonths(start, 1);
if (bucket === "quarter") return addMonths(start, 3);
return dayKey(dayNumber(start) + 7);
}
/**
* A running number for the bucket that starts on `start`: neighbouring
* buckets differ by one, so a difference counts the buckets between.
*/
export function bucketIndex(start: string, bucket: TimelineBucket): number {
if (bucket === "week") return Math.floor((dayNumber(start) - 4) / 7);
const months = Number(start.slice(0, 4)) * 12 + Number(start.slice(5, 7)) - 1;
return bucket === "quarter" ? Math.floor(months / 3) : months;
}
/** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */
export function monthsBetween(from: string, to: string): string[] {
const out: string[] = [];
@@ -148,3 +182,27 @@ export function formatAtPrecision(
if (precision === "MONTH") return formatMonthYearShort(key, intlLocale);
return formatDayMonthYear(key, intlLocale);
}
/**
* How a series bucket reads: "Januar 2026" for a month, otherwise its first
* and last day for the caller's "{from} to {to}" ("Jan to März 2026",
* "5. Jan. to 11. Jan. 2026").
*/
export function formatBucket(
start: string,
bucket: TimelineBucket,
intlLocale: string,
): string | { from: string; to: string } {
if (bucket === "month") return formatMonthYear(start, intlLocale);
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
if (bucket === "quarter") {
return {
from: formatMonthShort(start, intlLocale),
to: formatMonthYearShort(last, intlLocale),
};
}
return {
from: formatDayMonth(start, intlLocale),
to: formatDayMonthYear(last, intlLocale),
};
}
+82 -28
View File
@@ -17,6 +17,7 @@
* month in full.
*/
import type {
TimelineBucket,
TimelineItem,
TimelineItemKind,
TimelineLane,
@@ -27,6 +28,8 @@ import type {
import {
addMonths,
bucketAfter,
bucketIndex,
dayKey,
dayNumber,
endOfMonth,
@@ -688,42 +691,74 @@ function placeLabels(
/* ─── Value lines ─────────────────────────────────────────────────────────── */
export interface SeriesPointLayout {
/** The bucket's first day. */
t: string;
mean: number;
/** Readings behind the mean. */
count: number;
x: number;
y: number;
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
thin: boolean;
}
export interface SeriesLayout {
key: string;
unit: string | null;
top: number;
height: number;
/** SVG path data; empty when the window holds no point. */
/** SVG path data of the solid runs; empty when no two points touch. */
path: string;
/** SVG path data of the dashed bridges over short gaps; may be empty. */
bridges: string;
/** The most recent mean inside the window, or null. */
latest: number | null;
points: Array<{ t: string; mean: number; x: number; y: number }>;
points: SeriesPointLayout[];
}
const BUCKET_DAYS: Record<TimelineSeries["granularity"], number> = {
month: 30,
week: 7,
day: 1,
/** A bucket mean from fewer readings than this is drawn hollow. */
export const THIN_BUCKET_COUNT = 3;
/**
* The most missing buckets a dashed bridge spans. Beyond it the line stops
* and starts again, so a long silence reads as one and not as a trend.
*
* The rule is one length, about six months between the two points a bridge
* joins, never more, and never more than a third of what the zoom shows:
* one missing quarter (the points are six months apart), two missing months
* in a year (three months apart, a sixth of the window) and four missing
* weeks in three months (five weeks apart, the window's third). A longer
* bridge would draw a slope through a stretch where nothing was measured.
*/
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
quarter: 1,
month: 2,
week: 4,
};
/**
* One neutral line per series, scaled to its own range inside the window.
* A point sits in the middle of its bucket. Missing buckets break the line
* rather than bridging the gap: a straight stroke across eight empty months
* would claim values nobody measured.
* A point sits in the middle of its bucket, and every bucket with a reading
* gets its point, so one month with no neighbours stays visible. Missing
* buckets stay missing: a short gap is bridged by a dashed stroke
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
* between them, a longer one breaks the line.
*/
export function layoutSeries(
series: readonly TimelineSeries[],
top: number,
window: TimeWindow,
scale: Scale,
bucket: TimelineBucket,
): SeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
const bucket = BUCKET_DAYS[s.granularity];
// A bucket that starts before the window still belongs to it while any
// of its days lie inside.
const inside = s.points
.filter((p) => p.t >= startOfMonthIf(window.from, s) && p.t <= window.to)
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
.sort((a, b) => (a.t < b.t ? -1 : 1));
if (inside.length === 0) {
return {
@@ -732,6 +767,7 @@ export function layoutSeries(
top: rowTop,
height,
path: "",
bridges: "",
latest: null,
points: [],
};
@@ -742,18 +778,34 @@ export function layoutSeries(
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const placed = inside.map((p) => {
const mid = dayNumber(p.t) + bucket / 2;
const points = inside.map((p) => {
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
return { t: p.t, mean: p.mean, x, y: y(p.mean) };
return {
t: p.t,
mean: p.mean,
count: p.count,
x,
y: y(p.mean),
thin: p.count < THIN_BUCKET_COUNT,
};
});
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
let path = "";
let prevDay: number | null = null;
for (const p of placed) {
const day = dayNumber(p.t);
const broken = prevDay === null || day - prevDay > bucket * 1.6;
path += `${broken ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
prevDay = day;
let bridges = "";
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const missing =
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
if (missing === 0) {
// Continue the run, or open a new one at the previous point.
path += path.endsWith(xy(prev))
? `L${xy(cur)}`
: `M${xy(prev)}L${xy(cur)}`;
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
bridges += `M${xy(prev)}L${xy(cur)}`;
}
}
return {
key: s.key,
@@ -761,18 +813,13 @@ export function layoutSeries(
top: rowTop,
height,
path,
bridges,
latest: inside[inside.length - 1].mean,
points: placed,
points,
};
});
}
function startOfMonthIf(from: string, s: TimelineSeries): string {
// A month bucket keyed on the 1st still belongs to a window that starts
// mid-month.
return s.granularity === "month" ? startOfMonth(from) : from;
}
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
export interface TimelineLayout {
@@ -794,6 +841,7 @@ export function layoutTimeline(input: {
window: TimeWindow;
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
bucket: TimelineBucket;
startMissing: string;
today: string;
}): TimelineLayout {
@@ -814,7 +862,13 @@ export function layoutTimeline(input: {
top += layout.height;
}
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
const series = layoutSeries(input.series, seriesTop, input.window, scale);
const series = layoutSeries(
input.series,
seriesTop,
input.window,
scale,
input.bucket,
);
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
return { width: input.width, height, scale, lanes, seriesTop, series };
}
+3 -3
View File
@@ -2,7 +2,7 @@
/**
* The two menus above the chart (v1.42, #613): which value lines run under
* the lanes (at most three), and which lanes show, with the way back into
* the lanes (at most six, the server's cap), and which lanes show, with the way back into
* the readiness inventory ("Data coverage").
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -17,11 +17,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { TimelineLaneKey } from "@/lib/day/contract";
import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
/** The most value lines the chart runs under its lanes. */
export const MAX_VALUE_SERIES = 3;
export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES;
export function ValueSeriesMenu({
options,
+40 -12
View File
@@ -3,9 +3,8 @@
/**
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
* medications, vaccinations, visits, documents and the person's own life
* events in lanes, up to three neutral value lines below them, all on one
* time axis. "Stacked means at the same time, not cause and effect" is said
* once, in the legend, and nothing on the page draws a connection.
* events in lanes, up to six neutral value lines below them, all on one
* time axis. Nothing on the page draws a connection between them.
*
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
* Enter on the chart, a double click, a chip, a chronicle row) pushes the
@@ -35,6 +34,7 @@ import {
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
type LifeEventDTO,
type TimelineBucket,
type TimelineLaneKey,
type TimelineZoom,
} from "@/lib/day/contract";
@@ -385,10 +385,14 @@ export function TimelineView() {
timeline={data}
selected={effectiveSelected}
today={today}
seriesLabel={seriesLabel}
onOpenDay={openDay}
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
/>
<Legend />
<Legend
bucket={data.bucket}
hasSeries={data.series.length > 0}
/>
</CardContent>
</Card>
</div>
@@ -436,7 +440,19 @@ export function TimelineView() {
);
}
function Legend() {
const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
};
function Legend({
bucket,
hasSeries,
}: {
bucket: TimelineBucket;
hasSeries: boolean;
}) {
const { t } = useTranslations();
return (
<div
@@ -457,13 +473,25 @@ function Legend() {
/>
{t("timeline.legendEvent")}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t("timeline.legendMean")}
</span>
<span className="sm:ml-auto" data-slot="timeline-legend-causality">
{t("timeline.legend")}
</span>
{hasSeries && (
<>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t(LEGEND_MEAN_KEY[bucket])}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="border-foreground bg-card size-2 rounded-full border"
aria-hidden="true"
/>
{t("timeline.legendThin")}
</span>
</>
)}
</div>
);
}
+16
View File
@@ -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:<key>`) and disappears with it.
+50 -5
View File
@@ -30,6 +30,15 @@ import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read";
/** Day key → the day's value, per type. */
export type DailyStats = Map<MeasurementType, Map<string, number>>;
/** 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<MeasurementType, Map<string, DayCell>>;
interface StatRow {
type: MeasurementType;
source: MeasurementSource;
@@ -60,7 +69,22 @@ export async function readLocalDailyStats(args: {
tz: string;
priorityJson: unknown;
}): Promise<DailyStats> {
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<DailyCells> {
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<string, number>();
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<string, number>();
const perDay = new Map<string, DayCell>();
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);
}
+28 -3
View File
@@ -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) }),
+64 -9
View File
@@ -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", () => {
+31 -14
View File
@@ -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<Record<TimelineZoom, SeriesGranularity>> = {
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<Record<Exclude<TimelineZoom, "all">, number>> = {
year: 365,
@@ -64,7 +78,8 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = 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 })),
};
+134 -63
View File
@@ -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<string, number>,
granularity: SeriesGranularity,
): Array<{ t: string; mean: number }> {
const buckets = new Map<string, { sum: number; n: number }>();
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<string, DayCell>,
bucket: TimelineBucket,
): SeriesPoint[] {
const buckets = new Map<string, { sum: number; n: number; count: number }>();
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<TimelineBucket, "week">,
): 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<Array<{ t: string; mean: number }>> {
}): Promise<SeriesPoint[]> {
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<Array<{ t: string; mean: number }>>;
) as Promise<SeriesPoint[]>;
}
async function monthlyFromRollups(args: {
async function monthsFromRollups(args: {
userId: string;
type: MeasurementType;
from: Date;
to: Date;
priorityJson: unknown;
}): Promise<Array<{ t: string; mean: number }>> {
}): Promise<RolledMonth[]> {
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<TimelineSeries | null> {
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<string, number>();
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<string, SeriesPoint>();
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<TimelineSeries | null> {
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<string, number>();
const days = new Map<string, DayCell>();
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<TimelineSeries[]> {
+119
View File
@@ -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<import("@/lib/day/contract").TimelineResponse>(
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<import("@/lib/day/contract").TimelineResponse>(
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<import("@/lib/day/contract").TimelineResponse>(
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", () => {