mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
f8557f57a0
commit
61155d1856
26 files changed
+839
-59
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user