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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 10:02:37 +02:00
1 parent f8557f57a0
commit 61155d1856
26 files changed
+839 -59

No files matched your search

+9 -1
View File
@@ -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:
+1
View File
@@ -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 })
+70
View File
@@ -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);
+30 -4
View File
@@ -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) =>
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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": "삶의 사건",
+11 -2
View File
@@ -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",
@@ -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 (
<input
aria-label={props["aria-label"]}
data-testid={props["data-testid"]}
data-min={props.min}
data-max={props.max}
value={props.value}
readOnly
/>
);
},
}));
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(
<I18nProvider initialLocale="de">
<RangeFields
range={{ from: "2026-03-01", to: "2026-04-15" }}
today={TODAY}
dataFrom="2019-03-10"
onChange={onChange}
/>
</I18nProvider>,
);
const [from, to] = fields;
return { html, from, to };
}
describe("<RangeFields>", () => {
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();
});
});
@@ -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");
});
});
+78
View File
@@ -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 (
<div
role="group"
aria-label={t("timeline.range.label")}
data-slot="timeline-range"
data-from={range.from}
data-to={range.to}
className="grid grid-cols-2 gap-3 sm:flex sm:items-end"
>
<div className="flex min-w-0 flex-col gap-1.5 sm:w-44">
{/* The field's own input carries the name; this is its caption. */}
<span className="text-xs font-medium" aria-hidden="true">
{t("timeline.range.from")}
</span>
<DateField
aria-label={t("timeline.range.from")}
value={range.from}
min={dataFrom ?? undefined}
max={range.to}
onChange={(from) => {
if (from) commit({ from, to: range.to });
}}
data-testid="timeline-range-from"
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5 sm:w-44">
{/* The field's own input carries the name; this is its caption. */}
<span className="text-xs font-medium" aria-hidden="true">
{t("timeline.range.to")}
</span>
<DateField
aria-label={t("timeline.range.to")}
value={range.to}
min={range.from}
max={today}
onChange={(to) => {
if (to) commit({ from: range.from, to });
}}
data-testid="timeline-range-to"
/>
</div>
</div>
);
}
+2 -1
View File
@@ -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<typeof useTranslations>["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);
+6 -4
View File
@@ -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<HTMLDivElement>) {
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({
<ChartBody
layout={layout}
window={window}
zoom={zoom}
zoom={shape}
today={today}
selected={selected}
dataFrom={timeline.range.dataFrom}
@@ -277,7 +279,7 @@ export function TimelineChart({
interface ChartBodyProps {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
window: TimeWindow;
zoom: TimelineZoom;
zoom: Exclude<TimelineZoom, "range">;
today: string;
selected: string | null;
dataFrom: string | null;
@@ -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") {
+26 -4
View File
@@ -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<TimelineZoom, "range"> {
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<TimelineZoom, "range">,
scale: Scale,
fmt: GridFormat,
): GridTick[] {
@@ -735,6 +752,11 @@ export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
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<TimelineZoom, "range">,
today: string,
floor: string,
): string {
+97
View File
@@ -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<URLSearchParams, "get">,
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}` : "";
}
+108 -9
View File
@@ -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<TimelineZoom>(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<ChronicleGrouping>("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() {
</Button>
) : 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 ? (
<div className="flex flex-wrap items-end gap-3">
<RangeFields
range={range}
today={today}
dataFrom={data?.range.dataFrom ?? null}
onChange={(next) => navigate("range", next)}
/>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-10 md:hidden"
onClick={() => setZoom("all")}
data-slot="timeline-range-clear"
>
{t("timeline.range.clear")}
</Button>
</div>
) : null;
const layers = (iconOnly: boolean) => (
<LayersMenu
lanes={presentLanes}
@@ -335,6 +399,25 @@ export function TimelineView() {
description={t("timeline.loadFailed")}
onRetry={() => 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.
<div className="space-y-4" data-slot="timeline-range-empty">
{rangeControls}
<EmptyState
title={t("timeline.selection.empty")}
action={
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setZoom("all")}
>
{t("timeline.range.clear")}
</Button>
}
/>
</div>
) : isEmpty || !data || !window_ ? (
<EmptyState
title={t("timeline.empty.title")}
@@ -367,6 +450,7 @@ export function TimelineView() {
{layers(false)}
</div>
</div>
{rangeControls}
<Card className="md:gap-4">
<CardContent className="space-y-4">
<TimelineChart
@@ -385,7 +469,7 @@ export function TimelineView() {
<SelectionBar
timeline={data}
selected={effectiveSelected}
showHint={selected === null}
showHint={chosen === null}
today={today}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
@@ -411,8 +495,22 @@ export function TimelineView() {
label={t("timeline.chronicle.groupingLabel")}
slot="timeline-grouping"
/>
{layers(true)}
<div className="flex items-center gap-2">
<Button
variant={zoom === "range" ? "secondary" : "outline"}
size="icon"
className="size-11"
aria-label={t("timeline.range.open")}
aria-pressed={zoom === "range"}
onClick={() => setZoom(zoom === "range" ? "all" : "range")}
data-slot="timeline-range-toggle"
>
<CalendarRange className="size-4" aria-hidden="true" />
</Button>
{layers(true)}
</div>
</div>
{rangeControls}
<TimelineChronicle
timeline={{
...data,
@@ -450,6 +548,7 @@ const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
day: "timeline.legendMeanDay",
};
function Legend({
+16 -4
View File
@@ -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];
+39 -12
View File
@@ -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(
+24 -1
View File
@@ -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", () => {
+30 -3
View File
@@ -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<Exclude<TimelineBucket, "day">, 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<Record<Exclude<TimelineZoom, "all">, number>> = {
const ZOOM_DAYS: Readonly<
Record<Exclude<TimelineZoom, "all" | "range">, 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)
+9 -2
View File
@@ -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<TimelineBucket, "week">,
bucket: Exclude<TimelineBucket, "week" | "day">,
): 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.
+37
View File
@@ -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<import("@/lib/day/contract").TimelineResponse>(
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<import("@/lib/day/contract").TimelineResponse>(
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(