mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user