mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
b8fc648bfc
commit
55b4374b26
3 files changed
+229
-6
No files matched your search
+15
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user