fix(pulse): make a day of pulse the mean of its hours' means

A watch samples pulse about every 5 to 10 minutes at rest and about every
5 seconds in a workout. The daily value was the plain mean over a day's
readings, so a workout hour of several hundred readings outweighed the other
23 hours: a day with one hard hour and 23 resting hours (60 bpm) read about
128 bpm, not 64.

A day of pulse is now the mean of its per-hour means, in the rollup DAY
bucket, the live per-day read the chart serves, and the series route's day
grain. Windows over several days weigh each day once instead of by its
reading count, so the bias does not return one level up. Reading counts,
min/max, and the regression sums still run over every reading. The chart
tooltip and the data table name the statistic, in all seven locales.

The statistic is limited to pulse; HRV, SpO2 and glucose keep the plain mean
(see src/lib/measurements/day-statistic.ts). Stored DAY rows are rewritten by
the next rollup recompute (admin recompute route), not on read.

Fixes #1107
This commit is contained in:
BernhardBuckel authored and kilian committed 2026-10-04 10:41:08 +02:00
1 parent 808b2672cd
commit 497da7ca8b
25 files changed
+816 -50

No files matched your search

@@ -135,4 +135,65 @@ describe("<ChartDataTable>", () => {
/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/,
);
});
describe("naming the statistic", () => {
const pulsePoints = points(3).map((p) => ({ ...p, PULSE: 64 }));
const pulseProps = (over: Partial<ChartDataTableProps> = {}) =>
props({
points: pulsePoints,
columns: [{ key: "PULSE", label: "Pulse" }],
unit: "bpm",
metricLabel: "Pulse",
...over,
});
it("says a day of pulse is the average of its hours, so a workout does not outweigh the day", () => {
const html = render(<ChartDataTable {...pulseProps()} />);
expect(html).toContain('data-slot="chart-data-table-statistic"');
expect(html).toContain(
"Pulse is each day&#x27;s average, with every hour counting once",
);
});
it("says it in German too", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<ChartDataTable {...pulseProps()} />
</I18nProvider>,
);
expect(html).toContain("jede Stunde einmal zählt");
});
it("says it for the week and month grains as well", () => {
for (const bucket of ["week", "month"] as const) {
expect(
render(<ChartDataTable {...pulseProps({ bucket })} />),
).toContain('data-slot="chart-data-table-statistic"');
}
});
it("adds nothing for a metric that is the plain mean of its readings", () => {
const html = render(<ChartDataTable {...props()} />);
expect(html).not.toContain('data-slot="chart-data-table-statistic"');
});
it("names only the pulse column when it shares a table with others", () => {
const html = render(
<ChartDataTable
{...props({
points: pulsePoints,
columns: [
{ key: "BLOOD_PRESSURE_SYS", label: "Systolic" },
{ key: "PULSE", label: "Pulse" },
],
})}
/>,
);
expect(
html.match(/data-slot="chart-data-table-statistic"/g),
).toHaveLength(1);
expect(html).toContain("Pulse is each day");
expect(html).not.toContain("Systolic is each day");
});
});
});
@@ -15,6 +15,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ChartBucketType } from "@/lib/charts/bucket-time-series";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
/**
* The data points behind the chart, as a table.
@@ -132,6 +133,21 @@ export function ChartDataTable({
<Table>
<TableCaption className="mt-2 text-left text-xs">
{t(BUCKET_CAPTION_KEY[bucket], { metric: metricLabel })}
{/* Name the statistic where it is not the plain mean of the
readings: a day of pulse is the mean of its hours' means. */}
{columns
.filter((column) => usesHourlyMeanDay(column.key))
.map((column) => (
<span
key={column.key}
data-slot="chart-data-table-statistic"
className="mt-1 block"
>
{t("charts.dataTable.hourlyMeanNote", {
metric: column.label,
})}
</span>
))}
</TableCaption>
<TableHeader>
<TableRow>
+9 -1
View File
@@ -34,6 +34,7 @@ import {
type ComponentType,
} from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartErrorState } from "./chart-error-state";
@@ -2240,7 +2241,14 @@ export function HealthChart({
}
}
rows.push({
name: item.name ?? dataKey,
// Name the statistic where it is not the plain mean
// of the readings: a day of pulse is the mean of its
// hours' means.
name: usesHourlyMeanDay(dataKey)
? t("charts.tooltipHourlyMean", {
metric: item.name ?? dataKey,
})
: (item.name ?? dataKey),
value: `${formatTooltipValue(item.value)}${
unit ? ` ${unit}` : ""
}`,