fix(charts): read the 7-day range as daily rows

On a per-minute heart-rate stream the 7-day chart showed only the first
half of the week. The range read raw rows oldest-first under a
5000-row limit, and seven days of per-minute samples are 10 080 rows,
so the newest days never arrived.

The chart folds every range into days anyway, so the 7-day range now
asks for one row per local day like the longer ranges. The whole week
arrives whatever the sampling rate, and the 7-day and 30-day ranges
share one day boundary and one source pick per day. The 7-day range
still draws no spread band, so its picture is unchanged.

Refs #1026
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:03:04 +02:00
1 parent 5a0b6ee153
commit 2f5d58dad2
4 files changed
+155 -54

No files matched your search

@@ -93,9 +93,10 @@ describe("<HealthChart> — 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("<HealthChart> — 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 () => {
@@ -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;
}
+17 -50
View File
@@ -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,
);
+106
View File
@@ -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",
]);
});
});