From 8dc5c230dafd7cc0f3d6ef8787701ae8d14d4ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 18:57:39 +0200 Subject: [PATCH] feat(insights): pair a mood tag against next-day resting heart rate and HRV The tag crosstab already ran a with-versus-without Welch test per structured tag, with a D to D+1 pairing mode whose stated example was an evening tag against next-day recovery. It only ever had one channel at that lag, and a recovery score is a wearable composite plenty of accounts never record. The rated-factor board next to it has tested next-day resting heart rate and HRV since v1.14.0, but a rated factor is a continuous score, so a binary tag never reached either vital. Add both to the tag map. Same engine, same per-side day floors, same Benjamini-Hochberg family, same card. The fetch filter derives from the map, so the channels come along without a second query. The delta read-out goes neutral to match the rated-factor board: with resting heart rate on the board, a positive delta is worse on one row and better on the next, and a green plus sign would claim a verdict the association cannot carry. Refs #616 --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../mood-tag-metric-crosstab.test.tsx | 91 +++++++++++++++++++ .../mood/mood-tag-metric-crosstab.tsx | 20 ++-- .../__tests__/mood-aggregates.test.ts | 89 ++++++++++++++++++ src/lib/insights/mood-crosstab.ts | 33 +++++-- 10 files changed, 242 insertions(+), 15 deletions(-) create mode 100644 src/components/insights/mood/__tests__/mood-tag-metric-crosstab.test.tsx diff --git a/messages/de.json b/messages/de.json index b10ea57da..225b1a535 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3432,9 +3432,13 @@ "metricActiveEnergy": "Aktive Energie", "metricSleepDuration": "Schlaf", "metricNextDayRecovery": "Erholung am Folgetag", + "metricNextDayRestingHr": "Ruhepuls am Folgetag", + "metricNextDayHrv": "Herzfrequenzvariabilität am Folgetag", "unitHours": "Std.", "unitKcal": "kcal", "unitScore": "Pkt.", + "unitBpm": "bpm", + "unitMs": "ms", "detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.", "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst." }, diff --git a/messages/en.json b/messages/en.json index ee47dc886..f57f2e8e2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3432,9 +3432,13 @@ "metricActiveEnergy": "Active energy", "metricSleepDuration": "Sleep", "metricNextDayRecovery": "Next-day recovery", + "metricNextDayRestingHr": "Next-day resting heart rate", + "metricNextDayHrv": "Next-day heart-rate variability", "unitHours": "h", "unitKcal": "kcal", "unitScore": "pts", + "unitBpm": "bpm", + "unitMs": "ms", "detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.", "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise." }, diff --git a/messages/es.json b/messages/es.json index 50a18d9f4..76cf71896 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3432,9 +3432,13 @@ "metricActiveEnergy": "Energía activa", "metricSleepDuration": "Sueño", "metricNextDayRecovery": "Recuperación al día siguiente", + "metricNextDayRestingHr": "Frecuencia cardíaca en reposo al día siguiente", + "metricNextDayHrv": "Variabilidad de la frecuencia cardíaca al día siguiente", "unitHours": "h", "unitKcal": "kcal", "unitScore": "pts", + "unitBpm": "ppm", + "unitMs": "ms", "detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.", "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto." }, diff --git a/messages/fr.json b/messages/fr.json index 713e46e24..f9fbc926a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3432,9 +3432,13 @@ "metricActiveEnergy": "Énergie active", "metricSleepDuration": "Sommeil", "metricNextDayRecovery": "Récupération le lendemain", + "metricNextDayRestingHr": "Fréquence cardiaque au repos le lendemain", + "metricNextDayHrv": "Variabilité de la fréquence cardiaque le lendemain", "unitHours": "h", "unitKcal": "kcal", "unitScore": "pts", + "unitBpm": "bpm", + "unitMs": "ms", "detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.", "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon." }, diff --git a/messages/it.json b/messages/it.json index 6bc653e8c..756e26c52 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3432,9 +3432,13 @@ "metricActiveEnergy": "Energia attiva", "metricSleepDuration": "Sonno", "metricNextDayRecovery": "Recupero il giorno dopo", + "metricNextDayRestingHr": "Frequenza cardiaca a riposo il giorno dopo", + "metricNextDayHrv": "Variabilità della frequenza cardiaca il giorno dopo", "unitHours": "h", "unitKcal": "kcal", "unitScore": "pt", + "unitBpm": "bpm", + "unitMs": "ms", "detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.", "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti." }, diff --git a/messages/pl.json b/messages/pl.json index b05dc3814..9720559da 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3432,9 +3432,13 @@ "metricActiveEnergy": "Aktywna energia", "metricSleepDuration": "Sen", "metricNextDayRecovery": "Regeneracja następnego dnia", + "metricNextDayRestingHr": "Tętno spoczynkowe następnego dnia", + "metricNextDayHrv": "Zmienność rytmu serca następnego dnia", "unitHours": "godz.", "unitKcal": "kcal", "unitScore": "pkt", + "unitBpm": "bpm", + "unitMs": "ms", "detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.", "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni." }, diff --git a/src/components/insights/mood/__tests__/mood-tag-metric-crosstab.test.tsx b/src/components/insights/mood/__tests__/mood-tag-metric-crosstab.test.tsx new file mode 100644 index 000000000..5f2ccdbf7 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-tag-metric-crosstab.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MoodTagMetricCrosstab, + type MoodTagMetricCrosstabRow, +} from "../mood-tag-metric-crosstab"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function row( + over: Partial = {}, +): MoodTagMetricCrosstabRow { + return { + tag: "alcohol", + labelKey: "mood.tag.alcohol", + label: null, + categoryKey: "consumption", + icon: "Wine", + metricKey: "nextDayRestingHeartRate", + display: "bpm", + mode: "nextDay", + withDays: 12, + withoutDays: 14, + withAvg: 62.4, + withoutAvg: 54.1, + delta: 8.3, + pValue: 0.002, + qValue: 0.02, + confidence: "high", + ...over, + }; +} + +describe("", () => { + it("renders nothing for an empty list", () => { + expect(render()).toBe(""); + }); + + it("labels a next-day resting-heart-rate row and prints its bpm unit", () => { + const html = render(); + expect(html).toContain("Next-day resting heart rate"); + expect(html).toContain("+8.3 bpm"); + expect(html).toContain('data-metric="nextDayRestingHeartRate"'); + }); + + it("labels a next-day HRV row and prints its ms unit", () => { + const html = render( + , + ); + expect(html).toContain("Next-day heart-rate variability"); + expect(html).toContain("-17.4 ms"); + expect(html).toContain('data-metric="nextDayHeartRateVariability"'); + }); + + it("captions a next-day row with the day-after wording", () => { + const html = render(); + expect(html).toContain("the day after the 12 days you tagged this"); + }); + + // The board mixes metrics of opposite valence — a higher next-day resting + // heart rate is worse, a higher active energy is better — so the delta + // read-out must stay neutral rather than assert a verdict in colour. + it("keeps the delta read-out neutral instead of colouring it by sign", () => { + const up = render(); + const down = render( + , + ); + for (const html of [up, down]) { + expect(html).not.toContain("var(--success)"); + expect(html).not.toContain("var(--destructive)"); + } + expect(up).toContain('data-direction="up"'); + expect(down).toContain('data-direction="down"'); + }); +}); diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index 147b312a9..ef37dbf06 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -16,9 +16,15 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence"; * relations surface uses; only FDR-surviving rows reach here. Observational * only: the generic "associations, not causes" caveat lives once in the * page-level Insights footer, so this card no longer repeats it. + * + * The delta read-out is NEUTRAL, matching the rated-factor board. Once the + * board carries next-day resting heart rate alongside sleep and active energy, + * a positive delta is good on one row and bad on the next, so a green/red + * sign would assert a health verdict the association does not support. The + * sign prefix and `data-direction` carry the direction instead. */ -export type MoodCrosstabDisplay = "hours" | "kcal" | "score"; +export type MoodCrosstabDisplay = "hours" | "kcal" | "score" | "bpm" | "ms"; export type MoodCrosstabMode = "sameDay" | "nextDay"; export interface MoodTagMetricCrosstabRow { @@ -49,12 +55,16 @@ const METRIC_LABEL_KEY: Record = { activeEnergy: "insights.mood.crosstab.metricActiveEnergy", sleepDuration: "insights.mood.crosstab.metricSleepDuration", nextDayRecovery: "insights.mood.crosstab.metricNextDayRecovery", + nextDayRestingHeartRate: "insights.mood.crosstab.metricNextDayRestingHr", + nextDayHeartRateVariability: "insights.mood.crosstab.metricNextDayHrv", }; const UNIT_KEY: Record = { hours: "insights.mood.crosstab.unitHours", kcal: "insights.mood.crosstab.unitKcal", score: "insights.mood.crosstab.unitScore", + bpm: "insights.mood.crosstab.unitBpm", + ms: "insights.mood.crosstab.unitMs", }; const CONFIDENCE_KEY: Record = { @@ -101,9 +111,6 @@ export function MoodTagMetricCrosstab({ const unit = t(UNIT_KEY[row.display]); const up = row.delta >= 0; const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; - // Semantic feedback tokens (not raw `--dracula-*`): the delta read-out - // is COLORED TEXT, so it needs the Alucard light-mode override for AA. - const deltaColor = up ? "var(--success)" : "var(--destructive)"; return (
  • - + {deltaText} { expect(row!.delta).toBeLessThan(0); }); + // The alcohol question the tag board could not answer before: a binary + // evening tag against the two overnight-recovery VITALS, at the same + // D → D+1 lag the recovery channel already used. + const ALCOHOL_TAG: StructuredTagRef = { + key: "alcohol", + categoryKey: "consumption", + labelKey: "mood.tag.alcohol", + icon: "Wine", + }; + + /** + * 24 consecutive days: the tag on days 1..12 paired to `presentValue` on + * the following day, no tag on days 13..24 paired to `absentValue`. + */ + function nextDayFixture( + type: string, + presentValue: number, + absentValue: number, + ) { + const entries: MoodAggregateEntry[] = []; + const measurements: CrossMetricMeasurement[] = []; + for (let i = 1; i <= 12; i++) { + entries.push(structuredEntry(i, 3, [ALCOHOL_TAG])); + measurements.push( + meas(i - 1, type, presentValue + (i % 2 === 0 ? 2 : -2)), + ); + } + for (let i = 13; i <= 24; i++) { + entries.push(structuredEntry(i, 4, [])); + measurements.push( + meas(i - 1, type, absentValue + (i % 2 === 0 ? 2 : -2)), + ); + } + return { entries, measurements }; + } + + it("pairs a tag against the NEXT day's resting heart rate (D → D+1 lag)", () => { + const { entries, measurements } = nextDayFixture( + "RESTING_HEART_RATE", + 62, + 54, + ); + const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW }); + const row = rows.find( + (r) => r.tag === "alcohol" && r.metricKey === "nextDayRestingHeartRate", + ); + expect(row).toBeDefined(); + expect(row!.mode).toBe("nextDay"); + expect(row!.display).toBe("bpm"); + expect(row!.withDays).toBe(12); + expect(row!.withoutDays).toBe(12); + // bpm passes through un-scaled — only SLEEP_DURATION divides by 60. + expect(row!.withAvg).toBeCloseTo(62, 0); + expect(row!.withoutAvg).toBeCloseTo(54, 0); + expect(row!.delta).toBeCloseTo(8, 0); + expect(row!.qValue).toBeLessThanOrEqual(CROSSTAB_FDR_Q); + }); + + it("pairs a tag against the NEXT day's heart-rate variability (D → D+1 lag)", () => { + const { entries, measurements } = nextDayFixture( + "HEART_RATE_VARIABILITY", + 38, + 55, + ); + const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW }); + const row = rows.find( + (r) => + r.tag === "alcohol" && r.metricKey === "nextDayHeartRateVariability", + ); + expect(row).toBeDefined(); + expect(row!.mode).toBe("nextDay"); + expect(row!.display).toBe("ms"); + expect(row!.withAvg).toBeCloseTo(38, 0); + expect(row!.withoutAvg).toBeCloseTo(55, 0); + expect(row!.delta).toBeCloseTo(-17, 0); + }); + + it("reads both overnight vitals from the fetched channel set", () => { + // The fetch filter is derived from the map, so a metric added to + // CROSSTAB_METRICS without a channel to read it from is impossible. + expect(CROSSTAB_METRIC_TYPES).toContain("RESTING_HEART_RATE"); + expect(CROSSTAB_METRIC_TYPES).toContain("HEART_RATE_VARIABILITY"); + expect(CROSSTAB_METRICS.nextDayRestingHeartRate.mode).toBe("nextDay"); + expect(CROSSTAB_METRICS.nextDayHeartRateVariability.mode).toBe("nextDay"); + }); + it("drops a tag below the per-side day floors", () => { const entries: MoodAggregateEntry[] = []; const measurements: CrossMetricMeasurement[] = []; diff --git a/src/lib/insights/mood-crosstab.ts b/src/lib/insights/mood-crosstab.ts index f03d49617..c49003a06 100644 --- a/src/lib/insights/mood-crosstab.ts +++ b/src/lib/insights/mood-crosstab.ts @@ -53,7 +53,8 @@ import type { MeasurementSource } from "@/generated/prisma/client"; * sleep tag × SLEEP_DURATION). * - "nextDay" — the metric on the day AFTER the tag (D → D+1 lag join, the * same lag the correlation-discovery engine uses). Used for an - * alcohol/food tag × next-day recovery/readiness (RECOVERY_SCORE), + * alcohol/food tag × the overnight-recovery channels — readiness + * (RECOVERY_SCORE), resting heart rate and heart-rate variability — * where the plausible direction is "tonight's choice → tomorrow's * recovery". * @@ -89,7 +90,7 @@ export const CROSSTAB_METRICS: Record< { type: MeasurementType; mode: CrosstabMode; - display: "hours" | "kcal" | "score"; + display: "hours" | "kcal" | "score" | "bpm" | "ms"; } > = { // A workout / active tag × same-day active energy. @@ -106,10 +107,31 @@ export const CROSSTAB_METRICS: Record< mode: "nextDay", display: "score", }, + // The two overnight-recovery vitals, same D → D+1 lag as recovery. A + // recovery score is a wearable composite that many accounts never record, + // and resting heart rate / HRV are the channels that carry the same + // "last night's body" signal on a plain phone or scale sync. The rated-factor + // board (`FACTOR_CROSSTAB_METRICS`) already tests both at this lag; the tag + // board did not, so a BINARY evening tag — alcohol, a late meal, a nightcap — + // had no path to either vital. Same engine, same floors, same BH family. + nextDayRestingHeartRate: { + type: "RESTING_HEART_RATE", + mode: "nextDay", + display: "bpm", + }, + nextDayHeartRateVariability: { + type: "HEART_RATE_VARIABILITY", + mode: "nextDay", + display: "ms", + }, } as const; export type CrosstabMetricKey = keyof typeof CROSSTAB_METRICS; +/** Display units the tag crosstab emits — derived so the row can't drift. */ +export type CrosstabMetricDisplay = + (typeof CROSSTAB_METRICS)[CrosstabMetricKey]["display"]; + /** Distinct measurement types the crosstab reads — single-sourced. */ export const CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( new Set(Object.values(CROSSTAB_METRICS).map((m) => m.type)), @@ -129,7 +151,7 @@ export interface TagMetricCrosstabRow { /** Which metric channel this row compares against. */ metricKey: CrosstabMetricKey; /** Display unit hint for the client formatter. */ - display: "hours" | "kcal" | "score"; + display: CrosstabMetricDisplay; /** Pairing mode used (echoed so the UI can caption "next-day"). */ mode: CrosstabMode; /** Days the tag was present that had a paired metric value. */ @@ -237,10 +259,7 @@ export function shiftDayKey(day: string, lagDays: number): string { } /** Convert a raw metric value to the row's display unit. */ -function toDisplayUnit( - value: number, - display: "hours" | "kcal" | "score", -): number { +function toDisplayUnit(value: number, display: CrosstabMetricDisplay): number { return display === "hours" ? value / 60 : value; }