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;
}