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