mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #1138 from MBombeck/feat/pulse-hourly-mean-complete
fix(pulse): a day of pulse is the mean of its hours' means, everywhere
This commit is contained in:
75 files changed
+4699
-230
No files matched your search
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
-- Pulse: a day is the mean of its hours' means, a coarser bucket the mean of
|
||||
-- its days.
|
||||
--
|
||||
-- A watch samples pulse about every 5 to 10 minutes at rest and about every
|
||||
-- 5 seconds in a workout, so the plain mean over a day's readings let one
|
||||
-- workout hour outweigh the other twenty-three. The rollup writer now stores a
|
||||
-- PULSE DAY bucket's `mean` as the mean of its per-hour means, and a WEEK /
|
||||
-- MONTH / YEAR bucket's as the mean of its days' values (see
|
||||
-- `src/lib/measurements/day-statistic.ts`). Nothing rewrites a stored bucket
|
||||
-- on its own: the boot backfill and the freshness check fill only missing
|
||||
-- days, so rows written before this release would keep the reading mean
|
||||
-- until a write happened to touch their day. This migration recomputes them.
|
||||
--
|
||||
-- Scope, exactly:
|
||||
-- - only rows of type PULSE, in every granularity;
|
||||
-- - only the `mean` column. `count`, `min_value`, `max_value`, `sum_value`,
|
||||
-- `sd`, `slope`, `r2` and the regression sums stay over every reading, as
|
||||
-- the writer keeps them;
|
||||
-- - hours, days, weeks, months and years are `date_trunc` on `measured_at`
|
||||
-- in the session zone, exactly as the writer groups them, per user / source
|
||||
-- over non-deleted readings.
|
||||
--
|
||||
-- Recomputing from `measurements` makes the migration idempotent: a rerun
|
||||
-- derives the same means whatever the column held before. A stored row whose
|
||||
-- readings are all gone is left as it is; the writer prunes such rows on the
|
||||
-- next fold of their range.
|
||||
--
|
||||
-- The hour means are built once into a temporary table, so the readings are
|
||||
-- scanned once for all four granularities.
|
||||
--
|
||||
-- Reversibility (down): recompute `mean` as `AVG(value)` over the same groups,
|
||||
-- or refold the PULSE rollups on a release before this one. No column changes
|
||||
-- shape.
|
||||
|
||||
CREATE TEMPORARY TABLE pulse_hour_means AS
|
||||
SELECT
|
||||
m."user_id" AS user_id,
|
||||
m."source" AS source,
|
||||
date_trunc('hour', m."measured_at") AS hr,
|
||||
AVG(m."value") AS hour_mean
|
||||
FROM measurements m
|
||||
WHERE m."type" = 'PULSE'
|
||||
AND m."deleted_at" IS NULL
|
||||
GROUP BY m."user_id", m."source", date_trunc('hour', m."measured_at");
|
||||
|
||||
CREATE TEMPORARY TABLE pulse_day_means AS
|
||||
SELECT
|
||||
h.user_id,
|
||||
h.source,
|
||||
date_trunc('day', h.hr) AS day,
|
||||
AVG(h.hour_mean) AS day_mean
|
||||
FROM pulse_hour_means h
|
||||
GROUP BY h.user_id, h.source, date_trunc('day', h.hr);
|
||||
|
||||
CREATE INDEX pulse_day_means_key ON pulse_day_means (user_id, source, day);
|
||||
|
||||
-- DAY buckets: the mean of the day's hour means.
|
||||
UPDATE "measurement_rollups" r
|
||||
SET "mean" = d.day_mean
|
||||
FROM pulse_day_means d
|
||||
WHERE r."granularity" = 'DAY'
|
||||
AND r."type" = 'PULSE'
|
||||
AND r."user_id" = d.user_id
|
||||
AND r."source" = d.source
|
||||
AND r."bucket_start" = d.day;
|
||||
|
||||
-- WEEK buckets: the mean of the week's day values.
|
||||
UPDATE "measurement_rollups" r
|
||||
SET "mean" = s.mean
|
||||
FROM (
|
||||
SELECT d.user_id, d.source, date_trunc('week', d.day) AS bucket_start,
|
||||
AVG(d.day_mean) AS mean
|
||||
FROM pulse_day_means d
|
||||
GROUP BY d.user_id, d.source, date_trunc('week', d.day)
|
||||
) s
|
||||
WHERE r."granularity" = 'WEEK'
|
||||
AND r."type" = 'PULSE'
|
||||
AND r."user_id" = s.user_id
|
||||
AND r."source" = s.source
|
||||
AND r."bucket_start" = s.bucket_start;
|
||||
|
||||
-- MONTH buckets.
|
||||
UPDATE "measurement_rollups" r
|
||||
SET "mean" = s.mean
|
||||
FROM (
|
||||
SELECT d.user_id, d.source, date_trunc('month', d.day) AS bucket_start,
|
||||
AVG(d.day_mean) AS mean
|
||||
FROM pulse_day_means d
|
||||
GROUP BY d.user_id, d.source, date_trunc('month', d.day)
|
||||
) s
|
||||
WHERE r."granularity" = 'MONTH'
|
||||
AND r."type" = 'PULSE'
|
||||
AND r."user_id" = s.user_id
|
||||
AND r."source" = s.source
|
||||
AND r."bucket_start" = s.bucket_start;
|
||||
|
||||
-- YEAR buckets.
|
||||
UPDATE "measurement_rollups" r
|
||||
SET "mean" = s.mean
|
||||
FROM (
|
||||
SELECT d.user_id, d.source, date_trunc('year', d.day) AS bucket_start,
|
||||
AVG(d.day_mean) AS mean
|
||||
FROM pulse_day_means d
|
||||
GROUP BY d.user_id, d.source, date_trunc('year', d.day)
|
||||
) s
|
||||
WHERE r."granularity" = 'YEAR'
|
||||
AND r."type" = 'PULSE'
|
||||
AND r."user_id" = s.user_id
|
||||
AND r."source" = s.source
|
||||
AND r."bucket_start" = s.bucket_start;
|
||||
|
||||
DROP TABLE pulse_day_means;
|
||||
DROP TABLE pulse_hour_means;
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Structural guard: every reader that averages pulse goes through the
|
||||
* day-mean helper.
|
||||
*
|
||||
* A watch samples pulse every few minutes at rest and every few seconds in a
|
||||
* workout, so a plain mean over pulse readings lets one workout hour outweigh
|
||||
* the rest of the day. A day of pulse is the mean of its hours' means and a
|
||||
* window the mean of its days (`src/lib/measurements/day-statistic.ts`), and
|
||||
* `src/lib/measurements/day-mean.ts` is the one place that computes it. The
|
||||
* statistic first shipped in the chart and the rollup tier while some twenty
|
||||
* other readers kept the plain mean, so the dashboard's year delta compared
|
||||
* one statistic against another.
|
||||
*
|
||||
* Two matchers, both frozen both ways (a new unlisted match fails, a listed
|
||||
* file that no longer matches fails):
|
||||
*
|
||||
* 1. Database means over measurement values: `AVG(value)`, the per-day
|
||||
* folds `SUM(total) / SUM(cnt)` and `SUM(count * mean)`, and Prisma's
|
||||
* `_avg: { value }`. A file that holds one imports the shared statistic
|
||||
* (it averages pulse among other types and the helper's CASE keeps the
|
||||
* others on the plain mean).
|
||||
*
|
||||
* 2. In-memory means in a file that names pulse: a left fold divided by a
|
||||
* length, `avgInWindow(`, `summarize(`, `bucketWeekly(`,
|
||||
* `buildDailyValueRows(` or `applyPayloadBudget(`. A file that holds one
|
||||
* imports the shared statistic, hands the type to a fold that does, or
|
||||
* is listed with the reason it never averages pulse readings.
|
||||
*
|
||||
* Comments are stripped before matching, the matchers are whitespace-tolerant,
|
||||
* and the floors fail the guard if either matcher stops finding anything. The
|
||||
* planted cases run each matcher over an offender; dropping the type from the
|
||||
* mood correlation's fold, or a new file with a plain `AVG(value)` over pulse,
|
||||
* fails the two list checks. It is a tripwire, not a proof: a mean written
|
||||
* some other way slips it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The shared statistic: the helper, or the type set it is keyed on. */
|
||||
const HELPER_IMPORT =
|
||||
/from\s+["'](?:@\/lib\/measurements\/|\.\/)day-(?:mean|statistic)["']/;
|
||||
|
||||
/** Comments go first: a mean named in prose is not a mean computed. */
|
||||
function stripComments(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "")
|
||||
.replace(/^\s*--.*$/gm, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Database means over a measurement value: SQL `AVG(value)`, the per-day folds
|
||||
* `SUM(total) / SUM(cnt)` and `SUM(count * mean)`, and Prisma's
|
||||
* `_avg: { value: ... }`.
|
||||
*/
|
||||
const SQL_MEAN =
|
||||
/_avg\s*:\s*\{\s*value\s*:|\bAVG\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?value"?\s*\)|\bSUM\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?total"?\s*\)\s*\/\s*SUM\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?cnt"?\s*\)|\bSUM\s*\(\s*"?count"?\s*\*\s*"?mean"?\s*\)/gi;
|
||||
|
||||
/** In-memory means and the folds that make them, counted in files that name pulse. */
|
||||
const TS_MEAN =
|
||||
/\.reduce\(\s*\(\s*\w+\s*,\s*\w+\s*\)\s*=>\s*\w+\s*\+\s*[\w.]+\s*,\s*0\s*\)\s*\/\s*[\w.]+\.length|\bavgInWindow\s*\(|\bsummarize\s*\(|\bbucketWeekly\s*\(|\bbuildDailyValueRows\s*\(|\bapplyPayloadBudget\s*\(/g;
|
||||
|
||||
const NAMES_PULSE = /\bPULSE\b/;
|
||||
|
||||
type Entry =
|
||||
/** Averages pulse and imports the shared statistic. */
|
||||
| { helper: true }
|
||||
/** Averages pulse through a fold that imports it, and hands that fold the type. */
|
||||
| { via: string; passes: RegExp }
|
||||
/** Never averages pulse readings; the reason is the claim. */
|
||||
| { exempt: string };
|
||||
|
||||
const HELPER: Entry = { helper: true };
|
||||
|
||||
/** Files with a database mean over values. */
|
||||
const SQL_READERS: Record<string, Entry> = {
|
||||
"app/api/measurements/series/route.ts": HELPER,
|
||||
"lib/ai/coach/tools/availability.ts": HELPER,
|
||||
"lib/analytics/summaries-slice.ts": HELPER,
|
||||
"lib/doctor-report/dense-buckets.ts": HELPER,
|
||||
"lib/insights/comprehensive-aggregator.ts": HELPER,
|
||||
"lib/insights/comprehensive-generate.ts": HELPER,
|
||||
"lib/measurements/daily-series-read.ts": HELPER,
|
||||
"lib/rollups/measurement-rollups.ts": HELPER,
|
||||
"lib/rollups/tiered-context.ts": HELPER,
|
||||
};
|
||||
|
||||
/** Files that name pulse and hold an in-memory mean or a mean-making fold. */
|
||||
const TS_READERS: Record<string, Entry> = {
|
||||
"app/api/measurements/series/route.ts": HELPER,
|
||||
"lib/doctor-report/collect.ts": HELPER,
|
||||
"lib/doctor-report-pdf/clinical-summary.ts": HELPER,
|
||||
"lib/insights/comprehensive-generate.ts": HELPER,
|
||||
"lib/insights/features.ts": HELPER,
|
||||
"lib/insights/mood-status.ts": {
|
||||
via: "lib/insights/bucket-series.ts",
|
||||
passes: /\{\s*now\s*,\s*tz\s*,\s*type\s*\}/,
|
||||
},
|
||||
"lib/insights/pulse-status.ts": {
|
||||
via: "lib/insights/bucket-series.ts",
|
||||
passes:
|
||||
/applyPayloadBudget\(\s*pulsePoints\s*,\s*\{[^}]*type\s*:\s*"PULSE"/,
|
||||
},
|
||||
"lib/insights/signals-of-day.ts": {
|
||||
via: "lib/insights/features.ts",
|
||||
passes: /byType\(\s*"PULSE"\s*\)[\s\S]{0,80}type\s*:\s*"PULSE"/,
|
||||
},
|
||||
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts": {
|
||||
via: "lib/ai/coach/snapshot-series.ts",
|
||||
passes: /buildDailyValueRows\([^)]*"PULSE"/,
|
||||
},
|
||||
"components/charts/health-chart.tsx": {
|
||||
exempt:
|
||||
"a rolling average over the points the chart draws: display smoothing, not a stated figure",
|
||||
},
|
||||
"lib/analytics/correlations-fast-path.ts": {
|
||||
exempt: "mood mean; pulse enters as the resting series, never a day mean",
|
||||
},
|
||||
"lib/dashboard/snapshot.ts": {
|
||||
exempt: "summarises water-intake days and glucose contexts",
|
||||
},
|
||||
"lib/gamification/expansion-metrics.ts": {
|
||||
exempt: "means of mood day means; pulse is only counted",
|
||||
},
|
||||
"lib/illness/correlation.ts": { exempt: "mean of episode durations" },
|
||||
"lib/insights/mood-aggregates.ts": { exempt: "the mood series only" },
|
||||
"lib/insights/narrative/period-narrative.ts": {
|
||||
exempt: "means of day-keyed series, already one value per day",
|
||||
},
|
||||
"lib/mcp/rich-reads.ts": {
|
||||
exempt:
|
||||
"changepoints over rollup bucket means, which weigh pulse days once (readCanonicalRollupBuckets)",
|
||||
},
|
||||
"lib/medications/efficacy/build-efficacy.ts": {
|
||||
exempt: "means of the day-mean series (readDayMeanSeries)",
|
||||
},
|
||||
"lib/targets/build-response.ts": {
|
||||
exempt:
|
||||
"pulse is excluded from the 30-day averages; the resting proxy stands in",
|
||||
},
|
||||
"lib/targets/vitals-builder.ts": {
|
||||
exempt:
|
||||
"trend halves over the other vitals and the resting proxy's day points",
|
||||
},
|
||||
"app/api/insights/cards/route.ts": {
|
||||
exempt: "pulse is summarised from day means (readDayMeanSeries)",
|
||||
},
|
||||
"app/api/insights/comprehensive/route.ts": {
|
||||
exempt: "summarises the mood series only",
|
||||
},
|
||||
};
|
||||
|
||||
function scan(): { sql: Map<string, number>; ts: Map<string, number> } {
|
||||
const files = walkSourceFiles(SRC, { floor: 1500 }).filter(
|
||||
(f) =>
|
||||
!f.startsWith("generated/") &&
|
||||
!f.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(f) &&
|
||||
f !== "lib/measurements/day-mean.ts",
|
||||
);
|
||||
const sql = new Map<string, number>();
|
||||
const ts = new Map<string, number>();
|
||||
for (const f of files) {
|
||||
const text = stripComments(readFileSync(join(SRC, f), "utf8"));
|
||||
const s = text.match(SQL_MEAN)?.length ?? 0;
|
||||
if (s > 0) sql.set(f, s);
|
||||
if (NAMES_PULSE.test(text)) {
|
||||
const t = text.match(TS_MEAN)?.length ?? 0;
|
||||
if (t > 0) ts.set(f, t);
|
||||
}
|
||||
}
|
||||
return { sql, ts };
|
||||
}
|
||||
|
||||
const { sql, ts } = scan();
|
||||
const source = (f: string) => readFileSync(join(SRC, f), "utf8");
|
||||
|
||||
function check(found: Map<string, number>, listed: Record<string, Entry>) {
|
||||
const unlisted = [...found.keys()].filter((f) => !(f in listed)).sort();
|
||||
const stale = Object.keys(listed)
|
||||
.filter((f) => !found.has(f))
|
||||
.sort();
|
||||
const broken: string[] = [];
|
||||
for (const [f, entry] of Object.entries(listed)) {
|
||||
if (!found.has(f)) continue;
|
||||
if ("helper" in entry && !HELPER_IMPORT.test(source(f))) {
|
||||
broken.push(`${f}: does not import the day-mean helper`);
|
||||
}
|
||||
if ("via" in entry) {
|
||||
if (!HELPER_IMPORT.test(source(entry.via))) {
|
||||
broken.push(`${f}: ${entry.via} does not import the day-mean helper`);
|
||||
}
|
||||
if (!entry.passes.test(source(f))) {
|
||||
broken.push(`${f}: no longer hands the type to ${entry.via}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { unlisted, stale, broken };
|
||||
}
|
||||
|
||||
describe("pulse day-mean reader guard", () => {
|
||||
it("finds the SQL means it is meant to find", () => {
|
||||
expect(sql.size).toBeGreaterThanOrEqual(9);
|
||||
expect([...sql.values()].reduce((a, b) => a + b, 0)).toBeGreaterThanOrEqual(
|
||||
15,
|
||||
);
|
||||
});
|
||||
|
||||
it("finds the in-memory pulse means it is meant to find", () => {
|
||||
expect(ts.size).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("every SQL mean over values is listed, and every listed pulse reader uses the helper", () => {
|
||||
expect(check(sql, SQL_READERS)).toEqual({
|
||||
unlisted: [],
|
||||
stale: [],
|
||||
broken: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("every in-memory pulse mean is listed, and every listed reader uses the helper", () => {
|
||||
expect(check(ts, TS_READERS)).toEqual({
|
||||
unlisted: [],
|
||||
stale: [],
|
||||
broken: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("catches a planted plain AVG and a planted fold", () => {
|
||||
expect(
|
||||
`SELECT AVG( m."value" ) FROM measurements m`.match(SQL_MEAN),
|
||||
).toHaveLength(1);
|
||||
expect(`(SUM(c.total) / SUM(c.cnt))`.match(SQL_MEAN)).toHaveLength(1);
|
||||
expect(`SUM("count" * "mean")`.match(SQL_MEAN)).toHaveLength(1);
|
||||
expect(`aggregate({ _avg: { value: true } })`.match(SQL_MEAN)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(stripComments("// AVG(value) in prose").match(SQL_MEAN)).toBeNull();
|
||||
const planted = `const pulse = byType("PULSE");
|
||||
const avg = pulse.reduce((s, r) => s + r.value, 0) / pulse.length;`;
|
||||
expect(NAMES_PULSE.test(planted)).toBe(true);
|
||||
expect(planted.match(TS_MEAN)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,13 @@ 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 {
|
||||
dayWeightedRows,
|
||||
readingsMean,
|
||||
windowMeanSql,
|
||||
zoneDayFrame,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
@@ -462,6 +469,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;
|
||||
@@ -470,7 +482,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",
|
||||
@@ -501,17 +545,50 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// strip's mean/min/max/stdDev/count are the same figures the raw path
|
||||
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
|
||||
// JS population-variance helper, exact row count. Aggregate-only —
|
||||
// no row transfer.
|
||||
const [rawAgg] = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
// no row transfer. For an hourly-mean type (pulse) the mean is the mean
|
||||
// of the local days' values, each day the mean of its local hours'
|
||||
// means, so it is the mean of the day points the chart draws; count,
|
||||
// min, max and the spread stay over every reading.
|
||||
const [rawAgg] = usesHourlyMeanDay(type)
|
||||
? await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
WITH src AS (
|
||||
SELECT m.*
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $2
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
|
||||
AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
userTz,
|
||||
)
|
||||
: await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
@@ -521,9 +598,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $2
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) =>
|
||||
grain === "day"
|
||||
@@ -701,6 +778,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
valueMax: row.valueMax ?? null,
|
||||
};
|
||||
});
|
||||
// An hourly-mean type's strip mean is the mean of its local days'
|
||||
// values, each day the mean of its local hours' means, so a dense
|
||||
// workout hour does not outweigh the rest of the window. Count, min,
|
||||
// max and the spread stay over every reading.
|
||||
if (usesHourlyMeanDay(type) && rows.length > 0) {
|
||||
const userTz = await resolveUserTimezone(user.id);
|
||||
const values = rows.map((r) => r.value);
|
||||
const rawSummary = summarize(
|
||||
rows.map((r) => ({ date: r.measuredAt, value: r.value })),
|
||||
);
|
||||
const mean = readingsMean(type, rows, userTz);
|
||||
statsOverride = {
|
||||
mean: mean === null ? 0 : Math.round(mean * 100) / 100,
|
||||
min: rawSummary.min ?? 0,
|
||||
max: rawSummary.max ?? 0,
|
||||
stdDev: stdDev(values),
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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}` : ""
|
||||
}`,
|
||||
|
||||
@@ -48,6 +48,9 @@ interface GroupRow {
|
||||
}
|
||||
let measurementRows: GroupRow[] = [];
|
||||
let measurementThrows = false;
|
||||
/** What the day-weighted pulse query returns, per `type|unit`. */
|
||||
let pulseDayMeans: Array<{ type: string; unit: string | null; mean: number }> =
|
||||
[];
|
||||
const workoutAggregate = vi.fn();
|
||||
const moodAggregate = vi.fn();
|
||||
const intakeAggregate = vi.fn();
|
||||
@@ -66,6 +69,7 @@ vi.mock("@/lib/db", () => ({
|
||||
);
|
||||
},
|
||||
},
|
||||
$queryRaw: () => Promise.resolve(pulseDayMeans),
|
||||
workout: { aggregate: (a: unknown) => workoutAggregate(a) },
|
||||
moodEntry: { aggregate: (a: unknown) => moodAggregate(a) },
|
||||
medicationIntakeEvent: { aggregate: (a: unknown) => intakeAggregate(a) },
|
||||
@@ -386,6 +390,33 @@ describe("classifyAvailability", () => {
|
||||
});
|
||||
|
||||
describe("probeCoachAvailability", () => {
|
||||
// A pulse history's mean is the mean of its days, each the mean of its
|
||||
// hours' means; the reading AVG a workout's dense samples tip is not what
|
||||
// the Coach is told.
|
||||
it("states the day-weighted pulse mean, not the reading mean", async () => {
|
||||
measurementRows = [
|
||||
{
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
_count: { _all: 930 },
|
||||
_min: { measuredAt: new Date("2026-07-20T00:05:00Z"), value: 60 },
|
||||
_max: { measuredAt: new Date("2026-07-20T23:55:00Z"), value: 150 },
|
||||
_avg: { value: 127.7 },
|
||||
},
|
||||
];
|
||||
pulseDayMeans = [{ type: "PULSE", unit: "bpm", mean: 63.75 }];
|
||||
const probed = await probeCoachAvailability(
|
||||
"u1",
|
||||
new Map([["pulse", { kind: "measurement", types: ["PULSE"] }]]),
|
||||
{ now: NOW },
|
||||
);
|
||||
pulseDayMeans = [];
|
||||
const series = [...probed.values()][0]?.series;
|
||||
expect(series).toEqual([
|
||||
expect.objectContaining({ series: "PULSE", mean: 63.8, count: 930 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no entry for a domain the record is empty for", async () => {
|
||||
measurementRows = [];
|
||||
const probed = await probeCoachAvailability(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCoreMetricsBlocks } from "../core-metrics-block";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* The Coach's pulse timeline: a day is the mean of its hours' means and a week
|
||||
* the mean of its days (`day-mean.ts`). A workout day of twelve readings at
|
||||
* 150 in one hour and three resting hours at 60 reads 82.5, not 132.
|
||||
*/
|
||||
describe("Coach pulse block", () => {
|
||||
it("states a pulse day as the mean of its hours, a week as its days", () => {
|
||||
const first = Date.UTC(2026, 2, 4);
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
const day = (start: number) => {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 150,
|
||||
measuredAt: new Date(start + 10 * HOUR + i * 300_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(start + h * HOUR),
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(start + DAY + 8 * HOUR),
|
||||
});
|
||||
};
|
||||
day(first - 14 * DAY); // older: lands in the weekly fold
|
||||
day(first); // recent
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
buildCoreMetricsBlocks({
|
||||
sources: new Set(["pulse"]),
|
||||
features: { pulse: { coverage: { count: rows.length } } } as never,
|
||||
measurementRows: rows,
|
||||
moodRows: null,
|
||||
recentCutoff: new Date(first),
|
||||
userTz: "UTC",
|
||||
coarseTails: {},
|
||||
snapshot,
|
||||
windows: new Set(),
|
||||
metrics: new Set(),
|
||||
counts: {},
|
||||
registerBlock: () => {},
|
||||
groundingValues: new Map(),
|
||||
units: {} as never,
|
||||
});
|
||||
const pulse = snapshot.pulse as {
|
||||
timeline: {
|
||||
recent: Array<{ value: number }>;
|
||||
weekly: Array<{ mean: number; count: number }>;
|
||||
};
|
||||
};
|
||||
expect(pulse.timeline.recent.map((d) => d.value)).toEqual([82.5, 60]);
|
||||
expect(pulse.timeline.weekly).toHaveLength(1);
|
||||
expect(pulse.timeline.weekly[0].mean).toBe(71.3);
|
||||
expect(pulse.timeline.weekly[0].count).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -133,13 +133,21 @@ export function buildCoreMetricsBlocks(
|
||||
|
||||
if (ctx.sources.has("pulse") && ctx.features.pulse) {
|
||||
const rows = byType("PULSE");
|
||||
// A pulse day is the mean of its hours' means, a week the mean of its
|
||||
// days (`day-mean.ts`).
|
||||
ctx.snapshot.pulse = {
|
||||
aggregate: ctx.features.pulse,
|
||||
timeline: {
|
||||
recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz),
|
||||
recent: buildDailyValueRows(
|
||||
rows,
|
||||
ctx.recentCutoff,
|
||||
ctx.userTz,
|
||||
"PULSE",
|
||||
),
|
||||
weekly: bucketWeekly(
|
||||
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
|
||||
ctx.userTz,
|
||||
"PULSE",
|
||||
),
|
||||
...(ctx.coarseTails.pulse ? { coarse: ctx.coarseTails.pulse } : {}),
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* is one-way (snapshot → series), never back.
|
||||
*/
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dayValue, readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
type DisplayTransform,
|
||||
@@ -135,11 +137,33 @@ function dailyMeans<T extends { measuredAt: Date; value: number }>(
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function groupRowsByDay(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): Map<string, Array<{ measuredAt: Date; value: number }>> {
|
||||
const out = new Map<string, Array<{ measuredAt: Date; value: number }>>();
|
||||
for (const r of rows) {
|
||||
const key = tzDayKey(r.measuredAt, tz);
|
||||
const list = out.get(key);
|
||||
if (list) list.push(r);
|
||||
else out.set(key, [r]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `type`, when given, names the measurement type: for a type whose day is the
|
||||
* mean of its hours' means (pulse, `day-mean.ts`) a week's mean is the mean of
|
||||
* its days and a day's value that day value. `count` stays the readings.
|
||||
*/
|
||||
export function bucketWeekly(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
type?: string,
|
||||
): WeeklyBucket[] {
|
||||
const hourly = type !== undefined && usesHourlyMeanDay(type);
|
||||
const grouped = new Map<string, number[]>();
|
||||
const members = new Map<string, Array<{ measuredAt: Date; value: number }>>();
|
||||
for (const r of rows) {
|
||||
const key = isoWeekKey(r.measuredAt, tz);
|
||||
const list = grouped.get(key);
|
||||
@@ -148,15 +172,23 @@ export function bucketWeekly(
|
||||
} else {
|
||||
grouped.set(key, [r.value]);
|
||||
}
|
||||
if (hourly) {
|
||||
const kept = members.get(key);
|
||||
if (kept) kept.push(r);
|
||||
else members.set(key, [r]);
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.entries())
|
||||
.map(([weekISO, values]) => ({
|
||||
weekISO,
|
||||
mean:
|
||||
Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) /
|
||||
10,
|
||||
count: values.length,
|
||||
}))
|
||||
.map(([weekISO, values]) => {
|
||||
const mean = hourly
|
||||
? (readingsMean(type!, members.get(weekISO) ?? [], tz) ?? 0)
|
||||
: values.reduce((s, v) => s + v, 0) / values.length;
|
||||
return {
|
||||
weekISO,
|
||||
mean: Math.round(mean * 10) / 10,
|
||||
count: values.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
|
||||
}
|
||||
|
||||
@@ -164,12 +196,19 @@ export function buildDailyValueRows(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
recentCutoff: Date,
|
||||
tz: string,
|
||||
type?: string,
|
||||
): DailyValueRow[] {
|
||||
const recent = rows.filter((r) => r.measuredAt >= recentCutoff);
|
||||
const grouped = dailyMeans(recent, tz);
|
||||
const hourly = type !== undefined && usesHourlyMeanDay(type);
|
||||
const members = hourly
|
||||
? groupRowsByDay(recent, tz)
|
||||
: new Map<string, Array<{ measuredAt: Date; value: number }>>();
|
||||
return Array.from(grouped.entries())
|
||||
.map(([date, info]) => {
|
||||
const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
|
||||
const mean = hourly
|
||||
? (dayValue(type!, members.get(date) ?? [], tz) ?? 0)
|
||||
: info.values.reduce((s, v) => s + v, 0) / info.values.length;
|
||||
return {
|
||||
date,
|
||||
weekday: tzWeekday(info.date, tz),
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
* is enough for the Coach to answer the question instead of only naming it.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -155,13 +157,18 @@ async function readMeasurementBounds(
|
||||
_max: { measuredAt: true, value: true },
|
||||
_avg: { value: true },
|
||||
});
|
||||
const dayMeans = await readHourlyMeanTypeMeans(userId, types);
|
||||
const out = new Map<MeasurementType, RawBounds>();
|
||||
for (const row of rows) {
|
||||
const firstAt = row._min.measuredAt;
|
||||
const lastAt = row._max.measuredAt;
|
||||
const min = row._min.value;
|
||||
const max = row._max.value;
|
||||
const mean = row._avg.value;
|
||||
// A pulse mean is the mean of its days, each the mean of its hours'
|
||||
// means, so a dense workout hour does not stand in for the history.
|
||||
const mean = usesHourlyMeanDay(row.type)
|
||||
? (dayMeans.get(`${row.type}|${row.unit}`) ?? null)
|
||||
: row._avg.value;
|
||||
if (!firstAt || !lastAt) continue;
|
||||
const existing = out.get(row.type);
|
||||
const unitEntry =
|
||||
@@ -197,6 +204,50 @@ async function readMeasurementBounds(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-time mean per type and unit for the hourly-mean types among
|
||||
* `types` (pulse, see `day-mean.ts`): the mean of the user's local days, each
|
||||
* the mean of its local hours' means. Empty when none of `types` is one.
|
||||
*/
|
||||
async function readHourlyMeanTypeMeans(
|
||||
userId: string,
|
||||
types: readonly MeasurementType[],
|
||||
): Promise<Map<string, number>> {
|
||||
const hourly = types.filter((t) => usesHourlyMeanDay(t));
|
||||
const out = new Map<string, number>();
|
||||
if (hourly.length === 0) return out;
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; unit: string | null; mean: number | null }>
|
||||
>`
|
||||
WITH src AS (
|
||||
SELECT *
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${Prisma.join(
|
||||
hourly.map((t) => Prisma.sql`${t}::measurement_type`),
|
||||
)})
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
m."unit" AS unit,
|
||||
${Prisma.raw(
|
||||
windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
}),
|
||||
)}::double precision AS mean
|
||||
FROM ${dayWeightedRowsSql("src", tz)} m
|
||||
GROUP BY m."type", m."unit"
|
||||
`;
|
||||
for (const r of rows) {
|
||||
if (r.mean !== null) out.set(`${r.type}|${r.unit}`, Number(r.mean));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One `aggregate()` per non-measurement subject kind. */
|
||||
async function readTableBounds(
|
||||
userId: string,
|
||||
|
||||
@@ -45,11 +45,9 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
// 90-day FILTER caps the slice itself writes — is exercised without
|
||||
// coupling the slice unit test to the rank builder's enum-whitelist
|
||||
// internals.
|
||||
vi.mock("@/lib/analytics/source-rank-sql", () => ({
|
||||
buildSourceRankCase: vi.fn(() => "90"),
|
||||
canonicalMeasurementsFrom: vi.fn(
|
||||
(_rank: string, sinceInterval?: string) =>
|
||||
`(
|
||||
vi.mock("@/lib/analytics/source-rank-sql", () => {
|
||||
const cte = (_rank: string, sinceInterval?: string) =>
|
||||
`
|
||||
SELECT mm.*
|
||||
FROM measurements mm
|
||||
WHERE mm."user_id" = $1
|
||||
@@ -58,13 +56,15 @@ vi.mock("@/lib/analytics/source-rank-sql", () => ({
|
||||
sinceInterval
|
||||
? `AND mm."measured_at" >= NOW() - INTERVAL '${sinceInterval}'`
|
||||
: ""
|
||||
}
|
||||
) m`,
|
||||
),
|
||||
}));
|
||||
}`;
|
||||
return {
|
||||
buildSourceRankCase: vi.fn(() => "90"),
|
||||
canonicalMeasurementsCte: vi.fn(cte),
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { canonicalMeasurementsFrom } from "@/lib/analytics/source-rank-sql";
|
||||
import { canonicalMeasurementsCte } from "@/lib/analytics/source-rank-sql";
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import { computeSummariesSlice } from "../summaries-slice";
|
||||
|
||||
@@ -79,7 +79,7 @@ const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_FIRST = prisma.measurementRollup
|
||||
.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const CANONICAL_FROM = canonicalMeasurementsFrom as unknown as ReturnType<
|
||||
const CANONICAL_FROM = canonicalMeasurementsCte as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
@@ -609,11 +609,12 @@ describe("computeSummariesSlice", () => {
|
||||
expect(narrowsCap?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// v1.11.1 — the cold-fallback `windowed` scan delegates its outer
|
||||
// 90-day cap to `canonicalMeasurementsFrom(rank, "90 days")`. The
|
||||
// 90-day cap to `canonicalMeasurementsCte(rank, "90 days")` (a CTE
|
||||
// now, so the day weights can read it twice). The
|
||||
// helper lives in `@/lib/analytics/source-rank-sql` (stubbed
|
||||
// above), so pin the contract at the call boundary: the slice
|
||||
// must ask for the 90-day window.
|
||||
expect(canonicalMeasurementsFrom).toHaveBeenCalledWith(
|
||||
expect(canonicalMeasurementsCte).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"90 days",
|
||||
);
|
||||
@@ -621,7 +622,7 @@ describe("computeSummariesSlice", () => {
|
||||
// 15-year scan-DoS floor (far beyond any real history) so a
|
||||
// coverage-miss cold read can't trigger an unbounded full-partition
|
||||
// scan. It no longer calls the helper without an interval.
|
||||
expect(canonicalMeasurementsFrom).toHaveBeenCalledWith(
|
||||
expect(canonicalMeasurementsCte).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"15 years",
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,6 +60,18 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
usesHourlyMeanDay,
|
||||
windowWeighting,
|
||||
} from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
SESSION_DAY_FRAME,
|
||||
dayWeightedRows,
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
isHourlyMeanTypeSql,
|
||||
windowMeanSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -79,7 +91,7 @@ import {
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import {
|
||||
buildSourceRankCase,
|
||||
canonicalMeasurementsFrom,
|
||||
canonicalMeasurementsCte,
|
||||
} from "@/lib/analytics/source-rank-sql";
|
||||
import {
|
||||
isFullyCovered,
|
||||
@@ -136,11 +148,33 @@ interface AllTimeAggregateRow {
|
||||
interface RollupAggregateRow {
|
||||
type: string;
|
||||
count: number;
|
||||
/** Days that hold a reading: the weight of the mean for an hourly-mean type. */
|
||||
days: number;
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window means over canonical readings that carry a `day_weight` (see
|
||||
* `day-mean.ts`): the mean of the day values for an hourly-mean type (pulse),
|
||||
* the unchanged `AVG(value)` for every other type. Days are UTC days, the
|
||||
* frame the canonical-source pick and the rollup tier bucket by.
|
||||
*/
|
||||
function windowedMean(filter: string): string {
|
||||
return windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
filter,
|
||||
});
|
||||
}
|
||||
const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`);
|
||||
const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`);
|
||||
const AVG30_LAST_MONTH_SQL = windowedMean(
|
||||
`m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`,
|
||||
);
|
||||
|
||||
interface NarrowAggregateRow {
|
||||
type: string;
|
||||
avg7: number | null;
|
||||
@@ -454,26 +488,23 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
// inner DISTINCT ON picks the ladder-winning source for each day, and the
|
||||
// join keeps only that source's readings so the 90-day AVG / median / slope
|
||||
// never blend two devices that both reported the same vital.
|
||||
// The window means weigh each day once for an hourly-mean type
|
||||
// (`windowedMean`); the median stays over every reading.
|
||||
prisma.$queryRawUnsafe<NarrowAggregateRow[]>(
|
||||
`
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")}
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month,
|
||||
${AVG7_SQL}::double precision AS avg7,
|
||||
${AVG30_SQL}::double precision AS avg30,
|
||||
${AVG30_LAST_MONTH_SQL}::double precision AS avg30_last_month,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
||||
ORDER BY m."value"
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS median
|
||||
FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")}
|
||||
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
userId,
|
||||
@@ -526,12 +557,19 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
SELECT
|
||||
"type"::text AS type,
|
||||
SUM("count")::int AS count,
|
||||
COUNT(*)::int AS days,
|
||||
MIN("min_value")::double precision AS min,
|
||||
MAX("max_value")::double precision AS max,
|
||||
(
|
||||
SUM("count" * "mean")::double precision
|
||||
/ NULLIF(SUM("count")::double precision, 0)
|
||||
) AS mean
|
||||
-- An hourly-mean type (pulse): every day once, the DAY mean already
|
||||
-- being the mean of its hours' means. Every other type: every
|
||||
-- reading once.
|
||||
(CASE WHEN ${isHourlyMeanTypeSql('"type"')}
|
||||
THEN AVG("mean")::double precision
|
||||
ELSE (
|
||||
SUM("count" * "mean")::double precision
|
||||
/ NULLIF(SUM("count")::double precision, 0)
|
||||
)
|
||||
END) AS mean
|
||||
FROM collapsed
|
||||
GROUP BY "type"
|
||||
`,
|
||||
@@ -575,16 +613,27 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
// day first (a hash aggregate, a few rows per day however dense the
|
||||
// stream), each day then keeps its ladder-canonical source, so the
|
||||
// sort never sees more than one row per source per day.
|
||||
// An hourly-mean type folds each source's day as the mean of its
|
||||
// hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
|
||||
// the remainder once each, the statistic the DAY rollup stores.
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
days: number;
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
}>
|
||||
>`
|
||||
WITH per_source AS (
|
||||
WITH src AS (
|
||||
SELECT *
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldSplit}
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
@@ -592,25 +641,33 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(m."value")::double precision AS total,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldSplit}
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
SUM(m."value" * m.day_weight)::double precision AS wsum,
|
||||
SUM(m.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
|
||||
GROUP BY m."type", m."source", 3
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
|
||||
FROM per_source p
|
||||
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
|
||||
)
|
||||
SELECT
|
||||
c."type"::text AS type,
|
||||
SUM(c.cnt)::int AS count,
|
||||
COUNT(*)::int AS days,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max,
|
||||
(SUM(c.total) / SUM(c.cnt))::double precision AS mean
|
||||
${Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
}),
|
||||
)}::double precision AS mean
|
||||
FROM canon c
|
||||
GROUP BY c."type"
|
||||
`,
|
||||
@@ -618,11 +675,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
|
||||
const preFoldByType = new Map<
|
||||
string,
|
||||
{ count: number; min: number; max: number; mean: number }
|
||||
{ count: number; days: number; min: number; max: number; mean: number }
|
||||
>();
|
||||
for (const row of preFoldRemainder) {
|
||||
preFoldByType.set(row.type, {
|
||||
count: Number(row.count),
|
||||
days: Number(row.days),
|
||||
min: Number(row.min),
|
||||
max: Number(row.max),
|
||||
mean: Number(row.mean),
|
||||
@@ -753,8 +811,13 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
const allCount = row.count + (pre?.count ?? 0);
|
||||
const allMin = pre ? Math.min(row.min, pre.min) : row.min;
|
||||
const allMax = pre ? Math.max(row.max, pre.max) : row.max;
|
||||
// Both halves weigh their days once for an hourly-mean type (pulse), so
|
||||
// they combine by day count; every other type combines by readings.
|
||||
const allMean = pre
|
||||
? (row.mean * row.count + pre.mean * pre.count) / allCount
|
||||
? usesHourlyMeanDay(row.type)
|
||||
? (row.mean * Number(row.days) + pre.mean * pre.days) /
|
||||
(Number(row.days) + pre.days)
|
||||
: (row.mean * row.count + pre.mean * pre.count) / allCount
|
||||
: row.mean;
|
||||
summaries[row.type] = {
|
||||
count: allCount,
|
||||
@@ -888,31 +951,29 @@ async function computeFromLiveAggregate(
|
||||
const [allTime, windowed, latests] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<AllTimeAggregateRow[]>(
|
||||
`
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, ALL_TIME_LIVE_CAP_INTERVAL)}
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM ${canonicalMeasurementsFrom(rankUnqualified, ALL_TIME_LIVE_CAP_INTERVAL)}
|
||||
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
|
||||
AS mean_value
|
||||
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
userId,
|
||||
),
|
||||
prisma.$queryRawUnsafe<WindowedAggregateRow[]>(
|
||||
`
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")}
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month,
|
||||
${AVG7_SQL}::double precision AS avg7,
|
||||
${AVG30_SQL}::double precision AS avg30,
|
||||
${AVG30_LAST_MONTH_SQL}::double precision AS avg30_last_month,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
||||
ORDER BY m."value"
|
||||
) FILTER (
|
||||
@@ -955,7 +1016,7 @@ async function computeFromLiveAggregate(
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC'
|
||||
)::double precision AS r2_30
|
||||
FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")}
|
||||
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
userId,
|
||||
@@ -1109,6 +1170,7 @@ async function computeAvg30LastYearForType(
|
||||
minValue: r.minValue,
|
||||
maxValue: r.maxValue,
|
||||
})),
|
||||
windowWeighting(type),
|
||||
);
|
||||
return composed.count > 0 ? composed.mean : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildClinicalSummaryLines } from "../clinical-summary";
|
||||
import type { DoctorReportData } from "../../doctor-report-data";
|
||||
|
||||
/**
|
||||
* The cover sheet's trend arrow compares the first half of a series with the
|
||||
* second. On the raw path a pulse series holds every reading, so its halves
|
||||
* were halves by reading count and a dense workout hour decided the arrow. It
|
||||
* now compares day values, each the mean of its hours' means.
|
||||
*
|
||||
* Day 1: three resting readings at 60. Day 2: a workout hour of twelve
|
||||
* readings at 150, then three resting hours at 60. By readings the halves are
|
||||
* equal (120 and 120, "→"); by day the pulse rose from 60 to 82.5 ("↑").
|
||||
*/
|
||||
function series(type: string) {
|
||||
const at = (d: number, h: number, m = 0) =>
|
||||
new Date(Date.UTC(2026, 2, d, h, m)).toISOString();
|
||||
return [
|
||||
...[8, 9, 10].map((h) => ({ value: 60, measuredAt: at(2, h) })),
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
value: 150,
|
||||
measuredAt: at(3, 10, i * 5),
|
||||
})),
|
||||
...[12, 14, 16].map((h) => ({ value: 60, measuredAt: at(3, h) })),
|
||||
].map((p) => ({ ...p, type }));
|
||||
}
|
||||
|
||||
function arrowFor(type: string): string | undefined {
|
||||
const points = series(type).map(({ value, measuredAt }) => ({
|
||||
value,
|
||||
measuredAt,
|
||||
}));
|
||||
const data = {
|
||||
period: { days: 2, since: "", start: "", end: "" },
|
||||
measurements: { [type]: points },
|
||||
stats: {
|
||||
[type]: { avg: 0, min: 60, max: 150, count: points.length, latest: 60 },
|
||||
},
|
||||
compliance: {},
|
||||
} as unknown as DoctorReportData;
|
||||
const lines = buildClinicalSummaryLines(
|
||||
data,
|
||||
(key, vars) => `${key}|${String(vars?.arrow ?? "")}`,
|
||||
(n) => String(n),
|
||||
);
|
||||
return lines
|
||||
.find((l) => l.startsWith("doctorReport.summaryTrend"))
|
||||
?.split("|")[1];
|
||||
}
|
||||
|
||||
describe("clinical summary trend arrow", () => {
|
||||
it("reads pulse by day, not by reading count", () => {
|
||||
expect(arrowFor("PULSE")).toBe("↑");
|
||||
});
|
||||
|
||||
it("keeps the reading halves for every other type", () => {
|
||||
expect(arrowFor("WEIGHT")).toBe("→");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
DoctorReportNumberFormatter,
|
||||
DoctorReportTranslator,
|
||||
} from "./render-context";
|
||||
import { dayValueRows } from "@/lib/measurements/day-mean";
|
||||
|
||||
export function getBmiClassificationKey(bmi: number): string {
|
||||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||||
@@ -76,7 +77,22 @@ export function buildClinicalSummaryLines(
|
||||
const series = data.measurements[type];
|
||||
const stat = data.stats[type];
|
||||
if (!series || series.length === 0 || !stat) continue;
|
||||
const arrow = trendArrow(series.map((point) => point.value));
|
||||
// A pulse series on the raw path holds every reading, so halves of it
|
||||
// are halves by reading count and a dense workout hour tips the arrow.
|
||||
// Compare day values instead, each the mean of its hours' means (see
|
||||
// `day-mean.ts`); the report's day points already are one per day. Days
|
||||
// are UTC days: the arrow needs only their order. Every other type keeps
|
||||
// its points as they are.
|
||||
const arrow = trendArrow(
|
||||
dayValueRows(
|
||||
type,
|
||||
series.map((point) => ({
|
||||
value: point.value,
|
||||
measuredAt: new Date(point.measuredAt),
|
||||
})),
|
||||
"UTC",
|
||||
).map((point) => point.value),
|
||||
);
|
||||
lines.push(
|
||||
t("doctorReport.summaryTrend", {
|
||||
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
|
||||
@@ -45,6 +45,8 @@ function bucket(
|
||||
clinicalTar2Count: 0,
|
||||
clinicalLowRiskSum: null,
|
||||
clinicalHighRiskSum: null,
|
||||
hourMeanSum: null,
|
||||
hourCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
|
||||
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
|
||||
@@ -446,8 +447,19 @@ export async function collectDoctorReportData(
|
||||
const stats: Record<string, DoctorReportStats> = {};
|
||||
for (const [type, entries] of Object.entries(byType)) {
|
||||
const values = entries.map((e) => e.value);
|
||||
// A day of pulse is the mean of its local hours' means and the period
|
||||
// average the mean of its days (`day-mean.ts`); every other type keeps the
|
||||
// mean of its readings. The dense types' stats are replaced below.
|
||||
stats[type] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
avg:
|
||||
readingsMean(
|
||||
type,
|
||||
entries.map((e) => ({
|
||||
value: e.value,
|
||||
measuredAt: new Date(e.measuredAt),
|
||||
})),
|
||||
reportTz,
|
||||
) ?? 0,
|
||||
...minMaxOf(values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isHourlyMeanTypeSql } from "@/lib/measurements/day-mean";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementSource,
|
||||
@@ -44,6 +45,13 @@ export interface DenseMeasurementBucket {
|
||||
clinicalTar2Count: number;
|
||||
clinicalLowRiskSum: number | null;
|
||||
clinicalHighRiskSum: number | null;
|
||||
/**
|
||||
* For an hourly-mean type (pulse, see `day-mean.ts`): the sum of the
|
||||
* bucket's local hours' means and the number of those hours, so the day
|
||||
* value is their ratio. `null` for every other type.
|
||||
*/
|
||||
hourMeanSum: number | null;
|
||||
hourCount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +73,8 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
const typeList = Prisma.join(
|
||||
denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`),
|
||||
);
|
||||
// Closed code constant (the hourly-mean type list), spliced as literals.
|
||||
const hourlyMeanType = Prisma.raw(isHourlyMeanTypeSql('"type"'));
|
||||
return prisma.$queryRaw<DenseMeasurementBucket[]>`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
@@ -78,7 +88,11 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
date_trunc(
|
||||
'day',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
|
||||
) AS local_day
|
||||
) AS local_day,
|
||||
date_trunc(
|
||||
'hour',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
|
||||
) AS local_hour
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${start}
|
||||
@@ -121,6 +135,43 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
"measured_at" DESC,
|
||||
"id" DESC
|
||||
),
|
||||
-- The hourly-mean day (pulse): each local hour's mean, then their sum
|
||||
-- and count per bucket. Empty for every other type.
|
||||
hour_means AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
AVG("value") AS hour_mean
|
||||
FROM localized
|
||||
WHERE ${hourlyMeanType}
|
||||
GROUP BY
|
||||
local_day,
|
||||
local_hour,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
),
|
||||
hour_agg AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
SUM(hour_mean)::double precision AS hour_mean_sum,
|
||||
COUNT(*)::int AS hour_count
|
||||
FROM hour_means
|
||||
GROUP BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
local_day,
|
||||
@@ -196,7 +247,9 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
grouped.clinical_tar1_count AS "clinicalTar1Count",
|
||||
grouped.clinical_tar2_count AS "clinicalTar2Count",
|
||||
grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
|
||||
grouped.clinical_high_risk_sum AS "clinicalHighRiskSum"
|
||||
grouped.clinical_high_risk_sum AS "clinicalHighRiskSum",
|
||||
hour_agg.hour_mean_sum AS "hourMeanSum",
|
||||
hour_agg.hour_count AS "hourCount"
|
||||
FROM grouped
|
||||
INNER JOIN latest_rows
|
||||
ON latest_rows.local_day = grouped.local_day
|
||||
@@ -204,6 +257,12 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
AND latest_rows."source" = grouped."source"
|
||||
AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
LEFT JOIN hour_agg
|
||||
ON hour_agg.local_day = grouped.local_day
|
||||
AND hour_agg."type" = grouped."type"
|
||||
AND hour_agg."source" = grouped."source"
|
||||
AND hour_agg."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND hour_agg."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
ORDER BY "bucketStart" ASC, grouped."type" ASC
|
||||
|
||||
`;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
|
||||
import { glucoseContextBucket } from "@/lib/glucose";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import type { DoctorReportStats } from "@/lib/doctor-report-types";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DenseMeasurementBucket } from "./dense-buckets";
|
||||
@@ -61,6 +62,8 @@ export function summariseDenseBuckets(
|
||||
max: number;
|
||||
latestAt: Date;
|
||||
latest: number;
|
||||
hourMeanSum: number;
|
||||
hourCount: number;
|
||||
}
|
||||
>();
|
||||
for (const row of canonical) {
|
||||
@@ -76,11 +79,15 @@ export function summariseDenseBuckets(
|
||||
max: row.maxValue,
|
||||
latestAt: row.latestAt,
|
||||
latest: row.latestValue,
|
||||
hourMeanSum: row.hourMeanSum ?? 0,
|
||||
hourCount: row.hourCount ?? 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.count += row.count;
|
||||
current.sum += row.sumValue;
|
||||
current.hourMeanSum += row.hourMeanSum ?? 0;
|
||||
current.hourCount += row.hourCount ?? 0;
|
||||
current.min = Math.min(current.min, row.minValue);
|
||||
current.max = Math.max(current.max, row.maxValue);
|
||||
if (row.latestAt > current.latestAt) {
|
||||
@@ -109,12 +116,26 @@ export function summariseDenseBuckets(
|
||||
}
|
||||
};
|
||||
|
||||
// For an hourly-mean type (pulse, see `day-mean.ts`) a day is the mean of
|
||||
// its local hours' means and the period average the mean of its days, so a
|
||||
// workout hour's dense samples do not outweigh the rest of the day, nor a
|
||||
// workout day the rest of the period. Count, min and max stay over every
|
||||
// reading. Every other type keeps the mean of its readings.
|
||||
const dayMeans = new Map<string, { sum: number; days: number }>();
|
||||
for (const row of byTypeDay.values()) {
|
||||
const value =
|
||||
usesHourlyMeanDay(row.type) && row.hourCount > 0
|
||||
? row.hourMeanSum / row.hourCount
|
||||
: row.sum / row.count;
|
||||
(byType[row.type] ??= []).push({
|
||||
value: row.sum / row.count,
|
||||
value,
|
||||
measuredAt: row.bucketStart.toISOString(),
|
||||
});
|
||||
accumulate(statsAcc, row.type, row);
|
||||
const dm = dayMeans.get(row.type) ?? { sum: 0, days: 0 };
|
||||
dm.sum += value;
|
||||
dm.days += 1;
|
||||
dayMeans.set(row.type, dm);
|
||||
}
|
||||
|
||||
for (const row of canonical) {
|
||||
@@ -137,9 +158,13 @@ export function summariseDenseBuckets(
|
||||
}
|
||||
const stats: DenseMeasurementSummary["stats"] = {};
|
||||
for (const [key, value] of statsAcc) {
|
||||
const dm = dayMeans.get(key);
|
||||
stats[key] = {
|
||||
count: value.count,
|
||||
avg: value.sum / value.count,
|
||||
avg:
|
||||
usesHourlyMeanDay(key) && dm !== undefined && dm.days > 0
|
||||
? dm.sum / dm.days
|
||||
: value.sum / value.count,
|
||||
min: value.min,
|
||||
max: value.max,
|
||||
latest: value.latest,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The illness read's live per-day pulse is the mean of the day's local hours'
|
||||
* means, so a workout hour's dense samples do not read as a fever-day pulse
|
||||
* spike. Other vitals keep the mean of their readings.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const NOW = new Date("2026-01-21T12:00:00Z");
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
measurement: { findMany: vi.fn() },
|
||||
illnessDayLog: { findMany: vi.fn() },
|
||||
}));
|
||||
const engine = vi.hoisted(() => ({ compute: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn(async () => new Map<string, boolean>()),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("../correlation", async (orig) => ({
|
||||
...(await orig<typeof import("../correlation")>()),
|
||||
computeIllnessCorrelation: (input: unknown) => {
|
||||
engine.compute(input);
|
||||
return { status: "insufficient" };
|
||||
},
|
||||
}));
|
||||
|
||||
import { computeEpisodeCorrelation } from "../correlation-read";
|
||||
|
||||
/** A workout hour (six readings at 150) and two resting hours at 60, in Kolkata local time. */
|
||||
const DAY_ROWS = [
|
||||
...[0, 5, 10, 15, 20, 25].map((m) => ({
|
||||
value: 150,
|
||||
// 04:45Z + m = 10:15 + m local (one local hour, two UTC hours).
|
||||
measuredAt: new Date(Date.parse("2026-01-15T04:45:00Z") + m * 60_000),
|
||||
})),
|
||||
{ value: 60, measuredAt: new Date("2026-01-15T06:30:00Z") },
|
||||
{ value: 60, measuredAt: new Date("2026-01-15T08:30:00Z") },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.illnessDayLog.findMany.mockResolvedValue([]);
|
||||
db.measurement.findMany.mockImplementation(
|
||||
async ({ where }: { where: { type: string } }) =>
|
||||
where.type === "PULSE" || where.type === "HEART_RATE_VARIABILITY"
|
||||
? DAY_ROWS
|
||||
: [],
|
||||
);
|
||||
});
|
||||
|
||||
describe("illness read, live per-day means", () => {
|
||||
it("makes a pulse day the mean of its local hours' means; HRV keeps the reading mean", async () => {
|
||||
await computeEpisodeCorrelation(
|
||||
"u1",
|
||||
{
|
||||
id: "ep",
|
||||
onsetAt: new Date("2026-01-16T08:00:00Z"),
|
||||
resolvedAt: null,
|
||||
lifecycle: "ACUTE",
|
||||
},
|
||||
"Asia/Kolkata",
|
||||
NOW,
|
||||
);
|
||||
const { series } = engine.compute.mock.calls[0][0] as {
|
||||
series: Array<{
|
||||
type: string;
|
||||
episodeDays: Array<{ day: string; mean: number }>;
|
||||
episodeDayMax: Array<{ day: string; mean: number }>;
|
||||
}>;
|
||||
};
|
||||
const pulse = series.find((s) => s.type === "PULSE")!;
|
||||
// Hours' means 150, 60, 60 → 90 (the readings average 127.5).
|
||||
expect(pulse.episodeDays).toEqual([{ day: "2026-01-15", mean: 90 }]);
|
||||
// The day's max stays over every reading.
|
||||
expect(pulse.episodeDayMax).toEqual([{ day: "2026-01-15", mean: 150 }]);
|
||||
const hrv = series.find((s) => s.type === "HEART_RATE_VARIABILITY")!;
|
||||
expect(hrv.episodeDays).toEqual([{ day: "2026-01-15", mean: 127.5 }]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { prisma } from "@/lib/db";
|
||||
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import { dayValue } from "@/lib/measurements/day-mean";
|
||||
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
||||
import { reconstructNights } from "@/lib/insights/derived/sleep-score";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
@@ -250,18 +251,24 @@ async function readWindowMeans(
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
if (rows.length === 0) return { points: [], source: "none" };
|
||||
const byDay = new Map<string, { sum: number; count: number; max: number }>();
|
||||
const byDay = new Map<string, { rows: typeof rows; max: number }>();
|
||||
for (const row of rows) {
|
||||
const day = dayKeyForUserTz(row.measuredAt, tz);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0, max: -Infinity };
|
||||
acc.sum += row.value;
|
||||
acc.count += 1;
|
||||
const acc = byDay.get(day) ?? { rows: [], max: -Infinity };
|
||||
acc.rows.push(row);
|
||||
acc.max = Math.max(acc.max, row.value);
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
// The day's mean is the type's day value (`day-mean.ts`): the mean of its
|
||||
// local hours' means for pulse, the mean of its readings otherwise. The
|
||||
// max stays over every reading.
|
||||
const points = [...byDay.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count, max: acc.max }));
|
||||
.map(([day, acc]) => ({
|
||||
day,
|
||||
mean: dayValue(type, acc.rows, tz) as number,
|
||||
max: acc.max,
|
||||
}));
|
||||
return { points, source: "live" };
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
const findUnique = vi.fn();
|
||||
const measurementFindMany = vi.fn();
|
||||
const measurementAggregate = vi.fn();
|
||||
const queryRaw = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
@@ -33,6 +34,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: (...a: unknown[]) => measurementFindMany(...a),
|
||||
aggregate: (...a: unknown[]) => measurementAggregate(...a),
|
||||
},
|
||||
$queryRaw: (...a: unknown[]) => queryRaw(...a),
|
||||
},
|
||||
}));
|
||||
// SLEEP_DURATION branch helpers — most tests never exercise the sleep
|
||||
@@ -84,6 +86,8 @@ beforeEach(() => {
|
||||
measurementFindMany.mockResolvedValue([]);
|
||||
// No measurements by default; individual tests override per call.
|
||||
measurementAggregate.mockResolvedValue({ _avg: { value: null } });
|
||||
// Pulse reads its day-weighted window mean through one raw query per window.
|
||||
queryRaw.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("buildComparisonSnapshotForUser — aggregate windows", () => {
|
||||
@@ -131,9 +135,10 @@ describe("buildComparisonSnapshotForUser — aggregate windows", () => {
|
||||
expect(lteMs).toBeGreaterThanOrEqual(before - 30 * DAY);
|
||||
expect(lteMs).toBeLessThanOrEqual(after - 30 * DAY);
|
||||
}
|
||||
// Six non-sleep types × 2 windows = 12 aggregate reads; no raw non-sleep read.
|
||||
expect(currentCalls.length).toBe(6);
|
||||
expect(baselineCalls.length).toBe(6);
|
||||
// Five non-sleep, non-pulse types × 2 windows = 10 aggregate reads; no raw
|
||||
// non-sleep read. Pulse reads its day-weighted mean instead (below).
|
||||
expect(currentCalls.length).toBe(5);
|
||||
expect(baselineCalls.length).toBe(5);
|
||||
});
|
||||
|
||||
it("reads SLEEP_DURATION through the raw ~400-day findMany, never aggregate", async () => {
|
||||
@@ -163,7 +168,7 @@ describe("buildComparisonSnapshotForUser — aggregate windows", () => {
|
||||
.measuredAt,
|
||||
)
|
||||
.filter((m): m is { gt: Date; lte: Date } => m.lte != null);
|
||||
expect(baselineCalls.length).toBe(6);
|
||||
expect(baselineCalls.length).toBe(5);
|
||||
for (const w of baselineCalls) {
|
||||
const gtMs = w.gt.getTime();
|
||||
const lteMs = w.lte.getTime();
|
||||
@@ -175,6 +180,27 @@ describe("buildComparisonSnapshotForUser — aggregate windows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComparisonSnapshotForUser — pulse", () => {
|
||||
// A pulse window average is the mean of its days, each the mean of its
|
||||
// hours' means, so a workout's dense readings do not decide the delta. A
|
||||
// bare AVG cannot express it, so pulse never goes through the aggregate.
|
||||
it("reads pulse through the day-weighted query, never the reading AVG", async () => {
|
||||
queryRaw
|
||||
.mockResolvedValueOnce([{ mean: 64 }])
|
||||
.mockResolvedValueOnce([{ mean: 60 }]);
|
||||
const snapshot = await buildComparisonSnapshotForUser("u1");
|
||||
const pulseAggregates = measurementAggregate.mock.calls.filter(
|
||||
(c) => (c[0] as { where: { type: string } }).where.type === "PULSE",
|
||||
);
|
||||
expect(pulseAggregates).toHaveLength(0);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(queryRaw.mock.calls[0])).toContain("day_weight");
|
||||
const pulse = snapshot!.metrics.find((m) => m.type === "pulse");
|
||||
expect(pulse).toMatchObject({ unit: "bpm" });
|
||||
expect([pulse!.currentAvg, pulse!.baselineAvg].sort()).toEqual([60, 64]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComparisonSnapshotForUser — output-equivalence to the raw-average path", () => {
|
||||
it("emits identical metrics to findMany-then-summarize() for a fixture (lastMonth)", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
|
||||
@@ -239,6 +239,50 @@ describe("rollup ↔ raw parity — multi-source day (source collapse)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pulse from the DAY rollup tier", () => {
|
||||
it("reads each source's stored DAY mean (the hourly-mean day), not its readings' sum over count", async () => {
|
||||
mockCoverage([["PULSE", true]]);
|
||||
// The stored DAY mean of pulse is the mean of the day's hours' means (90),
|
||||
// while its readings average 127.5. A second source on another day is
|
||||
// single-source and kept as is.
|
||||
rollupFindMany.mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
bucketStart: new Date(`${utcDayKey(at(8, 7))}T00:00:00.000Z`),
|
||||
count: 8,
|
||||
mean: 90,
|
||||
sumValue: 1020,
|
||||
},
|
||||
]);
|
||||
const result = await fetchMeasurementDailySeriesTiered(
|
||||
USER,
|
||||
"Europe/Berlin",
|
||||
SINCE(),
|
||||
["PULSE"],
|
||||
);
|
||||
expect(result.rollupTypes).toEqual(["PULSE"]);
|
||||
expect(result.byType.get("PULSE")).toEqual([
|
||||
{ day: utcDayKey(at(8, 7)), value: 90 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("weighs each source's day once on a day two sources report", async () => {
|
||||
mockCoverage([["PULSE", true]]);
|
||||
const bucketStart = new Date(`${utcDayKey(at(8, 7))}T00:00:00.000Z`);
|
||||
rollupFindMany.mockResolvedValue([
|
||||
{ type: "PULSE", bucketStart, count: 900, mean: 70, sumValue: 900 * 120 },
|
||||
{ type: "PULSE", bucketStart, count: 3, mean: 60, sumValue: 180 },
|
||||
]);
|
||||
const result = await fetchMeasurementDailySeriesTiered(
|
||||
USER,
|
||||
"Europe/Berlin",
|
||||
SINCE(),
|
||||
["PULSE"],
|
||||
);
|
||||
expect(result.byType.get("PULSE")![0].value).toBeCloseTo(65, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollup ↔ raw parity — far-from-UTC profile timezone", () => {
|
||||
const savedTz = process.env.TZ;
|
||||
afterAll(() => {
|
||||
|
||||
@@ -145,6 +145,39 @@ describe("buildMeasurementDailySeries — grain consistency (v1.29.6)", () => {
|
||||
|
||||
expect(points).toEqual([{ day: "2026-06-04", value: 62 }]);
|
||||
});
|
||||
|
||||
it("makes a pulse day the mean of its local hours' means, not of its readings", () => {
|
||||
// A dense workout hour (six readings at 150) and two resting hours at 60.
|
||||
// The plain mean would be (6 * 150 + 120) / 8 = 127.5; each hour once
|
||||
// gives (150 + 60 + 60) / 3 = 90.
|
||||
const rows: MeasurementSeriesRow[] = [
|
||||
...[0, 10, 20, 30, 40, 50].map((m) =>
|
||||
row(`2026-06-04T10:${String(m).padStart(2, "0")}:00.000Z`, 150),
|
||||
),
|
||||
row("2026-06-04T12:00:00.000Z", 60),
|
||||
row("2026-06-04T14:00:00.000Z", 60),
|
||||
];
|
||||
expect(buildMeasurementDailySeries("PULSE", rows, "UTC", null)).toEqual([
|
||||
{ day: "2026-06-04", value: 90 },
|
||||
]);
|
||||
// HRV keeps the plain mean of its readings.
|
||||
expect(
|
||||
buildMeasurementDailySeries("HEART_RATE_VARIABILITY", rows, "UTC", null),
|
||||
).toEqual([{ day: "2026-06-04", value: 127.5 }]);
|
||||
});
|
||||
|
||||
it("reads the pulse hours on the profile zone's own clock", () => {
|
||||
// Asia/Kolkata (UTC+05:30): 10:20Z and 10:40Z are 15:50 and 16:10 local,
|
||||
// two local hours (100 and 40), though one UTC hour.
|
||||
const rows: MeasurementSeriesRow[] = [
|
||||
row("2026-06-04T10:20:00.000Z", 100),
|
||||
row("2026-06-04T10:25:00.000Z", 100),
|
||||
row("2026-06-04T10:40:00.000Z", 40),
|
||||
];
|
||||
expect(
|
||||
buildMeasurementDailySeries("PULSE", rows, "Asia/Kolkata", null),
|
||||
).toEqual([{ day: "2026-06-04", value: 70 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchMeasurementWindowSeries — desc+cap+resort (v1.30.3 QA F1/F2/F3)", () => {
|
||||
|
||||
@@ -624,3 +624,61 @@ describe("extractFeatures — S10 ECG device-verdict descriptor", () => {
|
||||
expect(call?.select?.rhythmClassification).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — a day of pulse is the mean of its hours' means", () => {
|
||||
/**
|
||||
* Two days: one with a workout hour of twelve readings at 150 and three
|
||||
* resting hours of one reading at 60 (day value 82.5), one with a single
|
||||
* resting reading at 60. Every window weighs each day once: 71.25. The plain
|
||||
* mean over the seventeen readings would be about 123.5.
|
||||
*/
|
||||
function pulseRows() {
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * dayMs;
|
||||
const workout = midnight(2);
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 150,
|
||||
measuredAt: new Date(workout + 10 * 3_600_000 + i * 5 * 60_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(workout + h * 3_600_000),
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(midnight(3) + 8 * 3_600_000),
|
||||
});
|
||||
// The bulk read is newest-first; the builder reverses it.
|
||||
return rows
|
||||
.map((r) => ({
|
||||
...r,
|
||||
sleepStage: null,
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
}))
|
||||
.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime());
|
||||
}
|
||||
|
||||
it("weighs each day once in every pulse window and in the all-time mean", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue(pulseRows());
|
||||
const f = await extractFeatures("user-pulse-day", false);
|
||||
expect(f.pulse?.avg7).toBe(71.25);
|
||||
expect(f.pulse?.avg30).toBe(71.25);
|
||||
expect(f.pulse?.avg90).toBe(71.25);
|
||||
expect(f.pulse?.allTimeAvg).toBe(71.25);
|
||||
// Extremes stay over every reading.
|
||||
expect(f.pulse?.allTimeMin).toBe(60);
|
||||
expect(f.pulse?.allTimeMax).toBe(150);
|
||||
expect(f.historicalComparison?.pulse?.current7dAvg).toBe(71.25);
|
||||
});
|
||||
});
|
||||
@@ -259,3 +259,52 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
|
||||
expect(notes[0].text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateGeneralStatusForUser — a pulse day is the mean of its hours", () => {
|
||||
it("folds pulse days and buckets by hours and days, other types unchanged", async () => {
|
||||
// Yesterday: a workout hour of twelve readings at 150 and three resting
|
||||
// hours at 60 (day value 82.5); the day before one reading at 60. The
|
||||
// plain mean of yesterday's fifteen readings would be 132.
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * dayMs;
|
||||
const at = (daysAgo: number, h: number, m = 0) =>
|
||||
new Date(midnight(daysAgo) + h * 3_600_000 + m * 60_000);
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
for (const type of ["PULSE", "WEIGHT"]) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
rows.push({ type, value: 150, measuredAt: at(1, 10, i * 5) });
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({ type, value: 60, measuredAt: at(1, h) });
|
||||
}
|
||||
rows.push({ type, value: 60, measuredAt: at(2, 8) });
|
||||
}
|
||||
rows.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime());
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generateGeneralStatusForUser("user-pulse-hours", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const pulse = snapshot.measurementSeries.PULSE;
|
||||
expect(pulse.summary.mean).toBe(71.25);
|
||||
expect(pulse.series.recent.map((d: { mean: number }) => d.mean)).toEqual([
|
||||
60, 82.5,
|
||||
]);
|
||||
const weight = snapshot.measurementSeries.WEIGHT;
|
||||
expect(weight.series.recent.map((d: { mean: number }) => d.mean)).toEqual([
|
||||
60, 132,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { crossMetricDailySeries } from "../mood-status";
|
||||
|
||||
/**
|
||||
* The mood correlations pair each mood day with that day's pulse. A pulse
|
||||
* day is the mean of its hours' means, so a workout hour of many readings does
|
||||
* not decide the value the mood is paired with.
|
||||
*/
|
||||
describe("crossMetricDailySeries", () => {
|
||||
const now = new Date("2026-03-05T12:00:00Z");
|
||||
const workoutDay = [
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
type: "PULSE",
|
||||
value: 150,
|
||||
measuredAt: new Date(Date.UTC(2026, 2, 3, 10, i * 5)),
|
||||
})),
|
||||
...[12, 14, 16].map((h) => ({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(Date.UTC(2026, 2, 3, h)),
|
||||
})),
|
||||
];
|
||||
|
||||
it("gives a pulse day the mean of its hours' means", () => {
|
||||
const series = crossMetricDailySeries(workoutDay, "PULSE", now, "UTC");
|
||||
expect(series.daily.map((d) => d.value)).toEqual([82.5]);
|
||||
// The reading count stays.
|
||||
expect(series.daily[0].n).toBe(15);
|
||||
});
|
||||
|
||||
it("keeps the plain daily mean for every other type", () => {
|
||||
const weight = workoutDay.map((m) => ({ ...m, type: "WEIGHT" }));
|
||||
const series = crossMetricDailySeries(weight, "WEIGHT", now, "UTC");
|
||||
expect(series.daily.map((d) => d.value)).toEqual([132]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* A day of pulse is the mean of its hours' means and a longer bucket the mean
|
||||
* of its days (`day-mean.ts`), in every in-memory fold the status cards and the
|
||||
* Coach snapshot build. One workout day (twelve readings at 150 in one hour,
|
||||
* three resting hours at 60: day value 82.5) and one resting day (one reading
|
||||
* at 60): a bucket holding both reads 71.25; the plain mean of the sixteen
|
||||
* readings is 127.5. Every other type keeps the plain mean, and the counts,
|
||||
* minima and maxima stay over the readings.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { bucketSeries } from "../bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromDayAggregates,
|
||||
buildGradedSeriesFromPoints,
|
||||
} from "../graded-series";
|
||||
import {
|
||||
bucketWeekly,
|
||||
buildDailyValueRows,
|
||||
} from "@/lib/ai/coach/snapshot-series";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
const TZ = "UTC";
|
||||
|
||||
function twoDays(firstDay: number) {
|
||||
const rows: Array<{ measuredAt: Date; value: number }> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
measuredAt: new Date(firstDay + 10 * HOUR + i * 300_000),
|
||||
value: 150,
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({ measuredAt: new Date(firstDay + h * HOUR), value: 60 });
|
||||
}
|
||||
rows.push({ measuredAt: new Date(firstDay + DAY + 8 * HOUR), value: 60 });
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe("graded series from points", () => {
|
||||
// Wednesday 2026-03-04 and Thursday 2026-03-05, both in ISO week 10.
|
||||
const first = Date.UTC(2026, 2, 4);
|
||||
const rows = twoDays(first);
|
||||
|
||||
it("gives a recent pulse day the mean of its hours", () => {
|
||||
const g = buildGradedSeriesFromPoints(
|
||||
rows,
|
||||
new Date(first + 2 * DAY),
|
||||
TZ,
|
||||
"PULSE",
|
||||
);
|
||||
const day = g.recent.find((d) => d.date === "2026-03-04")!;
|
||||
expect(day.mean).toBe(82.5);
|
||||
expect(day.n).toBe(15);
|
||||
expect(day.min).toBe(60);
|
||||
expect(day.max).toBe(150);
|
||||
});
|
||||
|
||||
it("gives a pulse week the mean of its days", () => {
|
||||
// 30 days later both days fall in the weekly slice.
|
||||
const g = buildGradedSeriesFromPoints(
|
||||
rows,
|
||||
new Date(first + 30 * DAY),
|
||||
TZ,
|
||||
"PULSE",
|
||||
);
|
||||
expect(g.weekly).toHaveLength(1);
|
||||
expect(g.weekly[0].mean).toBe(71.25);
|
||||
expect(g.weekly[0].n).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the plain mean for any other type", () => {
|
||||
const g = buildGradedSeriesFromPoints(
|
||||
rows,
|
||||
new Date(first + 30 * DAY),
|
||||
TZ,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
);
|
||||
expect(g.weekly[0].mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("graded series from day aggregates", () => {
|
||||
it("takes a bucket's mean from the day weights when the rows carry them", () => {
|
||||
const g = buildGradedSeriesFromDayAggregates([
|
||||
{
|
||||
day: "2026-03-04",
|
||||
segment: 1,
|
||||
n: 15,
|
||||
sum: 12 * 150 + 3 * 60,
|
||||
min: 60,
|
||||
max: 150,
|
||||
weightedSum: 82.5,
|
||||
weightSum: 1,
|
||||
},
|
||||
{
|
||||
day: "2026-03-05",
|
||||
segment: 1,
|
||||
n: 1,
|
||||
sum: 60,
|
||||
min: 60,
|
||||
max: 60,
|
||||
weightedSum: 60,
|
||||
weightSum: 1,
|
||||
},
|
||||
]);
|
||||
expect(g.weekly[0].mean).toBe(71.25);
|
||||
expect(g.weekly[0].n).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketSeries", () => {
|
||||
const now = new Date(Date.UTC(2026, 2, 6, 12));
|
||||
const rows = twoDays(Date.UTC(2026, 2, 4));
|
||||
|
||||
it("gives a pulse day its hours' mean and keeps n the readings", () => {
|
||||
const s = bucketSeries(rows, { now, tz: TZ, type: "PULSE" });
|
||||
const workout = s.daily.find((d) => d.dayOffset === 2)!;
|
||||
expect(workout.value).toBe(82.5);
|
||||
expect(workout.n).toBe(15);
|
||||
});
|
||||
|
||||
it("gives a pulse month the mean of its days", () => {
|
||||
const s = bucketSeries(rows, {
|
||||
now: new Date(Date.UTC(2026, 2, 4) + 20 * DAY),
|
||||
tz: TZ,
|
||||
type: "PULSE",
|
||||
dailyDays: 10,
|
||||
});
|
||||
expect(s.monthly).toHaveLength(1);
|
||||
expect(s.monthly[0].value).toBe(71.25);
|
||||
expect(s.monthly[0].n).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the plain mean without a type or for any other type", () => {
|
||||
expect(
|
||||
bucketSeries(rows, { now, tz: TZ }).daily.find((d) => d.dayOffset === 2)!
|
||||
.value,
|
||||
).toBe(132);
|
||||
expect(
|
||||
bucketSeries(rows, { now, tz: TZ, type: "WEIGHT" }).daily.find(
|
||||
(d) => d.dayOffset === 2,
|
||||
)!.value,
|
||||
).toBe(132);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach snapshot timeline", () => {
|
||||
const first = Date.UTC(2026, 2, 4);
|
||||
const rows = twoDays(first);
|
||||
|
||||
it("gives a recent pulse day the mean of its hours", () => {
|
||||
const daily = buildDailyValueRows(rows, new Date(first), TZ, "PULSE");
|
||||
expect(daily.map((d) => d.value)).toEqual([82.5, 60]);
|
||||
expect(
|
||||
buildDailyValueRows(rows, new Date(first), TZ).map((d) => d.value),
|
||||
).toEqual([132, 60]);
|
||||
});
|
||||
|
||||
it("gives a pulse week the mean of its days, count the readings", () => {
|
||||
const [week] = bucketWeekly(rows, TZ, "PULSE");
|
||||
expect(week.mean).toBe(71.3);
|
||||
expect(week.count).toBe(16);
|
||||
expect(bucketWeekly(rows, TZ)[0].mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -347,3 +347,52 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
|
||||
expect(notes[0].text).toContain("Your pulse is stable.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — a pulse day is the mean of its hours", () => {
|
||||
it("gives the latest day and the summary the hours' and days' means", async () => {
|
||||
// Yesterday: a workout hour of twelve readings at 150 and three resting
|
||||
// hours at 60 (day value 82.5). The day before: one reading at 60. The
|
||||
// plain mean of yesterday's fifteen readings would be 132.
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * dayMs;
|
||||
const records: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
records.push({
|
||||
value: 150,
|
||||
measuredAt: new Date(midnight(1) + 10 * 3_600_000 + i * 300_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
records.push({
|
||||
value: 60,
|
||||
measuredAt: new Date(midnight(1) + h * 3_600_000),
|
||||
});
|
||||
}
|
||||
records.push({
|
||||
value: 60,
|
||||
measuredAt: new Date(midnight(2) + 8 * 3_600_000),
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(((args: {
|
||||
where: { type: string };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
args.where.type === "PULSE" ? [...records].reverse() : [],
|
||||
)) as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generatePulseStatusForUser("user-pulse-hours", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
expect(snapshot.pulse.latestDayFocus.value).toBe(82.5);
|
||||
expect(snapshot.pulse.summary.mean).toBe(71.25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeSignalsOfDay } from "../signals-of-day";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* A day of pulse is the mean of its hours' means and a window the mean of its
|
||||
* days (`day-mean.ts`). Two days: a workout hour of twelve readings at 150 and
|
||||
* three resting hours at 60 (day value 82.5), then a day with one reading at
|
||||
* 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings.
|
||||
*/
|
||||
function rows(now: number) {
|
||||
const today = new Date(now);
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) -
|
||||
daysAgo * DAY;
|
||||
const out: Array<{ value: number; measuredAt: Date }> = [];
|
||||
const workout = midnight(3);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
out.push({
|
||||
value: 150,
|
||||
measuredAt: new Date(workout + 10 * 3_600_000 + i * 300_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
out.push({ value: 60, measuredAt: new Date(workout + h * 3_600_000) });
|
||||
}
|
||||
out.push({ value: 60, measuredAt: new Date(midnight(2) + 8 * 3_600_000) });
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("the pulse signal of the day", () => {
|
||||
it("weighs each day once in its 7- and 30-day means", () => {
|
||||
const now = Date.now();
|
||||
const pulse = rows(now);
|
||||
const [signal] = computeSignalsOfDay(
|
||||
(type) => (type === "PULSE" ? pulse : []),
|
||||
now,
|
||||
"UTC",
|
||||
);
|
||||
expect(signal.metric).toBe("pulse");
|
||||
expect(signal.avg7).toBe(71.25);
|
||||
expect(signal.avg30).toBe(71.25);
|
||||
expect(signal.deltaVs7).toBe(-11.25);
|
||||
});
|
||||
|
||||
it("leaves every other metric on the plain mean of its readings", () => {
|
||||
const now = Date.now();
|
||||
const weight = rows(now);
|
||||
const [signal] = computeSignalsOfDay(
|
||||
(type) => (type === "WEIGHT" ? weight : []),
|
||||
now,
|
||||
"UTC",
|
||||
);
|
||||
expect(signal.avg7).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
*/
|
||||
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -61,6 +63,13 @@ export interface BucketOptions {
|
||||
* Berlin; this threads the user TZ the display surfaces already use.
|
||||
*/
|
||||
tz?: string;
|
||||
/**
|
||||
* The measurement type, when known. For a type whose day is the mean of its
|
||||
* hours' means (pulse, `day-mean.ts`) a daily bucket's value is that day
|
||||
* value and a monthly bucket's the mean of its days; `n` stays the number
|
||||
* of readings.
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +153,26 @@ export function bucketSeries(
|
||||
|
||||
const daily = new Map<number, { sum: number; n: number }>();
|
||||
const monthly = new Map<number, { sum: number; n: number }>();
|
||||
// An hourly-mean type keeps each bucket's readings for its mean of days.
|
||||
const hourly = options.type !== undefined && usesHourlyMeanDay(options.type);
|
||||
const dailyRows = new Map<
|
||||
number,
|
||||
Array<{ measuredAt: Date; value: number }>
|
||||
>();
|
||||
const monthlyRows = new Map<
|
||||
number,
|
||||
Array<{ measuredAt: Date; value: number }>
|
||||
>();
|
||||
const keep = (
|
||||
rows: Map<number, Array<{ measuredAt: Date; value: number }>>,
|
||||
key: number,
|
||||
record: { measuredAt: Date; value: number },
|
||||
) => {
|
||||
if (!hourly) return;
|
||||
const list = rows.get(key);
|
||||
if (list) list.push(record);
|
||||
else rows.set(key, [record]);
|
||||
};
|
||||
|
||||
// monthOffset 12 → first 30-day window past the daily horizon. Keeping
|
||||
// the labelling stable regardless of `dailyDays` so prompts stay
|
||||
@@ -160,6 +189,7 @@ export function bucketSeries(
|
||||
bucket.sum += record.value;
|
||||
bucket.n += 1;
|
||||
daily.set(offset, bucket);
|
||||
keep(dailyRows, offset, record);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -170,13 +200,24 @@ export function bucketSeries(
|
||||
bucket.sum += record.value;
|
||||
bucket.n += 1;
|
||||
monthly.set(monthOffset, bucket);
|
||||
keep(monthlyRows, monthOffset, record);
|
||||
}
|
||||
const meanOf = (
|
||||
rows: Map<number, Array<{ measuredAt: Date; value: number }>>,
|
||||
key: number,
|
||||
agg: { sum: number; n: number },
|
||||
) => {
|
||||
const list = rows.get(key);
|
||||
return list
|
||||
? (readingsMean(options.type!, list, tz) ?? agg.sum / agg.n)
|
||||
: agg.sum / agg.n;
|
||||
};
|
||||
|
||||
const dailyOut: DailyBucket[] = Array.from(daily.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([dayOffset, agg]) => ({
|
||||
dayOffset,
|
||||
value: round(agg.sum / agg.n),
|
||||
value: round(meanOf(dailyRows, dayOffset, agg)),
|
||||
n: agg.n,
|
||||
}));
|
||||
|
||||
@@ -184,7 +225,7 @@ export function bucketSeries(
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([monthOffset, agg]) => ({
|
||||
monthOffset,
|
||||
value: round(agg.sum / agg.n),
|
||||
value: round(meanOf(monthlyRows, monthOffset, agg)),
|
||||
n: agg.n,
|
||||
}));
|
||||
|
||||
|
||||
@@ -113,6 +113,12 @@ import {
|
||||
loadUserSourcePriority,
|
||||
type AccumulatorBucketRow,
|
||||
} from "@/lib/rollups/measurement-read";
|
||||
import { windowWeighting } from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
SESSION_DAY_FRAME,
|
||||
dayWeightedRows,
|
||||
windowMeanSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import {
|
||||
buildSourceRankCase,
|
||||
@@ -125,6 +131,26 @@ import {
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
/**
|
||||
* Window means over canonical readings carrying a `day_weight` (see
|
||||
* `day-mean.ts`): the mean of the day values for an hourly-mean type (pulse),
|
||||
* the unchanged `AVG(value)` otherwise. Days are UTC days, the frame the
|
||||
* canonical-source pick buckets by.
|
||||
*/
|
||||
function windowedMean(filter: string): string {
|
||||
return windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
filter,
|
||||
});
|
||||
}
|
||||
const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`);
|
||||
const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`);
|
||||
const AVG30_LAST_MONTH_SQL = windowedMean(
|
||||
`m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Heavy aggregate row — count/min/max/mean alongside the non-composable
|
||||
* windowed columns. Used on the cold-mount fallback path where the
|
||||
@@ -418,6 +444,9 @@ async function buildFromRollups(
|
||||
-- instead of twice per rebuild. Output is identical.
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")}
|
||||
),
|
||||
-- The z-score's mean and spread stay over every reading: they measure
|
||||
-- how far one reading sits from the others, a per-reading dispersion,
|
||||
-- not the level the window means below report.
|
||||
window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -439,18 +468,11 @@ async function buildFromRollups(
|
||||
(ROUND(((m."value" - ws.mean_value) / ws.stddev_value)::numeric, 2))
|
||||
) > 2
|
||||
) AS anomaly_count,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
${AVG7_SQL}::double precision AS avg7,
|
||||
${AVG30_SQL}::double precision AS avg30,
|
||||
-- Prior 30d window (days [30, 60) ago) used by tile delta callout.
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month
|
||||
FROM cm m
|
||||
${AVG30_LAST_MONTH_SQL}::double precision AS avg30_last_month
|
||||
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
|
||||
JOIN window_stats ws ON ws."type" = m."type"
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
@@ -544,7 +566,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);
|
||||
@@ -661,6 +683,9 @@ async function buildFromLiveAggregate(
|
||||
-- so the DISTINCT-ON pick runs once. Output is identical.
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")}
|
||||
),
|
||||
-- The z-score's mean and spread stay over every reading: they measure
|
||||
-- how far one reading sits from the others, a per-reading dispersion,
|
||||
-- not the level the window means below report.
|
||||
window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -674,7 +699,8 @@ async function buildFromLiveAggregate(
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
|
||||
AS mean_value,
|
||||
ws.stddev_value::double precision AS stddev_value,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ws.stddev_value IS NOT NULL
|
||||
@@ -683,16 +709,9 @@ async function buildFromLiveAggregate(
|
||||
(ROUND(((m."value" - ws.mean_value) / ws.stddev_value)::numeric, 2))
|
||||
) > 2
|
||||
) AS anomaly_count,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month,
|
||||
${AVG7_SQL}::double precision AS avg7,
|
||||
${AVG30_SQL}::double precision AS avg30,
|
||||
${AVG30_LAST_MONTH_SQL}::double precision AS avg30_last_month,
|
||||
-- A2-M2 — the regression windows anchor on the UTC-midnight day
|
||||
-- boundary, NOT the wall-clock instant, so this cold-fallback path
|
||||
-- returns the SAME boundary-day membership the warm rollup path
|
||||
@@ -730,7 +749,7 @@ async function buildFromLiveAggregate(
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC'
|
||||
)::double precision AS r2_30
|
||||
FROM cm m
|
||||
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
|
||||
JOIN window_stats ws ON ws."type" = m."type"
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
|
||||
@@ -78,6 +78,9 @@ import {
|
||||
type ComparisonBaseline,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma as PrismaSql } from "@/generated/prisma/client";
|
||||
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
|
||||
@@ -321,6 +324,48 @@ function parseComprehensiveResult(
|
||||
: (parsed as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window average of an hourly-mean type (pulse, see `day-mean.ts`) for
|
||||
* the comparison snapshot: the mean of the user's local days in the window,
|
||||
* each the mean of its local hours' means, so the current-vs-baseline delta
|
||||
* compares the statistic the dashboard tile shows rather than a reading mean
|
||||
* a workout tips.
|
||||
*/
|
||||
export async function hourlyMeanWindowAverage(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
measuredAt: { gt: Date; lte?: Date },
|
||||
): Promise<number | null> {
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const upper =
|
||||
measuredAt.lte === undefined
|
||||
? PrismaSql.empty
|
||||
: PrismaSql.sql`AND m."measured_at" <= ${measuredAt.lte}`;
|
||||
const [row] = await prisma.$queryRaw<Array<{ mean: number | null }>>`
|
||||
WITH src AS (
|
||||
SELECT *
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = ${type}::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" > ${measuredAt.gt}
|
||||
${upper}
|
||||
)
|
||||
SELECT ${PrismaSql.raw(
|
||||
windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
}),
|
||||
)}::double precision AS mean
|
||||
FROM ${dayWeightedRowsSql("src", tz)} m
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
return row?.mean === null || row?.mean === undefined
|
||||
? null
|
||||
: Number(row.mean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison-snapshot builder — shared with the on-demand route. Returns
|
||||
* null when the user's comparison toggle is off (most users), so the
|
||||
@@ -406,11 +451,14 @@ export async function buildComparisonSnapshotForUser(
|
||||
type: MeasurementType,
|
||||
measuredAt: { gt: Date; lte?: Date },
|
||||
): Promise<number | null> => {
|
||||
const agg = await prisma.measurement.aggregate({
|
||||
where: { userId, type, deletedAt: null, measuredAt },
|
||||
_avg: { value: true },
|
||||
});
|
||||
const avg = agg._avg.value;
|
||||
const avg = usesHourlyMeanDay(type)
|
||||
? await hourlyMeanWindowAverage(userId, type, measuredAt)
|
||||
: (
|
||||
await prisma.measurement.aggregate({
|
||||
where: { userId, type, deletedAt: null, measuredAt },
|
||||
_avg: { value: true },
|
||||
})
|
||||
)._avg.value;
|
||||
return avg === null ? null : Math.round(avg * 100) / 100;
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { hourMeanRows } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { decryptSymptomText } from "@/lib/symptoms/server";
|
||||
import {
|
||||
MAX_ACTIVE_SYMPTOM_DEFINITIONS,
|
||||
@@ -689,8 +691,15 @@ export function buildMeasurementDailySeries(
|
||||
if (CUMULATIVE_HK_TYPES.has(type)) {
|
||||
return buildCumulativeDailySeries(type, rows, tz, priorityJson);
|
||||
}
|
||||
// A day of pulse is the mean of its local hours' means (`day-mean.ts`):
|
||||
// collapse each local hour to its mean first, so the daily mean below
|
||||
// weighs every hour once. Every other type passes through unchanged.
|
||||
return toDailyMeans(
|
||||
rows.map((r) => ({ value: r.value, at: r.at })),
|
||||
hourMeanRows(
|
||||
type,
|
||||
rows.map((r) => ({ value: r.value, measuredAt: r.at })),
|
||||
tz,
|
||||
).map((r) => ({ value: r.value, at: r.measuredAt })),
|
||||
tz,
|
||||
);
|
||||
}
|
||||
@@ -808,6 +817,7 @@ function isRollupSwapEligible(type: MeasurementType): boolean {
|
||||
* mean exactly up to float addition order.
|
||||
*/
|
||||
function composeRollupDailyMeans(
|
||||
type: MeasurementType,
|
||||
rows: Array<{
|
||||
bucketStart: Date;
|
||||
count: number;
|
||||
@@ -815,10 +825,14 @@ function composeRollupDailyMeans(
|
||||
sumValue: number | null;
|
||||
}>,
|
||||
): DailySeriesPoint[] {
|
||||
// A pulse DAY row's stored `mean` is the mean of its hours' means
|
||||
// (`day-mean.ts`), which `sumValue / count` would undo; each source's day
|
||||
// then weighs one. Every other type keeps the count-weighted compose.
|
||||
const hourly = usesHourlyMeanDay(type);
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
if (r.count <= 0) continue;
|
||||
const sum = r.sumValue ?? r.mean * r.count;
|
||||
const sum = hourly ? r.mean : (r.sumValue ?? r.mean * r.count);
|
||||
if (!Number.isFinite(sum)) continue;
|
||||
// DAY buckets are minted at UTC midnight; the UTC date IS the bucket's
|
||||
// day key (the same convention graded-series and the derived baselines
|
||||
@@ -826,7 +840,7 @@ function composeRollupDailyMeans(
|
||||
const day = dateOnlyKey(r.bucketStart);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += sum;
|
||||
acc.count += r.count;
|
||||
acc.count += hourly ? 1 : r.count;
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
@@ -920,7 +934,10 @@ export async function fetchMeasurementDailySeriesTiered(
|
||||
rowsByType.set(bucket.type, list);
|
||||
}
|
||||
for (const type of covered) {
|
||||
const series = composeRollupDailyMeans(rowsByType.get(type) ?? []);
|
||||
const series = composeRollupDailyMeans(
|
||||
type,
|
||||
rowsByType.get(type) ?? [],
|
||||
);
|
||||
if (series.length === 0) {
|
||||
// Coverage said "has buckets" but the window resolved empty —
|
||||
// backfill pending or all data older than the window. Fall back
|
||||
|
||||
@@ -282,7 +282,12 @@ export async function readDayMeanSeries(
|
||||
timeZone: timeZone ?? "UTC",
|
||||
valueRange: plausibleMetricRange(type),
|
||||
});
|
||||
const points = days.map((d) => ({ day: d.day, mean: d.sum / d.n }));
|
||||
// A pulse day is the mean of its local hours' means (`dayMean`, see
|
||||
// `day-mean.ts`); every other type's day is the mean of its readings.
|
||||
const points = days.map((d) => ({
|
||||
day: d.day,
|
||||
mean: d.dayMean ?? d.sum / d.n,
|
||||
}));
|
||||
if (points.length === 0) {
|
||||
return { points: [], source: "none" };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import {
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
isHourlyMeanTypeSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
@@ -64,10 +69,41 @@ export async function readAllTimeExtremes(
|
||||
const typeList = Prisma.join(
|
||||
types.map((t) => Prisma.sql`${t}::measurement_type`),
|
||||
);
|
||||
// A day of pulse is the mean of its hours' means and the full history the
|
||||
// mean of its days (`day-mean.ts`). `weighted` carries, per source and day,
|
||||
// the day-weighted sum and the day's weight (one) for those types only;
|
||||
// every other type keeps the reading-weighted mean below, unchanged.
|
||||
const mean = Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
}),
|
||||
);
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; mean: number; min: number; max: number }>
|
||||
>`
|
||||
WITH per_source AS (
|
||||
WITH hourly_src AS (
|
||||
SELECT m.*
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList})
|
||||
AND ${Prisma.raw(isHourlyMeanTypeSql('m."type"'))}
|
||||
),
|
||||
weighted AS (
|
||||
SELECT
|
||||
w."type",
|
||||
w."source",
|
||||
date_trunc('day', w."measured_at") AS day,
|
||||
SUM(w."value" * w.day_weight)::double precision AS wsum,
|
||||
SUM(w.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("hourly_src", null, { bySource: true })} w
|
||||
GROUP BY w."type", w."source", 3
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
@@ -84,13 +120,15 @@ export async function readAllTimeExtremes(
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
|
||||
FROM per_source p
|
||||
LEFT JOIN weighted w
|
||||
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
|
||||
ORDER BY p."type", p.day, (${rank}), p."source"::text
|
||||
)
|
||||
SELECT
|
||||
c."type"::text AS type,
|
||||
(SUM(c.total) / SUM(c.cnt))::double precision AS mean,
|
||||
${mean}::double precision AS mean,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max
|
||||
FROM canon c
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||||
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
|
||||
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
|
||||
@@ -914,12 +916,19 @@ function computeCoverage(
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute average of values within a time window (days ago from now). */
|
||||
/**
|
||||
* Compute average of values within a time window (days ago from now).
|
||||
*
|
||||
* `day` names the type and the zone its days are read in. For a type whose
|
||||
* day is the mean of its hours' means (pulse, see `day-mean.ts`) the window
|
||||
* is then the mean of its day values; every other type keeps the plain mean.
|
||||
*/
|
||||
export function avgInWindow(
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
fromDaysAgo: number,
|
||||
toDaysAgo: number = 0,
|
||||
day?: { type: string; tz: string },
|
||||
): number | null {
|
||||
const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000;
|
||||
const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000;
|
||||
@@ -928,6 +937,10 @@ export function avgInWindow(
|
||||
return t >= fromMs && t <= toMs;
|
||||
});
|
||||
if (filtered.length === 0) return null;
|
||||
if (day && usesHourlyMeanDay(day.type)) {
|
||||
const mean = readingsMean(day.type, filtered, day.tz);
|
||||
return mean === null ? null : Math.round(mean * 100) / 100;
|
||||
}
|
||||
const sum = filtered.reduce((s, r) => s + r.value, 0);
|
||||
return Math.round((sum / filtered.length) * 100) / 100;
|
||||
}
|
||||
@@ -1033,6 +1046,10 @@ export async function extractFeatures(
|
||||
: null;
|
||||
|
||||
const byType = (type: string) => measurements.filter((m) => m.type === type);
|
||||
// The zone pulse days are read in; resolved only when there is pulse to read.
|
||||
const pulseTz = measurements.some((m) => m.type === "PULSE")
|
||||
? await resolveUserTimezone(userId)
|
||||
: DEFAULT_TIMEZONE;
|
||||
|
||||
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
|
||||
|
||||
@@ -1231,16 +1248,23 @@ export async function extractFeatures(
|
||||
const pulseData = byType("PULSE");
|
||||
if (pulseData.length > 0) {
|
||||
const summary = summarize(toDataPoints(pulseData));
|
||||
// A day of pulse is the mean of its local hours' means and a window the
|
||||
// mean of its days (see `day-mean.ts`): `summarize` means every reading,
|
||||
// so the means come from the shared helper over the same windows.
|
||||
const pulseDays = { type: "PULSE", tz: pulseTz };
|
||||
const pulseMean = (fromDaysAgo: number) =>
|
||||
avgInWindow(pulseData, now, fromDaysAgo, 0, pulseDays);
|
||||
const allPulse = readingsMean("PULSE", pulseData, pulseTz);
|
||||
features.pulse = {
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
avg90: avgInWindow(pulseData, now, 90),
|
||||
avg7: pulseMean(7),
|
||||
avg30: pulseMean(30),
|
||||
avg90: pulseMean(90),
|
||||
// v1.18.11 P1 — full-history extremes when the bulk read is windowed.
|
||||
allTimeAvg: allTimeExtremes
|
||||
? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null)
|
||||
: summary.count > 0
|
||||
? summary.mean
|
||||
: null,
|
||||
: allPulse === null
|
||||
? null
|
||||
: Math.round(allPulse * 100) / 100,
|
||||
allTimeMin: allTimeExtremes
|
||||
? (allTimeExtremes.get("PULSE")?.min ?? null)
|
||||
: summary.count > 0
|
||||
@@ -1650,6 +1674,7 @@ export async function extractFeatures(
|
||||
features.historicalComparison.pulse = computeHistoricalComparison(
|
||||
pulseData,
|
||||
now,
|
||||
{ type: "PULSE", tz: pulseTz },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1768,7 +1793,7 @@ export async function extractFeatures(
|
||||
// in-memory measurement set (no extra DB round-trip). Lands inside the
|
||||
// compacted features payload, so it feeds both the briefing prompt AND the
|
||||
// content-hash gate — a fresh daily signal forces the briefing to refresh.
|
||||
const signalsOfDay = computeSignalsOfDay(byType, now);
|
||||
const signalsOfDay = computeSignalsOfDay(byType, now, pulseTz);
|
||||
if (signalsOfDay.length > 0) {
|
||||
features.signalsOfDay = signalsOfDay;
|
||||
}
|
||||
|
||||
@@ -216,9 +216,11 @@ export async function prepareGeneralStatusForUser(
|
||||
}));
|
||||
if (records.length === 0) return [];
|
||||
|
||||
const series = applyPayloadBudget(records, { now, tz: userTz });
|
||||
// `type` makes a pulse day the mean of its hours' means and a pulse
|
||||
// window the mean of its days (`day-mean.ts`); other types unchanged.
|
||||
const series = applyPayloadBudget(records, { now, tz: userTz, type });
|
||||
dailyByType.set(type, series);
|
||||
const graded = buildGradedSeriesFromPoints(records, now, userTz);
|
||||
const graded = buildGradedSeriesFromPoints(records, now, userTz, type);
|
||||
|
||||
return [
|
||||
[
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
readDayAggregates,
|
||||
type DayAggregateRow,
|
||||
} from "@/lib/measurements/day-aggregates";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -169,6 +171,20 @@ interface Agg {
|
||||
min: number;
|
||||
max: number;
|
||||
n: number;
|
||||
/**
|
||||
* Day-weighted sums for a type whose day is the mean of its hours' means
|
||||
* (`DayAggregateRow.weightedSum` / `weightSum`); the bucket mean is then
|
||||
* their ratio, the mean of its days. Absent for every other type.
|
||||
*/
|
||||
ws?: number;
|
||||
wd?: number;
|
||||
}
|
||||
|
||||
/** A bucket's mean: of its days for an hourly-mean type, of its readings otherwise. */
|
||||
function aggMean(a: Agg): number {
|
||||
return a.wd !== undefined && a.wd > 0 && a.ws !== undefined
|
||||
? a.ws / a.wd
|
||||
: a.sum / a.n;
|
||||
}
|
||||
|
||||
function foldInto(map: Map<string, Agg>, key: string, value: number): void {
|
||||
@@ -214,8 +230,24 @@ export function buildGradedSeriesFromPoints(
|
||||
points: Point[],
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
/**
|
||||
* The measurement type, when known. For a type whose day is the mean of its
|
||||
* hours' means (pulse, `day-mean.ts`) every bucket's mean is the mean of its
|
||||
* days; min, max and n stay over the readings.
|
||||
*/
|
||||
type?: string,
|
||||
): GradedSeries {
|
||||
const nowMs = now.getTime();
|
||||
const hourly = type !== undefined && usesHourlyMeanDay(type);
|
||||
const members = new Map<Agg, Point[]>();
|
||||
const foldPoint = (map: Map<string, Agg>, key: string, p: Point) => {
|
||||
foldInto(map, key, p.value);
|
||||
if (!hourly) return;
|
||||
const agg = map.get(key)!;
|
||||
const list = members.get(agg);
|
||||
if (list) list.push(p);
|
||||
else members.set(agg, [p]);
|
||||
};
|
||||
|
||||
const recentAgg = new Map<string, Agg>();
|
||||
const weeklyAgg = new Map<string, Agg>();
|
||||
@@ -231,26 +263,31 @@ export function buildGradedSeriesFromPoints(
|
||||
// Every bucket is a period of the user's own calendar.
|
||||
const { year, month, day } = localYmd(p.measuredAt, timeZone);
|
||||
if (ageMs < RECENT_WINDOW_MS) {
|
||||
foldInto(recentAgg, `${year}-${month}-${day}`, p.value);
|
||||
foldPoint(recentAgg, `${year}-${month}-${day}`, p);
|
||||
} else if (ageMs < WEEKLY_WINDOW_MS) {
|
||||
foldInto(weeklyAgg, isoWeekKeyOfYmd(year, month, day), p.value);
|
||||
foldPoint(weeklyAgg, isoWeekKeyOfYmd(year, month, day), p);
|
||||
} else if (ageMs < MONTHLY_WINDOW_MS) {
|
||||
foldInto(monthlyAgg, `${year}-${month}`, p.value);
|
||||
foldPoint(monthlyAgg, `${year}-${month}`, p);
|
||||
} else {
|
||||
const yk = year;
|
||||
foldInto(yearlyAgg, yk, p.value);
|
||||
foldPoint(yearlyAgg, yk, p);
|
||||
const list = yearlyValues.get(yk);
|
||||
if (list) list.push(p.value);
|
||||
else yearlyValues.set(yk, [p.value]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [agg, list] of members) {
|
||||
agg.ws = readingsMean(type!, list, timeZone) ?? agg.sum / agg.n;
|
||||
agg.wd = 1;
|
||||
}
|
||||
|
||||
const recent: RecentDayBucket[] = Array.from(recentAgg.entries())
|
||||
.map(([date, a]) => ({
|
||||
date,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
@@ -260,7 +297,7 @@ export function buildGradedSeriesFromPoints(
|
||||
weekISO,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.weekISO.localeCompare(y.weekISO));
|
||||
@@ -270,7 +307,7 @@ export function buildGradedSeriesFromPoints(
|
||||
month,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.month.localeCompare(y.month));
|
||||
@@ -280,7 +317,7 @@ export function buildGradedSeriesFromPoints(
|
||||
year,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
// Order the readings oldest → newest for the slope. The map kept
|
||||
// insertion order; `points` arrives caller-ordered, so re-sort by
|
||||
@@ -304,8 +341,20 @@ function mergeInto(
|
||||
existing.n += row.n;
|
||||
if (row.min < existing.min) existing.min = row.min;
|
||||
if (row.max > existing.max) existing.max = row.max;
|
||||
if (row.weightedSum !== undefined && row.weightSum !== undefined) {
|
||||
existing.ws = (existing.ws ?? 0) + row.weightedSum;
|
||||
existing.wd = (existing.wd ?? 0) + row.weightSum;
|
||||
}
|
||||
} else {
|
||||
map.set(key, { sum: row.sum, min: row.min, max: row.max, n: row.n });
|
||||
map.set(key, {
|
||||
sum: row.sum,
|
||||
min: row.min,
|
||||
max: row.max,
|
||||
n: row.n,
|
||||
...(row.weightedSum !== undefined && row.weightSum !== undefined
|
||||
? { ws: row.weightedSum, wd: row.weightSum }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +408,9 @@ async function readGradedDayAggregates(
|
||||
* The graded series folded from per-day aggregates (segment = age class).
|
||||
*
|
||||
* recent / weekly / monthly and every bucket's min / max / mean / n match
|
||||
* the raw-row fold exactly. The yearly slope is taken over the day means in
|
||||
* the raw-row fold exactly, except that a type whose day is the mean of its
|
||||
* hours' means (pulse, `day-mean.ts`) takes each bucket's mean over its days
|
||||
* (the rows' `weightedSum` / `weightSum`), each day the mean of its hours. The yearly slope is taken over the day means in
|
||||
* date order rather than over individual readings: a direction signal either
|
||||
* way, and the only form that stays bounded on a dense stream.
|
||||
*/
|
||||
@@ -386,7 +437,10 @@ export function buildGradedSeriesFromDayAggregates(
|
||||
} else {
|
||||
mergeInto(yearlyAgg, year, row);
|
||||
const list = yearlyValues.get(year);
|
||||
const dayMean = row.sum / row.n;
|
||||
const dayMean =
|
||||
row.weightedSum !== undefined && row.weightSum
|
||||
? row.weightedSum / row.weightSum
|
||||
: row.sum / row.n;
|
||||
if (list) list.push(dayMean);
|
||||
else yearlyValues.set(year, [dayMean]);
|
||||
}
|
||||
@@ -398,7 +452,7 @@ export function buildGradedSeriesFromDayAggregates(
|
||||
key,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.key.localeCompare(y.key));
|
||||
|
||||
@@ -93,6 +93,30 @@ function stripFactorCrosstab(
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cross metric's daily buckets for the mood correlations. The type is
|
||||
* passed through, so a pulse day is the mean of its hours' means (see
|
||||
* `day-mean.ts`) and a workout hour does not decide the day the mood is
|
||||
* paired with; every other type keeps its plain daily mean.
|
||||
*/
|
||||
export function crossMetricDailySeries(
|
||||
measurements: ReadonlyArray<{
|
||||
type: string;
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
}>,
|
||||
type: string,
|
||||
now: Date,
|
||||
tz: string,
|
||||
): ReturnType<typeof applyPayloadBudget> {
|
||||
return applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === type)
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz, type },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry — unchanged signature. Prepares the card (cache-read,
|
||||
* read-only miss, snapshot build, hash gate) then, when the LLM is still
|
||||
@@ -389,25 +413,23 @@ export async function prepareMoodStatusForUser(
|
||||
annotate({ meta: { cross_metric_truncated: true } });
|
||||
}
|
||||
|
||||
const weightSeries = applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz: userTz },
|
||||
const weightSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"WEIGHT",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const sysSeries = applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz: userTz },
|
||||
const sysSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const pulseSeries = applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === "PULSE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz: userTz },
|
||||
const pulseSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"PULSE",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// Correlations between mood and other metrics — pair on dayOffset so
|
||||
|
||||
@@ -229,7 +229,12 @@ export async function preparePulseStatusForUser(
|
||||
// `applyPayloadBudget` daily buckets still drive the derived stats
|
||||
// below (latest, in-target %, delta). They are NOT embedded in the
|
||||
// prompt — the compact graded series replaces the full daily array.
|
||||
const pulseSeries = applyPayloadBudget(pulsePoints, { now, tz: userTz });
|
||||
// A pulse day is the mean of its hours' means (`day-mean.ts`).
|
||||
const pulseSeries = applyPayloadBudget(pulsePoints, {
|
||||
now,
|
||||
tz: userTz,
|
||||
type: "PULSE",
|
||||
});
|
||||
// Primary metric: recent / weekly fold from the bounded raw read, the
|
||||
// monthly / yearly tail comes from the MONTH / YEAR rollup tier (with
|
||||
// a full-history in-memory fallback on a cold-tier coverage miss).
|
||||
|
||||
@@ -54,13 +54,14 @@ export interface SignalOfDay {
|
||||
export function computeHistoricalComparison(
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
day?: { type: string; tz: string },
|
||||
): {
|
||||
current7dAvg: number | null;
|
||||
previous30dAvg: number | null;
|
||||
change: number | null;
|
||||
} {
|
||||
const current7dAvg = avgInWindow(records, now, 7, 0);
|
||||
const previous30dAvg = avgInWindow(records, now, 37, 7);
|
||||
const current7dAvg = avgInWindow(records, now, 7, 0, day);
|
||||
const previous30dAvg = avgInWindow(records, now, 37, 7, day);
|
||||
const change =
|
||||
current7dAvg !== null && previous30dAvg !== null
|
||||
? Math.round((current7dAvg - previous30dAvg) * 100) / 100
|
||||
@@ -98,6 +99,7 @@ function buildSignal(
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
unit?: string,
|
||||
day?: { type: string; tz: string },
|
||||
): SignalOfDay | null {
|
||||
if (records.length === 0) return null;
|
||||
const newest = records[records.length - 1];
|
||||
@@ -112,8 +114,10 @@ function buildSignal(
|
||||
);
|
||||
if (win30.length < 3) return null;
|
||||
|
||||
const avg7 = avgInWindow(records, now, 7);
|
||||
const avg30 = avgInWindow(records, now, 30);
|
||||
// For pulse each window is the mean of its day values (`day-mean.ts`); the
|
||||
// spread and the anomaly stay over the readings.
|
||||
const avg7 = avgInWindow(records, now, 7, 0, day);
|
||||
const avg30 = avgInWindow(records, now, 30, 0, day);
|
||||
const spread30 = stdDev(win30.map((rec) => rec.value));
|
||||
const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
|
||||
const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
|
||||
@@ -186,6 +190,8 @@ function buildSignal(
|
||||
export function computeSignalsOfDay(
|
||||
byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
/** The zone pulse days are read in (`day-mean.ts`). */
|
||||
pulseTz: string = "UTC",
|
||||
): SignalOfDay[] {
|
||||
const candidates: SignalOfDay[] = [];
|
||||
const push = (s: SignalOfDay | null) => {
|
||||
@@ -213,7 +219,12 @@ export function computeSignalsOfDay(
|
||||
"bpm",
|
||||
),
|
||||
);
|
||||
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
|
||||
push(
|
||||
buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm", {
|
||||
type: "PULSE",
|
||||
tz: pulseTz,
|
||||
}),
|
||||
);
|
||||
// Weight and glucose are canonical here and carry no unit: the reader's
|
||||
// unit is attached where the signal is converted for the prompt
|
||||
// (`featuresInReaderUnits`), so no canonical symbol can leak into one.
|
||||
|
||||
@@ -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,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
dayValue,
|
||||
dayValueRows,
|
||||
dayWeightedRows,
|
||||
foldMeanSql,
|
||||
hourMeanRows,
|
||||
isHourlyMeanTypeSql,
|
||||
readingsMean,
|
||||
windowMeanSql,
|
||||
zoneDayFrame,
|
||||
SESSION_DAY_FRAME,
|
||||
} from "../day-mean";
|
||||
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
/** One hard hour of 12 readings at 150 bpm, then three resting hours of one reading at 60. */
|
||||
function workoutDay(day: string) {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: 150,
|
||||
measuredAt: at(`${day}T10:${String(i * 5).padStart(2, "0")}:00Z`),
|
||||
}));
|
||||
for (const h of ["12", "14", "16"]) {
|
||||
rows.push({ value: 60, measuredAt: at(`${day}T${h}:00:00Z`) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe("dayValue", () => {
|
||||
it("is the mean of the hours' means for pulse", () => {
|
||||
expect(dayValue("PULSE", workoutDay("2026-03-02"), "UTC")).toBeCloseTo(
|
||||
(150 + 60 * 3) / 4,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it("is the plain mean for every other type", () => {
|
||||
const rows = workoutDay("2026-03-02");
|
||||
expect(dayValue("HEART_RATE_VARIABILITY", rows, "UTC")).toBe(
|
||||
rows.reduce((s, r) => s + r.value, 0) / rows.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("is null without readings", () => {
|
||||
expect(dayValue("PULSE", [], "UTC")).toBeNull();
|
||||
});
|
||||
|
||||
it("buckets hours on the zone's own half-hour boundaries", () => {
|
||||
// Asia/Kolkata is UTC+05:30: 10:20Z and 10:40Z are 15:50 and 16:10 local,
|
||||
// two different local hours, though one UTC hour.
|
||||
const rows = [
|
||||
{ value: 100, measuredAt: at("2026-03-02T10:20:00Z") },
|
||||
{ value: 100, measuredAt: at("2026-03-02T10:25:00Z") },
|
||||
{ value: 40, measuredAt: at("2026-03-02T10:40:00Z") },
|
||||
];
|
||||
expect(dayValue("PULSE", rows, "Asia/Kolkata")).toBe(70);
|
||||
expect(dayValue("PULSE", rows, "UTC")).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readingsMean", () => {
|
||||
it("weighs each local day once for pulse", () => {
|
||||
const rows = [
|
||||
...workoutDay("2026-03-02"),
|
||||
{ value: 60, measuredAt: at("2026-03-03T08:00:00Z") },
|
||||
];
|
||||
expect(readingsMean("PULSE", rows, "UTC")).toBeCloseTo((82.5 + 60) / 2, 10);
|
||||
});
|
||||
|
||||
it("splits days at the zone's midnight", () => {
|
||||
// 22:30Z is already the next day in Berlin (UTC+1 in March).
|
||||
const rows = [
|
||||
{ value: 100, measuredAt: at("2026-03-02T10:00:00Z") },
|
||||
{ value: 100, measuredAt: at("2026-03-02T11:00:00Z") },
|
||||
{ value: 40, measuredAt: at("2026-03-02T23:30:00Z") },
|
||||
];
|
||||
expect(readingsMean("PULSE", rows, "UTC")).toBe(80);
|
||||
expect(readingsMean("PULSE", rows, "Europe/Berlin")).toBe(70);
|
||||
});
|
||||
|
||||
it("keeps the plain left-to-right mean for every other type", () => {
|
||||
const rows = [0.1, 0.2, 0.3].map((value, i) => ({
|
||||
value,
|
||||
measuredAt: at(`2026-03-0${i + 1}T08:00:00Z`),
|
||||
}));
|
||||
expect(readingsMean("WEIGHT", rows, "UTC")).toBe((0 + 0.1 + 0.2 + 0.3) / 3);
|
||||
});
|
||||
|
||||
it("is null without readings", () => {
|
||||
expect(readingsMean("PULSE", [], "UTC")).toBeNull();
|
||||
expect(readingsMean("WEIGHT", [], "UTC")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hourMeanRows / dayValueRows", () => {
|
||||
it("collapse pulse to one row per hour / per day", () => {
|
||||
const rows = workoutDay("2026-03-02");
|
||||
const hours = hourMeanRows("PULSE", rows, "UTC");
|
||||
expect(hours.map((h) => h.value)).toEqual([150, 60, 60, 60]);
|
||||
expect(hours[0].measuredAt.toISOString()).toBe("2026-03-02T10:00:00.000Z");
|
||||
const days = dayValueRows("PULSE", rows, "UTC");
|
||||
expect(days).toHaveLength(1);
|
||||
expect(days[0].value).toBeCloseTo(82.5, 10);
|
||||
});
|
||||
|
||||
it("hand every other type back untouched", () => {
|
||||
const rows = workoutDay("2026-03-02");
|
||||
expect(hourMeanRows("BLOOD_GLUCOSE", rows, "UTC")).toBe(rows);
|
||||
expect(dayValueRows("BLOOD_GLUCOSE", rows, "UTC")).toBe(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL builders", () => {
|
||||
it("splice only the closed type list", () => {
|
||||
expect(isHourlyMeanTypeSql('m."type"')).toBe(
|
||||
`(m."type")::text IN ('PULSE')`,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuse a non-identifier source and an inline zone", () => {
|
||||
expect(() => dayWeightedRows("cm; DROP", SESSION_DAY_FRAME)).toThrow();
|
||||
expect(() => zoneDayFrame("'Europe/Berlin'")).toThrow();
|
||||
expect(zoneDayFrame("$3")).toEqual({ kind: "zone", tzSql: "$3" });
|
||||
});
|
||||
|
||||
it("keep AVG for every other type and weigh pulse", () => {
|
||||
const sql = windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
filter: "m.x",
|
||||
});
|
||||
expect(sql).toContain('ELSE AVG(m."value") FILTER (WHERE m.x) END');
|
||||
expect(sql).toContain('SUM(m."value" * m.day_weight) FILTER (WHERE m.x)');
|
||||
const fold = foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
});
|
||||
expect(fold).toContain("ELSE SUM(c.total) / SUM(c.cnt) END");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
SourceDayAggregateRow,
|
||||
} from "@/lib/measurements/day-aggregates";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dayValue } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
|
||||
export function foldDayAggregates(
|
||||
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
@@ -74,7 +76,39 @@ export async function fakeReadDayAggregates(
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
})) ?? []) as Array<{ measuredAt: Date; value: number }>;
|
||||
return foldDayAggregates(rows, opts);
|
||||
const days = foldDayAggregates(rows, opts);
|
||||
if (!usesHourlyMeanDay(opts.type)) return days;
|
||||
// Mirror the SQL's hourly-mean day: the mean of the (day, segment)'s local
|
||||
// hours' means.
|
||||
const starts = opts.segmentStarts ?? [];
|
||||
// Group once by (day, segment) with the fold's own filters, so a long
|
||||
// series stays linear instead of re-filtering every row per day.
|
||||
const membersByKey = new Map<
|
||||
string,
|
||||
Array<{ measuredAt: Date; value: number }>
|
||||
>();
|
||||
for (const r of rows) {
|
||||
const t = r.measuredAt.getTime();
|
||||
if (t < opts.since.getTime()) continue;
|
||||
if (opts.until && t > opts.until.getTime()) continue;
|
||||
if (
|
||||
opts.valueRange &&
|
||||
(r.value < opts.valueRange.min || r.value > opts.valueRange.max)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = `${userDayKey(r.measuredAt, opts.timeZone)}|${
|
||||
starts.filter((st) => t < st.getTime()).length
|
||||
}`;
|
||||
const list = membersByKey.get(key);
|
||||
if (list) list.push(r);
|
||||
else membersByKey.set(key, [r]);
|
||||
}
|
||||
return days.map((d) => {
|
||||
const members = membersByKey.get(`${d.day}|${d.segment}`) ?? [];
|
||||
const dayMean = dayValue(opts.type, members, opts.timeZone);
|
||||
return dayMean === null ? d : { ...d, dayMean };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,8 @@ import type {
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { dayWeightedRowsSql } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
|
||||
export interface DayAggregateRow {
|
||||
/** Calendar day in the requested zone, `YYYY-MM-DD`. */
|
||||
@@ -37,6 +39,21 @@ export interface DayAggregateRow {
|
||||
sum: number;
|
||||
min: number;
|
||||
max: number;
|
||||
/**
|
||||
* For a type whose day is the mean of its hours' means (pulse, see
|
||||
* `day-mean.ts`): `SUM(value * day_weight)` and `SUM(day_weight)` over the
|
||||
* row's readings. A whole day's weights add up to one, so `weightedSum /
|
||||
* weightSum` is the day value and the same ratio over several rows the mean
|
||||
* of their days. Absent for every other type.
|
||||
*/
|
||||
weightedSum?: number;
|
||||
weightSum?: number;
|
||||
/**
|
||||
* The row's value for an hourly-mean type, `weightedSum / weightSum`: the
|
||||
* mean of its local hours' means. Absent for every other type, whose value
|
||||
* stays `sum / n`. Read it as `dayMean ?? sum / n`.
|
||||
*/
|
||||
dayMean?: number;
|
||||
}
|
||||
|
||||
export interface ReadDayAggregatesOptions {
|
||||
@@ -78,6 +95,10 @@ export async function readDayAggregates(
|
||||
" + ",
|
||||
)})`;
|
||||
|
||||
if (usesHourlyMeanDay(opts.type)) {
|
||||
return readHourlyMeanDayAggregates(opts, timeZone, until, range, segment);
|
||||
}
|
||||
|
||||
// The day and segment are computed once per row in `src` and grouped by
|
||||
// name, so the bound zone / boundary parameters never have to match
|
||||
// themselves across SELECT and GROUP BY.
|
||||
@@ -125,6 +146,74 @@ export async function readDayAggregates(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `readDayAggregates` for an hourly-mean type: the same rows, plus the day
|
||||
* weights of `day-mean.ts` cut in the same zone, so a day's hours count once
|
||||
* each however densely they were sampled.
|
||||
*/
|
||||
async function readHourlyMeanDayAggregates(
|
||||
opts: ReadDayAggregatesOptions,
|
||||
timeZone: string,
|
||||
until: Prisma.Sql,
|
||||
range: Prisma.Sql,
|
||||
segment: Prisma.Sql,
|
||||
): Promise<DayAggregateRow[]> {
|
||||
const rows = await (opts.db ?? prisma).$queryRaw<
|
||||
Array<{
|
||||
day: string;
|
||||
segment: number;
|
||||
n: number;
|
||||
sum: number;
|
||||
min: number;
|
||||
max: number;
|
||||
wsum: number;
|
||||
wdays: number;
|
||||
}>
|
||||
>`
|
||||
WITH base AS (
|
||||
SELECT m.*
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${opts.userId}
|
||||
AND m."type" = ${opts.type}::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${opts.since}
|
||||
${until}
|
||||
${range}
|
||||
),
|
||||
src AS (
|
||||
SELECT
|
||||
to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day,
|
||||
${segment} AS segment,
|
||||
m."value" AS value,
|
||||
m.day_weight AS day_weight
|
||||
FROM ${dayWeightedRowsSql("base", timeZone)} m
|
||||
)
|
||||
SELECT
|
||||
day,
|
||||
segment::int AS segment,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(value)::double precision AS sum,
|
||||
MIN(value)::double precision AS min,
|
||||
MAX(value)::double precision AS max,
|
||||
SUM(value * day_weight)::double precision AS wsum,
|
||||
SUM(day_weight)::double precision AS wdays
|
||||
FROM src
|
||||
GROUP BY day, segment
|
||||
ORDER BY day ASC, segment DESC
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
day: r.day,
|
||||
segment: Number(r.segment),
|
||||
n: Number(r.n),
|
||||
sum: Number(r.sum),
|
||||
min: Number(r.min),
|
||||
max: Number(r.max),
|
||||
weightedSum: Number(r.wsum),
|
||||
weightSum: Number(r.wdays),
|
||||
dayMean: Number(r.wsum) / Number(r.wdays),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SourceDayAggregateRow {
|
||||
type: MeasurementType;
|
||||
/** Calendar day in the requested zone, `YYYY-MM-DD`. */
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* The one place that turns readings into a day value and a window mean for the
|
||||
* types in `HOURLY_MEAN_DAY_TYPES` (see `day-statistic.ts`).
|
||||
*
|
||||
* For such a type a day is the mean of its hours' means, each hour weighing
|
||||
* one, and a window of days is the mean of its day values, each day weighing
|
||||
* one. Count, min and max stay over every reading. Every other type keeps the
|
||||
* plain mean over its readings, computed exactly as before.
|
||||
*
|
||||
* "Day" and "hour" are the reader's own frame: a reader that buckets by the
|
||||
* user's local day passes that zone, a reader that buckets by the session's
|
||||
* (UTC) day passes `SESSION_DAY_FRAME`. The hours are always the hours of
|
||||
* that same frame, so a day never splits an hour.
|
||||
*
|
||||
* Two shapes are offered, one per kind of reader:
|
||||
*
|
||||
* - TypeScript, for readers that hold the readings in memory:
|
||||
* `readingsMean`, `dayValue` and `dayValueRows`.
|
||||
* - SQL, for readers that aggregate in Postgres: `dayWeightedRows` attaches
|
||||
* a `day_weight` to every reading (1 / (readings in its hour × hours in its
|
||||
* day) for an hourly-mean type, 1 otherwise), so a day's weights add up to
|
||||
* one and `SUM(value * day_weight) / SUM(day_weight)` is the mean of the
|
||||
* day values over any window. `windowMeanSql` and `foldMeanSql` build the
|
||||
* mean expressions, falling back to the unchanged plain mean for every
|
||||
* other type.
|
||||
*
|
||||
* Type lists are closed code constants checked against an enum-shaped regex
|
||||
* before they are spliced; every value (user id, zone, bounds) stays a bound
|
||||
* parameter of the caller's query.
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { tzOffsetMinutes } from "@/lib/tz/format";
|
||||
|
||||
import { HOURLY_MEAN_DAY_TYPES, usesHourlyMeanDay } from "./day-statistic";
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
const MS_PER_HOUR = 3_600_000;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
/** Zone offsets change on quarter hours at the finest, so a quarter-hour slot shares one offset. */
|
||||
const OFFSET_SLOT_MS = 15 * MS_PER_MINUTE;
|
||||
|
||||
export interface DayReading {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
// ─── TypeScript ───────────────────────────────────────────
|
||||
|
||||
/** Local wall-clock milliseconds of an instant, with a per-call offset cache. */
|
||||
function makeLocalMs(tz: string): (at: Date) => number {
|
||||
const offsets = new Map<number, number>();
|
||||
return (at: Date) => {
|
||||
const t = at.getTime();
|
||||
const slot = Math.floor(t / OFFSET_SLOT_MS);
|
||||
let offset = offsets.get(slot);
|
||||
if (offset === undefined) {
|
||||
offset = tzOffsetMinutes(at, tz);
|
||||
offsets.set(slot, offset);
|
||||
}
|
||||
return t + offset * MS_PER_MINUTE;
|
||||
};
|
||||
}
|
||||
|
||||
function plainMean(rows: readonly DayReading[]): number | null {
|
||||
if (rows.length === 0) return null;
|
||||
return rows.reduce((s, r) => s + r.value, 0) / rows.length;
|
||||
}
|
||||
|
||||
/** Mean of the per-hour means of readings that share one day. */
|
||||
function meanOfHourMeans(
|
||||
rows: readonly DayReading[],
|
||||
localMs: (at: Date) => number,
|
||||
): number {
|
||||
const hours = new Map<number, { sum: number; n: number }>();
|
||||
for (const r of rows) {
|
||||
const hour = Math.floor(localMs(r.measuredAt) / MS_PER_HOUR);
|
||||
const acc = hours.get(hour);
|
||||
if (acc) {
|
||||
acc.sum += r.value;
|
||||
acc.n += 1;
|
||||
} else {
|
||||
hours.set(hour, { sum: r.value, n: 1 });
|
||||
}
|
||||
}
|
||||
let sum = 0;
|
||||
for (const h of hours.values()) sum += h.sum / h.n;
|
||||
return sum / hours.size;
|
||||
}
|
||||
|
||||
/** Group readings by local day (days since the epoch in `tz`). */
|
||||
function groupByLocalDay<T extends DayReading>(
|
||||
rows: readonly T[],
|
||||
localMs: (at: Date) => number,
|
||||
): Map<number, T[]> {
|
||||
const days = new Map<number, T[]>();
|
||||
for (const r of rows) {
|
||||
const day = Math.floor(localMs(r.measuredAt) / MS_PER_DAY);
|
||||
const list = days.get(day);
|
||||
if (list) list.push(r);
|
||||
else days.set(day, [r]);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value of ONE day's readings: the mean of its local hours' means for an
|
||||
* hourly-mean type, the plain mean otherwise. `null` for no readings.
|
||||
*/
|
||||
export function dayValue(
|
||||
type: string,
|
||||
rows: readonly DayReading[],
|
||||
tz: string,
|
||||
): number | null {
|
||||
if (rows.length === 0) return null;
|
||||
if (!usesHourlyMeanDay(type)) return plainMean(rows);
|
||||
return meanOfHourMeans(rows, makeLocalMs(tz));
|
||||
}
|
||||
|
||||
/**
|
||||
* The mean of a window of readings: for an hourly-mean type the mean of its
|
||||
* local days' values (each day the mean of its hours' means), otherwise the
|
||||
* plain mean over every reading, summed left to right as the readers always
|
||||
* did. `null` for no readings.
|
||||
*/
|
||||
export function readingsMean(
|
||||
type: string,
|
||||
rows: readonly DayReading[],
|
||||
tz: string,
|
||||
): number | null {
|
||||
if (rows.length === 0) return null;
|
||||
if (!usesHourlyMeanDay(type)) return plainMean(rows);
|
||||
const localMs = makeLocalMs(tz);
|
||||
let sum = 0;
|
||||
let days = 0;
|
||||
for (const dayRows of groupByLocalDay(rows, localMs).values()) {
|
||||
sum += meanOfHourMeans(dayRows, localMs);
|
||||
days += 1;
|
||||
}
|
||||
return sum / days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readings of an hourly-mean type replaced by one row per local hour carrying
|
||||
* the hour's mean, stamped at the hour's first reading. A reader that already
|
||||
* folds its rows into day buckets by plain mean (and a window by mean of
|
||||
* those) then yields the hourly-mean day without knowing the statistic. Every
|
||||
* other type comes back untouched (the same array).
|
||||
*/
|
||||
export function hourMeanRows<T extends DayReading>(
|
||||
type: string,
|
||||
rows: readonly T[],
|
||||
tz: string,
|
||||
): readonly DayReading[] {
|
||||
if (!usesHourlyMeanDay(type) || rows.length === 0) return rows;
|
||||
const localMs = makeLocalMs(tz);
|
||||
const hours = new Map<number, { sum: number; n: number; at: Date }>();
|
||||
for (const r of rows) {
|
||||
const hour = Math.floor(localMs(r.measuredAt) / MS_PER_HOUR);
|
||||
const acc = hours.get(hour);
|
||||
if (acc) {
|
||||
acc.sum += r.value;
|
||||
acc.n += 1;
|
||||
if (r.measuredAt < acc.at) acc.at = r.measuredAt;
|
||||
} else {
|
||||
hours.set(hour, { sum: r.value, n: 1, at: r.measuredAt });
|
||||
}
|
||||
}
|
||||
return [...hours.values()]
|
||||
.map((h) => ({ value: h.sum / h.n, measuredAt: h.at }))
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Readings of an hourly-mean type replaced by one row per local day carrying
|
||||
* the day value, stamped at the day's first reading; a reader that folds rows
|
||||
* into a window by plain mean then weighs every day once. Every other type
|
||||
* comes back untouched (the same array).
|
||||
*/
|
||||
export function dayValueRows<T extends DayReading>(
|
||||
type: string,
|
||||
rows: readonly T[],
|
||||
tz: string,
|
||||
): readonly DayReading[] {
|
||||
if (!usesHourlyMeanDay(type) || rows.length === 0) return rows;
|
||||
const localMs = makeLocalMs(tz);
|
||||
const out: DayReading[] = [];
|
||||
for (const dayRows of groupByLocalDay(rows, localMs).values()) {
|
||||
let first = dayRows[0].measuredAt;
|
||||
for (const r of dayRows) if (r.measuredAt < first) first = r.measuredAt;
|
||||
out.push({ value: meanOfHourMeans(dayRows, localMs), measuredAt: first });
|
||||
}
|
||||
return out.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
}
|
||||
|
||||
// ─── SQL ──────────────────────────────────────────────────
|
||||
|
||||
const ENUM_RE = /^[A-Z0-9_]+$/;
|
||||
|
||||
/** `'PULSE'` — the hourly-mean types as a spliceable text-literal list. */
|
||||
export function hourlyMeanTypeLiterals(): string {
|
||||
return [...HOURLY_MEAN_DAY_TYPES]
|
||||
.map((t) => {
|
||||
if (!ENUM_RE.test(t)) {
|
||||
throw new Error(`unsafe enum literal for SQL splice: ${t}`);
|
||||
}
|
||||
return `'${t}'`;
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/** SQL predicate: the type column names an hourly-mean type. */
|
||||
export function isHourlyMeanTypeSql(typeColumn: string): string {
|
||||
return `(${typeColumn})::text IN (${hourlyMeanTypeLiterals()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame a reader buckets its days in. `SESSION_DAY_FRAME` is
|
||||
* `date_trunc('day', measured_at)` on the timestamptz itself, the frame of the
|
||||
* rollup tier and of every reader that groups by that expression. A zone frame
|
||||
* names a bound parameter (`$3`) or a Prisma placeholder carrying an IANA zone.
|
||||
*/
|
||||
export type DayFrame = { kind: "session" } | { kind: "zone"; tzSql: string };
|
||||
|
||||
export const SESSION_DAY_FRAME: DayFrame = { kind: "session" };
|
||||
|
||||
/** A zone frame whose zone is the caller's bound parameter `tzSql` (e.g. `$3`). */
|
||||
export function zoneDayFrame(tzSql: string): DayFrame {
|
||||
if (!/^\$\d+$/.test(tzSql) && tzSql !== "'UTC'") {
|
||||
throw new Error(`zone frame must be a bound parameter: ${tzSql}`);
|
||||
}
|
||||
return { kind: "zone", tzSql };
|
||||
}
|
||||
|
||||
function frameTs(frame: DayFrame, column: string): string {
|
||||
return frame.kind === "session"
|
||||
? column
|
||||
: `((${column} AT TIME ZONE 'UTC') AT TIME ZONE ${frame.tzSql})`;
|
||||
}
|
||||
|
||||
function weightedRowsSql(
|
||||
source: string,
|
||||
frame: DayFrame,
|
||||
bySource: boolean,
|
||||
): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(source)) {
|
||||
throw new Error(`source must be a CTE name: ${source}`);
|
||||
}
|
||||
const hourOf = (alias: string) =>
|
||||
`date_trunc('hour', ${frameTs(frame, `${alias}."measured_at"`)})`;
|
||||
const dayOf = (alias: string) =>
|
||||
`date_trunc('day', ${frameTs(frame, `${alias}."measured_at"`)})`;
|
||||
const src = bySource ? `, t."source"` : "";
|
||||
const part = bySource ? `, x."source"` : "";
|
||||
const join = bySource ? ` AND hw."source" = s."source"` : "";
|
||||
return `(
|
||||
SELECT s.*,
|
||||
(CASE WHEN ${isHourlyMeanTypeSql('s."type"')}
|
||||
THEN 1.0 / (hw.n * hw.hours)
|
||||
ELSE 1.0 END)::double precision AS day_weight
|
||||
FROM ${source} s
|
||||
LEFT JOIN (
|
||||
SELECT x."type"${part}, x.local_hour, x.n,
|
||||
COUNT(*) OVER (PARTITION BY x."type"${part}, x.local_day) AS hours
|
||||
FROM (
|
||||
SELECT t."type"${src},
|
||||
${hourOf("t")} AS local_hour,
|
||||
${dayOf("t")} AS local_day,
|
||||
COUNT(*) AS n
|
||||
FROM ${source} t
|
||||
WHERE ${isHourlyMeanTypeSql('t."type"')}
|
||||
GROUP BY ${bySource ? "1, 2, 3, 4" : "1, 2, 3"}
|
||||
) x
|
||||
) hw
|
||||
ON hw."type" = s."type"${join}
|
||||
AND hw.local_hour = ${hourOf("s")}
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A subquery over the CTE `source` (which must expose `type`, `measured_at`
|
||||
* and, with `bySource`, `source`) that adds `day_weight` to every row. Alias
|
||||
* it at the call site: `FROM ${dayWeightedRows("cm", frame)} m`.
|
||||
*
|
||||
* `bySource` keys the hours per source, for a reader that folds each source's
|
||||
* days before it picks one; a reader over one canonical source per day leaves
|
||||
* it off.
|
||||
*/
|
||||
export function dayWeightedRows(
|
||||
source: string,
|
||||
frame: DayFrame,
|
||||
opts: { bySource?: boolean } = {},
|
||||
): string {
|
||||
return weightedRowsSql(source, frame, opts.bySource === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Prisma tagged-template twin of `dayWeightedRows`, for readers written
|
||||
* with `prisma.$queryRaw`: the zone, when given, is a bound parameter.
|
||||
*/
|
||||
export function dayWeightedRowsSql(
|
||||
source: string,
|
||||
tz: string | null,
|
||||
opts: { bySource?: boolean } = {},
|
||||
): Prisma.Sql {
|
||||
if (tz === null) {
|
||||
return Prisma.raw(
|
||||
weightedRowsSql(source, SESSION_DAY_FRAME, opts.bySource === true),
|
||||
);
|
||||
}
|
||||
// Split around a placeholder so the zone binds as a parameter.
|
||||
const marker = "__DAY_MEAN_TZ__";
|
||||
const pieces = weightedRowsSql(
|
||||
source,
|
||||
{ kind: "zone", tzSql: marker },
|
||||
opts.bySource === true,
|
||||
).split(marker);
|
||||
let sql = Prisma.raw(pieces[0]);
|
||||
for (const piece of pieces.slice(1)) {
|
||||
sql = Prisma.sql`${sql}${tz}${Prisma.raw(piece)}`;
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window mean over weighted rows (`dayWeightedRows`): the mean of the day
|
||||
* values for an hourly-mean type, the unchanged `AVG(value)` otherwise. The
|
||||
* query groups by the type column (or filters to one type). `filter` is an
|
||||
* optional SQL predicate applied as an aggregate FILTER.
|
||||
*/
|
||||
export function windowMeanSql(opts: {
|
||||
typeColumn: string;
|
||||
value: string;
|
||||
weight: string;
|
||||
filter?: string;
|
||||
}): string {
|
||||
const f = opts.filter ? ` FILTER (WHERE ${opts.filter})` : "";
|
||||
return `(CASE WHEN ${isHourlyMeanTypeSql(opts.typeColumn)}
|
||||
THEN SUM(${opts.value} * ${opts.weight})${f} / NULLIF(SUM(${opts.weight})${f}, 0)
|
||||
ELSE AVG(${opts.value})${f} END)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold of per-day rows into a window mean: `SUM(wsum) / SUM(wdays)` (each day
|
||||
* once) for an hourly-mean type, the unchanged `SUM(total) / SUM(cnt)`
|
||||
* otherwise. `wsum` / `wdays` are `SUM(value * day_weight)` /
|
||||
* `SUM(day_weight)` per day.
|
||||
*/
|
||||
export function foldMeanSql(opts: {
|
||||
typeColumn: string;
|
||||
total: string;
|
||||
count: string;
|
||||
weightedSum: string;
|
||||
weightSum: string;
|
||||
}): string {
|
||||
return `(CASE WHEN ${isHourlyMeanTypeSql(opts.typeColumn)}
|
||||
THEN SUM(${opts.weightedSum}) / NULLIF(SUM(${opts.weightSum}), 0)
|
||||
ELSE SUM(${opts.total}) / SUM(${opts.count}) END)`;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,103 @@ 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 bucket's `mean`: a DAY bucket's is the mean
|
||||
* of its hours' means, a WEEK / MONTH / YEAR bucket's the mean of its days'
|
||||
* values, each day once. The coarse rows are not read for their mean (every
|
||||
* coarse read folds the DAY tier, `readCanonicalRollupBuckets`), but a stored
|
||||
* row should not contradict the tier it is folded from. 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 and days are UTC, matching the UTC day this tier buckets by; migration
|
||||
* 0373 applies the same statistic to rows stored before it.
|
||||
*/
|
||||
async function applyHourlyMeanDay(
|
||||
db: Pick<typeof prisma, "$queryRawUnsafe">,
|
||||
input: {
|
||||
userId: string;
|
||||
granularity: RollupGranularity;
|
||||
from: Date;
|
||||
to: Date;
|
||||
},
|
||||
rows: RollupRow[],
|
||||
): Promise<RollupRow[]> {
|
||||
const truncUnit = DATE_TRUNC_UNIT[input.granularity];
|
||||
if (!["day", "week", "month", "year"].includes(truncUnit)) {
|
||||
throw new Error(`unexpected granularity: ${input.granularity}`);
|
||||
}
|
||||
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 d."type"::text AS type,
|
||||
d."source"::text AS source,
|
||||
date_trunc('${truncUnit}', d.day) AS bucket_start,
|
||||
AVG(d.day_mean)::double precision AS mean
|
||||
FROM (
|
||||
SELECT h."type", h."source",
|
||||
date_trunc('day', h.hr) AS day,
|
||||
AVG(h.hour_mean) AS day_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
|
||||
) d
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,9 @@ import {
|
||||
} from "./measurement-rollups";
|
||||
import { probeRollupCoverage } from "./measurement-coverage";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -270,9 +273,11 @@ async function readRecentDaily(
|
||||
since,
|
||||
timeZone: tz,
|
||||
});
|
||||
// A pulse day is the mean of its local hours' means (`dayMean`, see
|
||||
// `day-mean.ts`); every other type's day is the mean of its readings.
|
||||
return days.map((d) => ({
|
||||
date: d.day,
|
||||
value: round(d.sum / d.n),
|
||||
value: round(d.dayMean ?? d.sum / d.n),
|
||||
count: d.n,
|
||||
}));
|
||||
}
|
||||
@@ -316,6 +321,9 @@ async function readBandLive(
|
||||
tzKey?: string,
|
||||
): Promise<RollupBucket[]> {
|
||||
const unit = BAND_TRUNC_UNIT[granularity];
|
||||
if (usesHourlyMeanDay(type)) {
|
||||
return readHourlyMeanBandLive(userId, type, unit, from, to, tzKey);
|
||||
}
|
||||
try {
|
||||
const rows = tzKey
|
||||
? await prisma.$queryRaw<
|
||||
@@ -387,6 +395,78 @@ async function readBandLive(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `readBandLive` for a type whose day is the mean of its hours' means (pulse,
|
||||
* `day-mean.ts`): a bucket's mean is the mean of its days, each the mean of
|
||||
* its hours, so a workout's dense samples do not outweigh the rest of the
|
||||
* bucket. Count, min, max and the spread stay over every reading. The bucket
|
||||
* boundary is the one the plain branch uses; hours and days are cut in the
|
||||
* user's zone when `tzKey` is set, in the session's (UTC) frame otherwise.
|
||||
*/
|
||||
async function readHourlyMeanBandLive(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
unit: "day" | "week" | "month" | "year",
|
||||
from: Date,
|
||||
to: Date,
|
||||
tzKey?: string,
|
||||
): Promise<RollupBucket[]> {
|
||||
const mean = Prisma.raw(
|
||||
windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
}),
|
||||
);
|
||||
const bucket = tzKey
|
||||
? Prisma.sql`date_trunc(${unit}, m."measured_at" AT TIME ZONE ${tzKey})`
|
||||
: Prisma.sql`date_trunc(${unit}, m."measured_at")`;
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
sd: number | null;
|
||||
}>
|
||||
>`
|
||||
WITH src AS (
|
||||
SELECT * FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" < ${to}
|
||||
)
|
||||
SELECT
|
||||
${bucket} AS bucket_start,
|
||||
COUNT(*)::int AS count,
|
||||
${mean}::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM ${dayWeightedRowsSql("src", tzKey ?? null)} m
|
||||
GROUP BY 1, m."type"
|
||||
ORDER BY 1
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
bucketStart: new Date(r.bucket_start),
|
||||
count: Number(r.count),
|
||||
mean: Number(r.mean),
|
||||
minValue: Number(r.min_value),
|
||||
maxValue: Number(r.max_value),
|
||||
sd: r.sd === null ? null : Number(r.sd),
|
||||
slope: null,
|
||||
r2: null,
|
||||
}));
|
||||
} catch {
|
||||
// Degrades to empty, like the plain branch.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readBand(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* The per-day fold the background readers share (`readDayAggregates`) carries
|
||||
* the hourly-mean day for pulse, cut on the requested zone's own hours and
|
||||
* days; the vitals baseline and the tiered context read their live pulse days
|
||||
* from it.
|
||||
*
|
||||
* Asia/Kolkata (UTC+05:30). Day one: six readings at 150 from 04:45Z to
|
||||
* 04:55Z and two at 150 at 05:05Z and 05:10Z (all local 10:15 to 10:40, one
|
||||
* local hour, two UTC hours), then one reading at 60 in each of local hours
|
||||
* 12, 14 and 16. Hours' means 150, 60, 60, 60, day value 82.5; the readings
|
||||
* average (8 x 150 + 180) / 11 = 125.45. Day two: one reading at 60.
|
||||
*
|
||||
* A second workout day ten days earlier (12:45Z to 13:10Z, local 18:15 to
|
||||
* 18:40, then one reading at 60 at 14:00Z, 15:00Z and 16:00Z) sits in the
|
||||
* tiered context's day band, which a zone this far from UTC reads live.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "day-aggregates-pulse-hourly";
|
||||
const TZ = "Asia/Kolkata";
|
||||
const now = new Date();
|
||||
const D = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - 10,
|
||||
);
|
||||
const at = (h: number, m: number) => new Date(D + h * 3_600_000 + m * 60_000);
|
||||
const dayKey = (ms: number) =>
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(new Date(ms));
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: TZ },
|
||||
});
|
||||
invalidateUserTimezone(USER);
|
||||
const readings: Array<[Date, number]> = [
|
||||
...[45, 47, 49, 51, 53, 55].map((m): [Date, number] => [at(4, m), 150]),
|
||||
[at(5, 5), 150],
|
||||
[at(5, 10), 150],
|
||||
[at(6, 30), 60],
|
||||
[at(8, 30), 60],
|
||||
[at(10, 30), 60],
|
||||
[at(24 + 4, 0), 60],
|
||||
...[45, 47, 49, 51, 53, 55, 65, 70].map((m): [Date, number] => [
|
||||
at(-10 * 24 + 12, m),
|
||||
150,
|
||||
]),
|
||||
[at(-10 * 24 + 14, 0), 60],
|
||||
[at(-10 * 24 + 15, 0), 60],
|
||||
[at(-10 * 24 + 16, 0), 60],
|
||||
];
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
for (const type of ["PULSE", "HEART_RATE_VARIABILITY"] as const) {
|
||||
readings.forEach(([measuredAt, value], i) =>
|
||||
rows.push({
|
||||
id: `${type}-${i}`,
|
||||
userId: USER,
|
||||
type,
|
||||
unit: type === "PULSE" ? "bpm" : "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
}, 120_000);
|
||||
|
||||
describe("readDayAggregates", () => {
|
||||
it("carries the hourly-mean day for pulse, on the zone's own hours", async () => {
|
||||
const days = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "PULSE",
|
||||
since: new Date(D - 86_400_000),
|
||||
timeZone: TZ,
|
||||
});
|
||||
expect(days.map((d) => d.day)).toEqual([
|
||||
dayKey(D + 5 * 3_600_000),
|
||||
dayKey(D + 28 * 3_600_000),
|
||||
]);
|
||||
expect(days[0]).toMatchObject({ n: 11, min: 60, max: 150 });
|
||||
expect(days[0].sum).toBe(8 * 150 + 3 * 60);
|
||||
expect(days[0].dayMean).toBeCloseTo(82.5, 9);
|
||||
expect(days[1].dayMean).toBeCloseTo(60, 9);
|
||||
});
|
||||
|
||||
it("splits the hours per segment", async () => {
|
||||
const days = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "PULSE",
|
||||
since: new Date(D - 86_400_000),
|
||||
timeZone: TZ,
|
||||
// Readings before 08:00Z (the workout and local hour 12) fall into
|
||||
// segment 1, the rest of the day into segment 0.
|
||||
segmentStarts: [at(8, 0)],
|
||||
});
|
||||
const first = days.filter((d) => d.day === dayKey(D + 5 * 3_600_000));
|
||||
expect(first.map((d) => [d.segment, d.dayMean])).toEqual([
|
||||
[1, expect.closeTo(105, 9)],
|
||||
[0, expect.closeTo(60, 9)],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves every other type without a day mean", async () => {
|
||||
const days = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
since: new Date(D - 86_400_000),
|
||||
timeZone: TZ,
|
||||
});
|
||||
expect(days[0].dayMean ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("live pulse days of the readers built on it", () => {
|
||||
it("the vitals baseline", async () => {
|
||||
const { points, source } = await readDayMeanSeries(
|
||||
USER,
|
||||
"PULSE",
|
||||
30,
|
||||
now,
|
||||
new Map(),
|
||||
TZ,
|
||||
);
|
||||
expect(source).toBe("live");
|
||||
expect(points.map((p) => p.mean)).toEqual([
|
||||
expect.closeTo(82.5, 9),
|
||||
expect.closeTo(82.5, 9),
|
||||
expect.closeTo(60, 9),
|
||||
]);
|
||||
const hrv = await readDayMeanSeries(
|
||||
USER,
|
||||
"HEART_RATE_VARIABILITY",
|
||||
30,
|
||||
now,
|
||||
new Map(),
|
||||
TZ,
|
||||
);
|
||||
expect(hrv.points[0].mean).toBeCloseTo((8 * 150 + 3 * 60) / 11, 9);
|
||||
});
|
||||
|
||||
it("the tiered context's recent days and its live day band", async () => {
|
||||
const series = await buildTieredSeries(USER, "PULSE", {
|
||||
now: now.getTime(),
|
||||
tz: TZ,
|
||||
skipEnsureFresh: true,
|
||||
});
|
||||
expect(series.recentDaily.map((p) => [p.value, p.count])).toEqual([
|
||||
[82.5, 11],
|
||||
[60, 1],
|
||||
]);
|
||||
const workout = series.dayBand.find((b) => b.count === 11);
|
||||
expect(workout?.mean).toBeCloseTo(82.5, 2);
|
||||
const hrv = await buildTieredSeries(USER, "HEART_RATE_VARIABILITY", {
|
||||
now: now.getTime(),
|
||||
tz: TZ,
|
||||
skipEnsureFresh: true,
|
||||
});
|
||||
expect(hrv.recentDaily[0].value).toBeCloseTo((8 * 150 + 180) / 11, 2);
|
||||
expect(hrv.dayBand.find((b) => b.count === 11)?.mean).toBeCloseTo(
|
||||
(8 * 150 + 180) / 11,
|
||||
2,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ import { buildGradedSeriesWithRollups } from "@/lib/insights/graded-series";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { dayValue } from "@/lib/measurements/day-mean";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -202,7 +204,31 @@ describe("pulse status over a dense stream (#1023)", () => {
|
||||
rows.map((r) => ({ measuredAt: r.at, value: r.value })),
|
||||
opts,
|
||||
);
|
||||
expect(sql).toEqual(memory);
|
||||
// Pulse rows also carry the hourly-mean day (`day-mean.ts`); the plain
|
||||
// fold fields agree with the in-memory fold, and each row's day value is
|
||||
// the mean of its local hours' means over the same readings.
|
||||
expect(
|
||||
sql.map(
|
||||
({ dayMean: _d, weightedSum: _w, weightSum: _s, ...plain }) => plain,
|
||||
),
|
||||
).toEqual(memory);
|
||||
for (const row of sql) {
|
||||
const members = rows.filter(
|
||||
(r) =>
|
||||
r.value >= 20 &&
|
||||
r.value <= 300 &&
|
||||
userDayKey(r.at, tz) === row.day &&
|
||||
opts.segmentStarts.filter((s) => r.at < s).length === row.segment,
|
||||
);
|
||||
expect(row.dayMean).toBeCloseTo(
|
||||
dayValue(
|
||||
"PULSE",
|
||||
members.map((r) => ({ value: r.value, measuredAt: r.at })),
|
||||
tz,
|
||||
)!,
|
||||
9,
|
||||
);
|
||||
}
|
||||
// Sanity: the impossible value was dropped and the edge row split off.
|
||||
expect(sql.reduce((n, r) => n + r.n, 0)).toBe(5);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The doctor report's pulse figures use the hourly-mean day: a day is the mean
|
||||
* of its local hours' means and the period average is the mean of its days,
|
||||
* on both the raw path (a short window) and the day-bucket path (a long one).
|
||||
*
|
||||
* The account sits in Asia/Kolkata (UTC+05:30), so a local hour starts at half
|
||||
* past a UTC hour. The workout runs 04:45Z to 05:14:30Z: one local hour
|
||||
* (10:15 to 10:44:30), two UTC hours.
|
||||
*
|
||||
* day one 60 readings at 150 in local 10:xx, one at 60 in local 12, 14, 16
|
||||
* hours' means 150, 60, 60, 60, day value 82.5
|
||||
* (plain mean 145.7; UTC hours would give 96)
|
||||
* day two one reading at 60, day value 60
|
||||
* period (82.5 + 60) / 2 = 71.25 (plain mean over readings 144.375)
|
||||
*
|
||||
* Count, min and max stay over every reading.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const MODULES = {
|
||||
cycle: false,
|
||||
mood: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
workouts: false,
|
||||
recovery: false,
|
||||
labs: false,
|
||||
illness: false,
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
environment: true,
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
vaccinations: true,
|
||||
} as const;
|
||||
|
||||
const SELECTION = selectionFromLeaves(["PULSE"]);
|
||||
const END = new Date("2026-05-01T00:00:00.000Z");
|
||||
const LONG = {
|
||||
start: new Date("2026-01-01T00:00:00.000Z"),
|
||||
end: END,
|
||||
days: 120,
|
||||
};
|
||||
const SHORT = {
|
||||
start: new Date("2026-02-15T00:00:00.000Z"),
|
||||
end: END,
|
||||
days: 75,
|
||||
};
|
||||
|
||||
let userId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "doctor-report-pulse-hourly",
|
||||
email: "doctor-report-pulse-hourly@example.test",
|
||||
timezone: "Asia/Kolkata",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const add = (iso: string, value: number) =>
|
||||
rows.push({
|
||||
userId,
|
||||
type: "PULSE",
|
||||
value,
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date(iso),
|
||||
});
|
||||
const workoutStart = Date.parse("2026-03-10T04:45:00.000Z");
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
add(new Date(workoutStart + i * 30_000).toISOString(), 150);
|
||||
}
|
||||
add("2026-03-10T06:30:00.000Z", 60);
|
||||
add("2026-03-10T08:30:00.000Z", 60);
|
||||
add("2026-03-10T10:30:00.000Z", 60);
|
||||
add("2026-03-11T03:30:00.000Z", 60);
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
}, 120_000);
|
||||
|
||||
describe.each([
|
||||
["day-bucket path (long window)", LONG],
|
||||
["raw path (short window)", SHORT],
|
||||
] as const)("doctor report pulse, %s", (_label, range) => {
|
||||
it("averages the days, each the mean of its local hours", async () => {
|
||||
const data = await collectDoctorReportData(userId, range, SELECTION, {
|
||||
moduleMap: { ...MODULES },
|
||||
});
|
||||
expect(data.stats.PULSE).toMatchObject({ count: 64, min: 60, max: 150 });
|
||||
expect(data.stats.PULSE.avg).toBeCloseTo(71.25, 9);
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the long window's day points as hourly-mean days", async () => {
|
||||
const data = await collectDoctorReportData(userId, LONG, SELECTION, {
|
||||
moduleMap: { ...MODULES },
|
||||
});
|
||||
expect(data.measurements.PULSE.map((p) => p.value)).toEqual([
|
||||
expect.closeTo(82.5, 9),
|
||||
expect.closeTo(60, 9),
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The full-history pulse mean the insight features carry (`allTimeAvg`) is the
|
||||
* mean of the day values, each day the mean of its hours' means, like every
|
||||
* other pulse mean (`day-mean.ts`). Count-weighted, a workout day's dense hour
|
||||
* outweighed every resting hour of every other day.
|
||||
*
|
||||
* Two days: a workout hour of twelve readings at 150 and three resting hours
|
||||
* at 60 (day value 82.5), and a day with one reading at 60: 71.25. The same
|
||||
* readings as systolic pressure keep the plain mean (127.5) as the control.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { readAllTimeExtremes } from "@/lib/insights/feature-blocks";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "feature-blocks-pulse-day";
|
||||
const DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * DAY;
|
||||
|
||||
function readings(
|
||||
type: "PULSE" | "BLOOD_PRESSURE_SYS",
|
||||
unit: string,
|
||||
): Prisma.MeasurementCreateManyInput[] {
|
||||
const workout = midnight(40);
|
||||
const at: Array<[number, number]> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
at.push([workout + 10 * 3_600_000 + i * 300_000, 150]);
|
||||
}
|
||||
for (const h of [12, 14, 16]) at.push([workout + h * 3_600_000, 60]);
|
||||
at.push([midnight(39) + 8 * 3_600_000, 60]);
|
||||
return at.map(([ms, value], i) => ({
|
||||
id: `${USER}-${type}-${i}`,
|
||||
userId: USER,
|
||||
type,
|
||||
unit,
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(ms),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
...readings("PULSE", "bpm"),
|
||||
...readings("BLOOD_PRESSURE_SYS", "mmHg"),
|
||||
],
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
describe("readAllTimeExtremes", () => {
|
||||
it("gives pulse the mean of its days, each the mean of its hours", async () => {
|
||||
const out = await readAllTimeExtremes(USER, [
|
||||
"PULSE",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
]);
|
||||
const pulse = out.get("PULSE");
|
||||
expect(pulse?.mean).toBeCloseTo(71.25, 9);
|
||||
// The extremes stay over every reading.
|
||||
expect(pulse?.min).toBe(60);
|
||||
expect(pulse?.max).toBe(150);
|
||||
});
|
||||
|
||||
it("keeps the plain mean of the readings for every other type", async () => {
|
||||
const out = await readAllTimeExtremes(USER, ["BLOOD_PRESSURE_SYS"]);
|
||||
expect(out.get("BLOOD_PRESSURE_SYS")?.mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The pulse status card's graded series reads per-day aggregates folded in
|
||||
* Postgres (`readDayAggregates`). For pulse those rows carry the day weights
|
||||
* of `day-mean.ts`, so a recent day is the mean of its hours' means and a
|
||||
* week the mean of its days, against a real database.
|
||||
*
|
||||
* Each pair of days: a workout hour of twelve readings at 150 and three
|
||||
* resting hours at 60 (day value 82.5), then a day with one reading at 60.
|
||||
* A bucket over both reads 71.25; the plain mean of the readings is 127.5.
|
||||
* Systolic pressure with the same readings keeps the plain mean.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { buildGradedSeriesWithRollups } from "@/lib/insights/graded-series";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "graded-pulse-day";
|
||||
const DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
const today = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
);
|
||||
/** A Monday about six weeks back: its Tuesday and Wednesday share a week. */
|
||||
const dow = (new Date(today).getUTCDay() + 6) % 7;
|
||||
const OLD_MONDAY = today - dow * DAY - 6 * 7 * DAY;
|
||||
const RECENT = today - 4 * DAY;
|
||||
|
||||
function pair(
|
||||
type: "PULSE" | "BLOOD_PRESSURE_SYS",
|
||||
start: number,
|
||||
tag: string,
|
||||
): Prisma.MeasurementCreateManyInput[] {
|
||||
const at: Array<[number, number]> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
at.push([start + 10 * 3_600_000 + i * 300_000, 150]);
|
||||
}
|
||||
for (const h of [12, 14, 16]) at.push([start + h * 3_600_000, 60]);
|
||||
at.push([start + DAY + 8 * 3_600_000, 60]);
|
||||
return at.map(([ms, value], i) => ({
|
||||
id: `${USER}-${type}-${tag}-${i}`,
|
||||
userId: USER,
|
||||
type,
|
||||
unit: type === "PULSE" ? "bpm" : "mmHg",
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(ms),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
const data: Prisma.MeasurementCreateManyInput[] = [];
|
||||
for (const type of ["PULSE", "BLOOD_PRESSURE_SYS"] as const) {
|
||||
data.push(...pair(type, RECENT, "recent"));
|
||||
data.push(...pair(type, OLD_MONDAY + DAY, "old"));
|
||||
}
|
||||
await prisma.measurement.createMany({ data });
|
||||
}, 120_000);
|
||||
|
||||
describe("readDayAggregates for pulse", () => {
|
||||
it("carries day weights whose ratio is the day value", async () => {
|
||||
const rows = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "PULSE",
|
||||
since: new Date(RECENT),
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const workout = rows.find(
|
||||
(r) => r.day === new Date(RECENT).toISOString().slice(0, 10),
|
||||
)!;
|
||||
expect(workout.n).toBe(15);
|
||||
expect(workout.min).toBe(60);
|
||||
expect(workout.max).toBe(150);
|
||||
expect(workout.weightSum).toBeCloseTo(1, 9);
|
||||
expect(workout.weightedSum! / workout.weightSum!).toBeCloseTo(82.5, 9);
|
||||
});
|
||||
|
||||
it("leaves every other type without them", async () => {
|
||||
const rows = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
since: new Date(RECENT),
|
||||
timeZone: "UTC",
|
||||
});
|
||||
expect(rows.every((r) => r.weightedSum === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pulse graded series", () => {
|
||||
it("gives a recent day its hours' mean and a week its days' mean", async () => {
|
||||
const g = await buildGradedSeriesWithRollups(USER, "PULSE", now, "UTC");
|
||||
const workout = g.recent.find(
|
||||
(d) => d.date === new Date(RECENT).toISOString().slice(0, 10),
|
||||
)!;
|
||||
expect(workout.mean).toBe(82.5);
|
||||
expect(workout.n).toBe(15);
|
||||
expect(g.weekly).toHaveLength(1);
|
||||
expect(g.weekly[0].mean).toBe(71.25);
|
||||
expect(g.weekly[0].n).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the plain mean for systolic pressure", async () => {
|
||||
const g = await buildGradedSeriesWithRollups(
|
||||
USER,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
now,
|
||||
"UTC",
|
||||
);
|
||||
expect(g.weekly[0].mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -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,91 @@
|
||||
/**
|
||||
* The comparison snapshot's window average and the Coach's availability mean
|
||||
* read pulse as the mean of its days, each the mean of its hours' means, on a
|
||||
* real Postgres.
|
||||
*
|
||||
* Two days, three days back and two days back (inside the 30-day window): a
|
||||
* workout day with one hour of 12 readings at 150 and three resting hours of
|
||||
* one reading at 60 (day value 82.5), and a resting day of two hours at 70.
|
||||
* Mean of the days: 76.25. Mean of the readings: (12 x 150 + 3 x 60 + 2 x 70)
|
||||
* / 17 = 124.7.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { probeCoachAvailability } from "@/lib/ai/coach/tools/availability";
|
||||
import { hourlyMeanWindowAverage } from "@/lib/insights/comprehensive-generate";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "pulse-comparison-availability";
|
||||
const DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
const D1 = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - 3,
|
||||
);
|
||||
const D2 = D1 + DAY;
|
||||
const DAY_MEAN = (82.5 + 70) / 2;
|
||||
const READING_MEAN = (12 * 150 + 3 * 60 + 2 * 70) / 17;
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: "UTC" },
|
||||
});
|
||||
invalidateUserTimezone(USER);
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const add = (at: number, value: number) =>
|
||||
rows.push({
|
||||
id: `${USER}-${rows.length}`,
|
||||
userId: USER,
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(at),
|
||||
});
|
||||
for (let i = 0; i < 12; i += 1) add(D1 + 10 * 3_600_000 + i * 300_000, 150);
|
||||
for (const h of [12, 14, 16]) add(D1 + h * 3_600_000, 60);
|
||||
for (const h of [8, 9]) add(D2 + h * 3_600_000, 70);
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
}, 120_000);
|
||||
|
||||
describe("pulse window means on a real database", () => {
|
||||
it("the fixture separates the two statistics", () => {
|
||||
expect(READING_MEAN).toBeCloseTo(124.71, 2);
|
||||
expect(DAY_MEAN).toBe(76.25);
|
||||
});
|
||||
|
||||
it("the comparison snapshot's window average is the mean of the days", async () => {
|
||||
const avg = await hourlyMeanWindowAverage(USER, "PULSE", {
|
||||
gt: new Date(Date.now() - 30 * DAY),
|
||||
});
|
||||
expect(avg).toBeCloseTo(DAY_MEAN, 9);
|
||||
const empty = await hourlyMeanWindowAverage(USER, "PULSE", {
|
||||
gt: new Date(Date.now() - 60 * DAY),
|
||||
lte: new Date(Date.now() - 30 * DAY),
|
||||
});
|
||||
expect(empty).toBeNull();
|
||||
});
|
||||
|
||||
it("the Coach availability mean is the mean of the days", async () => {
|
||||
const probed = await probeCoachAvailability(
|
||||
USER,
|
||||
new Map([["pulse", { kind: "measurement", types: ["PULSE"] }]]),
|
||||
{ now },
|
||||
);
|
||||
const series = probed.get("pulse")?.series;
|
||||
expect(series).toEqual([
|
||||
expect.objectContaining({
|
||||
series: "PULSE",
|
||||
count: 17,
|
||||
mean: Math.round(DAY_MEAN * 10) / 10,
|
||||
min: 60,
|
||||
max: 150,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* A day of pulse is the mean of its LOCAL hours' means, and the days are LOCAL
|
||||
* days, for a profile zone that is not UTC.
|
||||
*
|
||||
* Two zones that each break a UTC assumption:
|
||||
*
|
||||
* - Asia/Kolkata (UTC+05:30). Its hours start at half past a UTC hour, so
|
||||
* 10:20Z, 10:25Z (both 15:5x local) and 10:40Z (16:10 local) are two local
|
||||
* hours (100 and 40, day value 70), though one UTC hour (80). 18:45Z is
|
||||
* already 00:15 the next local day.
|
||||
* - Europe/Berlin. 23:30Z is the next local day, so 10:00Z, 11:00Z (three
|
||||
* readings) and 23:30Z are two local days (100 and 40), though one UTC day
|
||||
* (80).
|
||||
*
|
||||
* Asserted through the live per-day read the chart serves, the series route's
|
||||
* day bucket, and the shared SQL day weight every other live reader uses.
|
||||
*/
|
||||
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 {
|
||||
dayWeightedRows,
|
||||
dayWeightedRowsSql,
|
||||
readingsMean,
|
||||
windowMeanSql,
|
||||
zoneDayFrame,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
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 DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
/** A UTC midnight three weeks back: inside every window the reads use. */
|
||||
const D = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - 21,
|
||||
);
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
const hm = (h: number, m: number) => D + h * 3_600_000 + m * 60_000;
|
||||
|
||||
const KOLKATA = {
|
||||
user: "pulse-zone-kolkata",
|
||||
tz: "Asia/Kolkata",
|
||||
readings: [
|
||||
[hm(10, 20), 100],
|
||||
[hm(10, 25), 100],
|
||||
[hm(10, 40), 40],
|
||||
[hm(18, 45), 90],
|
||||
] as const,
|
||||
/** Local days: 70 (two local hours) and 90. */
|
||||
days: [70, 90],
|
||||
/** What a UTC frame would give: one day, hours 80 and 90. */
|
||||
utcDay: 85,
|
||||
};
|
||||
|
||||
const BERLIN = {
|
||||
user: "pulse-zone-berlin",
|
||||
tz: "Europe/Berlin",
|
||||
readings: [
|
||||
[hm(10, 0), 100],
|
||||
[hm(11, 0), 100],
|
||||
[hm(11, 10), 100],
|
||||
[hm(11, 20), 100],
|
||||
[hm(23, 30), 40],
|
||||
] as const,
|
||||
/** Local days: 100 and 40. */
|
||||
days: [100, 40],
|
||||
/** What a UTC frame would give: one day, hours 100, 100 and 40. */
|
||||
utcDay: 80,
|
||||
};
|
||||
|
||||
const CASES = [KOLKATA, BERLIN];
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
for (const c of CASES) {
|
||||
await prisma.user.create({
|
||||
data: { id: c.user, username: c.user, timezone: c.tz },
|
||||
});
|
||||
invalidateUserTimezone(c.user);
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = c.readings.map(
|
||||
([at, value], i) => ({
|
||||
id: `${c.user}-${i}`,
|
||||
userId: c.user,
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(at),
|
||||
}),
|
||||
);
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
describe.each(CASES)("profile zone $tz", (c) => {
|
||||
it("the TypeScript helper reads local hours and local days", () => {
|
||||
const rows = c.readings.map(([at, value]) => ({
|
||||
value,
|
||||
measuredAt: new Date(at),
|
||||
}));
|
||||
expect(readingsMean("PULSE", rows, c.tz)).toBeCloseTo(
|
||||
(c.days[0] + c.days[1]) / 2,
|
||||
9,
|
||||
);
|
||||
expect(readingsMean("PULSE", rows, "UTC")).toBeCloseTo(c.utcDay, 9);
|
||||
});
|
||||
|
||||
it("the live per-day read buckets by local hour and local day", async () => {
|
||||
const rows = await readLiveBuckets({
|
||||
userId: c.user,
|
||||
type: "PULSE",
|
||||
from: new Date(D - DAY),
|
||||
to: new Date(D + 2 * DAY),
|
||||
cap: 400,
|
||||
priorityJson: null,
|
||||
grain: "daily",
|
||||
timeZone: c.tz,
|
||||
});
|
||||
expect(rows.map((r) => r.value)).toEqual(
|
||||
c.days.map((v) => expect.closeTo(v, 6)),
|
||||
);
|
||||
// Count and band stay over every reading.
|
||||
expect(rows.reduce((s, r) => s + (r.count ?? 0), 0)).toBe(
|
||||
c.readings.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("the shared SQL day weight gives the mean of the local days", async () => {
|
||||
const [row] = await prisma.$queryRawUnsafe<Array<{ mean: number }>>(
|
||||
`
|
||||
WITH src AS (
|
||||
SELECT * FROM measurements
|
||||
WHERE "user_id" = $1 AND "deleted_at" IS NULL
|
||||
)
|
||||
SELECT ${windowMeanSql({
|
||||
typeColumn: 'm."type"',
|
||||
value: 'm."value"',
|
||||
weight: "m.day_weight",
|
||||
})}::double precision AS mean
|
||||
FROM ${dayWeightedRows("src", zoneDayFrame("$2"))} m
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
c.user,
|
||||
c.tz,
|
||||
);
|
||||
expect(row.mean).toBeCloseTo((c.days[0] + c.days[1]) / 2, 9);
|
||||
|
||||
const [tagged] = await prisma.$queryRaw<Array<{ days: number }>>`
|
||||
WITH src AS (
|
||||
SELECT * FROM measurements
|
||||
WHERE "user_id" = ${c.user} AND "deleted_at" IS NULL
|
||||
)
|
||||
SELECT SUM(m.day_weight)::double precision AS days
|
||||
FROM ${dayWeightedRowsSql("src", c.tz)} m
|
||||
`;
|
||||
// Each local day's weights add up to one.
|
||||
expect(tagged.days).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it("the series route's day bucket is the local day of local hours", async () => {
|
||||
const s = await prisma.session.create({
|
||||
data: { userId: c.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=pulse&days=365",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
points: Array<{ id: string; value: number }>;
|
||||
stats: { mean: number; count: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.points.every((p) => p.id.startsWith("day:"))).toBe(true);
|
||||
expect(body.data.points.map((p) => p.value)).toEqual(
|
||||
c.days.map((v) => expect.closeTo(v, 2)),
|
||||
);
|
||||
// The stats strip: the mean of the day values, the count of readings.
|
||||
expect(body.data.stats.mean).toBeCloseTo((c.days[0] + c.days[1]) / 2, 2);
|
||||
expect(body.data.stats.count).toBe(c.readings.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("the fixture really separates the frames", () => {
|
||||
expect(iso(hm(18, 45))).toMatch(/T18:45/);
|
||||
for (const c of CASES) {
|
||||
expect((c.days[0] + c.days[1]) / 2).not.toBeCloseTo(c.utcDay, 2);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Migration 0372 rewrites the `mean` of stored PULSE rollup rows to the
|
||||
* hourly-mean statistic, and touches nothing else.
|
||||
*
|
||||
* The rows are first built by the writer, then their `mean` is set to a
|
||||
* sentinel the way a row written before this release still holds the reading
|
||||
* mean. The migration must restore exactly what the writer stores now: DAY =
|
||||
* mean of the day's hour means, WEEK / MONTH / YEAR = mean of their days. A
|
||||
* second type (HRV) with the same readings keeps its sentinel, every other
|
||||
* column keeps its value, and a rerun changes nothing.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "pulse-mean-migration";
|
||||
const SENTINEL = 999;
|
||||
|
||||
const MIGRATION = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
"0372_pulse_hourly_mean_day",
|
||||
"migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function steps(): string[] {
|
||||
return MIGRATION.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("--"))
|
||||
.join("\n")
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
// One connection: the temporary tables live in the session that made them.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const step of steps()) await tx.$executeRawUnsafe(step);
|
||||
});
|
||||
}
|
||||
|
||||
/** Tuesday 2026-03-03: one hard hour of 12 readings at 150, three hours of one reading at 60. */
|
||||
const TUE = Date.UTC(2026, 2, 3);
|
||||
/** Wednesday: two hours at 70. */
|
||||
const WED = Date.UTC(2026, 2, 4);
|
||||
const TUE_VALUE = (150 + 3 * 60) / 4;
|
||||
const WED_VALUE = 70;
|
||||
|
||||
function rows(
|
||||
type: "PULSE" | "HEART_RATE_VARIABILITY",
|
||||
): Prisma.MeasurementCreateManyInput[] {
|
||||
const out: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const add = (at: number, value: number) =>
|
||||
out.push({
|
||||
id: `${type}-${at}`,
|
||||
userId: USER,
|
||||
type,
|
||||
unit: type === "PULSE" ? "bpm" : "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(at),
|
||||
});
|
||||
for (let i = 0; i < 12; i += 1) add(TUE + 10 * 3_600_000 + i * 300_000, 150);
|
||||
for (const h of [12, 14, 16]) add(TUE + h * 3_600_000, 60);
|
||||
for (const h of [8, 9]) add(WED + h * 3_600_000, 70);
|
||||
return out;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
type: string;
|
||||
granularity: string;
|
||||
bucket_start: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
sum_value: number | null;
|
||||
sd: number | null;
|
||||
sum_x: number | null;
|
||||
sum_xy: number | null;
|
||||
sum_xx: number | null;
|
||||
sum_yy: number | null;
|
||||
};
|
||||
|
||||
const readRows = () =>
|
||||
prisma.$queryRaw<Row[]>`
|
||||
SELECT "type"::text AS type, "granularity"::text AS granularity,
|
||||
"bucket_start", "count", "mean", "min_value", "max_value",
|
||||
"sum_value", "sd", "sum_x", "sum_xy", "sum_xx", "sum_yy"
|
||||
FROM measurement_rollups
|
||||
WHERE "user_id" = ${USER}
|
||||
ORDER BY 1, 2, 3
|
||||
`;
|
||||
|
||||
let before: Row[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: "UTC" },
|
||||
});
|
||||
await prisma.measurement.createMany({
|
||||
data: [...rows("PULSE"), ...rows("HEART_RATE_VARIABILITY")],
|
||||
});
|
||||
await recomputeUserRollups(USER, {
|
||||
types: ["PULSE", "HEART_RATE_VARIABILITY"],
|
||||
from: new Date(Date.UTC(2026, 0, 1)),
|
||||
to: new Date(Date.UTC(2027, 0, 1)),
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE measurement_rollups SET "mean" = ${SENTINEL} WHERE "user_id" = ${USER}
|
||||
`;
|
||||
before = await readRows();
|
||||
await runMigration();
|
||||
}, 120_000);
|
||||
|
||||
const meanOf = (all: Row[], type: string, granularity: string, at?: number) =>
|
||||
all.find(
|
||||
(r) =>
|
||||
r.type === type &&
|
||||
r.granularity === granularity &&
|
||||
(at === undefined || r.bucket_start.getTime() === at),
|
||||
)?.mean;
|
||||
|
||||
describe("migration 0372", () => {
|
||||
it("covers all four granularities of both types", () => {
|
||||
for (const type of ["PULSE", "HEART_RATE_VARIABILITY"]) {
|
||||
for (const g of ["DAY", "WEEK", "MONTH", "YEAR"]) {
|
||||
expect(meanOf(before, type, g)).toBe(SENTINEL);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a PULSE day the mean of its hours' means", async () => {
|
||||
const after = await readRows();
|
||||
expect(meanOf(after, "PULSE", "DAY", TUE)).toBeCloseTo(TUE_VALUE, 9);
|
||||
expect(meanOf(after, "PULSE", "DAY", WED)).toBeCloseTo(WED_VALUE, 9);
|
||||
});
|
||||
|
||||
it("gives a PULSE week, month and year the mean of their days", async () => {
|
||||
const after = await readRows();
|
||||
for (const g of ["WEEK", "MONTH", "YEAR"]) {
|
||||
expect(meanOf(after, "PULSE", g)).toBeCloseTo(
|
||||
(TUE_VALUE + WED_VALUE) / 2,
|
||||
9,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches what the writer stores now", async () => {
|
||||
const migrated = await readRows();
|
||||
await recomputeUserRollups(USER, {
|
||||
types: ["PULSE"],
|
||||
from: new Date(Date.UTC(2026, 0, 1)),
|
||||
to: new Date(Date.UTC(2027, 0, 1)),
|
||||
});
|
||||
const written = await readRows();
|
||||
for (const r of written.filter((x) => x.type === "PULSE")) {
|
||||
const m = migrated.find(
|
||||
(x) =>
|
||||
x.type === r.type &&
|
||||
x.granularity === r.granularity &&
|
||||
x.bucket_start.getTime() === r.bucket_start.getTime(),
|
||||
);
|
||||
expect(m?.mean).toBeCloseTo(r.mean, 9);
|
||||
}
|
||||
// Restore the migrated state for the remaining assertions.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE measurement_rollups SET "mean" = ${SENTINEL}
|
||||
WHERE "user_id" = ${USER} AND "type" = 'HEART_RATE_VARIABILITY'
|
||||
`;
|
||||
});
|
||||
|
||||
it("leaves every other type's mean alone", async () => {
|
||||
const after = await readRows();
|
||||
for (const r of after.filter((x) => x.type !== "PULSE")) {
|
||||
expect(r.mean).toBe(SENTINEL);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves every column but mean alone", async () => {
|
||||
const after = await readRows();
|
||||
const strip = ({ mean: _mean, ...rest }: Row) => rest;
|
||||
expect(after.map(strip)).toEqual(before.map(strip));
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
const first = await readRows();
|
||||
await runMigration();
|
||||
expect(await readRows()).toEqual(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* The dashboard's and the insights' window means of pulse weigh each day once,
|
||||
* each day the mean of its hours' means; heart-rate variability, seeded with
|
||||
* the very same readings, keeps the plain mean over its readings.
|
||||
*
|
||||
* A workout day: twelve readings at 150 bpm inside one hour, then three
|
||||
* resting hours of one reading at 60 bpm. Its day value is (150 + 3 x 60) / 4
|
||||
* = 82.5, its readings' mean (12 x 150 + 3 x 60) / 15 = 132. A quiet day: one
|
||||
* reading at 60.
|
||||
*
|
||||
* last 30 days workout day + quiet day 71.25 (plain 127.5)
|
||||
* days 30 to 60 workout day + quiet day 71.25 (plain 127.5)
|
||||
* six years ago workout day alone 82.5 (plain 132)
|
||||
* all time five days 73.5 (plain 6060 / 47)
|
||||
*
|
||||
* Asserted on both paths of the dashboard summaries slice (the rollup tier,
|
||||
* with the six-year-old day in the pre-fold remainder, and the live
|
||||
* fallback), both paths of the insights aggregate, and the stats strip of the
|
||||
* series route's raw window.
|
||||
*/
|
||||
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 { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
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),
|
||||
}));
|
||||
|
||||
// The reads kick off a background rollup refresh; keep it off so the live
|
||||
// user stays on the live path for the whole file.
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/rollups/measurement-rollups")>();
|
||||
return { ...actual, ensureUserRollupsFresh: vi.fn(async () => undefined) };
|
||||
});
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
const T0 = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
|
||||
const LIVE = "pulse-window-live";
|
||||
const ROLLUP = "pulse-window-rollup";
|
||||
|
||||
const WORKOUT_DAYS = [T0 - 3 * DAY, T0 - 45 * DAY, T0 - 6 * 365 * DAY];
|
||||
const QUIET_DAYS = [T0 - 2 * DAY, T0 - 44 * DAY];
|
||||
|
||||
const HOURLY = {
|
||||
window: (82.5 + 60) / 2,
|
||||
allTime: (82.5 * 3 + 60 * 2) / 5,
|
||||
};
|
||||
const PLAIN = {
|
||||
window: (12 * 150 + 3 * 60 + 60) / 16,
|
||||
allTime: (3 * (12 * 150 + 3 * 60) + 2 * 60) / 47,
|
||||
};
|
||||
|
||||
function rows(
|
||||
userId: string,
|
||||
type: "PULSE" | "HEART_RATE_VARIABILITY",
|
||||
): Prisma.MeasurementCreateManyInput[] {
|
||||
const unit = type === "PULSE" ? "bpm" : "ms";
|
||||
const out: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const push = (at: number, value: number) =>
|
||||
out.push({
|
||||
id: `${userId}-${type}-${at}`,
|
||||
userId,
|
||||
type,
|
||||
unit,
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(at),
|
||||
});
|
||||
for (const d of WORKOUT_DAYS) {
|
||||
for (let i = 0; i < 12; i += 1) push(d + 10 * 3_600_000 + i * 300_000, 150);
|
||||
for (const h of [12, 14, 16]) push(d + h * 3_600_000, 60);
|
||||
}
|
||||
for (const d of QUIET_DAYS) push(d + 8 * 3_600_000, 60);
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
for (const user of [LIVE, ROLLUP]) {
|
||||
await prisma.user.create({
|
||||
data: { id: user, username: user, timezone: "UTC" },
|
||||
});
|
||||
invalidateUserTimezone(user);
|
||||
await prisma.measurement.createMany({
|
||||
data: [...rows(user, "PULSE"), ...rows(user, "HEART_RATE_VARIABILITY")],
|
||||
});
|
||||
}
|
||||
// The rollup user's DAY tier covers the last 400 days; the six-year-old
|
||||
// workout day stays outside it, in the pre-fold remainder.
|
||||
await recomputeUserRollups(ROLLUP, {
|
||||
types: ["PULSE", "HEART_RATE_VARIABILITY"],
|
||||
from: new Date(T0 - 400 * DAY),
|
||||
to: new Date(T0 + DAY),
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
describe.each([
|
||||
{ path: "rollup", user: ROLLUP },
|
||||
{ path: "live", user: LIVE },
|
||||
])("dashboard summaries slice, $path path", ({ user }) => {
|
||||
it("weighs each day once for pulse", async () => {
|
||||
const { summaries } = await computeSummariesSlice(user);
|
||||
const pulse = summaries.PULSE;
|
||||
expect(pulse.avg7).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(pulse.avg30).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(pulse.avg30LastMonth).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(pulse.mean).toBeCloseTo(HOURLY.allTime, 2);
|
||||
// Count, min and max stay over every reading.
|
||||
expect(pulse.count).toBe(47);
|
||||
expect(pulse.min).toBe(60);
|
||||
expect(pulse.max).toBe(150);
|
||||
});
|
||||
|
||||
it("keeps the plain mean for HRV", async () => {
|
||||
const { summaries } = await computeSummariesSlice(user);
|
||||
const hrv = summaries.HEART_RATE_VARIABILITY;
|
||||
expect(hrv.avg7).toBeCloseTo(PLAIN.window, 2);
|
||||
expect(hrv.avg30).toBeCloseTo(PLAIN.window, 2);
|
||||
expect(hrv.avg30LastMonth).toBeCloseTo(PLAIN.window, 2);
|
||||
expect(hrv.mean).toBeCloseTo(PLAIN.allTime, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ path: "rollup", user: ROLLUP },
|
||||
{ path: "live", user: LIVE },
|
||||
])("insights aggregate, $path path", ({ user }) => {
|
||||
it("weighs each day once for pulse and keeps the plain mean for HRV", async () => {
|
||||
const { summaries } = await buildComprehensiveAggregate(user);
|
||||
expect(summaries.PULSE.avg7).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(summaries.PULSE.avg30).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(summaries.PULSE.avg30LastMonth).toBeCloseTo(HOURLY.window, 2);
|
||||
// The 90-day window holds two workout days and two quiet days.
|
||||
expect(summaries.PULSE.mean).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(summaries.PULSE.count).toBe(32);
|
||||
const hrv = summaries.HEART_RATE_VARIABILITY;
|
||||
expect(hrv.avg7).toBeCloseTo(PLAIN.window, 2);
|
||||
expect(hrv.avg30LastMonth).toBeCloseTo(PLAIN.window, 2);
|
||||
expect(hrv.mean).toBeCloseTo(PLAIN.window, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("series route, raw window", () => {
|
||||
async function stats(kind: string) {
|
||||
const s = await prisma.session.create({
|
||||
data: { userId: LIVE, 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=30`,
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return (
|
||||
(await res.json()) as {
|
||||
data: {
|
||||
points: unknown[];
|
||||
stats: { mean: number; count: number; min: number; max: number };
|
||||
};
|
||||
}
|
||||
).data;
|
||||
}
|
||||
|
||||
it("gives pulse the mean of its days on the stats strip", async () => {
|
||||
const data = await stats("pulse");
|
||||
// Raw points: every reading of the last 30 days.
|
||||
expect(data.points).toHaveLength(16);
|
||||
expect(data.stats.mean).toBeCloseTo(HOURLY.window, 2);
|
||||
expect(data.stats.count).toBe(16);
|
||||
expect(data.stats.min).toBe(60);
|
||||
expect(data.stats.max).toBe(150);
|
||||
});
|
||||
|
||||
it("keeps the plain mean for HRV", async () => {
|
||||
const data = await stats("heartRateVariability");
|
||||
expect(data.stats.mean).toBeCloseTo(PLAIN.window, 2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user