fix(charts): honour batch coverage on range-tab change

A range tab wider than the dashboard's batched series slice (90 / All on a
~30-day batch) re-read the narrow slice instead of fetching the requested
window, so the chart kept showing ~30 days. Thread the batch's coverage
day-span to the chart and read the preloaded slice only when the requested
window fits within it; a wider tab falls through to a real fetch. Also raise
the All-range request window past the old 365-day cap so longer histories
are no longer silently truncated client-side.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:59:39 +02:00
1 parent b8fc648bfc
commit 55b4374b26
3 files changed
+229 -6

No files matched your search

+15 -1
View File
@@ -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"}
@@ -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<unknown>;
}) => {
(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("<HealthChart> — 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(
<I18nProvider initialLocale="en">
<HealthChart
types={["WEIGHT"]}
title="Weight"
unit="kg"
windowOverride="last30days"
preloadedSeries={preloadedSlice}
preloadedCoverageDays={31}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<HealthChart
types={["WEIGHT"]}
title="Weight"
unit="kg"
windowOverride="last90days"
preloadedSeries={preloadedSlice}
preloadedCoverageDays={31}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<HealthChart
types={["WEIGHT"]}
title="Weight"
unit="kg"
windowOverride="last30days"
preloadedSeries={preloadedSlice}
/>
</I18nProvider>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
const measurementsUrl = fetchCalls.find((u) =>
u.includes("/api/measurements"),
);
expect(measurementsUrl).toBeDefined();
});
});
+48 -5
View File
@@ -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<string, MeasurementApiRow[]>;
/**
* 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`