From 23e0cc35af476cf3cbe31e261e25d18921a532df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:04:14 +0200 Subject: [PATCH] fix(timeline): open on everything when the default window is empty Opened without a zoom, three months that hold nothing while the record has older entries now show everything instead of a dead end, without writing the zoom into the URL. A window named in the URL that is empty offers "Show everything" when the record has entries before it. An unreadable ?zoom= or range opens on the same default as no zoom. --- e2e/timeline.spec.ts | 29 ++++ e2e/utils/mock-timeline.ts | 16 ++ .../__tests__/timeline-default-zoom.test.tsx | 150 ++++++++++++++++++ .../timeline/__tests__/timeline-url.test.ts | 20 ++- src/components/timeline/timeline-url.ts | 23 ++- src/components/timeline/timeline-view.tsx | 81 ++++++++-- src/components/timeline/use-timeline.ts | 2 + 7 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 src/components/timeline/__tests__/timeline-default-zoom.test.tsx diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index e9ec9e2a3..edb47b780 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -79,6 +79,35 @@ test("opens on three months when the URL names no zoom", async ({ page }) => { await setTimelineModule(page, false); }); +test("only old data opens on everything, and an empty window offers it", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY, { onlyOld: true }); + await page.goto("/timeline"); + // The default three months hold nothing: the page opens on everything, + // without writing it into the URL. + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="all"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); + await expect(page).toHaveURL(/\/timeline$/); + expect(log.timelineQueries.map((q) => q.get("zoom"))).toEqual( + expect.arrayContaining(["quarter", "all"]), + ); + + // Three months asked for by name stay three months, and offer the rest. + await page.goto("/timeline?zoom=quarter"); + const empty = page.locator('[data-slot="timeline-window-empty"]'); + await expect(empty).toBeVisible({ timeout: 20_000 }); + await page.locator('[data-slot="timeline-show-everything"]').click(); + await expect(page).toHaveURL(/zoom=all/); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); + + // 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 c99ebda10..528f36106 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -822,11 +822,16 @@ export interface TimelineMockLog { /** * Answer the timeline routes from the `full` or `ready` record. Returns a * log of what the page asked for and posted, for assertions. + * + * `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. */ export async function mockTimeline( page: Page, state: "full" | "ready", today: string, + options: { onlyOld?: boolean } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => @@ -844,6 +849,17 @@ export async function mockTimeline( const to = url.searchParams.get("to"); const bucket = bucketFor(zoom, from, to); const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { + return json(route, 200, { + ...body, + zoom, + bucket, + range: { from, to, dataFrom }, + lanes: body.lanes.map((lane) => ({ ...lane, items: [] })), + standing: [], + series: values.map((key) => ({ key, unit: null, points: [] })), + }); + } // A chosen range answers its own window, as the server does. const chosen = zoom === "range" && from && to; const all = diff --git a/src/components/timeline/__tests__/timeline-default-zoom.test.tsx b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx new file mode 100644 index 000000000..30b453198 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx @@ -0,0 +1,150 @@ +/** + * v1.42.2 — the timeline never opens on a dead end. + * + * - Opened without a zoom, when the default three months hold nothing but + * the record has older entries, the page shows everything (and leaves + * the URL alone). + * - A window named in the URL stays that window; when it is empty and the + * record has entries before it, it offers "Show everything". + * - An empty record keeps the plain empty state. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { TimelineResponse, TimelineZoom } from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; + +import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; + +let search = new URLSearchParams(); +let dataFrom: string | null = "2019-01-01"; +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 (zoom === "all" && dataFrom !== null) return { ...full, zoom }; + const empty: TimelineResponse = { + ...full, + zoom, + range: { from: from ?? "2026-08-01", to: to ?? TODAY, dataFrom }, + lanes: full.lanes.map((lane) => ({ ...lane, items: [] })), + standing: [], + series: [], + }; + return empty; +} + +vi.mock("next/navigation", () => ({ + usePathname: () => "/timeline", + useSearchParams: () => search, + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", timezone: "Europe/Berlin", modules: {} }, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: false }), +})); +vi.mock("../use-timeline", () => ({ + useTimeline: ( + zoom: TimelineZoom, + from: string | null, + to: string | null, + _values: readonly string[], + enabled = true, + ) => { + if (!enabled) { + return { + data: undefined, + isPending: true, + isError: false, + isPlaceholderData: false, + refetch: vi.fn(), + }; + } + asked.push(zoom); + return { + data: answer(zoom, from, to), + isPending: false, + isError: false, + isPlaceholderData: false, + refetch: vi.fn(), + }; + }, + useTimelineReadiness: () => ({ + data: readiness(), + isPending: false, + isError: false, + }), + useLifeEvents: () => ({ data: { events: [] } }), + useLifeEventMutations: () => ({ + create: { isPending: false }, + update: { isPending: false }, + remove: { isPending: false }, + }), +})); +vi.mock("../timeline-dates", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, todayKeyIn: () => TODAY }; +}); + +let TimelineView: typeof import("../timeline-view").TimelineView; + +beforeAll(async () => { + ({ TimelineView } = await import("../timeline-view")); +}, 60_000); + +beforeEach(() => { + search = new URLSearchParams(); + dataFrom = "2019-01-01"; + asked.length = 0; +}); + +function render(): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("the timeline's default window", () => { + it("opens on everything when three months hold nothing but the record is older", () => { + const html = render(); + expect(asked).toContain("quarter"); + expect(asked).toContain("all"); + expect(html).toMatch(/data-slot="timeline-desktop"[^>]*data-zoom="all"/); + expect(html).not.toContain('data-slot="timeline-window-empty"'); + }); + + it("an unreadable zoom opens the same way as none", () => { + search = new URLSearchParams({ zoom: "decade" }); + const html = render(); + // It asks for the default three months first, as without a zoom. + expect(asked[0]).toBe("quarter"); + expect(html).toMatch(/data-slot="timeline-desktop"[^>]*data-zoom="all"/); + }); + + it("a window named in the URL stays, and offers everything", () => { + search = new URLSearchParams({ zoom: "quarter" }); + const html = render(); + expect(asked).not.toContain("all"); + expect(html).toContain('data-slot="timeline-window-empty"'); + expect(html).toContain('data-slot="timeline-show-everything"'); + expect(html).not.toContain('data-slot="timeline-desktop"'); + }); + + it("an empty record keeps the plain empty state", () => { + dataFrom = null; + const html = render(); + expect(asked).not.toContain("all"); + expect(html).not.toContain('data-slot="timeline-show-everything"'); + expect(html).not.toContain('data-slot="timeline-desktop"'); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-url.test.ts b/src/components/timeline/__tests__/timeline-url.test.ts index 14fb1709a..eb36416c5 100644 --- a/src/components/timeline/__tests__/timeline-url.test.ts +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -3,7 +3,12 @@ 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"; +import { + clampRange, + hasDataBeforeWindow, + parseTimelineUrl, + timelineSearch, +} from "../timeline-url"; const TODAY = "2026-10-07"; const parse = (search: string) => @@ -26,7 +31,7 @@ describe("timeline URL", () => { }); }); - it("falls back to all, without a word, on anything it cannot read", () => { + it("reads anything it cannot read as no zoom, the same default as none", () => { for (const search of [ "zoom=decade", "zoom=range", @@ -36,10 +41,19 @@ describe("timeline URL", () => { "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 }); + expect(parse(search), search).toEqual(parse("")); } }); + it("knows when a window misses the record's older entries", () => { + const quarter = { from: "2026-08-01" }; + expect(hasDataBeforeWindow("2025-02-11", quarter)).toBe(true); + expect(hasDataBeforeWindow("2026-08-01", quarter)).toBe(false); + expect(hasDataBeforeWindow("2026-09-15", quarter)).toBe(false); + expect(hasDataBeforeWindow(null, quarter)).toBe(false); + expect(hasDataBeforeWindow("2025-02-11", null)).toBe(false); + }); + 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-url.ts b/src/components/timeline/timeline-url.ts index 7e95b26b8..f469c4365 100644 --- a/src/components/timeline/timeline-url.ts +++ b/src/components/timeline/timeline-url.ts @@ -2,8 +2,9 @@ * 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. + * a zoom, or a range without two valid ends, reads as no zoom at all and + * opens on the same default without a word: a stale bookmark should open + * the timeline, not an error. */ import { TIMELINE_RANGE_MAX_DAYS, @@ -57,7 +58,6 @@ export function parseTimelineUrl( 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); @@ -69,11 +69,26 @@ export function parseTimelineUrl( from > to || from > today ) { - return { zoom: "all", range: null }; + return { zoom: null, range: null }; } return { zoom, range: clampRange({ from, to }, today, null) }; } +/** The zoom the page opens on when the URL names none. */ +export const DEFAULT_ZOOM: TimelineZoom = "quarter"; + +/** + * 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 + * the earliest date anything in the record carries. + */ +export function hasDataBeforeWindow( + dataFrom: string | null, + window: { from: string } | null, +): boolean { + return dataFrom !== null && window !== null && dataFrom < window.from; +} + /** * The search string for a zoom (and, for `range`, its ends), keeping every * other parameter (`day`, `add`) as it is. diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index d375c18b4..bd95d6047 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -38,6 +38,7 @@ import { type LifeEventDTO, type TimelineBucket, type TimelineLaneKey, + type TimelineResponse, type TimelineZoom, } from "@/lib/day/contract"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -69,6 +70,8 @@ import { RangeFields } from "./range-fields"; import { RangeSheet, ZoomControl } from "./range-picker"; import { clampRange, + DEFAULT_ZOOM, + hasDataBeforeWindow, parseTimelineUrl, timelineSearch, type TimelineRange, @@ -131,6 +134,15 @@ export const DEFAULT_VALUES = [ "RESTING_HEART_RATE", ]; +/** Nothing to draw: no lane item, nothing standing, no reading. */ +function timelineIsEmpty(data: TimelineResponse): boolean { + return ( + data.lanes.every((l) => l.items.length === 0) && + data.standing.length === 0 && + data.series.every((s) => s.points.length === 0) + ); +} + const VALUES_KEY = "healthlog.timeline.values"; const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes"; @@ -149,8 +161,8 @@ export function TimelineView() { // and a bookmark return to them. Without a `zoom` the page opens on three // months: around an incoming `?day=`, otherwise up to today. const urlState = parseTimelineUrl(searchParams, today); - const zoom: TimelineZoom = urlState.zoom ?? "quarter"; - const range = zoom === "range" ? urlState.range : null; + const askedZoom: TimelineZoom = urlState.zoom ?? DEFAULT_ZOOM; + const range = askedZoom === "range" ? urlState.range : null; const [grouping, setGrouping] = useState("month"); // The selection follows `?day=` (Back, Forward, the day panel's previous // and next) and otherwise the last click or key. A pick remembers the @@ -216,18 +228,40 @@ export function TimelineView() { [hiddenList], ); - const requestWindow = - zoom === "all" + const askedWindow = + askedZoom === "all" ? null - : zoom === "range" + : askedZoom === "range" ? range - : windowFor(zoom, today, selected ?? dayParam); - const timeline = useTimeline( - zoom, - requestWindow?.from ?? null, - requestWindow?.to ?? null, + : windowFor(askedZoom, today, selected ?? dayParam); + // Opened without a zoom on three months that hold nothing, while the + // record has entries from before them: open on everything instead of a + // dead end. The URL stays as it is, so choosing a zoom still works. Once + // decided it holds until the URL names a zoom, so picking or opening a + // day on the full view does not snap it back to three months. + const [opensOnAll, setOpensOnAll] = useState(false); + if (opensOnAll && urlState.zoom !== null) setOpensOnAll(false); + const asked = useTimeline( + askedZoom, + askedWindow?.from ?? null, + askedWindow?.to ?? null, values, + !opensOnAll, ); + if ( + !opensOnAll && + urlState.zoom === null && + !asked.isPlaceholderData && + asked.data && + timelineIsEmpty(asked.data) && + hasDataBeforeWindow(asked.data.range.dataFrom, askedWindow) + ) { + setOpensOnAll(true); + } + const everything = useTimeline("all", null, null, values, opensOnAll); + const zoom: TimelineZoom = opensOnAll ? "all" : askedZoom; + const requestWindow = opensOnAll ? null : askedWindow; + const timeline = opensOnAll ? everything : asked; const readiness = useTimelineReadiness(); const lifeEvents = useLifeEvents(canAddLifeEvent); @@ -329,11 +363,12 @@ export function TimelineView() { .filter((l) => l.items.length > 0) .map((l) => l.key) .sort((a, b) => LANE_ORDER.indexOf(a) - LANE_ORDER.indexOf(b)); - const isEmpty = + const isEmpty = !!data && timelineIsEmpty(data); + // An empty window with entries before it offers everything. + const showEverything = !!data && - presentLanes.length === 0 && - data.standing.length === 0 && - data.series.every((s) => s.points.length === 0); + zoom !== "all" && + hasDataBeforeWindow(data.range.dataFrom, requestWindow); const zoomOptions = TIMELINE_ZOOMS.map((z) => ({ value: z, @@ -436,6 +471,24 @@ export function TimelineView() { } /> + ) : showEverything && (isEmpty || !window_) ? ( + // A window with nothing in it while the record has older entries: + // one step to all of them. + setZoom("all")} + data-slot="timeline-show-everything" + > + {t("timeline.range.clear")} + + } + /> ) : isEmpty || !data || !window_ ? ( apiGet(timelinePath(zoom, from, to, values)), // Moving between zoom levels keeps the last answer on screen until the