feat(insights): correlate rated mood factors against vitals

Rated mood factors (work / sleep-quality / stress, scored per entry) now
participate in the cross-metric correlation surface. Until now their score
series was captured but read by zero analytics.

Two paths, both on the existing FDR-controlled engine:

- A factor x vital crosstab on the mood insights page and the mood
  snapshot: for each factor the user scores, split its days low vs high by
  the factor's own median, then run the same Welch test + day floors +
  Benjamini-Hochberg correction the tag crosstab uses. Surfaces lines like
  "sleep runs ~38 min below baseline on the days you rated work low"
  (n-gated, q <= 0.10, never fabricated on thin data).
- Factor channels (FACTOR:<key>) fold into the period-narrative discovery
  matrix as both lag source and lag target, so the drivers the coach and
  narrative reference can cite a factor relationship.

An inverse factor (higher rating = worse day) gets the documented sign-flip
once, at the series boundary, so "up" always reads as a better day and the
low/high split and phrasing stay honest. Strings land in all six locales;
the surface carries the standing "association, not cause" caption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:58:40 +02:00
1 parent 30a7437f4d
commit dfda2a1c7e
15 files changed
+1133 -17

No files matched your search

@@ -0,0 +1,175 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
* v1.14.0 — RATED-factor × vital crosstab card.
*
* The cross-domain bridge: for each factor the user scores per entry (work /
* sleep-quality / stress …), a vital's mean on the days the factor was rated
* LOW (below its median) vs HIGH, the delta, and a confidence chip. The math
* is pre-computed in `mood-aggregates.ts` (`computeFactorMetricCrosstab`) —
* the same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the
* relations surface uses; only FDR-surviving rows reach here. An inverse
* factor (stress / conflict) already runs its split on the flipped series, so
* "low" always means a worse day; the phrasing reads "your worse <factor>
* days". Observational only: the page-level "associations, not causes" footer
* carries the caveat once.
*/
export type MoodFactorCrosstabDisplay =
| "hours"
| "score"
| "steps"
| "bpm"
| "ms"
| "kg"
| "mmHg";
export type MoodFactorCrosstabMode = "sameDay" | "nextDay";
export interface MoodFactorMetricCrosstabRow {
factor: string;
labelKey: string;
categoryKey: string;
icon: string | null;
inverse: boolean;
metricKey: string;
display: MoodFactorCrosstabDisplay;
mode: MoodFactorCrosstabMode;
lowDays: number;
highDays: number;
lowAvg: number;
highAvg: number;
delta: number;
pValue: number;
qValue: number;
confidence: MoodInfluenceConfidence;
}
const METRIC_LABEL_KEY: Record<string, string> = {
sleepDuration: "insights.mood.factorCrosstab.metricSleepDuration",
steps: "insights.mood.factorCrosstab.metricSteps",
restingHeartRate: "insights.mood.factorCrosstab.metricRestingHeartRate",
heartRateVariability: "insights.mood.factorCrosstab.metricHrv",
weight: "insights.mood.factorCrosstab.metricWeight",
bloodPressureSystolic: "insights.mood.factorCrosstab.metricBloodPressureSys",
};
const UNIT_KEY: Record<MoodFactorCrosstabDisplay, string> = {
hours: "insights.mood.factorCrosstab.unitHours",
score: "insights.mood.factorCrosstab.unitScore",
steps: "insights.mood.factorCrosstab.unitSteps",
bpm: "insights.mood.factorCrosstab.unitBpm",
ms: "insights.mood.factorCrosstab.unitMs",
kg: "insights.mood.factorCrosstab.unitKg",
mmHg: "insights.mood.factorCrosstab.unitMmHg",
};
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
low: "insights.mood.influence.confidenceLow",
medium: "insights.mood.influence.confidenceMedium",
high: "insights.mood.influence.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** Whole numbers for steps; one decimal otherwise. */
function fmt(value: number, display: MoodFactorCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
}
export function MoodFactorMetricCrosstab({
rows,
}: {
rows: MoodFactorMetricCrosstabRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div data-slot="mood-factor-metric-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.factorCrosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = moodTagIcon(row.icon);
const factorLabel = t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const unit = t(UNIT_KEY[row.display]);
// `delta` = lowAvg − highAvg: negative = vital runs lower on the
// low-factor (worse) days. The read-out is colored by sign.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
const deltaColor = up ? "var(--success)" : "var(--destructive)";
return (
<li
key={`${row.metricKey}:${row.factor}`}
className="flex flex-col gap-1.5 py-2"
data-slot="mood-factor-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
{Icon && (
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={`${factorLabel} · ${metricLabel}`}
>
{t("insights.mood.factorCrosstab.pairLabel", {
factor: factorLabel,
metric: metricLabel,
})}
</span>
<span
className="shrink-0 text-sm font-semibold tabular-nums"
style={{ color: deltaColor }}
>
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t(
row.inverse
? "insights.mood.factorCrosstab.detailInverse"
: "insights.mood.factorCrosstab.detail",
{
factor: factorLabel,
metric: metricLabel,
lowAvg: fmt(row.lowAvg, row.display),
highAvg: fmt(row.highAvg, row.display),
unit,
lowDays: row.lowDays,
},
)}
</p>
</li>
);
})}
</ul>
</div>
);
}
@@ -9,6 +9,7 @@ import {
CalendarDays,
CalendarRange,
Clock,
Gauge,
Grid3x3,
Link2,
Sparkles,
@@ -94,6 +95,10 @@ import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
import {
MoodFactorMetricCrosstab,
type MoodFactorMetricCrosstabRow,
} from "./mood-factor-metric-crosstab";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -130,6 +135,9 @@ interface MoodInsightsResponse {
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
@@ -244,6 +252,8 @@ export function MoodInsightsSections({
const hasBetterDays = betterDays.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -368,7 +378,17 @@ export function MoodInsightsSections({
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>