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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:04:14 +02:00
1 parent a4cccc11ba
commit 23e0cc35af
7 files changed
+300 -21

No files matched your search

+29
View File
@@ -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,
}) => {
+16
View File
@@ -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<TimelineMockLog> {
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 =
@@ -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<typeof import("../timeline-dates")>();
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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<TimelineView />
</I18nProvider>
</QueryClientProvider>,
);
}
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"');
});
});
@@ -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",
+19 -4
View File
@@ -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.
+67 -14
View File
@@ -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<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
@@ -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() {
}
/>
</div>
) : showEverything && (isEmpty || !window_) ? (
// A window with nothing in it while the record has older entries:
// one step to all of them.
<EmptyState
data-slot="timeline-window-empty"
title={t("timeline.selection.empty")}
action={
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setZoom("all")}
data-slot="timeline-show-everything"
>
{t("timeline.range.clear")}
</Button>
}
/>
) : isEmpty || !data || !window_ ? (
<EmptyState
title={t("timeline.empty.title")}
+2
View File
@@ -41,9 +41,11 @@ export function useTimeline(
from: string | null,
to: string | null,
values: readonly string[],
enabled = true,
) {
return useQuery({
queryKey: queryKeys.timeline(zoom, from, to, values.join(",")),
enabled,
queryFn: () =>
apiGet<TimelineResponse>(timelinePath(zoom, from, to, values)),
// Moving between zoom levels keeps the last answer on screen until the