From dc86c42bef8e57dc853af904209abf510ef0ab4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:08:57 +0200 Subject: [PATCH] feat(timeline): fit the default window to the record Without a zoom in the URL the window now follows the record: one that starts inside the last three months opens on three months clamped to its first entry (at least four weeks up to today), an older one with entries there opens on three months, an older one without opens on everything, and an empty one keeps the empty state. The choice is made from the first answer before anything is drawn, so the page does not jump between windows. --- e2e/timeline.spec.ts | 22 ++++++++++ e2e/utils/mock-timeline.ts | 14 +++++-- .../__tests__/timeline-default-zoom.test.tsx | 29 +++++++++++++ .../timeline/__tests__/timeline-url.test.ts | 27 ++++++++++++ src/components/timeline/timeline-chart.tsx | 2 + src/components/timeline/timeline-url.ts | 41 +++++++++++++++++++ src/components/timeline/timeline-view.tsx | 33 +++++++++++---- 7 files changed, 156 insertions(+), 12 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index edb47b780..f691d0417 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -108,6 +108,28 @@ test("only old data opens on everything, and an empty window offers it", async ( await setTimelineModule(page, false); }); +test("a young record opens on three months starting at its first entry", async ({ + page, +}) => { + await setTimelineModuleOn(page); + // The record starts ten days ago: the window spans the four-week minimum. + const day = (offset: number) => { + const [y, m, d] = TODAY.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d + offset)).toISOString().slice(0, 10); + }; + await mockTimeline(page, "full", TODAY, { dataFrom: day(-10) }); + await page.goto("/timeline"); + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toHaveAttribute( + "data-window-from", + day(-27), + ); + // The next test starts from the module switched off. + await setTimelineModule(page, false); +}); + test("switching it on opens the readiness inventory, which leads to the timeline", async ({ page, }) => { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 528f36106..512b90c8f 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -826,12 +826,15 @@ export interface TimelineMockLog { * `onlyOld`: the record's entries all lie before the last year, so a * quarter or year window answers empty (with the record's `dataFrom`) and * only everything, or a chosen range, carries them. + * + * `dataFrom`: the record's first entry as the answers name it, to stand in + * for a young record. */ export async function mockTimeline( page: Page, state: "full" | "ready", today: string, - options: { onlyOld?: boolean } = {}, + options: { onlyOld?: boolean; dataFrom?: string } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => @@ -848,7 +851,8 @@ export async function mockTimeline( 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"; + const dataFrom = + options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01"); if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { return json(route, 200, { ...body, @@ -870,7 +874,11 @@ export async function mockTimeline( ...body, zoom, bucket, - ...(chosen ? { range: { from, to, dataFrom } } : {}), + ...(chosen + ? { range: { from, to, dataFrom } } + : options.dataFrom + ? { range: { ...body.range, dataFrom } } + : {}), // Any other value the menu offers answers a flat line of its own. series: values.map( (key) => diff --git a/src/components/timeline/__tests__/timeline-default-zoom.test.tsx b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx index 30b453198..2ebf69f22 100644 --- a/src/components/timeline/__tests__/timeline-default-zoom.test.tsx +++ b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx @@ -19,11 +19,20 @@ import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; let search = new URLSearchParams(); let dataFrom: string | null = "2019-01-01"; +/** A young record: everything sits inside the default three months. */ +let young = false; const asked: TimelineZoom[] = []; /** Everything for `all`; any other window holds nothing. */ function answer(zoom: TimelineZoom, from: string | null, to: string | null) { const full = fullTimeline(); + if (young) { + return { + ...full, + zoom, + range: { from: from ?? "2026-08-01", to: to ?? TODAY, dataFrom }, + }; + } if (zoom === "all" && dataFrom !== null) return { ...full, zoom }; const empty: TimelineResponse = { ...full, @@ -101,6 +110,7 @@ beforeAll(async () => { beforeEach(() => { search = new URLSearchParams(); dataFrom = "2019-01-01"; + young = false; asked.length = 0; }); @@ -140,6 +150,25 @@ describe("the timeline's default window", () => { expect(html).not.toContain('data-slot="timeline-desktop"'); }); + it("a young record opens on three months clamped to its first entry", () => { + young = true; + dataFrom = "2026-09-01"; + const html = render(); + expect(asked).not.toContain("all"); + expect(html).toMatch( + /data-slot="timeline-desktop"[^>]*data-zoom="quarter"/, + ); + expect(html).toMatch(/data-window-from="2026-09-01"/); + expect(html).toMatch(/data-window-to="2026-10-31"/); + }); + + it("an older record with entries in the three months opens on them", () => { + young = true; + const html = render(); + expect(asked).not.toContain("all"); + expect(html).toMatch(/data-window-from="2026-08-01"/); + }); + it("an empty record keeps the plain empty state", () => { dataFrom = null; const html = render(); diff --git a/src/components/timeline/__tests__/timeline-url.test.ts b/src/components/timeline/__tests__/timeline-url.test.ts index eb36416c5..0aa30d565 100644 --- a/src/components/timeline/__tests__/timeline-url.test.ts +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -7,6 +7,7 @@ import { clampRange, hasDataBeforeWindow, parseTimelineUrl, + resolveDefaultWindow, timelineSearch, } from "../timeline-url"; @@ -54,6 +55,32 @@ describe("timeline URL", () => { expect(hasDataBeforeWindow("2025-02-11", null)).toBe(false); }); + it("adapts the default window to the record", () => { + // The default three months up to 2026-10-07. + const window = { from: "2026-08-01", to: "2026-10-31" }; + const decide = (dataFrom: string | null, windowHasData: boolean) => + resolveDefaultWindow({ dataFrom, window, windowHasData, today: TODAY }); + + // 1. A young record starts at its first entry... + expect(decide("2026-09-01", true)).toEqual({ + zoom: "quarter", + from: "2026-09-01", + }); + // ...but spans at least four weeks up to today. + expect(decide("2026-10-05", true)).toEqual({ + zoom: "quarter", + from: "2026-09-10", + }); + // A first entry on the window's first day is the plain three months. + expect(decide("2026-08-01", true)).toEqual({ zoom: "quarter", from: null }); + // 2. An older record with entries in the three months: three months. + expect(decide("2019-01-01", true)).toEqual({ zoom: "quarter", from: null }); + // 3. An older record with none there: everything. + expect(decide("2019-01-01", false)).toEqual({ zoom: "all" }); + // 4. No data at all: the plain empty state. + expect(decide(null, false)).toBeNull(); + }); + 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", diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 106d1839a..69b042257 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -230,6 +230,8 @@ export function TimelineChart({ onKeyDown={handleKey} data-slot="timeline-chart" data-width={width} + data-window-from={window.from} + data-window-to={window.to} className="focus-visible:ring-ring/50 relative rounded-lg outline-none focus-visible:ring-2" >

diff --git a/src/components/timeline/timeline-url.ts b/src/components/timeline/timeline-url.ts index f469c4365..149a0fd82 100644 --- a/src/components/timeline/timeline-url.ts +++ b/src/components/timeline/timeline-url.ts @@ -77,6 +77,47 @@ export function parseTimelineUrl( /** The zoom the page opens on when the URL names none. */ export const DEFAULT_ZOOM: TimelineZoom = "quarter"; +/** The shortest default window over a young record, in days to today. */ +export const YOUNG_RECORD_MIN_DAYS = 28; + +/** + * What the page opens on when the URL names no zoom, decided from the + * default three months' answer (`window`, whether it holds anything, and + * the record's first entry): + * + * - a record that starts inside the three months opens on three months + * clamped to its first entry, at least four weeks up to today, so a + * few days of data neither hide in a sliver nor stretch thin + * (`from` set); + * - an older record with entries in the three months opens on them + * (`from` null); + * - an older record with none there opens on everything; + * - an empty record: null, the plain empty state. + */ +export type DefaultWindow = + { zoom: "quarter"; from: string | null } | { zoom: "all" } | null; + +export function resolveDefaultWindow(input: { + dataFrom: string | null; + window: { from: string; to: string }; + windowHasData: boolean; + today: string; +}): DefaultWindow { + const { dataFrom, window, today } = input; + if (dataFrom === null) return null; + if (dataFrom >= window.from) { + const minimum = dayKey(dayNumber(today) - (YOUNG_RECORD_MIN_DAYS - 1)); + const start = dataFrom < minimum ? dataFrom : minimum; + return { + zoom: "quarter", + from: start > window.from ? start : null, + }; + } + return input.windowHasData + ? { zoom: "quarter", from: null } + : { zoom: "all" }; +} + /** * True when a window shows nothing while the record has entries from before * it: the page then offers everything instead of a dead end. `dataFrom` is diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index bd95d6047..feeb92d60 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -73,6 +73,7 @@ import { DEFAULT_ZOOM, hasDataBeforeWindow, parseTimelineUrl, + resolveDefaultWindow, timelineSearch, type TimelineRange, } from "./timeline-url"; @@ -248,16 +249,25 @@ export function TimelineView() { values, !opensOnAll, ); - if ( - !opensOnAll && + // Without a zoom the window adapts to the record (`resolveDefaultWindow`): + // decided from the three months' answer, before anything is drawn, so the + // page never draws one window and then jumps to another. + const defaultWindow = urlState.zoom === null && - !asked.isPlaceholderData && + !opensOnAll && + askedWindow && asked.data && - timelineIsEmpty(asked.data) && - hasDataBeforeWindow(asked.data.range.dataFrom, askedWindow) - ) { - setOpensOnAll(true); - } + !asked.isPlaceholderData + ? resolveDefaultWindow({ + dataFrom: asked.data.range.dataFrom, + window: askedWindow, + windowHasData: !timelineIsEmpty(asked.data), + today, + }) + : null; + if (defaultWindow?.zoom === "all") setOpensOnAll(true); + const youngFrom = + defaultWindow?.zoom === "quarter" ? defaultWindow.from : null; const everything = useTimeline("all", null, null, values, opensOnAll); const zoom: TimelineZoom = opensOnAll ? "all" : askedZoom; const requestWindow = opensOnAll ? null : askedWindow; @@ -334,7 +344,7 @@ export function TimelineView() { ? t("timeline.readiness.lanes.mood") : t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key); - const window_ = data + const shownWindow = data ? windowFor( zoom, today, @@ -342,6 +352,11 @@ export function TimelineView() { zoom === "all" || zoom === "range" ? data.range : null, ) : null; + // A young record's three months start at its first entry. + const window_ = + shownWindow && youngFrom && youngFrom > shownWindow.from + ? { from: youngFrom, to: shownWindow.to } + : shownWindow; // A new range starts as the chosen one, or as what is on screen, up to // today. const rangeDraft = clampRange(