feat(mood-insights): add tag influence and the "better days" relations board

Two new observational surfaces on the mood Insights page, both computed in
the single mood-aggregates engine so the charts and the coach snapshot stay
in sync.

Influence on mood (per tag): for each frequent flat and structured tag,
compare the average daily mood on days the tag is present against the
counterfactual baseline of days it is absent, with the delta and a
confidence band. The band comes from a Welch two-sample t-test reusing the
exact Student-t p-value already behind the correlation cards, not a naive
approximation. Both groups must clear a five-day floor; rows rank by
absolute delta and cap per axis. Sparse tags, single-day tags,
all-same-value days, and zero-delta tags are dropped cleanly — never NaN.

Better days board: one ranked list folding the tag deltas and the
mood x health-metric correlations (sleep, steps, pulse, weight, blood
pressure) into a single effect-size-ranked, confidence-gated surface — the
headline answer to "what goes with my good days?". Near-zero and
under-sampled correlations are excluded; the list is capped and observational
only, with the standing "association, not cause" caption.

Both surfaces ride the mood-status snapshot too so the assessment prose
never contradicts the on-screen relations. New copy in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:09:07 +02:00
1 parent 528ce93c71
commit f1edbd17d1
16 files changed
+1518 -6

No files matched your search

@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
MoodBetterDays,
type MoodBetterDayFactor,
} from "../mood-better-days";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const tagFactor: MoodBetterDayFactor = {
source: "tag",
key: "exercise",
labelKey: null,
categoryKey: null,
icon: null,
direction: "up",
n: 14,
confidence: "high",
effectSize: 1,
delta: 2,
r: null,
};
const metricFactor: MoodBetterDayFactor = {
source: "metric",
key: "sleep",
labelKey: null,
categoryKey: null,
icon: null,
direction: "up",
n: 40,
confidence: "medium",
effectSize: 0.6,
delta: null,
r: 0.6,
};
describe("<MoodBetterDays>", () => {
it("renders nothing for an empty board", () => {
expect(render(<MoodBetterDays factors={[]} />)).toBe("");
});
it("shows a tag factor with its mood delta", () => {
const html = render(<MoodBetterDays factors={[tagFactor]} />);
expect(html).toContain("exercise");
expect(html).toContain("+2.0");
expect(html).toContain("High confidence");
expect(html).toContain('data-source="tag"');
expect(html).toContain('data-direction="up"');
});
it("shows a metric factor with its labelled name and r value", () => {
const html = render(<MoodBetterDays factors={[metricFactor]} />);
// sleep maps to the localized correlation title, not the raw key
expect(html).toContain("Mood vs. sleep");
expect(html).toContain("r 0.60");
expect(html).toContain('data-source="metric"');
});
it("renders a down factor", () => {
const html = render(
<MoodBetterDays
factors={[
{
...metricFactor,
key: "bloodPressureSystolic",
direction: "down",
r: -0.45,
},
]}
/>,
);
expect(html).toContain('data-direction="down"');
expect(html).toContain("r -0.45");
});
it("renders the description and disclaimer once each", () => {
const html = render(
<MoodBetterDays factors={[tagFactor, metricFactor]} />,
);
expect((html.match(/Associations only/g) ?? []).length).toBe(1);
expect((html.match(/ranked by how strongly/g) ?? []).length).toBe(1);
// one row per factor
expect((html.match(/data-slot="mood-better-day-factor"/g) ?? []).length).toBe(
2,
);
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
MoodTagInfluence,
type MoodTagInfluenceRow,
} from "../mood-tag-influence";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function flatRow(over: Partial<MoodTagInfluenceRow> = {}): MoodTagInfluenceRow {
return {
tag: "exercise",
labelKey: null,
categoryKey: null,
icon: null,
withDays: 14,
withoutDays: 14,
withAvg: 4.5,
withoutAvg: 2.5,
delta: 2,
pValue: 0.001,
confidence: "high",
...over,
};
}
describe("<MoodTagInfluence>", () => {
it("renders nothing for an empty list", () => {
expect(render(<MoodTagInfluence rows={[]} />)).toBe("");
});
it("shows a flat tag verbatim with a signed delta and confidence chip", () => {
const html = render(<MoodTagInfluence rows={[flatRow()]} />);
expect(html).toContain("exercise");
expect(html).toContain("+2.0");
expect(html).toContain("High confidence");
// with/without detail uses the real averages + present-day count
expect(html).toContain("4.5");
expect(html).toContain("2.5");
expect(html).toContain("14");
expect(html).toContain('data-slot="mood-tag-influence"');
expect(html).toContain('data-direction="up"');
});
it("renders a negative delta as a down row without a plus sign", () => {
const html = render(
<MoodTagInfluence
rows={[
flatRow({ tag: "poor_sleep", delta: -1.4, confidence: "medium" }),
]}
/>,
);
expect(html).toContain("-1.4");
expect(html).not.toContain("+-1.4");
expect(html).toContain('data-direction="down"');
expect(html).toContain("Medium confidence");
});
it("resolves a structured tag label key", () => {
const html = render(
<MoodTagInfluence
rows={[
flatRow({
tag: "happy",
labelKey: "charts.weekdaysFull.mon",
categoryKey: "feelings",
icon: "Smile",
}),
]}
/>,
);
expect(html).toContain("Monday");
expect(html).not.toContain("charts.weekdaysFull.mon");
});
it("renders the observational disclaimer once", () => {
const html = render(
<MoodTagInfluence rows={[flatRow(), flatRow({ tag: "social" })]} />,
);
expect((html.match(/not proof of cause/g) ?? []).length).toBe(1);
});
});
@@ -0,0 +1,146 @@
"use client";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
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.11.5 (F2) — "What's associated with your better days" board.
*
* One ranked, confidence-gated list folding the F1 tag deltas and the
* mood × health-metric correlations into a single effect-size-ranked
* surface — the headline "relations" answer to "what goes with my good
* days?". The merge + ranking is pre-computed in `mood-aggregates.ts`
* (`computeBetterDays`); this component only paints. Observational only;
* the standing disclaimer is rendered once below the board.
*/
export interface MoodBetterDayFactor {
source: "tag" | "metric";
key: string;
labelKey: string | null;
categoryKey: string | null;
icon: string | null;
direction: "up" | "down";
n: number;
confidence: MoodInfluenceConfidence;
effectSize: number;
delta: number | null;
r: number | null;
}
const METRIC_LABEL_KEY: Record<string, string> = {
sleep: "insights.mood.correlation.sleepTitle",
steps: "insights.mood.correlation.stepsTitle",
pulse: "insights.mood.correlation.pulseTitle",
weight: "insights.mood.correlation.weightTitle",
bloodPressureSystolic: "insights.mood.correlation.bloodPressureTitle",
};
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-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
};
export function MoodBetterDays({
factors,
}: {
factors: MoodBetterDayFactor[];
}) {
const { t } = useTranslations();
if (factors.length === 0) return null;
return (
<div data-slot="mood-better-days">
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.betterDays.description")}
</p>
<ul className="divide-border divide-y">
{factors.map((factor) => {
const up = factor.direction === "up";
let label: string;
let Icon: ReturnType<typeof moodTagIcon> | null = null;
if (factor.source === "metric") {
label = t(METRIC_LABEL_KEY[factor.key] ?? factor.key);
} else if (factor.labelKey) {
label = t(factor.labelKey);
Icon = moodTagIcon(factor.icon);
} else {
label = factor.key;
}
const DirectionIcon = up ? ArrowUpRight : ArrowDownRight;
const directionColor = up
? "var(--dracula-green)"
: "var(--dracula-red)";
// The effect read-out: tag factors show the mood-point delta,
// metric factors show the correlation coefficient.
const effectText =
factor.source === "tag" && factor.delta != null
? `${factor.delta >= 0 ? "+" : ""}${factor.delta.toFixed(1)}`
: factor.r != null
? `r ${factor.r.toFixed(2)}`
: "";
return (
<li
key={`${factor.source}:${factor.key}`}
className="flex items-center gap-2 py-2 text-sm"
data-slot="mood-better-day-factor"
data-source={factor.source}
data-direction={factor.direction}
data-confidence={factor.confidence}
>
<DirectionIcon
className="h-4 w-4 shrink-0"
style={{ color: directionColor }}
aria-hidden="true"
/>
{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={label}
>
{label}
</span>
<span
className="shrink-0 text-xs font-semibold tabular-nums"
style={{ color: directionColor }}
>
{effectText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
CONFIDENCE_CLASS[factor.confidence],
)}
>
{t(CONFIDENCE_KEY[factor.confidence])}
</span>
</li>
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-[11px]">
{t("insights.mood.betterDays.disclaimer")}
</p>
</div>
);
}
@@ -42,6 +42,14 @@ import {
MoodStabilityTile,
type MoodStabilityData,
} from "./mood-stability-tile";
import {
MoodTagInfluence,
type MoodTagInfluenceRow,
} from "./mood-tag-influence";
import {
MoodBetterDays,
type MoodBetterDayFactor,
} from "./mood-better-days";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -68,6 +76,13 @@ interface MoodInsightsResponse {
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
// Optional only to tolerate a stale pre-v1.11.5 cached payload during a
// rollout; the live endpoint always populates both.
tagInfluence?: {
flat: MoodTagInfluenceRow[];
structured: MoodTagInfluenceRow[];
};
betterDays?: MoodBetterDayFactor[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
@@ -138,6 +153,19 @@ export function MoodInsightsSections() {
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
// F1 — fold the structured + flat influence axes into one list ranked by
// absolute delta, so the strongest "this tag moves my mood" rows lead
// regardless of which axis they came from. Defensive against a stale
// server-cache payload minted before the v1.11.5 shape landed (the
// aggregate is cached up to 60 s; a rollout can serve one old read).
const influenceRows = [
...(data.tagInfluence?.structured ?? []),
...(data.tagInfluence?.flat ?? []),
].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
const hasInfluence = influenceRows.length > 0;
const betterDays = data.betterDays ?? [];
const hasBetterDays = betterDays.length > 0;
return (
<div className="space-y-4">
{(hasInTargetTile || hasStabilityTile) && (
@@ -160,6 +188,12 @@ export function MoodInsightsSections() {
<MoodNarrativeFeed items={narratives} />
{hasBetterDays && (
<SectionCard title={t("insights.mood.betterDays.title")}>
<MoodBetterDays factors={betterDays} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.heatmapTitle")}>
<MoodHeatmap
cells={heatmapCells}
@@ -195,6 +229,12 @@ export function MoodInsightsSections() {
</SectionCard>
)}
{hasInfluence && (
<SectionCard title={t("insights.mood.influence.title")}>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
<div className="space-y-2">
<h3 className="text-base font-semibold">
{t("insights.mood.correlationsTitle")}
@@ -0,0 +1,118 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
/**
* v1.11.5 (F1) — tag "Influence on Mood".
*
* For each frequent flat / structured tag, the average daily mood on days
* the tag is PRESENT vs the counterfactual baseline of days it is ABSENT,
* the delta, and a confidence chip. The math is pre-computed in
* `mood-aggregates.ts` (shared with the LLM snapshot), gated on per-group
* sample floors — sparse tags never reach here. Observational only: the
* "association, not cause" caption is rendered once below the list.
*/
export type MoodInfluenceConfidence = "low" | "medium" | "high";
export interface MoodTagInfluenceRow {
tag: string;
labelKey: string | null;
categoryKey: string | null;
icon: string | null;
withDays: number;
withoutDays: number;
withAvg: number;
withoutAvg: number;
delta: number;
pValue: number;
confidence: MoodInfluenceConfidence;
}
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-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
};
function deltaColor(delta: number): string {
return delta >= 0 ? "var(--dracula-green)" : "var(--dracula-red)";
}
export function MoodTagInfluence({
rows,
}: {
rows: MoodTagInfluenceRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div data-slot="mood-tag-influence">
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
const label = row.labelKey ? t(row.labelKey) : row.tag;
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
return (
<li
key={`${row.labelKey ?? "flat"}:${row.tag}`}
className="flex flex-col gap-1.5 py-2"
data-slot="mood-influence-row"
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={label}
>
{label}
</span>
<span
className="shrink-0 text-sm font-semibold tabular-nums"
style={{ color: deltaColor(row.delta) }}
>
{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("insights.mood.influence.detail", {
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
withDays: row.withDays,
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-[11px]">
{t("insights.mood.influence.disclaimer")}
</p>
</div>
);
}