mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a4cccc11ba
commit
23e0cc35af
7 files changed
+300
-21
No files matched your search
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user