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(