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

+3 -1
View File
@@ -2437,13 +2437,15 @@
"loadErrorTitle": "Diagramm konnte nicht geladen werden",
"loadErrorReload": "Neu laden",
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
"tooltipHourlyMean": "{metric} (Durchschnitt der Stunden)",
"dataTable": {
"toggle": "Datenpunkte ({count})",
"columnDate": "Datum",
"noValue": "Kein Wert",
"captionDay": "Eine Zeile pro Tag für {metric} — genau die Punkte, die das Diagramm zeichnet.",
"captionWeek": "Eine Zeile pro Woche für {metric}, der Mittelwert der Tage dieser Woche — genau die Punkte, die das Diagramm zeichnet.",
"captionMonth": "Eine Zeile pro Monat für {metric}, der Mittelwert der Tage dieses Monats — genau die Punkte, die das Diagramm zeichnet."
"captionMonth": "Eine Zeile pro Monat für {metric}, der Mittelwert der Tage dieses Monats — genau die Punkte, die das Diagramm zeichnet.",
"hourlyMeanNote": "{metric} ist der Tagesdurchschnitt, bei dem jede Stunde einmal zählt, damit ein Training nicht den Rest des Tages überwiegt."
},
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
+3 -1
View File
@@ -2437,13 +2437,15 @@
"loadErrorTitle": "Chart could not be loaded",
"loadErrorReload": "Reload",
"sparseDataCaption": "More days will fill out the trend.",
"tooltipHourlyMean": "{metric} (average of the hours)",
"dataTable": {
"toggle": "Data points ({count})",
"columnDate": "Date",
"noValue": "No value",
"captionDay": "One row per day of {metric}, the same points the chart draws.",
"captionWeek": "One row per week of {metric}, the average of that week's days — the same points the chart draws.",
"captionMonth": "One row per month of {metric}, the average of that month's days — the same points the chart draws."
"captionMonth": "One row per month of {metric}, the average of that month's days — the same points the chart draws.",
"hourlyMeanNote": "{metric} is each day's average, with every hour counting once, so a workout does not outweigh the rest of the day."
},
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
+3 -1
View File
@@ -2437,13 +2437,15 @@
"loadErrorTitle": "No se pudo cargar el gráfico",
"loadErrorReload": "Recargar",
"sparseDataCaption": "Más días completarán la tendencia.",
"tooltipHourlyMean": "{metric} (promedio de las horas)",
"dataTable": {
"toggle": "Puntos de datos ({count})",
"columnDate": "Fecha",
"noValue": "Sin valor",
"captionDay": "Una fila por día de {metric}: los mismos puntos que dibuja el gráfico.",
"captionWeek": "Una fila por semana de {metric}, la media de los días de esa semana: los mismos puntos que dibuja el gráfico.",
"captionMonth": "Una fila por mes de {metric}, la media de los días de ese mes: los mismos puntos que dibuja el gráfico."
"captionMonth": "Una fila por mes de {metric}, la media de los días de ese mes: los mismos puntos que dibuja el gráfico.",
"hourlyMeanNote": "{metric} es el promedio de cada día, con cada hora contando una sola vez, para que un entrenamiento no pese más que el resto del día."
},
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
+3 -1
View File
@@ -2437,13 +2437,15 @@
"loadErrorTitle": "Impossible de charger le graphique",
"loadErrorReload": "Recharger",
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
"tooltipHourlyMean": "{metric} (moyenne des heures)",
"dataTable": {
"toggle": "Points de données ({count})",
"columnDate": "Date",
"noValue": "Aucune valeur",
"captionDay": "Une ligne par jour pour {metric} : les mêmes points que ceux tracés par le graphique.",
"captionWeek": "Une ligne par semaine pour {metric}, la moyenne des jours de cette semaine : les mêmes points que ceux tracés par le graphique.",
"captionMonth": "Une ligne par mois pour {metric}, la moyenne des jours de ce mois : les mêmes points que ceux tracés par le graphique."
"captionMonth": "Une ligne par mois pour {metric}, la moyenne des jours de ce mois : les mêmes points que ceux tracés par le graphique.",
"hourlyMeanNote": "{metric} est la moyenne de chaque jour, chaque heure comptant une seule fois, afin qu'un entraînement ne pèse pas plus que le reste de la journée."
},
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
+3 -1
View File
@@ -2437,13 +2437,15 @@
"loadErrorTitle": "Impossibile caricare il grafico",
"loadErrorReload": "Ricarica",
"sparseDataCaption": "Più giorni completeranno il trend.",
"tooltipHourlyMean": "{metric} (media delle ore)",
"dataTable": {
"toggle": "Punti dati ({count})",
"columnDate": "Data",
"noValue": "Nessun valore",
"captionDay": "Una riga per giorno di {metric}: gli stessi punti disegnati dal grafico.",
"captionWeek": "Una riga per settimana di {metric}, la media dei giorni di quella settimana: gli stessi punti disegnati dal grafico.",
"captionMonth": "Una riga per mese di {metric}, la media dei giorni di quel mese: gli stessi punti disegnati dal grafico."
"captionMonth": "Una riga per mese di {metric}, la media dei giorni di quel mese: gli stessi punti disegnati dal grafico.",
"hourlyMeanNote": "{metric} è la media di ogni giorno, con ogni ora conteggiata una sola volta, così un allenamento non pesa più del resto della giornata."
},
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
+3 -1
View File
@@ -2429,13 +2429,15 @@
"loadErrorTitle": "차트를 불러오지 못했어요",
"loadErrorReload": "다시 불러오기",
"sparseDataCaption": "날이 쌓이면 추이가 채워져요.",
"tooltipHourlyMean": "{metric} (시간별 평균)",
"dataTable": {
"toggle": "데이터 포인트 ({count})",
"columnDate": "날짜",
"noValue": "값 없음",
"captionDay": "{metric} 하루당 한 행, 차트가 그리는 값과 같아요.",
"captionWeek": "{metric} 한 주당 한 행, 그 주 일별 평균 — 차트가 그리는 값과 같아요.",
"captionMonth": "{metric} 한 달당 한 행, 그달 일별 평균 — 차트가 그리는 값과 같아요."
"captionMonth": "{metric} 한 달당 한 행, 그달 일별 평균 — 차트가 그리는 값과 같아요.",
"hourlyMeanNote": "{metric}은(는) 하루의 평균이며 각 시간을 한 번씩만 반영하므로, 운동이 하루의 나머지보다 더 크게 반영되지 않습니다."
},
"personalBaseline": "내 평소값",
"deltaVsBaseline": "평소값 대비 {delta}",
+3 -1
View File
@@ -2437,13 +2437,15 @@
"loadErrorTitle": "Nie udało się załadować wykresu",
"loadErrorReload": "Załaduj ponownie",
"sparseDataCaption": "Więcej dni dopełni trend.",
"tooltipHourlyMean": "{metric} (średnia z godzin)",
"dataTable": {
"toggle": "Punkty danych ({count})",
"columnDate": "Data",
"noValue": "Brak wartości",
"captionDay": "Jeden wiersz na dzień dla {metric} — dokładnie te punkty, które rysuje wykres.",
"captionWeek": "Jeden wiersz na tydzień dla {metric}, średnia z dni tego tygodnia — dokładnie te punkty, które rysuje wykres.",
"captionMonth": "Jeden wiersz na miesiąc dla {metric}, średnia z dni tego miesiąca — dokładnie te punkty, które rysuje wykres."
"captionMonth": "Jeden wiersz na miesiąc dla {metric}, średnia z dni tego miesiąca — dokładnie te punkty, które rysuje wykres.",
"hourlyMeanNote": "{metric} to średnia z każdego dnia, w której każda godzina liczy się raz, dzięki czemu trening nie przeważa nad resztą dnia."
},
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
+39 -1
View File
@@ -26,6 +26,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
@@ -410,6 +411,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
]);
const type = KIND_TO_TYPE[kind];
const seriesRows = seriesRowsFrom(priorityJson, type, days);
// A day of a type with an activity-dependent sampling rate (pulse) is the
// mean of its local hours' means, each hour once, so a workout hour with
// hundreds of times the readings does not outweigh the rest of the day (see
// `day-statistic.ts`). The hour grain is already one mean per hour.
const hourWeighted = grain === "day" && usesHourlyMeanDay(type);
const bucketRows = await prisma.$queryRawUnsafe<
Array<{
bucket_start: Date;
@@ -418,7 +424,39 @@ export const GET = apiHandler(async (request: NextRequest) => {
max_value: number;
}>
>(
`
hourWeighted
? `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
'hour',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_hour
FROM ${seriesRows}
WHERE m."measured_at" >= $4
),
hourly AS (
SELECT
date_trunc($2, local_hour) AS local_day,
AVG("value") AS hour_mean,
MIN(COALESCE("value_min", "value")) AS min_value,
MAX(COALESCE("value_max", "value")) AS max_value
FROM localized
GROUP BY local_hour
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
AVG(hour_mean)::double precision AS mean,
MIN(min_value)::double precision AS min_value,
MAX(max_value)::double precision AS max_value
FROM hourly
GROUP BY local_day
ORDER BY bucket_start ASC
`
: `
WITH localized AS (
SELECT
m."value",
@@ -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}` : ""
}`,
+8 -2
View File
@@ -40,6 +40,10 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import {
windowWeighting,
type WindowWeighting,
} from "@/lib/measurements/day-statistic";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -88,6 +92,7 @@ export function sliceWindowDelta(
rows: RollupBucketRow[],
windowDays: number,
now: number,
weighting: WindowWeighting = "count",
): { current: WindowAggregate; previous: WindowAggregate } {
const currentStart = now - windowDays * DAY_MS;
const previousStart = now - 2 * windowDays * DAY_MS;
@@ -102,8 +107,8 @@ export function sliceWindowDelta(
}
}
return {
current: aggregateWmyBuckets(currentRows),
previous: aggregateWmyBuckets(previousRows),
current: aggregateWmyBuckets(currentRows, weighting),
previous: aggregateWmyBuckets(previousRows, weighting),
};
}
@@ -334,6 +339,7 @@ export async function computeRangeDelta(
resolved.rows,
windowDays,
now,
windowWeighting(type),
);
const { delta, deltaPct } = composeDelta(current, previous);
return {
+2
View File
@@ -60,6 +60,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { windowWeighting } from "@/lib/measurements/day-statistic";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
@@ -1109,6 +1110,7 @@ async function computeAvg30LastYearForType(
minValue: r.minValue,
maxValue: r.maxValue,
})),
windowWeighting(type),
);
return composed.count > 0 ? composed.mean : null;
}
+2 -1
View File
@@ -113,6 +113,7 @@ import {
loadUserSourcePriority,
type AccumulatorBucketRow,
} from "@/lib/rollups/measurement-read";
import { windowWeighting } from "@/lib/measurements/day-statistic";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import {
buildSourceRankCase,
@@ -544,7 +545,7 @@ async function buildFromRollups(
// windowed columns (avg + anomaly) alongside; slope / r² / sd compose
// from the per-bucket regression accumulators (v1.20.0 F6).
for (const [type, buckets] of bucketsByType.entries()) {
const composed = aggregateBuckets(buckets);
const composed = aggregateBuckets(buckets, windowWeighting(type));
if (composed.count === 0) continue;
totalMeasurements += composed.count;
const latest = latestByType.get(type);
+9 -2
View File
@@ -31,6 +31,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { windowWeighting } from "@/lib/measurements/day-statistic";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
@@ -854,7 +855,10 @@ async function snapshotMetricWindow(
windowDays,
);
if (!read || read.rows.length === 0) return null;
const agg = aggregateWmyBuckets(read.rows);
const agg = aggregateWmyBuckets(
read.rows,
windowWeighting(metric.measurementType),
);
if (agg.count === 0) return null;
return {
label: metric.label,
@@ -895,7 +899,10 @@ async function snapshotMetricRange(
return t >= fromMs && t <= toMs;
});
if (rows.length === 0) return null;
const agg = aggregateWmyBuckets(rows);
const agg = aggregateWmyBuckets(
rows,
windowWeighting(metric.measurementType),
);
if (agg.count === 0) return null;
return {
label: metric.label,
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
HOURLY_MEAN_DAY_TYPES,
usesHourlyMeanDay,
windowWeighting,
} from "../day-statistic";
describe("which types a day is the mean of the hours' means for", () => {
it("is pulse, and only pulse", () => {
expect([...HOURLY_MEAN_DAY_TYPES]).toEqual(["PULSE"]);
});
it.each(["PULSE"])(
"%s uses the hourly mean and weighs each day once",
(type) => {
expect(usesHourlyMeanDay(type)).toBe(true);
expect(windowWeighting(type)).toBe("day");
},
);
// A CGM samples at a fixed rate, so glucose has no activity bias. HRV and
// SpO2 may follow once the effect is shown on real data. Cumulative types sum.
it.each([
"BLOOD_GLUCOSE",
"HEART_RATE_VARIABILITY",
"OXYGEN_SATURATION",
"RESTING_HEART_RATE",
"WEIGHT",
"ACTIVITY_STEPS",
"HRV_RMSSD",
])("%s keeps the plain mean and weighs each day by its readings", (type) => {
expect(usesHourlyMeanDay(type)).toBe(false);
expect(windowWeighting(type)).toBe("count");
});
});
+41 -2
View File
@@ -16,6 +16,7 @@ import {
type AggregateGrain,
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic";
import { readTieredRollupSeries } from "@/lib/rollups/measurement-read-wmy";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { annotate } from "@/lib/logging/context";
@@ -192,6 +193,11 @@ export async function readLiveBuckets(opts: {
.map((t) => `'${t}'`)
.join(",");
const isCumulative = Prisma.raw(`c."type"::text IN (${cumulativeList})`);
// Types whose day is the mean of its per-local-hour means (see
// `day-statistic.ts`). Closed code constants, spliced as enum literals.
const hourlyMeanList = Prisma.raw(
[...HOURLY_MEAN_DAY_TYPES].map((t) => `'${t}'::measurement_type`).join(","),
);
const rankRaw = Prisma.raw(
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
);
@@ -236,17 +242,50 @@ export async function readLiveBuckets(opts: {
${typeFilter}
GROUP BY m."type", m."source", 3
),
-- A day of a type with an activity-dependent sampling rate is the mean of
-- its local hours' means, each hour once, not the mean of its readings: a
-- workout hour holds hundreds of times as many readings as a resting one.
-- Empty for every other type.
hour_mean AS (
SELECT h."type", h."source", h.day,
AVG(h.hour_mean)::double precision AS day_mean
FROM (
SELECT
m."type",
m."source",
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day,
date_trunc('hour', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS hr,
AVG(m."value") AS hour_mean
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
AND m."type" IN (${hourlyMeanList})
${typeFilter}
GROUP BY m."type", m."source", 3, 4
) h
GROUP BY h."type", h."source", h.day
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.day, p.total, p.cnt, p.min_value, p.max_value
p."type", p.day, p.total, p.cnt, p.min_value, p.max_value, hm.day_mean
FROM per_source p
LEFT JOIN hour_mean hm
ON hm."type" = p."type" AND hm."source" = p."source" AND hm.day = p.day
ORDER BY p."type", p.day, (${rankRaw}), p."source"::text
),
folded AS (
SELECT
c."type",
date_trunc(${unit}, c.day) AS d,
(CASE WHEN ${isCumulative} THEN SUM(c.total) ELSE SUM(c.total) / SUM(c.cnt) END)::double precision AS avg,
(CASE
WHEN ${isCumulative} THEN SUM(c.total)
-- Hourly-mean types: every day once, so a window of days does not
-- hand a workout day its thousands of readings as weight.
WHEN COUNT(c.day_mean) = COUNT(*) THEN AVG(c.day_mean)
ELSE SUM(c.total) / SUM(c.cnt)
END)::double precision AS avg,
SUM(c.cnt)::int AS cnt,
MIN(c.min_value) AS min_value,
MAX(c.max_value) AS max_value
+32
View File
@@ -0,0 +1,32 @@
/**
* Which statistic a type's DAY value is, and how days combine into a window.
*
* A watch samples heart rate about every 5 to 10 minutes at rest and about
* every 5 seconds in a workout. A plain mean over a day's rows therefore counts
* the workout hour hundreds of times and the other twenty-three hours a few
* dozen times each, so a workout day reads far above the rest of the day.
*
* For such a type the DAY value is the mean of its per-hour means, each hour
* weighing one. That is deterministic, needs no extra column, and matches what
* an hourly bucket already is. A window of days then weighs each day once too
* (the mean of the daily means), because weighting days by their sample count
* would bring the same bias back one level up.
*
* Pulse only. A CGM samples glucose at a fixed rate, so glucose has no
* activity bias; HRV and SpO2 can follow when the effect is shown on real data.
*/
import type { MeasurementType } from "@/generated/prisma/client";
export const HOURLY_MEAN_DAY_TYPES: ReadonlySet<MeasurementType> =
new Set<MeasurementType>(["PULSE"]);
export function usesHourlyMeanDay(type: string): boolean {
return HOURLY_MEAN_DAY_TYPES.has(type as MeasurementType);
}
/** How the days of a window combine: by sample count, or one weight per day. */
export type WindowWeighting = "count" | "day";
export function windowWeighting(type: string): WindowWeighting {
return usesHourlyMeanDay(type) ? "day" : "count";
}
@@ -724,3 +724,44 @@ describe("server tier vs client caption ladder", () => {
}
});
});
describe("aggregateWmyBuckets, one weight per day", () => {
// A week of seven days and a week of two, both read at their own mean. With
// one weight per day the longer week counts for its seven days, not for its
// readings.
const week = (
days: number,
count: number,
mean: number,
): Parameters<typeof aggregateWmyBuckets>[0][number] => ({
bucketStart: new Date("2026-05-04T00:00:00.000Z"),
count,
days,
mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: mean - 5,
maxValue: mean + 5,
});
it("weighs a bucket by its readings by default", () => {
const result = aggregateWmyBuckets([week(7, 3000, 60), week(2, 1000, 90)]);
expect(result.mean).toBeCloseTo((3000 * 60 + 1000 * 90) / 4000, 9);
});
it("weighs a bucket by the days it holds when asked", () => {
const result = aggregateWmyBuckets(
[week(7, 3000, 60), week(2, 1000, 90)],
"day",
);
expect(result.mean).toBeCloseTo((7 * 60 + 2 * 90) / 9, 9);
expect(result.count).toBe(4000);
});
it("treats a row with no day count as one day", () => {
const row = { ...week(1, 10, 70), days: undefined };
expect(aggregateWmyBuckets([row], "day").mean).toBe(70);
});
});
@@ -65,3 +65,47 @@ describe("aggregateBuckets", () => {
expect(result.mean).toBe(82);
});
});
describe("aggregateBuckets, one weight per day", () => {
// A workout day (many readings) and a rest day (few). By readings the window
// mean is dominated by the workout day; with one weight per day it is the
// mean of the two days' means.
const workout: DailyMeanRow = {
day: new Date("2026-05-08T00:00:00.000Z"),
count: 900,
mean: 64,
minValue: 60,
maxValue: 150,
};
const rest: DailyMeanRow = {
day: new Date("2026-05-09T00:00:00.000Z"),
count: 100,
mean: 56,
minValue: 50,
maxValue: 70,
};
it("weighs a day by its readings by default", () => {
const result = aggregateBuckets([workout, rest]);
expect(result.mean).toBeCloseTo((900 * 64 + 100 * 56) / 1000, 9);
});
it("weighs every day once when asked, and still counts every reading", () => {
const result = aggregateBuckets([workout, rest], "day");
expect(result.mean).toBeCloseTo((64 + 56) / 2, 9);
expect(result.count).toBe(1000);
expect(result.min).toBe(50);
expect(result.max).toBe(150);
});
it("gives a day with no readings no weight under either rule", () => {
const empty: DailyMeanRow = { ...rest, count: 0, mean: 999 };
expect(aggregateBuckets([workout, empty], "day").mean).toBe(64);
expect(aggregateBuckets([empty], "day")).toEqual({
count: 0,
min: null,
max: null,
mean: null,
});
});
});
+18 -4
View File
@@ -37,6 +37,7 @@ import type {
} from "@/generated/prisma/client";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import type { WindowWeighting } from "@/lib/measurements/day-statistic";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
@@ -54,6 +55,8 @@ import {
export interface RollupBucketRow {
bucketStart: Date;
count: number;
/** Days of the bucket that hold a reading (1 for a DAY row). */
days?: number;
mean: number;
sd: number | null;
slope: number | null;
@@ -162,7 +165,10 @@ export async function readBestGranularityRollups(
* stats do not compose across coarser buckets and the consumers that
* need them stay on live SQL.
*/
export function aggregateWmyBuckets(rows: RollupBucketRow[]): {
export function aggregateWmyBuckets(
rows: RollupBucketRow[],
weighting: WindowWeighting = "count",
): {
count: number;
min: number | null;
max: number | null;
@@ -174,13 +180,20 @@ export function aggregateWmyBuckets(rows: RollupBucketRow[]): {
}
let totalCount = 0;
let sumWeighted = 0;
let totalWeight = 0;
let sumCumulative = 0;
let sawSum = false;
let min = Infinity;
let max = -Infinity;
for (const row of rows) {
totalCount += row.count;
sumWeighted += row.count * row.mean;
// Each reading weighs one, or, for a type whose DAY value is the mean of
// its hourly means (pulse), each DAY weighs one: a bucket weighs the days
// it holds. See `aggregateBuckets` in measurement-read.ts.
const weight =
weighting === "day" ? (row.count > 0 ? (row.days ?? 1) : 0) : row.count;
sumWeighted += weight * row.mean;
totalWeight += weight;
if (row.sumValue !== null && Number.isFinite(row.sumValue)) {
sumCumulative += row.sumValue;
sawSum = true;
@@ -188,14 +201,14 @@ export function aggregateWmyBuckets(rows: RollupBucketRow[]): {
if (row.minValue < min) min = row.minValue;
if (row.maxValue > max) max = row.maxValue;
}
if (totalCount === 0) {
if (totalCount === 0 || totalWeight === 0) {
return { count: 0, min: null, max: null, mean: null, sum: null };
}
return {
count: totalCount,
min: Number.isFinite(min) ? min : null,
max: Number.isFinite(max) ? max : null,
mean: sumWeighted / totalCount,
mean: sumWeighted / totalWeight,
sum: sawSum ? sumCumulative : null,
};
}
@@ -234,6 +247,7 @@ async function readGranularity(
return rows.map((r) => ({
bucketStart: r.bucketStart,
count: r.count,
days: r.days,
mean: r.mean,
sd: r.sd,
slope: r.slope,
+44 -14
View File
@@ -28,6 +28,10 @@ import type {
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import {
windowWeighting,
type WindowWeighting,
} from "@/lib/measurements/day-statistic";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import {
@@ -386,8 +390,19 @@ export function hasPendingAccumulatorBackfill(
* Combine DAY buckets into the linearly-composable window stats —
* `count`, `min`, `max`, `mean`. SD / slope / R² are intentionally
* NOT computed here because they don't compose across DAY rollups.
*
* `weighting` decides how the days make up the window mean. By default each
* day weighs its sample count, which is the plain mean over every reading. For
* a type whose DAY value is the mean of its hourly means (pulse, see
* `day-statistic.ts`) pass `"day"`: every day weighs one. Weighting days by how
* many samples they hold would hand a workout day, with its thousands of
* samples, the same outsized share the hourly mean exists to take away.
* `count` stays the number of readings either way.
*/
export function aggregateBuckets(rows: DailyMeanRow[]): {
export function aggregateBuckets(
rows: DailyMeanRow[],
weighting: WindowWeighting = "count",
): {
count: number;
min: number | null;
max: number | null;
@@ -398,22 +413,26 @@ export function aggregateBuckets(rows: DailyMeanRow[]): {
}
let totalCount = 0;
let sumWeighted = 0;
let totalWeight = 0;
let min = Infinity;
let max = -Infinity;
for (const r of rows) {
totalCount += r.count;
sumWeighted += r.count * r.mean;
// A day with no readings carries no weight under either rule.
const weight = weighting === "day" ? (r.count > 0 ? 1 : 0) : r.count;
sumWeighted += weight * r.mean;
totalWeight += weight;
if (r.minValue < min) min = r.minValue;
if (r.maxValue > max) max = r.maxValue;
}
if (totalCount === 0) {
if (totalCount === 0 || totalWeight === 0) {
return { count: 0, min: null, max: null, mean: null };
}
return {
count: totalCount,
min: Number.isFinite(min) ? min : null,
max: Number.isFinite(max) ? max : null,
mean: sumWeighted / totalCount,
mean: sumWeighted / totalWeight,
};
}
@@ -518,10 +537,18 @@ const COARSE_TRUNC_UNIT: Record<Exclude<RollupGranularity, "DAY">, string> = {
* `sd`, `slope` and `r2` come from the summed regression accumulators
* (`composeRegression`) and are `null` when a day lacks them.
*/
function composeFoldedBucket(row: FoldedBucketRow): CanonicalRollupBucket {
function composeFoldedBucket(
row: FoldedBucketRow,
weighting: WindowWeighting = "count",
): CanonicalRollupBucket {
const count = Number(row.count);
const sumY = Number(row.sum_y);
const mean = sumY / count;
// The mean of every reading: what the regression below composes from.
const sampleMean = sumY / count;
const dayMean = Number(row.sum_day_mean) / Number(row.days);
// The bucket's own `mean`: every reading, or, for a type whose DAY value is
// the mean of its hourly means (pulse), every day once.
const mean = weighting === "day" ? dayMean : sampleMean;
const accumulatorsComplete =
row.sum_x !== null &&
row.sum_xy !== null &&
@@ -533,14 +560,14 @@ function composeFoldedBucket(row: FoldedBucketRow): CanonicalRollupBucket {
const sumYy = accumulatorsComplete ? Number(row.sum_yy) : null;
const regression = accumulatorsComplete
? composeRegression([
{ count, mean, sumValue: sumY, sumX, sumXy, sumXx, sumYy },
{ count, mean: sampleMean, sumValue: sumY, sumX, sumXy, sumXx, sumYy },
])
: { slope: null, r2: null, sdPop: null };
return {
bucketStart: new Date(row.bucket_start),
count,
days: Number(row.days),
dayMean: Number(row.sum_day_mean) / Number(row.days),
dayMean,
mean,
minValue: Number(row.min_value),
maxValue: Number(row.max_value),
@@ -691,10 +718,13 @@ export async function readCanonicalRollupBuckets(opts: {
`;
const fromMs = from.getTime();
const toMs = to?.getTime() ?? null;
return rows.map(composeFoldedBucket).filter((b) => {
const start = b.bucketStart.getTime();
if (start < fromMs) return false;
if (toMs === null) return true;
return opts.toInclusive ? start <= toMs : start < toMs;
});
const weighting = windowWeighting(type);
return rows
.map((r) => composeFoldedBucket(r, weighting))
.filter((b) => {
const start = b.bucketStart.getTime();
if (start < fromMs) return false;
if (toMs === null) return true;
return opts.toInclusive ? start <= toMs : start < toMs;
});
}
+89 -7
View File
@@ -43,6 +43,7 @@ import {
utcBucketEnd,
utcBucketStart,
} from "@/lib/rollups/measurement-read";
import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import type {
MeasurementType,
@@ -526,11 +527,15 @@ async function runRollupAggregate(
AND m."deleted_at" IS NULL
GROUP BY m."type", m."source", ${dateTrunc}
`;
return db.$queryRawUnsafe<RollupRow[]>(
sql,
input.userId,
input.from,
input.to,
return applyHourlyMeanDay(
db,
input,
await db.$queryRawUnsafe<RollupRow[]>(
sql,
input.userId,
input.from,
input.to,
),
);
}
@@ -569,12 +574,89 @@ async function runRollupAggregate(
AND m."deleted_at" IS NULL
GROUP BY m."type", m."source", ${dateTrunc}
`;
return db.$queryRawUnsafe<RollupRow[]>(
sql,
return applyHourlyMeanDay(
db,
input,
await db.$queryRawUnsafe<RollupRow[]>(
sql,
input.userId,
input.from,
input.to,
),
);
}
/**
* For a type whose DAY value is the mean of its per-hour means (see
* `day-statistic.ts`), replace the DAY bucket's `mean`. Only `mean` changes:
* `count`, `min`, `max` and the sums the regression composes from stay over
* every reading, so the band and the windowed slope are untouched.
*
* Hours are UTC hours, matching the UTC day this tier buckets by.
*/
async function applyHourlyMeanDay(
db: Pick<typeof prisma, "$queryRawUnsafe">,
input: {
userId: string;
granularity: RollupGranularity;
from: Date;
to: Date;
},
rows: RollupRow[],
): Promise<RollupRow[]> {
if (input.granularity !== "DAY") return rows;
const types = [
...new Set(
rows
.map((r) => r.type)
.filter((t) => HOURLY_MEAN_DAY_TYPES.has(t as MeasurementType)),
),
];
if (types.length === 0) return rows;
for (const t of types) {
if (!/^[A-Z0-9_]+$/.test(t)) {
throw new Error(`invalid measurement type: ${t}`);
}
}
const typeList = types.map((t) => `'${t}'::"measurement_type"`).join(",");
const means = await db.$queryRawUnsafe<
Array<{ type: string; source: string; bucket_start: Date; mean: number }>
>(
`
SELECT h."type"::text AS type,
h."source"::text AS source,
date_trunc('day', h.hr) AS bucket_start,
AVG(h.hour_mean)::double precision AS mean
FROM (
SELECT m."type", m."source",
date_trunc('hour', m."measured_at") AS hr,
AVG(m."value") AS hour_mean
FROM measurements m
WHERE m."user_id" = $1
AND m."type" IN (${typeList})
AND m."measured_at" >= $2
AND m."measured_at" < $3
AND m."deleted_at" IS NULL
GROUP BY m."type", m."source", date_trunc('hour', m."measured_at")
) h
GROUP BY 1, 2, 3
`,
input.userId,
input.from,
input.to,
);
const byKey = new Map(
means.map((m) => [
`${m.type}|${m.source}|${new Date(m.bucket_start).getTime()}`,
m.mean,
]),
);
return rows.map((r) => {
const mean = byKey.get(
`${r.type}|${r.source}|${new Date(r.bucket_start).getTime()}`,
);
return mean === undefined ? r : { ...r, mean };
});
}
/**
+22 -9
View File
@@ -439,15 +439,28 @@ describe("measurement rollups — integration", () => {
Array<{ type: string; day: string; mean_value: number }>
>`
SELECT
m."type"::text AS type,
TO_CHAR(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') AS day,
(ROUND((AVG(m."value"))::numeric, 2))::double precision AS mean_value
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."type" IN ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'PULSE')
GROUP BY m."type", date_trunc('day', m."measured_at")
ORDER BY m."type", day ASC
h."type"::text AS type,
TO_CHAR(h.day, 'YYYY-MM-DD') AS day,
-- A day of pulse is the mean of its hours' means (day-statistic.ts);
-- every other type is the plain mean of its readings.
(ROUND((CASE WHEN h."type" = 'PULSE'
THEN AVG(h.hour_mean) ELSE AVG(h.plain_mean) END)::numeric, 2))::double precision AS mean_value
FROM (
SELECT
m."type",
date_trunc('day', m."measured_at") AS day,
AVG(m."value") AS hour_mean,
AVG(m."value") AS plain_mean
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."type" IN ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'PULSE')
GROUP BY m."type", date_trunc('day', m."measured_at"),
CASE WHEN m."type" = 'PULSE'
THEN date_trunc('hour', m."measured_at") END
) h
GROUP BY h."type", h.day
ORDER BY h."type", day ASC
`;
const liveByType = new Map<string, Array<{ day: string; value: number }>>();
@@ -0,0 +1,282 @@
/**
* A day of pulse is the mean of its hours' means, and a window of days weighs
* each day once.
*
* A watch samples about every 5 to 10 minutes at rest and about every 5 seconds
* in a workout, so a plain mean over a day's readings counts the workout hour
* hundreds of times and the other twenty-three hours a few dozen times each.
* The worked example here is the one from the issue: a workout hour of 700
* readings at 150 bpm and 23 resting hours of 10 readings at 60 bpm.
*
* mean of the readings (700 x 150 + 230 x 60) / 930 = 127.7
* mean of the hours' means (150 + 23 x 60) / 24 = 63.75
*
* Asserted against a real Postgres, in every place a daily pulse value or a
* window of days is made: the live per-day read the chart serves, the
* rollup's DAY bucket and the weeks folded from it, and the series route's
* day bucket. HRV is seeded with the very same readings as a control: a type
* without an activity-dependent sampling rate keeps the plain mean.
*/
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 type { Prisma } from "@/generated/prisma/client";
import { readLiveBuckets } from "@/lib/measurements/daily-series-read";
import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
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 prisma = getPrismaClient();
const USER = "pulse-hourly-mean";
const DAY = 86_400_000;
/** Monday of the ISO week two weeks back, 00:00 UTC. */
const now = new Date();
const dow = (now.getUTCDay() + 6) % 7;
const MONDAY = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - dow - 14,
);
/** Tuesday: the workout day. Wednesday: an ordinary day. Same ISO week. */
const WORKOUT_DAY = MONDAY + DAY;
const REST_DAY = MONDAY + 2 * DAY;
const WORKOUT_HOUR = 10;
const WORKOUT_READINGS = 700;
const RESTING_READINGS_PER_HOUR = 10;
function dayRows(
type: "PULSE" | "HEART_RATE_VARIABILITY",
unit: string,
dayStart: number,
workout: boolean,
): Prisma.MeasurementCreateManyInput[] {
const rows: Prisma.MeasurementCreateManyInput[] = [];
for (let h = 0; h < 24; h += 1) {
const isWorkoutHour = workout && h === WORKOUT_HOUR;
const n = isWorkoutHour ? WORKOUT_READINGS : RESTING_READINGS_PER_HOUR;
for (let i = 0; i < n; i += 1) {
// Inside the hour, at least 1 s apart; the workout hour is dense.
const offsetMs = isWorkoutHour ? i * 5_000 : (5 + i * 4) * 60_000;
rows.push({
id: `${type}-${dayStart}-${h}-${i}`,
userId: USER,
type,
unit,
source: "APPLE_HEALTH",
value: isWorkoutHour ? 150 : 60,
measuredAt: new Date(dayStart + h * 3_600_000 + offsetMs),
});
}
}
return rows;
}
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, timezone: "UTC" },
});
invalidateUserTimezone(USER);
for (const [type, unit] of [
["PULSE", "bpm"],
["HEART_RATE_VARIABILITY", "ms"],
] as const) {
await prisma.measurement.createMany({
data: [
...dayRows(type, unit, WORKOUT_DAY, true),
...dayRows(type, unit, REST_DAY, false),
],
});
}
await recomputeUserRollups(USER, {
types: ["PULSE", "HEART_RATE_VARIABILITY"],
from: new Date(MONDAY),
to: new Date(MONDAY + 7 * DAY),
});
}, 120_000);
const SAMPLE_MEAN_WORKOUT_DAY =
(WORKOUT_READINGS * 150 + 23 * RESTING_READINGS_PER_HOUR * 60) /
(WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR);
const HOURLY_MEAN_WORKOUT_DAY = (150 + 23 * 60) / 24;
describe("the worked example", () => {
it("really is two different numbers", () => {
expect(SAMPLE_MEAN_WORKOUT_DAY).toBeCloseTo(127.74, 2);
expect(HOURLY_MEAN_WORKOUT_DAY).toBeCloseTo(63.75, 5);
});
});
describe("the live per-day read the chart serves", () => {
const read = (
type: "PULSE" | "HEART_RATE_VARIABILITY",
grain: "daily" | "weekly",
) =>
readLiveBuckets({
userId: USER,
type,
from: new Date(MONDAY),
to: new Date(MONDAY + 7 * DAY),
cap: 400,
priorityJson: null,
grain,
timeZone: "UTC",
});
it("gives a workout day the mean of its hours, not of its readings", async () => {
const rows = await read("PULSE", "daily");
const workout = rows.find(
(r) => new Date(r.measuredAt).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 6);
// The reading count and the band stay over every reading.
expect(workout?.count).toBe(
WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR,
);
expect(workout?.minValue).toBe(60);
expect(workout?.maxValue).toBe(150);
const rest = rows.find(
(r) => new Date(r.measuredAt).getTime() === REST_DAY,
);
expect(rest?.value).toBeCloseTo(60, 6);
});
it("weighs each day once when it folds days into a week", async () => {
const [week] = await read("PULSE", "weekly");
// Mean of the two days' means, not of their readings (which would be ~114).
expect(week.value).toBeCloseTo((HOURLY_MEAN_WORKOUT_DAY + 60) / 2, 6);
expect(week.count).toBe(
WORKOUT_READINGS +
23 * RESTING_READINGS_PER_HOUR +
24 * RESTING_READINGS_PER_HOUR,
);
});
it("keeps the plain mean for a type with no activity-dependent sampling (HRV)", async () => {
const rows = await read("HEART_RATE_VARIABILITY", "daily");
const workout = rows.find(
(r) => new Date(r.measuredAt).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 6);
const [week] = await read("HEART_RATE_VARIABILITY", "weekly");
const total =
WORKOUT_READINGS * 150 +
23 * RESTING_READINGS_PER_HOUR * 60 +
24 * RESTING_READINGS_PER_HOUR * 60;
const readings = WORKOUT_READINGS + 47 * RESTING_READINGS_PER_HOUR;
expect(week.value).toBeCloseTo(total / readings, 6);
});
});
describe("the rollup tier", () => {
const rollup = (
type: "PULSE" | "HEART_RATE_VARIABILITY",
granularity: "DAY" | "WEEK",
) =>
readCanonicalRollupBuckets({
userId: USER,
type,
granularity,
from: new Date(MONDAY),
to: new Date(MONDAY + 7 * DAY),
toInclusive: true,
});
it("stores the mean of the hours' means as the DAY mean, and nothing else changes", async () => {
const days = await rollup("PULSE", "DAY");
const workout = days.find((d) => d.bucketStart.getTime() === WORKOUT_DAY)!;
expect(workout.mean).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 6);
expect(workout.count).toBe(
WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR,
);
expect(workout.minValue).toBe(60);
expect(workout.maxValue).toBe(150);
// The sums the regression composes from stay over every reading.
expect(workout.sumValue).toBe(
WORKOUT_READINGS * 150 + 23 * RESTING_READINGS_PER_HOUR * 60,
);
});
it("gives a week the mean of its days' means", async () => {
const [week] = await rollup("PULSE", "WEEK");
expect(week.days).toBe(2);
expect(week.mean).toBeCloseTo((HOURLY_MEAN_WORKOUT_DAY + 60) / 2, 6);
expect(week.dayMean).toBeCloseTo(week.mean, 9);
});
it("keeps the plain mean for HRV, DAY and WEEK alike", async () => {
const days = await rollup("HEART_RATE_VARIABILITY", "DAY");
const workout = days.find((d) => d.bucketStart.getTime() === WORKOUT_DAY)!;
expect(workout.mean).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 6);
const [week] = await rollup("HEART_RATE_VARIABILITY", "WEEK");
const total = WORKOUT_READINGS * 150 + 47 * RESTING_READINGS_PER_HOUR * 60;
expect(week.mean).toBeCloseTo(
total / (WORKOUT_READINGS + 47 * RESTING_READINGS_PER_HOUR),
6,
);
});
});
describe("the series route's day bucket", () => {
async function series(kind: string) {
const s = await prisma.session.create({
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", s.id);
const { GET } = await import("@/app/api/measurements/series/route");
const res = await GET(
new NextRequest(
`http://localhost/api/measurements/series?kind=${kind}&days=365`,
),
);
expect(res.status).toBe(200);
return (
(await res.json()) as {
data: { points: Array<{ id: string; at: string; value: number }> };
}
).data.points;
}
it("is the mean of the hours' means for pulse", async () => {
const points = await series("pulse");
expect(points.every((p) => p.id.startsWith("day:"))).toBe(true);
const workout = points.find(
(p) => new Date(p.at).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 2);
});
it("is the plain mean for HRV", async () => {
const points = await series("heartRateVariability");
const workout = points.find(
(p) => new Date(p.at).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 2);
});
});