From 61155d1856abb8fbe549624c6b8d29056111fca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 10:02:37 +0200 Subject: [PATCH] feat(timeline): add a chosen range as a fourth zoom Beside All, 12 months and 3 months the timeline takes a range of the person's own choosing, from and to, through the app's date field. Each change is clamped softly: never past today, never before the record's first entry, never inverted, never longer than fifteen years. On a phone, where the chronicle has no zoom control, a range button opens the same fields with a way back to the whole record. A range with nothing in it keeps its fields instead of the empty-record notice. The zoom and the range live in the URL (zoom, from, to), so Back and a bookmark return to them; a parameter that does not read falls back to All. The API accepts zoom=range with both ends, at most fifteen years, and picks the bucket by its length: quarters from two years, months from 120 days, weeks from 42 days and single days below, a new day bucket whose dashed bridge spans at most three missing days. A range borrows the grid and the keyboard steps of the fixed zoom nearest its length. The selection bar heads a day bucket with that day. --- docs/api/openapi.yaml | 10 +- e2e/coach-layout.spec.ts | 1 + e2e/timeline.spec.ts | 70 +++++++++++ e2e/utils/mock-timeline.ts | 34 ++++- messages/de.json | 13 +- messages/en.json | 13 +- messages/es.json | 13 +- messages/fr.json | 13 +- messages/it.json | 13 +- messages/ko.json | 13 +- messages/pl.json | 13 +- .../timeline/__tests__/range-fields.test.tsx | 108 ++++++++++++++++ .../timeline/__tests__/timeline-url.test.ts | 68 ++++++++++ src/components/timeline/range-fields.tsx | 78 ++++++++++++ src/components/timeline/selection-bar.tsx | 3 +- src/components/timeline/timeline-chart.tsx | 10 +- src/components/timeline/timeline-dates.ts | 4 + src/components/timeline/timeline-geometry.ts | 30 ++++- src/components/timeline/timeline-url.ts | 97 +++++++++++++++ src/components/timeline/timeline-view.tsx | 117 ++++++++++++++++-- src/lib/day/contract.ts | 20 ++- src/lib/day/wire-schemas.ts | 51 ++++++-- src/lib/timeline/__tests__/series.test.ts | 25 +++- src/lib/timeline/load-timeline.ts | 33 ++++- src/lib/timeline/series.ts | 11 +- tests/integration/day-view.test.ts | 37 ++++++ 26 files changed, 839 insertions(+), 59 deletions(-) create mode 100644 src/components/timeline/__tests__/range-fields.test.tsx create mode 100644 src/components/timeline/__tests__/timeline-url.test.ts create mode 100644 src/components/timeline/range-fields.tsx create mode 100644 src/components/timeline/timeline-url.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d17709fd3..35a388e5d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23449,11 +23449,16 @@ paths: name: zoom schema: default: all + description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series + average quarters, months, weeks or days by its length." type: string enum: - all - year - quarter + - range + description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series + average quarters, months, weeks or days by its length." - in: query name: from schema: @@ -81511,6 +81516,7 @@ components: - all - year - quarter + - range range: type: object properties: @@ -81567,8 +81573,10 @@ components: - quarter - month - week + - day 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`." + under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` + from 120 days, `week` from 42 days, `day` below." series: type: array items: diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index 274702f7a..b3b27ed17 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -728,6 +728,7 @@ test.describe("Coach page frame", () => { new RegExp(`(^|\\s)${theme}(\\s|$)`), ); await expect(panel(page)).toHaveAttribute("data-state", "open"); + await shot(page, testInfo, `coach-frame-1440-${theme}`); const scan = async (selector: string, label: string) => { const result = await new AxeBuilder({ page }) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 548c21f33..7c0a57534 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -270,6 +270,76 @@ test("value lines: quarterly means, gaps kept, short ones bridged, up to six lin await page.keyboard.press("Escape"); }); +/** `days` calendar days before `key`. */ +function daysBefore(key: string, days: number): string { + const [y, m, d] = key.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10); +} + +test("a chosen range lives in the URL: reload keeps it, Back returns to it", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + const chart = page.locator('[data-slot="timeline-chart"]'); + await expect(chart).toBeVisible({ timeout: 20_000 }); + const desktop = page.locator('[data-slot="timeline-desktop"]'); + const zoom = (value: string) => + page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`); + + // Choosing "Range" starts from what is on screen and writes it down. + await zoom("range").click(); + await expect(page).toHaveURL( + /[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/, + ); + const fields = desktop.locator('[data-slot="timeline-range"]'); + await expect(fields).toBeVisible(); + + // Three weeks: daily means, the bar headed by one day. + const from = daysBefore(TODAY, 20); + const start = desktop.locator('[data-testid="timeline-range-from"]'); + await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`); + await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 }); + await expect(fields).toHaveAttribute("data-to", TODAY); + await expect(zoom("range")).toHaveAttribute("aria-checked", "true"); + await expect( + page.locator('[data-slot="timeline-legend-mean"]'), + ).toHaveAttribute("data-bucket", "day"); + await expect( + page.locator('[data-slot="timeline-selection-bar"]'), + ).toHaveAttribute("data-bucket", "day"); + const asked = log.timelineQueries.at(-1)!; + expect(asked.get("zoom")).toBe("range"); + expect(asked.get("from")).toBe(from); + expect(asked.get("to")).toBe(TODAY); + + // A reload keeps it. + await page.reload(); + await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 }); + + // A fixed zoom replaces it; Back brings it back. + await zoom("year").click(); + await expect(page).toHaveURL(/[?&]zoom=year$/); + await expect(fields).toHaveCount(0); + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`)); + await expect(fields).toHaveAttribute("data-from", from); + + // Typing a start moves the range and the URL with it. + const later = daysBefore(TODAY, 10); + await start.fill(later); + await start.press("Enter"); + await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`)); + + // A parameter it cannot read opens the whole record. + await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); + await expect(zoom("all")).toHaveAttribute("aria-checked", "true", { + timeout: 20_000, + }); + await expect(fields).toHaveCount(0); +}); + async function setTimelineModuleOn(page: Page) { await page.goto("/settings/modules"); await setTimelineModule(page, true); diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index f2f2542ce..f43a4f72a 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -46,10 +46,25 @@ function rng(seed: number) { }; } -type Bucket = "quarter" | "month" | "week"; +type Bucket = "quarter" | "month" | "week" | "day"; /** The bucket the server picks for a zoom (`timelineBucket`). */ -export function bucketFor(zoom: string): Bucket { +export function bucketFor( + zoom: string, + from?: string | null, + to?: string | null, +): Bucket { + if (zoom === "range" && from && to) { + // `rangeBucket`: by the range's length in days. + const days = dayNum(to) - dayNum(from) + 1; + return days >= 730 + ? "quarter" + : days >= 120 + ? "month" + : days >= 42 + ? "week" + : "day"; + } return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter"; } @@ -65,6 +80,10 @@ function dayStr(n: number): string { /** 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 === "day") { + for (let d = dayNum(from); d <= dayNum(to); d++) out.push(dayStr(d)); + return out; + } if (bucket === "week") { const n = dayNum(from); // 1970-01-01 was a Thursday: day 4 is the first Monday. @@ -742,14 +761,21 @@ export async function mockTimeline( .split(",") .filter(Boolean); const zoom = url.searchParams.get("zoom") ?? "all"; - const bucket = bucketFor(zoom); + const from = url.searchParams.get("from"); + const to = url.searchParams.get("to"); + const bucket = bucketFor(zoom, from, to); const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + // A chosen range answers its own window, as the server does. + const chosen = zoom === "range" && from && to; const all = - bucket === body.bucket ? body.series : series(dataFrom, today, bucket); + !chosen && bucket === body.bucket + ? body.series + : series(chosen ? from : dataFrom, chosen ? to : today, bucket); return json(route, 200, { ...body, zoom, bucket, + ...(chosen ? { range: { from, to, dataFrom } } : {}), // Any other value the menu offers answers a flat line of its own. series: values.map( (key) => diff --git a/messages/de.json b/messages/de.json index bf76a35a6..d413240be 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "Alles", "year": "12 Monate", - "quarter": "3 Monate" + "quarter": "3 Monate", + "range": "Zeitraum" }, "lanes": { "life": "Leben", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "{count} Allergie", "standingAllergiesFew": "{count} Allergien", - "standingAllergiesOther": "{count} Allergien" + "standingAllergiesOther": "{count} Allergien", + "legendMeanDay": "Tagesmittel", + "range": { + "from": "Von", + "to": "Bis", + "label": "Gewählter Zeitraum", + "open": "Zeitraum wählen", + "clear": "Alles zeigen" + } }, "lifeEvents": { "title": "Lebensereignis", diff --git a/messages/en.json b/messages/en.json index 132459665..13e41db40 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "All", "year": "12 months", - "quarter": "3 months" + "quarter": "3 months", + "range": "Range" }, "lanes": { "life": "Life", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "{count} allergy", "standingAllergiesFew": "{count} allergies", - "standingAllergiesOther": "{count} allergies" + "standingAllergiesOther": "{count} allergies", + "legendMeanDay": "Daily mean", + "range": { + "from": "From", + "to": "To", + "label": "Chosen range", + "open": "Choose a range", + "clear": "Show everything" + } }, "lifeEvents": { "title": "Life event", diff --git a/messages/es.json b/messages/es.json index c36722994..5a9f16005 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "Todo", "year": "12 meses", - "quarter": "3 meses" + "quarter": "3 meses", + "range": "Periodo" }, "lanes": { "life": "Vida", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "{count} alergia", "standingAllergiesFew": "{count} alergias", - "standingAllergiesOther": "{count} alergias" + "standingAllergiesOther": "{count} alergias", + "legendMeanDay": "Media diaria", + "range": { + "from": "Desde", + "to": "Hasta", + "label": "Periodo elegido", + "open": "Elegir un periodo", + "clear": "Mostrar todo" + } }, "lifeEvents": { "title": "Acontecimiento vital", diff --git a/messages/fr.json b/messages/fr.json index 819d7bee3..6cb71bff6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "Tout", "year": "12 mois", - "quarter": "3 mois" + "quarter": "3 mois", + "range": "Période" }, "lanes": { "life": "Vie", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "{count} allergie", "standingAllergiesFew": "{count} allergies", - "standingAllergiesOther": "{count} allergies" + "standingAllergiesOther": "{count} allergies", + "legendMeanDay": "Moyenne journalière", + "range": { + "from": "Du", + "to": "Au", + "label": "Période choisie", + "open": "Choisir une période", + "clear": "Tout afficher" + } }, "lifeEvents": { "title": "Événement de vie", diff --git a/messages/it.json b/messages/it.json index 319242df5..5d3a75de3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "Tutto", "year": "12 mesi", - "quarter": "3 mesi" + "quarter": "3 mesi", + "range": "Periodo" }, "lanes": { "life": "Vita", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "{count} allergia", "standingAllergiesFew": "{count} allergie", - "standingAllergiesOther": "{count} allergie" + "standingAllergiesOther": "{count} allergie", + "legendMeanDay": "Media giornaliera", + "range": { + "from": "Dal", + "to": "Al", + "label": "Periodo scelto", + "open": "Scegli un periodo", + "clear": "Mostra tutto" + } }, "lifeEvents": { "title": "Evento di vita", diff --git a/messages/ko.json b/messages/ko.json index 2af6e8037..a2010c07a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "전체", "year": "12개월", - "quarter": "3개월" + "quarter": "3개월", + "range": "기간" }, "lanes": { "life": "삶", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "알레르기 {count}개", "standingAllergiesFew": "알레르기 {count}개", - "standingAllergiesOther": "알레르기 {count}개" + "standingAllergiesOther": "알레르기 {count}개", + "legendMeanDay": "일 평균", + "range": { + "from": "시작", + "to": "종료", + "label": "선택한 기간", + "open": "기간 선택", + "clear": "전체 보기" + } }, "lifeEvents": { "title": "삶의 사건", diff --git a/messages/pl.json b/messages/pl.json index 337572ce2..e031f62b8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12293,7 +12293,8 @@ "zoom": { "all": "Wszystko", "year": "12 miesięcy", - "quarter": "3 miesiące" + "quarter": "3 miesiące", + "range": "Zakres" }, "lanes": { "life": "Życie", @@ -12476,7 +12477,15 @@ }, "standingAllergiesOne": "{count} alergia", "standingAllergiesFew": "{count} alergie", - "standingAllergiesOther": "{count} alergii" + "standingAllergiesOther": "{count} alergii", + "legendMeanDay": "Średnia dzienna", + "range": { + "from": "Od", + "to": "Do", + "label": "Wybrany zakres", + "open": "Wybierz zakres", + "clear": "Pokaż wszystko" + } }, "lifeEvents": { "title": "Wydarzenie życiowe", diff --git a/src/components/timeline/__tests__/range-fields.test.tsx b/src/components/timeline/__tests__/range-fields.test.tsx new file mode 100644 index 000000000..7c12cdbd7 --- /dev/null +++ b/src/components/timeline/__tests__/range-fields.test.tsx @@ -0,0 +1,108 @@ +/** + * The chosen-range fields: two date fields with the record's limits, and + * every change made honest before it leaves (never past today, never before + * the first entry, never inverted). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DateFieldProps } from "@/components/ui/date-field"; + +const fields: DateFieldProps[] = []; +vi.mock("@/components/ui/date-field", () => ({ + DateField: (props: DateFieldProps) => { + fields.push(props); + return ( + + ); + }, +})); + +const { RangeFields } = await import("../range-fields"); + +const TODAY = "2026-10-07"; + +function render(onChange: (next: { from: string; to: string }) => void) { + fields.length = 0; + const html = renderToStaticMarkup( + + + , + ); + const [from, to] = fields; + return { html, from, to }; +} + +describe("", () => { + it("names both ends and carries the record's limits", () => { + const { html, from, to } = render(() => undefined); + expect(html).toContain('data-slot="timeline-range"'); + expect(html).toContain('data-from="2026-03-01"'); + expect(html).toContain('data-to="2026-04-15"'); + expect(html).toContain('aria-label="Gewählter Zeitraum"'); + expect(from["aria-label"]).toBe("Von"); + expect(to["aria-label"]).toBe("Bis"); + expect(from.min).toBe("2019-03-10"); + expect(from.max).toBe("2026-04-15"); + expect(to.min).toBe("2026-03-01"); + expect(to.max).toBe(TODAY); + }); + + it("passes a valid change on as it is", () => { + const onChange = vi.fn(); + const { from, to } = render(onChange); + from.onChange?.("2026-02-01"); + expect(onChange).toHaveBeenLastCalledWith({ + from: "2026-02-01", + to: "2026-04-15", + }); + to.onChange?.("2026-05-31"); + expect(onChange).toHaveBeenLastCalledWith({ + from: "2026-03-01", + to: "2026-05-31", + }); + }); + + it("clamps softly instead of refusing", () => { + const onChange = vi.fn(); + const { from, to } = render(onChange); + // Before the first entry: the first entry. + from.onChange?.("2010-01-01"); + expect(onChange).toHaveBeenLastCalledWith({ + from: "2019-03-10", + to: "2026-04-15", + }); + // Past today: today. + to.onChange?.("2027-01-01"); + expect(onChange).toHaveBeenLastCalledWith({ + from: "2026-03-01", + to: TODAY, + }); + // A start after the end: the end. + from.onChange?.("2026-05-01"); + expect(onChange).toHaveBeenLastCalledWith({ + from: "2026-04-15", + to: "2026-04-15", + }); + }); + + it("ignores a cleared field", () => { + const onChange = vi.fn(); + const { from } = render(onChange); + from.onChange?.(""); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-url.test.ts b/src/components/timeline/__tests__/timeline-url.test.ts new file mode 100644 index 000000000..14fb1709a --- /dev/null +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { TIMELINE_RANGE_MAX_DAYS } from "@/lib/day/contract"; + +import { dayKey, dayNumber } from "../timeline-dates"; +import { clampRange, parseTimelineUrl, timelineSearch } from "../timeline-url"; + +const TODAY = "2026-10-07"; +const parse = (search: string) => + parseTimelineUrl(new URLSearchParams(search), TODAY); + +describe("timeline URL", () => { + it("reads a fixed zoom and leaves the choice open without one", () => { + expect(parse("")).toEqual({ zoom: null, range: null }); + expect(parse("zoom=year")).toEqual({ zoom: "year", range: null }); + expect(parse("zoom=quarter&day=2026-01-03")).toEqual({ + zoom: "quarter", + range: null, + }); + }); + + it("reads a chosen range with both ends", () => { + expect(parse("zoom=range&from=2026-03-01&to=2026-04-15")).toEqual({ + zoom: "range", + range: { from: "2026-03-01", to: "2026-04-15" }, + }); + }); + + it("falls back to all, without a word, on anything it cannot read", () => { + for (const search of [ + "zoom=decade", + "zoom=range", + "zoom=range&from=2026-03-01", + "zoom=range&from=2026-02-30&to=2026-04-15", + "zoom=range&from=2026-05-01&to=2026-04-15", + "zoom=range&from=2027-01-01&to=2027-02-01", + "zoom=range&from=yesterday&to=today", + ]) { + expect(parse(search), search).toEqual({ zoom: "all", range: null }); + } + }); + + it("pulls a range's end back to today and its span under the cap", () => { + expect(parse("zoom=range&from=2026-09-01&to=2027-01-01").range).toEqual({ + from: "2026-09-01", + to: TODAY, + }); + const long = clampRange({ from: "1990-01-01", to: TODAY }, TODAY, null); + expect(dayNumber(long.to) - dayNumber(long.from) + 1).toBe( + TIMELINE_RANGE_MAX_DAYS, + ); + expect(long.from).toBe( + dayKey(dayNumber(TODAY) - TIMELINE_RANGE_MAX_DAYS + 1), + ); + }); + + it("writes the zoom and range and keeps every other parameter", () => { + expect( + timelineSearch("?day=2026-01-03", "range", { + from: "2026-01-01", + to: "2026-01-31", + }), + ).toBe("?day=2026-01-03&zoom=range&from=2026-01-01&to=2026-01-31"); + expect( + timelineSearch("?zoom=range&from=2026-01-01&to=2026-01-31", "year", null), + ).toBe("?zoom=year"); + }); +}); diff --git a/src/components/timeline/range-fields.tsx b/src/components/timeline/range-fields.tsx new file mode 100644 index 000000000..dd2964686 --- /dev/null +++ b/src/components/timeline/range-fields.tsx @@ -0,0 +1,78 @@ +"use client"; + +/** + * The two date fields of a chosen range (v1.42): from and to, through the + * app's own date field (the browser's native date input reads its order + * from the browser, not from the person's setting). Each change is made + * honest before it leaves (`clampRange`): never past today, never before + * the record's first entry, never inverted. The fields carry the same + * limits, so the calendar greys out what the clamp would move anyway. + */ +import { DateField } from "@/components/ui/date-field"; +import { useTranslations } from "@/lib/i18n/context"; + +import { clampRange, type TimelineRange } from "./timeline-url"; + +export function RangeFields({ + range, + today, + dataFrom, + onChange, +}: { + range: TimelineRange; + today: string; + /** The record's first entry, once known; the earliest the range may start. */ + dataFrom: string | null; + onChange: (next: TimelineRange) => void; +}) { + const { t } = useTranslations(); + const commit = (next: TimelineRange) => { + const clamped = clampRange(next, today, dataFrom); + if (clamped.from !== range.from || clamped.to !== range.to) { + onChange(clamped); + } + }; + return ( +
+
+ {/* The field's own input carries the name; this is its caption. */} + + { + if (from) commit({ from, to: range.to }); + }} + data-testid="timeline-range-from" + /> +
+
+ {/* The field's own input carries the name; this is its caption. */} + + { + if (to) commit({ from: range.from, to }); + }} + data-testid="timeline-range-to" + /> +
+
+ ); +} diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 62c2bdfe1..4b2b7cc73 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -207,7 +207,7 @@ export function SelectionBar({ /** * The bucket by name, in full: "July to September 2026", "September 2026", - * "Week of 22 September 2026". + * "Week of 22 September 2026", "22 September 2026". */ export function bucketTitle( start: string, @@ -215,6 +215,7 @@ export function bucketTitle( intl: string, t: ReturnType["t"], ): string { + if (bucket === "day") return formatDayMonthYearLong(start, intl); if (bucket === "month") return formatMonthYear(start, intl); if (bucket === "quarter") { const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1); diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 0182a50b6..ab863d9d1 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -72,6 +72,7 @@ import { wrapToWidth, layoutTimeline, stepSelection, + zoomShape, type PlacedPoint, type PlacedSpan, type TimeWindow, @@ -171,14 +172,15 @@ export function TimelineChart({ ); const floor = timeline.range.dataFrom ?? window.from; + const shape = zoomShape(zoom, window); function handleKey(event: KeyboardEvent) { const current = selected ?? today; let next: string | null = null; if (event.key === "ArrowLeft") - next = stepSelection(current, -1, zoom, today, floor); + next = stepSelection(current, -1, shape, today, floor); else if (event.key === "ArrowRight") - next = stepSelection(current, 1, zoom, today, floor); + next = stepSelection(current, 1, shape, today, floor); else if (event.key === "Home") next = floor; else if (event.key === "End") next = today; else if (event.key === "Enter" || event.key === " ") { @@ -242,7 +244,7 @@ export function TimelineChart({ >; window: TimeWindow; - zoom: TimelineZoom; + zoom: Exclude; today: string; selected: string | null; dataFrom: string | null; diff --git a/src/components/timeline/timeline-dates.ts b/src/components/timeline/timeline-dates.ts index 7df91b788..703583aee 100644 --- a/src/components/timeline/timeline-dates.ts +++ b/src/components/timeline/timeline-dates.ts @@ -61,6 +61,7 @@ export function addMonths(key: string, months: number): string { /** The first day of the bucket `key` falls in. Weeks start on Monday. */ export function bucketStart(key: string, bucket: TimelineBucket): string { + if (bucket === "day") return key; if (bucket === "month") return startOfMonth(key); if (bucket === "quarter") { const month = Number(key.slice(5, 7)); @@ -74,6 +75,7 @@ export function bucketStart(key: string, bucket: TimelineBucket): string { /** The first day after the bucket that starts on `start`. */ export function bucketAfter(start: string, bucket: TimelineBucket): string { + if (bucket === "day") return dayKey(dayNumber(start) + 1); if (bucket === "month") return addMonths(start, 1); if (bucket === "quarter") return addMonths(start, 3); return dayKey(dayNumber(start) + 7); @@ -84,6 +86,7 @@ export function bucketAfter(start: string, bucket: TimelineBucket): string { * buckets differ by one, so a difference counts the buckets between. */ export function bucketIndex(start: string, bucket: TimelineBucket): number { + if (bucket === "day") return dayNumber(start); 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; @@ -205,6 +208,7 @@ export function formatBucket( bucket: TimelineBucket, intlLocale: string, ): string | { from: string; to: string } { + if (bucket === "day") return formatDayMonthYear(start, intlLocale); if (bucket === "month") return formatMonthYear(start, intlLocale); const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1); if (bucket === "quarter") { diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index f43a92076..b3407c175 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -121,7 +121,7 @@ export interface TimeWindow { * fallback before an answer is the current and the previous year. `year` and * `quarter` are 12 and 3 whole months that keep `anchor` (the selected day) * in view, end no later than the month of `today`, and so never open onto an - * empty future. + * empty future. `range` is exactly the chosen stretch (`serverRange`). */ export function windowFor( zoom: TimelineZoom, @@ -129,7 +129,7 @@ export function windowFor( anchor: string | null, serverRange?: { from: string; to: string } | null, ): TimeWindow { - if (zoom === "all") { + if (zoom === "all" || zoom === "range") { if (serverRange) return { from: serverRange.from, to: serverRange.to }; const year = Number(today.slice(0, 4)); return { from: `${year - 1}-01-01`, to: `${year}-12-31` }; @@ -146,6 +146,23 @@ export function windowFor( }; } +/** + * How a window draws its grid and steps its selection: a chosen range + * borrows the fixed zoom nearest its length (years for two years and more, + * months from four months, days below), so a range never needs a grid of + * its own. + */ +export function zoomShape( + zoom: TimelineZoom, + window: TimeWindow, +): Exclude { + if (zoom !== "range") return zoom; + const days = dayNumber(window.to) - dayNumber(window.from) + 1; + if (days >= 2 * 365) return "all"; + if (days >= 120) return "year"; + return "quarter"; +} + /** A linear map from calendar days to x, over `[from, to + 1 day)`. */ export interface Scale { readonly x0: number; @@ -281,7 +298,7 @@ export interface GridFormat { */ export function gridTicks( window: TimeWindow, - zoom: TimelineZoom, + zoom: Exclude, scale: Scale, fmt: GridFormat, ): GridTick[] { @@ -735,6 +752,11 @@ export const MAX_BRIDGED_GAP: Readonly> = { quarter: 1, month: 2, week: 4, + // Days come only with a chosen range under six weeks. Three missing days + // (points four days apart) bridge a weekend away or a forgotten cuff; + // a longer silence in a window that short is a break worth seeing, and + // the bridge stays well under a third of even a two-week range. + day: 3, }; /** @@ -915,7 +937,7 @@ export function dateAtPointer( export function stepSelection( current: string, direction: -1 | 1, - zoom: TimelineZoom, + zoom: Exclude, today: string, floor: string, ): string { diff --git a/src/components/timeline/timeline-url.ts b/src/components/timeline/timeline-url.ts new file mode 100644 index 000000000..7e95b26b8 --- /dev/null +++ b/src/components/timeline/timeline-url.ts @@ -0,0 +1,97 @@ +/** + * The timeline's zoom and chosen range in the URL (v1.42): + * `?zoom=year`, `?zoom=range&from=2026-03-01&to=2026-04-15`. Back and a + * bookmark both land where the person was. Anything that does not read as + * a zoom, or a range without two valid ends, falls back to `all` without a + * word: a stale bookmark should open the timeline, not an error. + */ +import { + TIMELINE_RANGE_MAX_DAYS, + TIMELINE_ZOOMS, + type TimelineZoom, +} from "@/lib/day/contract"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; + +import { dayKey, dayNumber } from "./timeline-dates"; + +export const ZOOM_PARAM = "zoom"; +export const FROM_PARAM = "from"; +export const TO_PARAM = "to"; + +export interface TimelineRange { + from: string; + to: string; +} + +export interface TimelineUrlState { + /** The zoom the URL names, or null when it names none (or a bad one). */ + zoom: TimelineZoom | null; + /** The chosen range, with `zoom` `range` only. */ + range: TimelineRange | null; +} + +/** + * A chosen range made honest, softly: never past today, never before the + * record's first entry (when known), never inverted, and never longer than + * the server answers. Each end moves only as far as it must. + */ +export function clampRange( + range: TimelineRange, + today: string, + dataFrom: string | null, +): TimelineRange { + let { from, to } = range; + if (to > today) to = today; + if (dataFrom && dataFrom <= to && from < dataFrom) from = dataFrom; + if (from > to) from = to; + const earliest = dayKey(dayNumber(to) - (TIMELINE_RANGE_MAX_DAYS - 1)); + if (from < earliest) from = earliest; + return { from, to }; +} + +export function parseTimelineUrl( + params: Pick, + today: string, +): TimelineUrlState { + const raw = params.get(ZOOM_PARAM); + const zoom = (TIMELINE_ZOOMS as readonly string[]).includes(raw ?? "") + ? (raw as TimelineZoom) + : null; + if (raw !== null && zoom === null) return { zoom: "all", range: null }; + if (zoom !== "range") return { zoom, range: null }; + const from = params.get(FROM_PARAM); + const to = params.get(TO_PARAM); + if ( + !from || + !to || + !isCalendarDateKey(from) || + !isCalendarDateKey(to) || + from > to || + from > today + ) { + return { zoom: "all", range: null }; + } + return { zoom, range: clampRange({ from, to }, today, null) }; +} + +/** + * The search string for a zoom (and, for `range`, its ends), keeping every + * other parameter (`day`, `add`) as it is. + */ +export function timelineSearch( + current: string, + zoom: TimelineZoom, + range: TimelineRange | null, +): string { + const params = new URLSearchParams(current); + params.set(ZOOM_PARAM, zoom); + if (zoom === "range" && range) { + params.set(FROM_PARAM, range.from); + params.set(TO_PARAM, range.to); + } else { + params.delete(FROM_PARAM); + params.delete(TO_PARAM); + } + const search = params.toString(); + return search ? `?${search}` : ""; +} diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index 564e088bb..99962904f 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -19,7 +19,7 @@ */ import { useMemo, useState } from "react"; import { usePathname, useSearchParams } from "next/navigation"; -import { Plus } from "lucide-react"; +import { CalendarRange, Plus } from "lucide-react"; import { openDay as openDayLayer } from "@/components/day/day-layer-controller"; import { parseDayParam } from "@/components/day/day-url"; @@ -58,7 +58,14 @@ import { useSeriesValueFormat } from "./use-series-value-format"; import { SelectionBar } from "./selection-bar"; import { TimelineChart } from "./timeline-chart"; import { TimelineChronicle } from "./timeline-chronicle"; -import { todayKeyIn } from "./timeline-dates"; +import { dayKey, dayNumber, todayKeyIn } from "./timeline-dates"; +import { RangeFields } from "./range-fields"; +import { + clampRange, + parseTimelineUrl, + timelineSearch, + type TimelineRange, +} from "./timeline-url"; import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry"; import { LayersMenu, @@ -120,7 +127,12 @@ export function TimelineView() { // Life events are written in one's own record only (owner-only routes). const canAddLifeEvent = !caps.inSharedRecord; - const [zoom, setZoom] = useState(dayParam ? "quarter" : "all"); + // The zoom and a chosen range live in the URL (`timeline-url.ts`), so Back + // and a bookmark return to them. Without a `zoom`, an incoming `?day=` + // opens three months around that day and anything else the whole record. + const urlState = parseTimelineUrl(searchParams, today); + const zoom: TimelineZoom = urlState.zoom ?? (dayParam ? "quarter" : "all"); + const range = zoom === "range" ? urlState.range : null; const [grouping, setGrouping] = useState("month"); // The selection follows `?day=` (Back, Forward, the day panel's previous // and next) and otherwise the last click or key. A pick remembers the @@ -185,7 +197,11 @@ export function TimelineView() { ); const requestWindow = - zoom === "all" ? null : windowFor(zoom, today, selected ?? dayParam); + zoom === "all" + ? null + : zoom === "range" + ? range + : windowFor(zoom, today, selected ?? dayParam); const timeline = useTimeline( zoom, requestWindow?.from ?? null, @@ -217,12 +233,37 @@ export function TimelineView() { } const data = timeline.data; + // A pick outside a newly chosen range no longer counts as one. + const chosen = + selected !== null && + (!requestWindow || + (selected >= requestWindow.from && selected <= requestWindow.to)) + ? selected + : null; // Before anyone picks a day, the newest bucket the bar has something for, // inside the window that was asked for. const effectiveSelected = - selected ?? + chosen ?? (data ? latestDataDate(data, today, requestWindow ?? data.range) : null) ?? - today; + (range ? range.to : today); + + function navigate(nextZoom: TimelineZoom, nextRange: TimelineRange | null) { + const search = timelineSearch(window.location.search, nextZoom, nextRange); + window.history.pushState(null, "", `${pathname}${search}`); + } + + function setZoom(next: TimelineZoom) { + if (next !== "range") { + navigate(next, null); + return; + } + // A new range starts as what is on screen, up to today. + const shown = window_ ?? { + from: dayKey(dayNumber(today) - 29), + to: today, + }; + navigate("range", clampRange(shown, today, data?.range.dataFrom ?? null)); + } // The shell's day layer owns `?day=` and its history entries; this only // asks it to open the day, and keeps the selection on it. @@ -253,7 +294,7 @@ export function TimelineView() { zoom, today, effectiveSelected, - zoom === "all" ? data.range : null, + zoom === "all" || zoom === "range" ? data.range : null, ) : null; const presentLanes = (data?.lanes ?? []) @@ -292,6 +333,29 @@ export function TimelineView() { ) : null; + // The chosen range's fields, under the zoom; on a phone with a way back to + // the whole record, since the phone has no zoom control to switch with. + const rangeControls = + zoom === "range" && range ? ( +
+ navigate("range", next)} + /> + +
+ ) : null; + const layers = (iconOnly: boolean) => ( void timeline.refetch()} /> + ) : zoom === "range" && (isEmpty || !data || !window_) ? ( + // A chosen stretch with nothing in it: the fields stay, so the next + // choice is one step away. +
+ {rangeControls} + setZoom("all")} + > + {t("timeline.range.clear")} + + } + /> +
) : isEmpty || !data || !window_ ? ( + {rangeControls} - {layers(true)} +
+ + {layers(true)} +
+ {rangeControls} > = { quarter: "timeline.legendMeanQuarter", month: "timeline.legendMeanMonth", week: "timeline.legendMeanWeek", + day: "timeline.legendMeanDay", }; function Legend({ diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index ffb3ee430..505bcb7aa 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -211,17 +211,29 @@ export type DayNotableKind = (typeof DAY_NOTABLE_KINDS)[number]; /* ─── Timeline ────────────────────────────────────────────────────────────── */ -/** Zoom levels. `all` bundles hardest: no symptoms, no single intakes. */ -export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const; +/** + * Zoom levels. `all` bundles hardest: no symptoms, no single intakes. + * `range` is a stretch the person chose, named by `from` and `to` (both + * required, at most {@link TIMELINE_RANGE_MAX_DAYS} days). + */ +export const TIMELINE_ZOOMS = ["all", "year", "quarter", "range"] 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`. + * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`, + * and for a `range` by its length, down to single days under six weeks. */ -export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const; +export const TIMELINE_BUCKETS = ["quarter", "month", "week", "day"] as const; + +/** + * The longest `range` the timeline answers: fifteen years. Longer than any + * fixed zoom reads, and the series of such a window come from the rollup + * tier in quarters, so the cost stays that of an `all` view. + */ +export const TIMELINE_RANGE_MAX_DAYS = 15 * 366; export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number]; diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 0bbc1892f..a3dbd24eb 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -28,6 +28,7 @@ import { TIMELINE_READINESS_KEYS, TIMELINE_READINESS_STATUSES, TIMELINE_READINESS_VERDICTS, + TIMELINE_RANGE_MAX_DAYS, TIMELINE_ZOOMS, } from "./contract"; @@ -266,17 +267,43 @@ export const dayNotableResponseSchema = z /* ─── GET /api/timeline ───────────────────────────────────────────────────── */ -export const timelineQuerySchema = z.object({ - zoom: z.enum(TIMELINE_ZOOMS).default("all"), - from: dateKeySchema.optional(), - to: dateKeySchema.optional(), - values: z - .string() - .optional() - .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", - ), -}); +export const timelineQuerySchema = z + .object({ + zoom: z + .enum(TIMELINE_ZOOMS) + .default("all") + .describe( + "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.", + ), + from: dateKeySchema.optional(), + to: dateKeySchema.optional(), + values: z + .string() + .optional() + .describe( + "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", + ), + }) + .refine((q) => q.zoom !== "range" || (q.from && q.to), { + message: "`range` needs both `from` and `to`", + path: ["from"], + }) + .refine( + (q) => + q.zoom !== "range" || + !q.from || + !q.to || + // Both ends as the same instant of their calendar day, so the + // difference counts calendar days whatever the process zone. + (dateOnlyAtNoonUtc(q.to).getTime() - + dateOnlyAtNoonUtc(q.from).getTime()) / + 86_400_000 < + TIMELINE_RANGE_MAX_DAYS, + { + message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`, + path: ["to"], + }, + ); const timelineItem = z .object({ @@ -357,7 +384,7 @@ export const timelineResponseSchema = z 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`.", + "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.", ), series: z.array(timelineSeries), notable: z.array( diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts index b9aa6d4c4..f3b985d54 100644 --- a/src/lib/timeline/__tests__/series.test.ts +++ b/src/lib/timeline/__tests__/series.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from "vitest"; -import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline"; +import { + overlaps, + parseSeriesKeys, + rangeBucket, + timelineBucket, +} from "../load-timeline"; import { coveredWeeks, READINESS_WEEKS } from "../readiness"; import { bucketKey, foldDays, foldMonths } from "../series"; @@ -72,6 +77,24 @@ describe("buckets", () => { expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month"); expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter"); }); + + it("picks a chosen range's bucket by its length, down to days", () => { + // Two years and more: quarters. + expect(rangeBucket("2024-03-31", "2026-03-30")).toBe("quarter"); + expect(rangeBucket("2024-04-01", "2026-03-30")).toBe("month"); + // Four months (120 days) and more: months. + expect(rangeBucket("2026-01-01", "2026-04-30")).toBe("month"); + expect(rangeBucket("2026-01-02", "2026-04-30")).toBe("week"); + // Six weeks and more: weeks. + expect(rangeBucket("2026-03-01", "2026-04-11")).toBe("week"); + expect(rangeBucket("2026-03-02", "2026-04-11")).toBe("day"); + expect(rangeBucket("2026-03-01", "2026-03-01")).toBe("day"); + expect(timelineBucket("range", "2026-03-01", "2026-03-14")).toBe("day"); + }); + + it("names a day bucket by the day itself", () => { + expect(bucketKey("2026-03-05", "day")).toBe("2026-03-05"); + }); }); describe("window", () => { diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index bc97b723d..a24ef1a97 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -7,7 +7,8 @@ * read only the window. * * Windows by zoom, when the request names none: `quarter` the last 91 days, - * `year` the last 365, `all` from the earliest date the record carries. The + * `year` the last 365, `all` from the earliest date the record carries. A + * `range` always names its own (the query schema requires both ends). The * notable days of an `all` window are computed over its last three years at * most: an extreme is "the highest for at least three months", and older * history is what the comparison reads, not what it marks. @@ -48,6 +49,29 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ /** An `all` window longer than this many days averages quarters. */ const QUARTER_BUCKET_MIN_DAYS = 2 * 365; +/** + * The bucket for a chosen range, by its length: about the point count the + * fixed zooms draw (a dozen to a few dozen), never one point per reading. + * Two years and more average quarters (eight and up), four months and more + * months (four to twenty-four), six weeks and more weeks (six to + * seventeen), and anything shorter single days (at most six weeks of them). + */ +export const RANGE_BUCKET_MIN_DAYS: Readonly< + Record, number> +> = { + quarter: 2 * 365, + month: 120, + week: 42, +}; + +export function rangeBucket(from: string, to: string): TimelineBucket { + const days = daysBetweenDateKeys(from, to) + 1; + if (days >= RANGE_BUCKET_MIN_DAYS.quarter) return "quarter"; + if (days >= RANGE_BUCKET_MIN_DAYS.month) return "month"; + if (days >= RANGE_BUCKET_MIN_DAYS.week) return "week"; + return "day"; +} + /** * 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 @@ -61,13 +85,16 @@ export function timelineBucket( from: string, to: string, ): TimelineBucket { + if (zoom === "range") return rangeBucket(from, to); 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>> = { +const ZOOM_DAYS: Readonly< + Record, number> +> = { year: 365, quarter: 91, }; @@ -144,7 +171,7 @@ export async function loadTimeline(args: { const to = query.to ?? today; const from = query.from ?? - (query.zoom === "all" + (query.zoom === "all" || query.zoom === "range" ? dataFrom !== null && dataFrom < to ? dataFrom : shiftDateKey(to, -364) diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index 9dd6a56b7..4a6be0a76 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -61,6 +61,7 @@ export const MOOD_SERIES_KEY = "MOOD"; /** The bucket a local day belongs to, named by its first day. */ export function bucketKey(day: string, bucket: TimelineBucket): string { + if (bucket === "day") return day; if (bucket === "month") return `${day.slice(0, 7)}-01`; if (bucket === "quarter") { const month = Number(day.slice(5, 7)); @@ -110,7 +111,7 @@ export interface RolledMonth { */ export function foldMonths( months: readonly RolledMonth[], - bucket: Exclude, + bucket: Exclude, ): SeriesPoint[] { const buckets = new Map< string, @@ -230,7 +231,13 @@ async function measurementSeries(args: { const start = startOfLocalDayKey(args.from, args.tz); const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); let points: SeriesPoint[]; - if (args.bucket !== "week" && readsRollups(args.from, args.to)) { + // Weeks and days never read the rollups: their windows are short (a + // `day` range is under six weeks), and the rollups cut at UTC midnight. + if ( + args.bucket !== "week" && + args.bucket !== "day" && + 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. diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index 4bbb9a28f..bac08bd06 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -932,6 +932,43 @@ describe("timeline", () => { ["2026-03-02", 1], ]); + // A chosen range under six weeks averages single days; the two + // readings of 10 January stay one day. + const daily = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=range&from=2026-01-05&to=2026-01-25&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(daily.zoom).toBe("range"); + expect(daily.bucket).toBe("day"); + expect(daily.range).toMatchObject({ from: "2026-01-05", to: "2026-01-25" }); + expect(daily.series[0].points).toEqual([ + { t: "2026-01-10", mean: 125, count: 2 }, + { t: "2026-01-20", mean: 135, count: 1 }, + ]); + // Four months and more: months, as the fixed zooms draw them. + const monthly = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=range&from=2025-12-01&to=2026-03-31&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(monthly.bucket).toBe("month"); + // A range needs both ends, and spans fifteen years at most. + for (const query of [ + "zoom=range", + "zoom=range&from=2026-01-01", + "zoom=range&from=2000-01-01&to=2026-01-01", + ]) { + expect( + (await call(GET as Handler, "GET", `/api/timeline?${query}`)).status, + query, + ).toBe(422); + } + const six = "BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD"; expect(