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
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 19:23:52 +02:00
1 parent 4be01a67f0
commit 8dc5c230da
10 files changed
+242 -15

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function row(
over: Partial<MoodTagMetricCrosstabRow> = {},
): 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("<MoodTagMetricCrosstab>", () => {
it("renders nothing for an empty list", () => {
expect(render(<MoodTagMetricCrosstab rows={[]} />)).toBe("");
});
it("labels a next-day resting-heart-rate row and prints its bpm unit", () => {
const html = render(<MoodTagMetricCrosstab rows={[row()]} />);
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(
<MoodTagMetricCrosstab
rows={[
row({
metricKey: "nextDayHeartRateVariability",
display: "ms",
withAvg: 38.2,
withoutAvg: 55.6,
delta: -17.4,
}),
]}
/>,
);
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(<MoodTagMetricCrosstab rows={[row()]} />);
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(<MoodTagMetricCrosstab rows={[row()]} />);
const down = render(
<MoodTagMetricCrosstab rows={[row({ delta: -8.3 })]} />,
);
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"');
});
});
@@ -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<string, string> = {
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<MoodCrosstabDisplay, string> = {
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<MoodInfluenceConfidence, string> = {
@@ -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 (
<li
key={`${row.metricKey}:${row.tag}`}
@@ -129,10 +136,7 @@ export function MoodTagMetricCrosstab({
metric: metricLabel,
})}
</span>
<span
className="shrink-0 text-sm font-semibold tabular-nums"
style={{ color: deltaColor }}
>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
</span>
<span