diff --git a/src/app/page.tsx b/src/app/page.tsx index 0c27f4df8..e610e4940 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -66,6 +66,14 @@ const DASHBOARD_QUERY_OPTS = { refetchOnWindowFocus: false, } as const; +// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches. +// Threaded to every chart as `preloadedCoverageDays` so a chart reads the +// batched slice ONLY for a range tab whose window fits within it (7 / 30); +// the 90 / All tabs exceed it and self-fetch the wider window. Pre-fix the +// chart trusted the batch for any window > 7 days, so 90 / All silently +// re-rendered this ~30-day slice. Keep this in lockstep with `batchWindow`. +const BATCH_COVERAGE_DAYS = 31; + // v1.16.8 — the loaders retry a rejected chunk import once (a lazy // import caches its rejection permanently, so a transient 404 from a // stale shell used to brick the card for the session) and each mount @@ -609,7 +617,7 @@ export default function DashboardPage() { const [batchWindow] = useState(() => { const to = new Date(); to.setHours(23, 59, 59, 999); - const from = new Date(to.getTime() - 31 * 86_400_000); + const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000); return { from: from.toISOString(), to: to.toISOString() }; }); @@ -1164,6 +1172,7 @@ export default function DashboardPage() { key="weight-chart" onDataReady={() => markChartReady("weight-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="weight" types={["WEIGHT"]} title={t("dashboard.weight")} @@ -1185,6 +1194,7 @@ export default function DashboardPage() { key="bmi-chart" onDataReady={() => markChartReady("bmi-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="bmi" types={["WEIGHT"]} title={t("targets.bmi")} @@ -1215,6 +1225,7 @@ export default function DashboardPage() { key="bp-chart" onDataReady={() => markChartReady("bp-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="bp" types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("dashboard.bloodPressure")} @@ -1239,6 +1250,7 @@ export default function DashboardPage() { key="pulse-chart" onDataReady={() => markChartReady("pulse-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="pulse" // v1.15.12 A2 — chart the RESTING series against the // resting band when available; otherwise chart raw heart @@ -1266,6 +1278,7 @@ export default function DashboardPage() { key="bodyFat-chart" onDataReady={() => markChartReady("bodyFat-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="bodyFat" types={["BODY_FAT"]} title={t("dashboard.bodyFat")} @@ -1327,6 +1340,7 @@ export default function DashboardPage() { key="steps-chart" onDataReady={() => markChartReady("steps-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="steps" types={["ACTIVITY_STEPS"]} title={t("dashboard.steps") ?? "Steps"} diff --git a/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx b/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx new file mode 100644 index 000000000..b763bd522 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.19.0 W11b — preloaded-coverage guard. + * + * The dashboard batches a ~30-day series slice and hands each chart its + * portion via `preloadedSeries`. Pre-fix the chart consumed that slice for + * ANY range tab wider than 7 days, so selecting the 90 / All tab silently + * re-rendered the 30-day batched slice instead of fetching the wider + * window — the user picked "90 days" and still saw ~30. + * + * The fix threads `preloadedCoverageDays` (the batch's actual day-span) and + * reads the preloaded slice ONLY when the requested window fits within it. + * A wider tab falls through to a real fetch. + * + * These tests drive the queryFn through `windowOverride` (which pins the + * range at mount, observable in `renderToStaticMarkup`) and assert on the + * fetch the queryFn does — or does not — issue. + */ + +const fetchCalls: string[] = []; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ + queryFn, + queryKey, + }: { + queryKey: unknown[]; + queryFn: () => Promise; + }) => { + (globalThis as unknown as { __lastQueryKey?: unknown[] }).__lastQueryKey = + queryKey; + void queryFn(); + return { data: [], isLoading: false }; + }, + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +beforeEach(() => { + fetchCalls.length = 0; + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + fetchCalls.push(String(url)); + return new Response( + JSON.stringify({ data: { measurements: [], meta: { total: 0 } } }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; +}); + +// A 30-day batched slice keyed by type, in the chart's `MeasurementApiRow` +// shape. Its mere presence used to satisfy `usePreloaded` for any > 7-day +// window regardless of how wide the active tab was. +const preloadedSlice = { + WEIGHT: [{ measuredAt: "2026-06-01T08:00:00.000Z", value: 82 }], +}; + +describe(" — preloaded-coverage guard (W11b)", () => { + it("reads the preloaded slice (no fetch) when the window fits coverage", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 30-day window ≤ 31-day coverage → preloaded slice is consumed, the + // queryFn issues no `/api/measurements` network request. + const measurementsUrl = fetchCalls.find((u) => + u.includes("/api/measurements"), + ); + expect(measurementsUrl).toBeUndefined(); + + // Read-mode discriminator pins "preloaded" in the cache key. + const key = (globalThis as unknown as { __lastQueryKey?: unknown[] }) + .__lastQueryKey; + expect(key![key!.length - 1]).toBe("preloaded"); + }); + + it("fetches the wider window (does NOT read preloaded) when the tab exceeds coverage", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 90-day window > 31-day coverage → the chart must fetch the real + // window rather than re-render the 30-day batched slice. + const measurementsUrl = fetchCalls.find((u) => + u.includes("/api/measurements"), + ); + expect(measurementsUrl).toBeDefined(); + expect(measurementsUrl!).toMatch(/type=WEIGHT/); + expect(measurementsUrl!).toMatch(/aggregate=daily/); + + // The cache-key read-mode discriminator must flip to "fetch" so the + // batched-slice entry and the self-fetched entry never collide. + const key = (globalThis as unknown as { __lastQueryKey?: unknown[] }) + .__lastQueryKey; + expect(key![key!.length - 1]).toBe("fetch"); + }); + + it("fetches when no coverage is declared, even with a preloaded slice", async () => { + // A legacy mount that supplies `preloadedSeries` without + // `preloadedCoverageDays` must not be trusted blindly — coverage + // defaults to 0 so any > 7-day window self-fetches. + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const measurementsUrl = fetchCalls.find((u) => + u.includes("/api/measurements"), + ); + expect(measurementsUrl).toBeDefined(); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 305b927fa..38b37e72f 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -84,6 +84,17 @@ const TIME_RANGES_KEYS = [ }, ] as const; +/** + * v1.19.0 — day-span the "All" range tab (`rangePoints === 0`) fetches. + * A generous fixed bound (~10 years) that is clearly larger than any + * other range tab, so "All" means "all of my history" rather than the + * old silent 365-day truncation. Fixed (not per-account derived) so the + * fetch-window cache key stays stable across the session. The server's + * daily-aggregate reader still caps the returned bucket count, so this + * only widens the requested window, never the render cost. + */ +const ALL_RANGE_DAYS = 3650; + interface HealthChartProps { types: string[]; title: string; @@ -250,6 +261,19 @@ interface HealthChartProps { * a self-fetched one. */ preloadedSeries?: Record; + /** + * v1.19.0 — the day-span the `preloadedSeries` slice actually covers + * (the window the dashboard's batched fetch requested). The chart reads + * the preloaded slice ONLY when the active range tab's window fits + * within this coverage; a wider tab (90 / All on a 30-day batch) falls + * through to a real fetch for the requested window instead of silently + * re-rendering the narrow batched slice. + * + * Required whenever `preloadedSeries` is supplied. Mount sites set it to + * the exact day-span their batch fetches (the dashboard batch spans 31 + * days). Omitted on self-fetching mounts. + */ + preloadedCoverageDays?: number; } interface ChartDataPoint { @@ -560,6 +584,7 @@ export function HealthChart({ titleIcon, onDataReady, preloadedSeries, + preloadedCoverageDays, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -619,12 +644,19 @@ export function HealthChart({ // Comparison overlays (lastMonth / lastYear) extend the `from` // boundary backwards by the shift distance so the prior-period // slice rides the same fetch and `shiftDailySeriesForward` finds - // its input. "All" range (rangePoints === 0) defaults to a - // 365-day window; the chart's existing all-time UX already - // re-renders against whatever window we hand it. + // its input. + // + // v1.19.0 — "All" range (rangePoints === 0) requests `ALL_RANGE_DAYS` + // (~10 years) rather than the old 365-day cap, which silently truncated + // any account holding more than a year of history. The window is a + // generous fixed bound (not derived per-account) so the cache key stays + // stable across the session; the daily-aggregate reader caps the + // RETURNED bucket count at `BUCKET_CAP.daily` (365), so the chart still + // paints at most ~365 daily buckets — see the report's note on raising + // that server-side ceiling for true multi-year coverage. const fetchWindow = useMemo(() => { const to = new Date(); - const windowDays = rangePoints > 0 ? rangePoints : 365; + const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; const compareShift = effectiveCompareBaseline === "lastMonth" ? 30 @@ -649,13 +681,24 @@ export function HealthChart({ // SLEEP_DURATION is never batched (per-night `/series`), so a chart // that includes it always self-fetches. A range-tab change to a ≤7-day // window (raw rows) drops batched coverage and self-fetches. + // + // v1.19.0 — also require that the requested window FITS within the + // batch's actual coverage. The dashboard batches only a ~30-day slice; + // pre-fix, picking the 90 / All tab kept reading that 30-day slice + // (`usePreloaded` never compared windows), so the chart showed ~30 days + // no matter the tab. The mount now threads `preloadedCoverageDays`; a + // tab wider than the coverage falls through to a real fetch for the + // requested window. Without an explicit coverage (legacy mount), treat + // it as 0 so any preloaded slice is bypassed rather than trusted blindly. const usePreloaded = useMemo(() => { if (!preloadedSeries) return false; if (fetchWindow.windowDays <= 7) return false; + const coverage = preloadedCoverageDays ?? 0; + if (fetchWindow.windowDays > coverage) return false; return types.every( (type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null, ); - }, [preloadedSeries, fetchWindow.windowDays, types]); + }, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]); const { data, isLoading, isError, refetch } = useQuery({ // v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`