mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
4be01a67f0
commit
8dc5c230da
10 files changed
+242
-15
No files matched your search
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
CROSSTAB_MIN_PRESENT_DAYS,
|
||||
CROSSTAB_MIN_ABSENT_DAYS,
|
||||
CROSSTAB_MAX_ROWS,
|
||||
CROSSTAB_METRICS,
|
||||
CROSSTAB_METRIC_TYPES,
|
||||
CROSSTAB_FDR_Q,
|
||||
influenceConfidence,
|
||||
computeTimeOfDayAverages,
|
||||
computeWeekdayAverages,
|
||||
@@ -1069,6 +1072,92 @@ describe("computeTagMetricCrosstab", () => {
|
||||
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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user