diff --git a/messages/de.json b/messages/de.json index 0c7173452..206fc0f7a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3cd9aa9a4..cbe6ac31c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index d40131208..754446f0d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index a5d0afa08..60d00f7e7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index e0e489863..5a57cc3fb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index e51af7017..e6943d644 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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}", diff --git a/messages/pl.json b/messages/pl.json index caa7f9245..ecf3924de 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 35b22894e..794660abe 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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", diff --git a/src/components/charts/__tests__/chart-data-table.test.tsx b/src/components/charts/__tests__/chart-data-table.test.tsx index 32afaa285..507010898 100644 --- a/src/components/charts/__tests__/chart-data-table.test.tsx +++ b/src/components/charts/__tests__/chart-data-table.test.tsx @@ -135,4 +135,65 @@ describe("", () => { /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 = {}) => + 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(); + expect(html).toContain('data-slot="chart-data-table-statistic"'); + expect(html).toContain( + "Pulse is each day's average, with every hour counting once", + ); + }); + + it("says it in German too", () => { + const html = renderToStaticMarkup( + + + , + ); + 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(), + ).toContain('data-slot="chart-data-table-statistic"'); + } + }); + + it("adds nothing for a metric that is the plain mean of its readings", () => { + const html = render(); + 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( + , + ); + 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"); + }); + }); }); diff --git a/src/components/charts/chart-data-table.tsx b/src/components/charts/chart-data-table.tsx index 5c28eee29..dbb29c192 100644 --- a/src/components/charts/chart-data-table.tsx +++ b/src/components/charts/chart-data-table.tsx @@ -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({ {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) => ( + + {t("charts.dataTable.hourlyMeanNote", { + metric: column.label, + })} + + ))} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index ee00071dd..086ff7470 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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}` : "" }`, diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts index 667cf37c9..4bee215a5 100644 --- a/src/lib/analytics/range-delta.ts +++ b/src/lib/analytics/range-delta.ts @@ -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 { diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 11c74e262..76dc34e6d 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -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; } diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 548f89268..e828eb189 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -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); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 3893df9d8..05cb1823e 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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, diff --git a/src/lib/measurements/__tests__/day-statistic.test.ts b/src/lib/measurements/__tests__/day-statistic.test.ts new file mode 100644 index 000000000..9941ea553 --- /dev/null +++ b/src/lib/measurements/__tests__/day-statistic.test.ts @@ -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"); + }); +}); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 945345dd4..ef2b0659e 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -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 diff --git a/src/lib/measurements/day-statistic.ts b/src/lib/measurements/day-statistic.ts new file mode 100644 index 000000000..2b42cf4f0 --- /dev/null +++ b/src/lib/measurements/day-statistic.ts @@ -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 = + new Set(["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"; +} diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 8ec37ec0a..5dd53703b 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -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[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); + }); +}); diff --git a/src/lib/rollups/__tests__/measurement-read.test.ts b/src/lib/rollups/__tests__/measurement-read.test.ts index 6cbbac500..a0f192f24 100644 --- a/src/lib/rollups/__tests__/measurement-read.test.ts +++ b/src/lib/rollups/__tests__/measurement-read.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index a67d91c3e..865b08e67 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -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, diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index c183dc110..21fa56fdd 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -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, 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; + }); } diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 867863642..f5982bd4b 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -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( - sql, - input.userId, - input.from, - input.to, + return applyHourlyMeanDay( + db, + input, + await db.$queryRawUnsafe( + 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( - sql, + return applyHourlyMeanDay( + db, + input, + await db.$queryRawUnsafe( + 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, + input: { + userId: string; + granularity: RollupGranularity; + from: Date; + to: Date; + }, + rows: RollupRow[], +): Promise { + 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 }; + }); } /** diff --git a/tests/integration/measurement-rollups.test.ts b/tests/integration/measurement-rollups.test.ts index 5e09f9b3b..6e6613f1a 100644 --- a/tests/integration/measurement-rollups.test.ts +++ b/tests/integration/measurement-rollups.test.ts @@ -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>(); diff --git a/tests/integration/pulse-hourly-mean-day.test.ts b/tests/integration/pulse-hourly-mean-day.test.ts new file mode 100644 index 000000000..5d87bbc8a --- /dev/null +++ b/tests/integration/pulse-hourly-mean-day.test.ts @@ -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); + }); +});