Merge branch 'fix/chart-all-range-meaning' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 14:22:57 +02:00
commit f98f8eaa24
16 files changed
+452 -35

No files matched your search

+19 -10
View File
@@ -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({
) : (
<h2 className="text-sm font-semibold">{title}</h2>
)}
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
{bucketCaption && (
<span
data-slot="chart-bucket-caption"
className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
>
{t(bucketCaption)}
</span>
)}
{/* v1.4.16 phase B8 — comparison caption. Inline with the
@@ -2260,7 +2265,11 @@ export function HealthChart({
return (
<RichChartTooltip
active
label={dateLabel}
label={
bucketCaption
? `${dateLabel} · ${t(bucketCaption)}`
: dateLabel
}
rows={rows}
/>
);
@@ -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();
});
});
+29
View File
@@ -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";
}
+5 -1
View File
@@ -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(
@@ -122,9 +122,18 @@ function folded(
sumY += value;
}
const values = readings.map(([, v]) => v);
const byDay = new Map<string, number[]>();
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
+39 -3
View File
@@ -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(),
+16 -1
View File
@@ -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,