diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 50bccf26d..c92ecdfe4 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -40749,7 +40749,9 @@ components:
$ref: "#/components/schemas/MeasurementType"
value:
type: number
- description: Daily mean (spot metrics) or SUM (cumulative).
+ description: "The day's mean (spot metrics) or total (cumulative). A row of a coarser tier (a window longer than the
+ daily cap) carries the average day of its bucket, the same quantity: a spot metric's mean of its
+ daily means, a cumulative metric's total over the days of the bucket that hold a reading."
measuredAt:
type: string
format: date-time
diff --git a/messages/de.json b/messages/de.json
index a32f0bff0..096e51bd7 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2268,6 +2268,8 @@
"noData": "Keine Daten im gewählten Zeitraum",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
+ "bucketWeeklyDaily": "Tagesschnitt je Woche",
+ "bucketMonthlyDaily": "Tagesschnitt je Monat",
"days7Label": "7T",
"days7Title": "Letzte 7 Tage",
"days30Label": "30T",
diff --git a/messages/en.json b/messages/en.json
index 216062068..3261cdbe7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2268,6 +2268,8 @@
"noData": "No data in selected time range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
+ "bucketWeeklyDaily": "Daily avg per week",
+ "bucketMonthlyDaily": "Daily avg per month",
"days7Label": "7d",
"days7Title": "Last 7 days",
"days30Label": "30d",
diff --git a/messages/es.json b/messages/es.json
index a15519de2..856524ed4 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2268,6 +2268,8 @@
"noData": "Sin datos en el periodo seleccionado",
"bucketWeekly": "Media semanal",
"bucketMonthly": "Media mensual",
+ "bucketWeeklyDaily": "Media diaria por semana",
+ "bucketMonthlyDaily": "Media diaria por mes",
"days7Label": "7 d",
"days7Title": "Últimos 7 días",
"days30Label": "30 d",
diff --git a/messages/fr.json b/messages/fr.json
index ec8aaf4a0..d6209604a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2268,6 +2268,8 @@
"noData": "Aucune donnée sur la période sélectionnée",
"bucketWeekly": "Moyenne hebdomadaire",
"bucketMonthly": "Moyenne mensuelle",
+ "bucketWeeklyDaily": "Moyenne par jour, par semaine",
+ "bucketMonthlyDaily": "Moyenne par jour, par mois",
"days7Label": "7 j",
"days7Title": "7 derniers jours",
"days30Label": "30 j",
diff --git a/messages/it.json b/messages/it.json
index 7b5a9dc8e..151e22053 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2268,6 +2268,8 @@
"noData": "Nessun dato nel periodo selezionato",
"bucketWeekly": "Media settimanale",
"bucketMonthly": "Media mensile",
+ "bucketWeeklyDaily": "Media giornaliera per settimana",
+ "bucketMonthlyDaily": "Media giornaliera per mese",
"days7Label": "7 g",
"days7Title": "Ultimi 7 giorni",
"days30Label": "30 g",
diff --git a/messages/ko.json b/messages/ko.json
index eca3ce056..dd81bcdb4 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2268,6 +2268,8 @@
"noData": "선택한 기간에 데이터가 없어요",
"bucketWeekly": "주 평균",
"bucketMonthly": "월 평균",
+ "bucketWeeklyDaily": "주별 일평균",
+ "bucketMonthlyDaily": "월별 일평균",
"systolic": "수축기",
"diastolic": "이완기",
"bodyWater": "체수분",
diff --git a/messages/pl.json b/messages/pl.json
index 131f96e46..33cec9808 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2268,6 +2268,8 @@
"noData": "Brak danych w wybranym okresie",
"bucketWeekly": "Średnia tygodniowa",
"bucketMonthly": "Średnia miesięczna",
+ "bucketWeeklyDaily": "Średnia dzienna w tygodniu",
+ "bucketMonthlyDaily": "Średnia dzienna w miesiącu",
"days7Label": "7 dni",
"days7Title": "Ostatnie 7 dni",
"days30Label": "30 dni",
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index d2d766ce8..229f38a21 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -59,6 +59,7 @@ import type {
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
+import { bucketCaptionKey } from "@/lib/charts/bucket-caption";
import type { MeasurementType } from "@/generated/prisma/client";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table";
@@ -1018,8 +1019,9 @@ export function HealthChart({
// 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 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.
+ // day (or, for the coarser tiers of the "All" range, one row per
+ // week or month carrying its average day); a day that arrives
+ // as several rows must still add up rather than average.
const isCumulative = CUMULATIVE_HK_TYPES.has(
type as MeasurementType,
);
@@ -1339,6 +1341,10 @@ export function HealthChart({
// the decision separately" drift the visibleSlice doc-comment warns
// about, and the two had already diverged on the windowing semantics.
const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day";
+ // What a folded point is: the average day of its week or month (a daily
+ // total's average for steps and the like). Named on the caption chip and
+ // in the tooltip.
+ const bucketCaption = bucketCaptionKey(activeBucket, types);
const yDomain = useMemo<[number, number] | undefined>(() => {
if (!chartDataWithCompare?.length) return undefined;
@@ -1730,13 +1736,12 @@ export function HealthChart({
) : (
{title}
)}
- {activeBucket !== "day" && (
-
- {t(
- activeBucket === "week"
- ? "charts.bucketWeekly"
- : "charts.bucketMonthly",
- )}
+ {bucketCaption && (
+
+ {t(bucketCaption)}
)}
{/* v1.4.16 phase B8 — comparison caption. Inline with the
@@ -2260,7 +2265,11 @@ export function HealthChart({
return (
);
diff --git a/src/lib/charts/__tests__/bucket-caption.test.ts b/src/lib/charts/__tests__/bucket-caption.test.ts
new file mode 100644
index 000000000..70e7fd8ae
--- /dev/null
+++ b/src/lib/charts/__tests__/bucket-caption.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { bucketCaptionKey } from "../bucket-caption";
+
+describe("bucketCaptionKey", () => {
+ it("names a cumulative metric's folded point as a daily average", () => {
+ expect(bucketCaptionKey("week", ["ACTIVITY_STEPS"])).toBe(
+ "charts.bucketWeeklyDaily",
+ );
+ expect(bucketCaptionKey("month", ["ACTIVE_ENERGY_BURNED"])).toBe(
+ "charts.bucketMonthlyDaily",
+ );
+ });
+
+ it("keeps the plain average for a level", () => {
+ expect(bucketCaptionKey("week", ["WEIGHT"])).toBe("charts.bucketWeekly");
+ expect(
+ bucketCaptionKey("month", ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]),
+ ).toBe("charts.bucketMonthly");
+ });
+
+ it("says nothing for days", () => {
+ expect(bucketCaptionKey("day", ["ACTIVITY_STEPS"])).toBeNull();
+ });
+});
diff --git a/src/lib/charts/bucket-caption.ts b/src/lib/charts/bucket-caption.ts
new file mode 100644
index 000000000..2af076a8a
--- /dev/null
+++ b/src/lib/charts/bucket-caption.ts
@@ -0,0 +1,29 @@
+/**
+ * What a chart point means once the visible range folds days into weeks or
+ * months, as the caption and the tooltip name it.
+ *
+ * Every point of a week or month is the average day of that bucket, on every
+ * read path (the day points the chart folds itself, and the rollup tier rows
+ * the server serves for the "All" range). For a level (weight, pulse) that is
+ * simply the week's or month's average. For a cumulative metric (steps,
+ * energy, distance) it is the average DAILY total, which keeps the axis
+ * comparable with the 7, 30 and 90 day tabs; calling it a "weekly average"
+ * read as an average week's total, a figure seven times larger.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
+import type { ChartBucketType } from "@/lib/charts/bucket-time-series";
+
+export function bucketCaptionKey(
+ bucket: ChartBucketType,
+ types: readonly string[],
+): string | null {
+ if (bucket === "day") return null;
+ const daily =
+ types.length > 0 &&
+ types.every((type) => CUMULATIVE_HK_TYPES.has(type as MeasurementType));
+ if (bucket === "week") {
+ return daily ? "charts.bucketWeeklyDaily" : "charts.bucketWeekly";
+ }
+ return daily ? "charts.bucketMonthlyDaily" : "charts.bucketMonthly";
+}
diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts
index 103bea081..13158a3e4 100644
--- a/src/lib/openapi/routes/measurements.ts
+++ b/src/lib/openapi/routes/measurements.ts
@@ -760,7 +760,11 @@ const seriesResponse = z
// `GET /api/measurements?aggregate=daily&source=rollup` emits.
const seriesBatchRowSchema = z.object({
type: measurementTypeEnum,
- value: z.number().describe("Daily mean (spot metrics) or SUM (cumulative)."),
+ value: z
+ .number()
+ .describe(
+ "The day's mean (spot metrics) or total (cumulative). A row of a coarser tier (a window longer than the daily cap) carries the average day of its bucket, the same quantity: a spot metric's mean of its daily means, a cumulative metric's total over the days of the bucket that hold a reading.",
+ ),
measuredAt: z.iso
.datetime({ offset: true })
.describe(
diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts
index d436b6408..8ec37ec0a 100644
--- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts
+++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts
@@ -122,9 +122,18 @@ function folded(
sumY += value;
}
const values = readings.map(([, v]) => v);
+ const byDay = new Map();
+ for (const [day, value] of readings) {
+ byDay.set(day, [...(byDay.get(day) ?? []), value]);
+ }
+ const dayMeans = [...byDay.values()].map(
+ (v) => v.reduce((s, x) => s + x, 0) / v.length,
+ );
return {
bucket_start: new Date(bucketStart),
count: readings.length,
+ days: byDay.size,
+ sum_day_mean: dayMeans.reduce((s, x) => s + x, 0),
sum_y: sumY,
min_value: Math.min(...values),
max_value: Math.max(...values),
@@ -475,27 +484,115 @@ describe("readTieredRollupSeries", () => {
});
});
- it("serves the summed total for step-like metrics and drops the spread", async () => {
- aggregate.mockResolvedValueOnce(
- spanOf("2024-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"),
- );
- queryRaw.mockResolvedValueOnce([
- folded(
- "2024-01-01T00:00:00.000Z",
- Array.from({ length: 30 }, (_, i): [string, number] => [
- `2024-01-${String(i + 1).padStart(2, "0")}`,
- 8000,
- ]),
- ),
+ // The chart folds its shorter ranges (and the live fallback) from day
+ // points into the average day of each week or month. A tier row must be the
+ // same quantity, whichever tier the history's length selects.
+ describe("the average day at every tier", () => {
+ // January 2026: steps on 20 of its days, from 4 000 to 13 500.
+ const JAN = Array.from({ length: 20 }, (_, i): [string, number] => [
+ `2026-01-${String(i + 1).padStart(2, "0")}`,
+ 4_000 + i * 500,
]);
- const result = await readTieredRollupSeries({
- userId: "u",
- type: "ACTIVITY_STEPS",
- ...win(3650),
+ const averageDay = JAN.reduce((s, [, v]) => s + v, 0) / JAN.length;
+
+ /** How the chart folds a tier's rows into the month on screen. */
+ function chartMonth(rows: Array<{ measuredAt: string; value: number }>) {
+ return bucketTimeSeries(
+ rows.map((row) => ({
+ timestamp: new Date(row.measuredAt),
+ values: { v: row.value },
+ })),
+ { bucket: "month", timeZone: "Europe/Berlin" },
+ ).points.map((p) => p.values.v);
+ }
+
+ it("serves a MONTH row of a long history as its average day", async () => {
+ aggregate.mockResolvedValueOnce(
+ spanOf("2023-01-01T00:00:00.000Z", "2026-01-20T00:00:00.000Z"),
+ );
+ queryRaw.mockResolvedValueOnce([folded("2026-01-01T00:00:00.000Z", JAN)]);
+ const result = await readTieredRollupSeries({
+ userId: "u",
+ type: "ACTIVITY_STEPS",
+ ...win(3650),
+ });
+ expect(result?.granularity).toBe("MONTH");
+ expect(result?.rows[0].value).toBe(averageDay);
+ expect(result?.rows[0].count).toBe(JAN.length);
+ expect(result?.rows[0].minValue).toBeUndefined();
+ expect(result?.rows[0].maxValue).toBeUndefined();
+ });
+
+ it("gives the same month for a short history served by days", async () => {
+ aggregate.mockResolvedValueOnce(
+ spanOf("2026-01-01T00:00:00.000Z", "2026-01-20T00:00:00.000Z"),
+ );
+ findMany.mockResolvedValueOnce(
+ JAN.map(([day, value]) => dayRow(day, "APPLE_HEALTH", 1, value)),
+ );
+ const result = await readTieredRollupSeries({
+ userId: "u",
+ type: "ACTIVITY_STEPS",
+ ...win(3650),
+ });
+ expect(result?.granularity).toBe("DAY");
+ expect(chartMonth(result!.rows)).toEqual([averageDay]);
+ });
+
+ it("gives each WEEK row of a middle-length history its own average day", async () => {
+ aggregate.mockResolvedValueOnce(
+ spanOf("2025-01-01T00:00:00.000Z", "2026-01-20T00:00:00.000Z"),
+ );
+ const week = JAN.slice(4, 11); // Monday 5 to Sunday 11 January
+ queryRaw.mockResolvedValueOnce([
+ folded("2026-01-05T00:00:00.000Z", week),
+ ]);
+ const result = await readTieredRollupSeries({
+ userId: "u",
+ type: "ACTIVITY_STEPS",
+ ...win(3650),
+ });
+ expect(result?.granularity).toBe("WEEK");
+ expect(result?.rows[0].value).toBe(
+ week.reduce((s, [, v]) => s + v, 0) / week.length,
+ );
+ });
+
+ it("weighs each day once for a level, as the chart's own fold does", async () => {
+ // Two readings on the 3rd (120, 130), one on the 4th (100): the
+ // average day is 112.5, while every reading weighing one gives 116.7.
+ const readings: Array<[string, number]> = [
+ ["2026-01-03", 120],
+ ["2026-01-03", 130],
+ ["2026-01-04", 100],
+ ];
+ aggregate.mockResolvedValueOnce(
+ spanOf("2023-01-01T00:00:00.000Z", "2026-01-04T00:00:00.000Z"),
+ );
+ queryRaw.mockResolvedValueOnce([
+ folded("2026-01-01T00:00:00.000Z", readings),
+ ]);
+ const monthly = await readTieredRollupSeries({
+ userId: "u",
+ type: "BLOOD_PRESSURE_SYS",
+ ...win(3650),
+ });
+ expect(monthly?.rows[0].value).toBe(112.5);
+
+ aggregate.mockResolvedValueOnce(
+ spanOf("2026-01-03T00:00:00.000Z", "2026-01-04T00:00:00.000Z"),
+ );
+ findMany.mockResolvedValueOnce([
+ dayRow("2026-01-03", "MANUAL", 2, 125),
+ dayRow("2026-01-04", "MANUAL", 1, 100),
+ ]);
+ const daily = await readTieredRollupSeries({
+ userId: "u",
+ type: "BLOOD_PRESSURE_SYS",
+ ...win(3650),
+ });
+ expect(chartMonth(daily!.rows)).toEqual([112.5]);
});
- expect(result?.rows[0].value).toBe(240_000);
- expect(result?.rows[0].minValue).toBeUndefined();
- expect(result?.rows[0].maxValue).toBeUndefined();
});
// v1.37.29 — the tier keys off the ACTUAL data span, not the requested
diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts
index 8d353ad06..a67d91c3e 100644
--- a/src/lib/rollups/measurement-read-wmy.ts
+++ b/src/lib/rollups/measurement-read-wmy.ts
@@ -296,6 +296,37 @@ export function pickRollupGranularityForWindow(
*/
const TIER_ROW_ANCHOR_MS = 12 * 3_600_000;
+/**
+ * A cumulative type's tier row as the average day of its bucket: the bucket's
+ * total over the days that hold a reading. A day row is its own total, which
+ * is the same quantity. The chart folds its shorter ranges from day totals
+ * into the mean day of each week or month, and the live fallback serves day
+ * totals too; a WEEK or MONTH row carrying its whole total put a monthly sum
+ * where every other path puts a daily figure, so the "All" range read ~30
+ * times higher once a history was long enough to be served monthly, under a
+ * caption that said average.
+ */
+function dailyAverage(r: {
+ sumValue: number | null;
+ mean: number;
+ count: number;
+ days: number;
+}): number {
+ const total = r.sumValue ?? r.mean * r.count;
+ return r.days > 1 ? total / r.days : total;
+}
+
+/**
+ * A level type's tier row: the mean of its days' means. The count-weighted
+ * `mean` let a day with many readings outweigh the rest, while the chart's
+ * shorter ranges and the live fallback weigh each day once.
+ */
+function chartLevel(r: { mean: number; dayMean?: number }): number {
+ return typeof r.dayMean === "number" && Number.isFinite(r.dayMean)
+ ? r.dayMean
+ : r.mean;
+}
+
/** DAY → WEEK → MONTH → YEAR, finest first. */
const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"];
@@ -317,8 +348,10 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"];
* the compositional contract above), so a 5-year window rendered as ~60
* MONTH points carries the same trend as the ~1 800 DAY points would have
* — minus the truncation. `minValue` / `maxValue` ride through for the
- * range band on spot metrics; cumulative metrics (steps, energy,
- * distance) surface the bucket's summed total and drop the spread.
+ * range band on spot metrics. The value is the average day of the bucket
+ * at every tier, as the chart's own fold of day points gives it: a level's
+ * days each weigh one, and a cumulative metric (steps, energy, distance)
+ * carries its total over the days that hold a reading, without a spread.
*
* Coverage handling: the tier is chosen from the span of the window's DAY
* buckets and read folded from them. Returns `null` when the window holds no
@@ -409,7 +442,10 @@ export async function readTieredRollupSeries(opts: {
granularity,
rows: rows.map((r) => ({
type,
- value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
+ // The chart's quantity at every tier: the average day of the bucket,
+ // each day weighing one, as its shorter ranges and the live fallback
+ // fold it.
+ value: useSum ? dailyAverage(r) : chartLevel(r),
measuredAt: new Date(
r.bucketStart.getTime() + TIER_ROW_ANCHOR_MS,
).toISOString(),
diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts
index c598645ab..c183dc110 100644
--- a/src/lib/rollups/measurement-read.ts
+++ b/src/lib/rollups/measurement-read.ts
@@ -462,6 +462,13 @@ export function utcBucketEnd(at: Date, granularity: RollupGranularity): Date {
export interface CanonicalRollupBucket {
bucketStart: Date;
count: number;
+ /** Days of the bucket that hold a reading (1 for a DAY bucket). */
+ days: number;
+ /**
+ * The mean of the bucket's daily means, each day weighing one: what the
+ * chart's own fold of day points gives. `mean` weighs every reading.
+ */
+ dayMean: number;
mean: number;
minValue: number;
maxValue: number;
@@ -483,6 +490,10 @@ export interface CanonicalRollupBucket {
interface FoldedBucketRow {
bucket_start: Date;
count: number;
+ /** Canonical DAY rows folded into the bucket: its days with a reading. */
+ days: number;
+ /** The days' own means added up (each day weighs one). */
+ sum_day_mean: number;
sum_y: number;
min_value: number;
max_value: number;
@@ -528,6 +539,8 @@ function composeFoldedBucket(row: FoldedBucketRow): CanonicalRollupBucket {
return {
bucketStart: new Date(row.bucket_start),
count,
+ days: Number(row.days),
+ dayMean: Number(row.sum_day_mean) / Number(row.days),
mean,
minValue: Number(row.min_value),
maxValue: Number(row.max_value),
@@ -616,7 +629,7 @@ export async function readCanonicalRollupBuckets(opts: {
? opts.userPriorityJson
: await loadUserSourcePriority(userId);
return collapseRollupRowsBySource(dayRows, type, priority).map(
- ({ source: _source, ...day }) => day,
+ ({ source: _source, ...day }) => ({ ...day, days: 1, dayMean: day.mean }),
);
}
@@ -653,6 +666,8 @@ export async function readCanonicalRollupBuckets(opts: {
date_trunc(${unit}, c."bucket_start" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
AS bucket_start,
SUM(c."count")::int AS count,
+ COUNT(*)::int AS days,
+ SUM(c."mean")::double precision AS sum_day_mean,
SUM(COALESCE(c."sum_value", c."count" * c."mean"))::double precision
AS sum_y,
MIN(c."min_value")::double precision AS min_value,
diff --git a/tests/integration/chart-all-range-meaning.test.ts b/tests/integration/chart-all-range-meaning.test.ts
new file mode 100644
index 000000000..bfc406909
--- /dev/null
+++ b/tests/integration/chart-all-range-meaning.test.ts
@@ -0,0 +1,186 @@
+/**
+ * v1.39.4 — the chart's "All" range means one thing at every history length.
+ *
+ * A long history is served from the WEEK or MONTH rollup tier, a short one
+ * from DAY rows, and a coverage miss from the live table. The chart folds
+ * whatever arrives into the average day of each week (a range up to two
+ * years) or month (longer). Seeded with real rows and folded through the real
+ * rollup writer, a fully covered week or month must read the same average day
+ * on every path: for steps the average daily total, for a level the average
+ * of its days. Before, a MONTH-tier month of steps read
+ * as the month's total, about thirty times the same month served by days.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+import { readDailySeries } from "@/lib/measurements/daily-series-read";
+import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
+import { bucketTimeSeries } from "@/lib/charts/bucket-time-series";
+import type { MeasurementType } from "@/generated/prisma/client";
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/jobs/boss-instance", () => ({
+ getGlobalBoss: vi.fn(() => null),
+}));
+
+const TZ = "America/Los_Angeles";
+const DAY_MS = 86_400_000;
+const NOW = new Date("2026-09-20T17:00:00.000Z");
+/** The week and the month every history below covers fully. */
+const WEEK = { from: "2026-06-08", to: "2026-06-14" };
+const MONTH = { from: "2026-06-01", to: "2026-06-30" };
+
+let seq = 0;
+async function seedUser() {
+ seq += 1;
+ return getPrismaClient().user.create({
+ data: {
+ username: `chart-all-${seq}`,
+ email: `chart-all-${seq}@example.test`,
+ role: "USER",
+ timezone: TZ,
+ },
+ });
+}
+
+/**
+ * `days` days ending yesterday, at 18:00 UTC (11:00 in Los Angeles, the same
+ * calendar day in both). Steps come as two rows a day; the level as one or
+ * two readings, so a reading-weighted mean would differ from a day-weighted
+ * one.
+ */
+function history(days: number) {
+ const rows: Array<{ type: MeasurementType; value: number; at: Date }> = [];
+ const end = Date.parse("2026-09-19T18:00:00.000Z");
+ for (let i = 0; i < days; i += 1) {
+ const at = new Date(end - i * DAY_MS);
+ const steps = 6_000 + ((i * 37) % 5_000);
+ rows.push({ type: "ACTIVITY_STEPS", value: steps - 1_000, at });
+ rows.push({
+ type: "ACTIVITY_STEPS",
+ value: 1_000,
+ at: new Date(at.getTime() + 60_000),
+ });
+ rows.push({ type: "PULSE", value: 60 + (i % 9), at });
+ if (i % 3 === 0) {
+ rows.push({
+ type: "PULSE",
+ value: 90,
+ at: new Date(at.getTime() + 120_000),
+ });
+ }
+ }
+ return rows;
+}
+
+/** The average day of a period straight from the seeded rows. */
+function expectedAverageDay(
+ rows: ReturnType,
+ type: MeasurementType,
+ period: { from: string; to: string },
+): number {
+ const byDay = new Map();
+ for (const r of rows) {
+ if (r.type !== type) continue;
+ const day = r.at.toISOString().slice(0, 10);
+ if (day < period.from || day > period.to) continue;
+ byDay.set(day, [...(byDay.get(day) ?? []), r.value]);
+ }
+ const perDay = [...byDay.values()].map((values) =>
+ type === "ACTIVITY_STEPS"
+ ? values.reduce((s, v) => s + v, 0)
+ : values.reduce((s, v) => s + v, 0) / values.length,
+ );
+ return perDay.reduce((s, v) => s + v, 0) / perDay.length;
+}
+
+/** The chart's point for `period`, folded at the bucket the range shows. */
+async function chartPoint(
+ userId: string,
+ type: MeasurementType,
+ bucket: "week" | "month",
+ period: { from: string },
+) {
+ const rows = await readDailySeries({
+ userId,
+ type,
+ from: new Date(NOW.getTime() - 3_650 * DAY_MS),
+ to: NOW,
+ priorityJson: null,
+ timeZone: TZ,
+ });
+ const points = bucketTimeSeries(
+ rows.map((row) => ({
+ timestamp: new Date(row.measuredAt),
+ values: { v: row.value },
+ })),
+ { bucket, timeZone: TZ },
+ ).points;
+ const point = points.find(
+ (p) => new Date(p.timestamp).toISOString().slice(0, 10) === period.from,
+ );
+ return point?.values.v;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("chart All range — one quantity at every history length", () => {
+ it.each([
+ ["days (a short history)", 200],
+ ["weeks (one to two years)", 500],
+ ["months (a long history)", 900],
+ ])(
+ "reads the same average day for a period served by %s, and by the live table",
+ async (_label, days) => {
+ const user = await seedUser();
+ const rows = history(days);
+ await getPrismaClient().measurement.createMany({
+ data: rows.map((r) => ({
+ userId: user.id,
+ type: r.type,
+ value: r.value,
+ unit: r.type === "PULSE" ? "bpm" : "count",
+ source: "APPLE_HEALTH" as const,
+ measuredAt: r.at,
+ })),
+ });
+ await recomputeUserRollups(user.id, {
+ from: new Date(NOW.getTime() - (days + 40) * DAY_MS),
+ to: NOW,
+ });
+
+ // The chart folds a span over two years into months, else weeks.
+ const bucket = days > 730 ? "month" : "week";
+ const period = bucket === "month" ? MONTH : WEEK;
+ for (const type of ["ACTIVITY_STEPS", "PULSE"] as const) {
+ const expected = expectedAverageDay(rows, type, period);
+ const tier = await chartPoint(user.id, type, bucket, period);
+ expect(tier, `${type} from the tier`).toBeCloseTo(expected, 6);
+ }
+
+ // A coverage miss serves the live table's days instead. Its daily
+ // path keeps the oldest 365 days, so only the short history still
+ // reaches June through it.
+ if (days <= 365) {
+ await getPrismaClient().measurementRollup.deleteMany({
+ where: { userId: user.id },
+ });
+ for (const type of ["ACTIVITY_STEPS", "PULSE"] as const) {
+ const live = await chartPoint(user.id, type, bucket, period);
+ expect(live, `${type} from the live table`).toBeCloseTo(
+ expectedAverageDay(rows, type, period),
+ 6,
+ );
+ }
+ }
+ },
+ );
+});