diff --git a/src/components/charts/__tests__/health-chart-range.test.tsx b/src/components/charts/__tests__/health-chart-range.test.tsx index 9cbed264c..3db9cb2f6 100644 --- a/src/components/charts/__tests__/health-chart-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-range.test.tsx @@ -93,9 +93,10 @@ describe(" — bounded range fetch", () => { expect(measurementsUrl!).toMatch(/aggregate=daily/); }); - it("omits aggregate=daily on windows of 7 days or fewer", async () => { - // v1.4.29 C3 — short windows keep raw fetching so the user can - // see hour-by-hour detail on the 7-day view. + it("asks for daily rows on the 7-day window too", async () => { + // The chart folds every range into days. The 7-day window used to read + // raw rows oldest-first under the 5000-row cap, which on a per-minute + // stream cut off the newest half of the week. const { I18nProvider } = await import("@/lib/i18n/context"); const { HealthChart } = await import("../health-chart"); @@ -116,7 +117,7 @@ describe(" — bounded range fetch", () => { u.includes("/api/measurements"), ); expect(measurementsUrl).toBeDefined(); - expect(measurementsUrl!).not.toMatch(/aggregate=daily/); + expect(measurementsUrl!).toMatch(/aggregate=daily/); }); it("re-keys the query when the range window changes", async () => { diff --git a/src/components/charts/chart-series-request.ts b/src/components/charts/chart-series-request.ts new file mode 100644 index 000000000..3656f6956 --- /dev/null +++ b/src/components/charts/chart-series-request.ts @@ -0,0 +1,27 @@ +/** + * Query string for one type's chart series (`GET /api/measurements`). + * + * Every range asks for one row per local calendar day. The 7-day range used + * to ask for raw rows oldest-first under a 5 000-row limit; a per-minute + * heart-rate stream holds 10 080 rows in seven days, so the answer stopped + * half-way through the week and the newest days were silently missing. The + * chart folds rows into days either way, so the daily answer draws the same + * points while its size depends only on the number of days. + * + * Pure so the request the chart makes can be tested against the real route. + */ +export function chartSeriesParams( + type: string, + window: { from: string; to: string }, +): URLSearchParams { + const params = new URLSearchParams(); + params.set("type", type); + params.set("sortBy", "measuredAt"); + params.set("sortDir", "asc"); + params.set("from", window.from); + params.set("to", window.to); + params.set("limit", "5000"); + params.set("aggregate", "daily"); + params.set("source", "rollup"); + return params; +} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6948494cf..23f75dc76 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1,5 +1,6 @@ "use client"; +import { chartSeriesParams } from "@/components/charts/chart-series-request"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -327,8 +328,8 @@ interface MeasurementApiRow { value: number; // v1.8.5 — per-day min / max emitted by the rollup daily-aggregate // path (non-cumulative metrics only). Drive the Apple-Health-style - // range band shaded around the mean line. Absent on the raw-row path - // (windows ≤ 7 days) and on cumulative metrics. + // range band shaded around the mean line. Not drawn on the 7-day range + // and absent on cumulative metrics. // v1.18.6 — `null` accepted alongside `undefined` so the batched // series endpoint's row shape (which carries `number | null`) feeds // `preloadedSeries` directly. The bucketing loop already guards each @@ -340,8 +341,7 @@ interface MeasurementApiRow { // the underlying raw-row count per bucket. Used downstream to gate // the "more days needed" empty-state copy on the actual measurement // count rather than the number of distinct calendar days. Optional - // because the short-window raw-row path does not populate it (each - // row already represents one measurement, count = 1). + // because the per-night sleep series does not carry it. count?: number; } @@ -764,12 +764,11 @@ export function HealthChart({ }; }, [rangePoints, effectiveCompareBaseline]); - // v1.18.6 — honour the dashboard's batched slice ONLY for the - // daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY - // when EVERY non-sleep type the chart renders is present in the slice. - // 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.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 + // in the slice. SLEEP_DURATION is never batched (per-night `/series`), + // so a chart that includes it always self-fetches. The 7-day tab + // self-fetches its own window. // // v1.19.0 — also require that the requested window FITS within the // batch's actual coverage. The dashboard batches only a ~30-day slice; @@ -916,41 +915,7 @@ export function HealthChart({ await fetchSleepNights(); return; } - const typeParams = new URLSearchParams(); - typeParams.set("type", type); - typeParams.set("sortBy", "measuredAt"); - typeParams.set("sortDir", "asc"); - typeParams.set("from", fetchWindow.from); - typeParams.set("to", fetchWindow.to); - typeParams.set("limit", "5000"); - // v1.4.29 C3 — windows over 7 days ask the server to bucket - // daily. Caps the chart's per-type payload at ~365 rows - // instead of ~5 000 for high-density types (pulse), and drops - // Recharts paint cost ~50× on continuous-monitoring accounts. - // Short windows keep raw fetching so hour-by-hour detail - // stays visible. - // - // v1.4.36 W1 — also route the daily aggregate through the - // persistent rollup buckets via `source=rollup`. The route - // reads from `measurement_rollups` instead of running a live - // `date_trunc` scan over the raw measurements table; the - // three parallel daily fetches on the Insights trends row - // (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small - // indexed read against the ~5 k-row rollup table. The server - // falls back to live SQL when the rollup is empty for the - // requested window so brand-new accounts still see a chart - // on their first render. - // - // v1.19.2 — the `aggregate=daily` ask still holds, but for windows - // wider than the DAY bucket cap the server steps the rollup tier - // up to WEEK / MONTH and returns whole-history coverage at that - // tier. Each returned row is one coarse bucket; the daily fold + - // `bucketTimeSeries` below downsample it to the visible range, so - // the request contract is unchanged. - if (fetchWindow.windowDays > 7) { - typeParams.set("aggregate", "daily"); - typeParams.set("source", "rollup"); - } + const typeParams = chartSeriesParams(type, fetchWindow); // v1.18.6 — when the dashboard handed this chart a batched slice // for the type, fold those rows instead of a per-type fetch. The @@ -1011,8 +976,11 @@ export function HealthChart({ // same scale to the spread so the band tracks the line. The // BMI view drops the range tuple in its `select` callback (it // has never rendered the band), so the raw cache always - // carries the spread. + // carries the spread. The 7-day range never drew the band (it + // used to read raw rows, which carry no spread) and still does + // not, so its picture is unchanged now that it reads days too. if ( + fetchWindow.windowDays > 7 && typeof measurement.minValue === "number" && typeof measurement.maxValue === "number" ) { @@ -1044,10 +1012,9 @@ export function HealthChart({ for (const [type, stats] of Object.entries(bucket.values)) { // v1.4.29.1 — cumulative HealthKit types (steps, active energy, // distance, flights, daylight) must reduce with sum, not the - // per-sample average. The server already does this when the - // chart fetches with `aggregate=daily` (windows over 7 days); - // the 7-day path still pulls raw rows and aggregates here, - // so the same distinction must hold client-side. + // per-sample average. The server already returns one row per + // day; a day that arrives as several rows (the coarser tiers + // of the "All" range) must still add up rather than average. const isCumulative = CUMULATIVE_HK_TYPES.has( type as MeasurementType, ); diff --git a/tests/integration/chart-range-dense.test.ts b/tests/integration/chart-range-dense.test.ts new file mode 100644 index 000000000..5e36f45cd --- /dev/null +++ b/tests/integration/chart-range-dense.test.ts @@ -0,0 +1,106 @@ +/** + * The chart's own request, against the real route, on a dense stream. + * + * The 7-day range used to ask for raw rows oldest-first with a 5 000-row + * limit. Per-minute heart rate puts 10 080 rows in seven days, so the answer + * stopped about three and a half days in and the chart silently lost the + * most recent days. Every range now asks for one row per local day, so the + * whole window arrives whatever the sampling rate. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { chartSeriesParams } from "@/components/charts/chart-series-request"; +import { userDayKey } from "@/lib/tz/format"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "chart-range-dense-user"; +const TZ = "Europe/Berlin"; +const TO = new Date("2026-09-20T18:00:00.000Z"); +const MINUTES = 7 * 24 * 60; + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: TZ }, + }); + invalidateUserTimezone(USER); + const session = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, measured_at, + created_at, updated_at, sync_version) + SELECT 'cr' || lpad(g::text, 6, '0'), $1, 'PULSE'::measurement_type, + 60 + (g % 30), 'bpm', 'GOOGLE_HEALTH'::measurement_source, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'), + now(), now(), 1 + FROM generate_series(0, $3::int - 1) g`, + USER, + TO.toISOString(), + MINUTES, + ); +}, 120_000); + +describe("7-day chart range on per-minute pulse", () => { + it("returns every day of the window, the newest included", async () => { + const from = new Date(TO.getTime() - 7 * 86_400_000); + const params = chartSeriesParams("PULSE", { + from: from.toISOString(), + to: TO.toISOString(), + }); + const { GET } = await import("@/app/api/measurements/route"); + const res = await GET( + new NextRequest(`http://localhost/api/measurements?${params}`), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { measurements: Array<{ measuredAt: string }> }; + }; + const days = new Set( + json.data.measurements.map((m) => userDayKey(new Date(m.measuredAt), TZ)), + ); + // 20 Sep 20:00 Berlin back to 13 Sep 20:00: eight calendar days. + expect([...days].sort()).toEqual([ + "2026-09-13", + "2026-09-14", + "2026-09-15", + "2026-09-16", + "2026-09-17", + "2026-09-18", + "2026-09-19", + "2026-09-20", + ]); + }); +});