From fdd34f2c5e53821861541e9a7faffd67b116e251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:35:49 +0200 Subject: [PATCH] fix(charts): keep a chart's window open to the end of today A chart showing the last N days fixed its window end at the mount instant. The query key kept that bound, so the refetch after a new reading asked for the same old window and the reading stayed hidden until the range tab was switched. The window now ends at the end of the current day in the profile time zone (openEndedFetchWindow, keyed on the day): every reading saved today falls inside it, the cache key stays the same all day, and it moves on after midnight. The dashboard batch shares the end-of-day helper. Refs #1102 --- src/components/charts/health-chart.tsx | 29 ++++--- src/lib/charts/__tests__/fetch-window.test.ts | 77 +++++++++++++++++++ src/lib/charts/fetch-window.ts | 58 ++++++++++++++ src/lib/dashboard/batch-chart-types.ts | 13 +--- 4 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 src/lib/charts/__tests__/fetch-window.test.ts create mode 100644 src/lib/charts/fetch-window.ts diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 60cfe4cb8..ee00071dd 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -2,6 +2,10 @@ import { chartSeriesParams } from "@/components/charts/chart-series-request"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + localDayKeyFor, + openEndedFetchWindow, +} from "@/lib/charts/fetch-window"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -750,8 +754,14 @@ export function HealthChart({ // across the whole history (downsampled by tier) rather than the most // recent ~365 daily buckets. The client folds those coarse buckets and // `bucketTimeSeries` re-buckets to the visible range as before. + // + // The window ends at the end of TODAY in the profile time zone, not at the + // mount instant: a reading saved after the chart mounted (measuredAt = now) + // has to fall inside the window the invalidated query refetches. `todayKey` + // re-derives on every render, so the window stays put all day (a stable + // cache key) and moves on by itself after midnight. + const todayKey = localDayKeyFor(new Date(), userTimezone); const fetchWindow = useMemo(() => { - const to = new Date(); const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; const compareShift = effectiveCompareBaseline === "lastMonth" @@ -759,17 +769,12 @@ export function HealthChart({ : effectiveCompareBaseline === "lastYear" ? 365 : 0; - const totalDays = windowDays + compareShift; - const from = new Date(to.getTime() - totalDays * 86_400_000); - // Use ISO strings so the cache key is stable across the day; the - // server treats `to` as the upper bound and the chart re-applies the - // same day window client-side (see `visibleSlice`). - return { - from: from.toISOString(), - to: to.toISOString(), - windowDays: totalDays, - }; - }, [rangePoints, effectiveCompareBaseline]); + return openEndedFetchWindow( + windowDays + compareShift, + todayKey, + userTimezone, + ); + }, [rangePoints, effectiveCompareBaseline, userTimezone, todayKey]); // v1.18.6 — honour the dashboard's batched slice ONLY for windows over // 7 days and ONLY when EVERY non-sleep type the chart renders is present diff --git a/src/lib/charts/__tests__/fetch-window.test.ts b/src/lib/charts/__tests__/fetch-window.test.ts new file mode 100644 index 000000000..96287d8b6 --- /dev/null +++ b/src/lib/charts/__tests__/fetch-window.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { localDayKeyFor, openEndedFetchWindow } from "../fetch-window"; + +/** + * A chart that shows "the last N days" asks for a window that must still hold + * a reading saved after the chart mounted. The window used to end at the mount + * instant, so the invalidated refetch asked for the same old window and a new + * reading stayed invisible until the range tab was switched. + */ +function windowAt(now: Date, tz: string, days = 30) { + return openEndedFetchWindow(days, localDayKeyFor(now, tz), tz); +} + +function inside(w: { from: string; to: string }, at: Date) { + return at >= new Date(w.from) && at <= new Date(w.to); +} + +const previousTz = process.env.TZ; +afterEach(() => { + process.env.TZ = previousTz; +}); + +describe.each([ + ["Europe/Berlin", "UTC"], + ["America/Los_Angeles", "America/Los_Angeles"], + ["Pacific/Kiritimati", "Pacific/Kiritimati"], +])("open-ended chart window, profile %s, host %s", (profileTz, hostTz) => { + it("holds a reading saved after the chart mounted, under the same key", () => { + process.env.TZ = hostTz; + const mountedAt = new Date("2026-10-03T08:00:00.000Z"); + const savedAt = new Date(mountedAt.getTime() + 5 * 60_000); + + const atMount = windowAt(mountedAt, profileTz); + const afterSave = windowAt(savedAt, profileTz); + + expect(inside(atMount, savedAt)).toBe(true); + // Same day, same window: the query key does not churn, and the + // invalidation that follows a save refetches a window that holds it. + expect(afterSave).toEqual(atMount); + }); + + it("moves on to the next local day by itself", () => { + process.env.TZ = hostTz; + const today = new Date("2026-10-03T08:00:00.000Z"); + const tomorrow = new Date(today.getTime() + 86_400_000); + + const w = windowAt(tomorrow, profileTz); + + expect(inside(w, tomorrow)).toBe(true); + expect(w).not.toEqual(windowAt(today, profileTz)); + }); + + it("spans the requested number of days", () => { + process.env.TZ = hostTz; + const w = windowAt(new Date("2026-10-03T08:00:00.000Z"), profileTz, 7); + const span = new Date(w.to).getTime() - new Date(w.from).getTime(); + expect(span).toBe(7 * 86_400_000); + expect(w.windowDays).toBe(7); + }); +}); + +describe("health chart wiring", () => { + it("builds its fetch window from today's key, not the mount instant", () => { + const chart = readFileSync( + join(process.cwd(), "src/components/charts/health-chart.tsx"), + "utf8", + ); + const memo = chart.slice(chart.indexOf("const fetchWindow = useMemo(")); + const body = memo.slice(0, memo.indexOf("}, [")); + expect(body).toContain("openEndedFetchWindow("); + expect(body).not.toMatch(/const to = new Date\(\)/); + expect(memo).toMatch(/\}, \[[^\]]*todayKey[^\]]*\]\)/); + }); +}); diff --git a/src/lib/charts/fetch-window.ts b/src/lib/charts/fetch-window.ts new file mode 100644 index 000000000..709740a66 --- /dev/null +++ b/src/lib/charts/fetch-window.ts @@ -0,0 +1,58 @@ +/** + * The date window a chart asks the server for when it shows "the last N days". + * + * Such a window means "up to now", and "now" keeps moving after the chart + * mounts. A window that ended at the mount instant froze there: the query key + * kept it, an invalidation after a new reading refetched the same old window, + * and a reading saved a minute later fell outside it until the person switched + * the range tab and back. The window now ends at the end of the current day in + * the profile time zone. That bound includes every reading saved today, stays + * the same for the whole day so the cache key does not churn on every render, + * and moves on by itself once the day changes. The server's own rules about + * readings in the future are untouched: this only says how far a read looks. + */ +import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; +import { localDayWindow } from "@/lib/tz/local-day"; + +const MS_PER_DAY = 86_400_000; + +function safeZone(timezone: string): string { + return isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE; +} + +/** The local day (`YYYY-MM-DD`) `now` falls on in `timezone`. */ +export function localDayKeyFor(now: Date, timezone: string): string { + return userDayKey(now, safeZone(timezone)); +} + +/** Last millisecond of the local day `dayKey` in `timezone`, as a UTC instant. */ +function endOfDayKeyUtc(dayKey: string, timezone: string): Date { + const { dayEnd } = localDayWindow(dayKey, safeZone(timezone)); + return new Date(dayEnd.getTime() - 1); +} + +/** Last millisecond of the local day `now` falls on in `timezone`. */ +export function endOfLocalDayUtc(now: Date, timezone: string): Date { + return endOfDayKeyUtc(localDayKeyFor(now, timezone), timezone); +} + +export interface ChartFetchWindow { + from: string; + to: string; + windowDays: number; +} + +/** + * A window of `days` days that ends with the local day `todayKey` (from + * `localDayKeyFor(now, timezone)`). Keyed on the day, not the instant, so + * every call on the same local day returns the identical window. + */ +export function openEndedFetchWindow( + days: number, + todayKey: string, + timezone: string, +): ChartFetchWindow { + const to = endOfDayKeyUtc(todayKey, timezone); + const from = new Date(to.getTime() - days * MS_PER_DAY); + return { from: from.toISOString(), to: to.toISOString(), windowDays: days }; +} diff --git a/src/lib/dashboard/batch-chart-types.ts b/src/lib/dashboard/batch-chart-types.ts index 102d94240..a332e2beb 100644 --- a/src/lib/dashboard/batch-chart-types.ts +++ b/src/lib/dashboard/batch-chart-types.ts @@ -29,8 +29,7 @@ * helpers and the layout type). */ import type { DashboardLayout } from "@/lib/dashboard-layout"; -import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; -import { localDayWindow } from "@/lib/tz/local-day"; +import { endOfLocalDayUtc } from "@/lib/charts/fetch-window"; /** * Day-span the batched dashboard series (`series-batch`) fetches. Threaded to @@ -92,16 +91,6 @@ export interface BatchWindow { to: string; } -/** - * Last millisecond of the local day `now` falls on in `timezone`, as a UTC - * instant. Falls back to the project default zone for an unusable IANA id. - */ -function endOfLocalDayUtc(now: Date, timezone: string): Date { - const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE; - const { dayEnd } = localDayWindow(userDayKey(now, safeTz), safeTz); - return new Date(dayEnd.getTime() - 1); -} - /** * The batched-series window computed from the PROFILE timezone. End-of-current- * day in any zone (derived from `now`) is always ≥ `now`, so today's rows are