mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
0ff1340ff4
commit
dc86c42bef
7 files changed
+156
-12
No files matched your search
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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<TimelineMockLog> {
|
||||
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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<p id="timeline-chart-help" className="sr-only">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user