From f1edbd17d15c68b06ec1865655a7db11849cb03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 18:01:28 +0200 Subject: [PATCH] feat(mood-insights): add tag influence and the "better days" relations board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 25 ++ messages/en.json | 25 ++ messages/es.json | 25 ++ messages/fr.json | 25 ++ messages/it.json | 25 ++ messages/pl.json | 25 ++ .../mood/__tests__/mood-better-days.test.tsx | 94 ++++ .../__tests__/mood-tag-influence.test.tsx | 88 ++++ .../insights/mood/mood-better-days.tsx | 146 ++++++ .../insights/mood/mood-insights-sections.tsx | 40 ++ .../insights/mood/mood-tag-influence.tsx | 118 +++++ .../insights/__tests__/correlations.test.ts | 57 +++ .../__tests__/mood-aggregates.test.ts | 277 ++++++++++++ src/lib/insights/correlations.ts | 98 +++++ src/lib/insights/mood-aggregates.ts | 414 +++++++++++++++++- src/lib/insights/mood-status.ts | 42 ++ 16 files changed, 1518 insertions(+), 6 deletions(-) create mode 100644 src/components/insights/mood/__tests__/mood-better-days.test.tsx create mode 100644 src/components/insights/mood/__tests__/mood-tag-influence.test.tsx create mode 100644 src/components/insights/mood/mood-better-days.tsx create mode 100644 src/components/insights/mood/mood-tag-influence.tsx diff --git a/messages/de.json b/messages/de.json index fd04c3845..4c02c9b4e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2530,6 +2530,31 @@ "bandSteady": "Deine Stimmung war recht gleichmäßig", "bandVariable": "Deine Stimmung war merklich schwankend", "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" + }, + "influence": { + "title": "Einfluss auf die Stimmung", + "detail": "Durchschnitt {withAvg} an den {withDays} Tagen mit diesem Tag, {withoutAvg} ohne.", + "confidenceLow": "Geringe Sicherheit", + "confidenceMedium": "Mittlere Sicherheit", + "confidenceHigh": "Hohe Sicherheit", + "disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für eine Ursache. Muster, die einen Blick wert sind." + }, + "betterDays": { + "title": "Was zu deinen besseren Tagen passt", + "description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen.", + "disclaimer": "Nur Zusammenhänge, keine Ursachen. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst." + }, + "discovery": { + "title": "Entdeckte Muster", + "description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.", + "footer": "{discovered} von {tested} getesteten Zusammenhängen haben die Korrektur für Fehlentdeckungen überstanden.", + "empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.", + "disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.", + "moodUp": "Höheres {factor} geht in deinen Daten tendenziell mit besserer Stimmung am Folgetag einher.", + "moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.", + "outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.", + "outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.", + "stat": "{n} gepaarte Tage · r = {r} · q = {q}" } }, "derived": { diff --git a/messages/en.json b/messages/en.json index 57779b507..b8a03d7ef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2530,6 +2530,31 @@ "bandSteady": "Your mood has been fairly steady", "bandVariable": "Your mood has been noticeably variable", "bandVeryVariable": "Your mood has swung a lot lately" + }, + "influence": { + "title": "Influence on mood", + "detail": "Averages {withAvg} on the {withDays} days you tagged this vs {withoutAvg} without it.", + "confidenceLow": "Low confidence", + "confidenceMedium": "Medium confidence", + "confidenceHigh": "High confidence", + "disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching." + }, + "betterDays": { + "title": "What goes with your better days", + "description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together.", + "disclaimer": "Associations only, not causes. Talk to your doctor before changing anything based on these." + }, + "discovery": { + "title": "Discovered patterns", + "description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.", + "footer": "{discovered} of {tested} relationships tested survived the false-discovery correction.", + "empty": "No mood relationships cleared the statistical bar yet. Keep logging.", + "disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.", + "moodUp": "Higher {factor} tends to go with higher next-day mood in your data.", + "moodDown": "Higher {factor} tends to go with lower next-day mood in your data.", + "outcomeUp": "Higher mood tends to go with higher next-day {factor} in your data.", + "outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.", + "stat": "{n} paired days · r = {r} · q = {q}" } }, "derived": { diff --git a/messages/es.json b/messages/es.json index 0644325b5..952da3362 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2530,6 +2530,31 @@ "bandSteady": "Tu estado de ánimo ha sido bastante estable", "bandVariable": "Tu estado de ánimo ha sido notablemente variable", "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" + }, + "influence": { + "title": "Influencia en el estado de ánimo", + "detail": "Promedio de {withAvg} en los {withDays} días que lo etiquetaste, frente a {withoutAvg} sin él.", + "confidenceLow": "Confianza baja", + "confidenceMedium": "Confianza media", + "confidenceHigh": "Confianza alta", + "disclaimer": "Son asociaciones en tus propios datos, no una prueba de causa. Patrones que vale la pena observar." + }, + "betterDays": { + "title": "Qué acompaña a tus mejores días", + "description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas.", + "disclaimer": "Solo asociaciones, no causas. Consulta a tu médico antes de cambiar nada por esto." + }, + "discovery": { + "title": "Patrones descubiertos", + "description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.", + "footer": "{discovered} de {tested} relaciones probadas superaron la corrección de falsos descubrimientos.", + "empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.", + "disclaimer": "Filtrado estadísticamente, pero aún observacional: un patrón que vale la pena observar, no una causa.", + "moodUp": "Un mayor {factor} suele coincidir con mejor ánimo al día siguiente en tus datos.", + "moodDown": "Un mayor {factor} suele coincidir con peor ánimo al día siguiente en tus datos.", + "outcomeUp": "Mejor ánimo suele coincidir con un {factor} más alto al día siguiente en tus datos.", + "outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.", + "stat": "{n} días emparejados · r = {r} · q = {q}" } }, "derived": { diff --git a/messages/fr.json b/messages/fr.json index c5ae3f79e..c609638e0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2530,6 +2530,31 @@ "bandSteady": "Votre humeur a été plutôt stable", "bandVariable": "Votre humeur a été nettement variable", "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" + }, + "influence": { + "title": "Influence sur l'humeur", + "detail": "Moyenne de {withAvg} les {withDays} jours où vous l'avez indiqué, contre {withoutAvg} sans.", + "confidenceLow": "Confiance faible", + "confidenceMedium": "Confiance moyenne", + "confidenceHigh": "Confiance élevée", + "disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller." + }, + "betterDays": { + "title": "Ce qui accompagne vos meilleurs jours", + "description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien.", + "disclaimer": "Des associations seulement, pas des causes. Parlez-en à votre médecin avant de changer quoi que ce soit." + }, + "discovery": { + "title": "Tendances détectées", + "description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.", + "footer": "{discovered} relations sur {tested} testées ont passé la correction des fausses découvertes.", + "empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.", + "disclaimer": "Filtré statistiquement, mais toujours observationnel : une tendance à surveiller, pas une cause.", + "moodUp": "Un {factor} plus élevé tend à coïncider avec une meilleure humeur le lendemain dans vos données.", + "moodDown": "Un {factor} plus élevé tend à coïncider avec une moins bonne humeur le lendemain dans vos données.", + "outcomeUp": "Une meilleure humeur tend à coïncider avec un {factor} plus élevé le lendemain dans vos données.", + "outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.", + "stat": "{n} jours appariés · r = {r} · q = {q}" } }, "derived": { diff --git a/messages/it.json b/messages/it.json index 1355501b6..5932fb12e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2530,6 +2530,31 @@ "bandSteady": "Il tuo umore è stato abbastanza stabile", "bandVariable": "Il tuo umore è stato notevolmente variabile", "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" + }, + "influence": { + "title": "Influenza sull'umore", + "detail": "Media di {withAvg} nei {withDays} giorni in cui l'hai indicato, contro {withoutAvg} senza.", + "confidenceLow": "Affidabilità bassa", + "confidenceMedium": "Affidabilità media", + "confidenceHigh": "Affidabilità alta", + "disclaimer": "Sono associazioni nei tuoi dati, non una prova di causa. Schemi da tenere d'occhio." + }, + "betterDays": { + "title": "Cosa accompagna le tue giornate migliori", + "description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme.", + "disclaimer": "Solo associazioni, non cause. Parla con il tuo medico prima di cambiare qualcosa." + }, + "discovery": { + "title": "Schemi rilevati", + "description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.", + "footer": "{discovered} relazioni su {tested} testate hanno superato la correzione per le false scoperte.", + "empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.", + "disclaimer": "Filtrato statisticamente, ma sempre osservazionale: uno schema da tenere d'occhio, non una causa.", + "moodUp": "Un {factor} più alto tende a coincidere con un umore migliore il giorno dopo nei tuoi dati.", + "moodDown": "Un {factor} più alto tende a coincidere con un umore peggiore il giorno dopo nei tuoi dati.", + "outcomeUp": "Un umore migliore tende a coincidere con un {factor} più alto il giorno dopo nei tuoi dati.", + "outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.", + "stat": "{n} giorni appaiati · r = {r} · q = {q}" } }, "derived": { diff --git a/messages/pl.json b/messages/pl.json index 2c4e83608..05b1d234a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2530,6 +2530,31 @@ "bandSteady": "Twój nastrój był dość stabilny", "bandVariable": "Twój nastrój był zauważalnie zmienny", "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" + }, + "influence": { + "title": "Wpływ na nastrój", + "detail": "Średnia {withAvg} w {withDays} dniach z tym tagiem wobec {withoutAvg} bez niego.", + "confidenceLow": "Niska pewność", + "confidenceMedium": "Średnia pewność", + "confidenceHigh": "Wysoka pewność", + "disclaimer": "To powiązania w Twoich danych, a nie dowód przyczyny. Wzorce warte obserwacji." + }, + "betterDays": { + "title": "Co towarzyszy Twoim lepszym dniom", + "description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania.", + "disclaimer": "Tylko powiązania, nie przyczyny. Skonsultuj się z lekarzem, zanim coś zmienisz." + }, + "discovery": { + "title": "Wykryte wzorce", + "description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.", + "footer": "{discovered} z {tested} testowanych relacji przeszło korektę fałszywych odkryć.", + "empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.", + "disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.", + "moodUp": "Wyższy {factor} zwykle wiąże się z lepszym nastrojem następnego dnia w Twoich danych.", + "moodDown": "Wyższy {factor} zwykle wiąże się z gorszym nastrojem następnego dnia w Twoich danych.", + "outcomeUp": "Lepszy nastrój zwykle wiąże się z wyższym {factor} następnego dnia w Twoich danych.", + "outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.", + "stat": "{n} sparowanych dni · r = {r} · q = {q}" } }, "derived": { diff --git a/src/components/insights/mood/__tests__/mood-better-days.test.tsx b/src/components/insights/mood/__tests__/mood-better-days.test.tsx new file mode 100644 index 000000000..c2d483750 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-better-days.test.tsx @@ -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( + {node}, + ); +} + +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("", () => { + it("renders nothing for an empty board", () => { + expect(render()).toBe(""); + }); + + it("shows a tag factor with its mood delta", () => { + const html = render(); + 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(); + // 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( + , + ); + expect(html).toContain('data-direction="down"'); + expect(html).toContain("r -0.45"); + }); + + it("renders the description and disclaimer once each", () => { + const html = render( + , + ); + 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, + ); + }); +}); diff --git a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx new file mode 100644 index 000000000..317409bc5 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx @@ -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( + {node}, + ); +} + +function flatRow(over: Partial = {}): 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("", () => { + it("renders nothing for an empty list", () => { + expect(render()).toBe(""); + }); + + it("shows a flat tag verbatim with a signed delta and confidence chip", () => { + const html = render(); + 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( + , + ); + 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( + , + ); + expect(html).toContain("Monday"); + expect(html).not.toContain("charts.weekdaysFull.mon"); + }); + + it("renders the observational disclaimer once", () => { + const html = render( + , + ); + expect((html.match(/not proof of cause/g) ?? []).length).toBe(1); + }); +}); diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx new file mode 100644 index 000000000..4a489a4e7 --- /dev/null +++ b/src/components/insights/mood/mood-better-days.tsx @@ -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 = { + 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 = { + low: "insights.mood.influence.confidenceLow", + medium: "insights.mood.influence.confidenceMedium", + high: "insights.mood.influence.confidenceHigh", +}; + +const CONFIDENCE_CLASS: Record = { + 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 ( +
+

+ {t("insights.mood.betterDays.description")} +

+
    + {factors.map((factor) => { + const up = factor.direction === "up"; + + let label: string; + let Icon: ReturnType | 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 ( +
  • +
  • + ); + })} +
+

+ {t("insights.mood.betterDays.disclaimer")} +

+
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 15d157dc0..56f705271 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -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 (
{(hasInTargetTile || hasStabilityTile) && ( @@ -160,6 +188,12 @@ export function MoodInsightsSections() { + {hasBetterDays && ( + + + + )} + )} + {hasInfluence && ( + + + + )} +

{t("insights.mood.correlationsTitle")} diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx new file mode 100644 index 000000000..e2ca76acd --- /dev/null +++ b/src/components/insights/mood/mood-tag-influence.tsx @@ -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 = { + low: "insights.mood.influence.confidenceLow", + medium: "insights.mood.influence.confidenceMedium", + high: "insights.mood.influence.confidenceHigh", +}; + +const CONFIDENCE_CLASS: Record = { + 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 ( +
+
    + {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 ( +
  • +
    + {Icon && ( +
    +

    + {t("insights.mood.influence.detail", { + withAvg: row.withAvg.toFixed(1), + withoutAvg: row.withoutAvg.toFixed(1), + withDays: row.withDays, + })} +

    +
  • + ); + })} +
+

+ {t("insights.mood.influence.disclaimer")} +

+
+ ); +} diff --git a/src/lib/insights/__tests__/correlations.test.ts b/src/lib/insights/__tests__/correlations.test.ts index e2da34e36..168feb44f 100644 --- a/src/lib/insights/__tests__/correlations.test.ts +++ b/src/lib/insights/__tests__/correlations.test.ts @@ -391,3 +391,60 @@ describe("correlateWeightWeekday", () => { } }); }); + +// ── welchTTest() ───────────────────────────────────────────────────── + +import { welchTTest } from "../correlations"; + +describe("welchTTest", () => { + it("flags too_few_samples below the per-group floor", () => { + const result = welchTTest([3], [2, 2, 2], { minPerGroup: 2 }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("too_few_samples"); + } + }); + + it("flags no_variance when both groups are perfectly constant", () => { + const result = welchTTest([4, 4, 4], [3, 3, 3]); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_variance"); + } + }); + + it("computes the difference of means with the right sign", () => { + const withVals = [4, 5, 4, 5, 4, 5]; + const withoutVals = [2, 3, 2, 3, 2, 3]; + const result = welchTTest(withVals, withoutVals); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.meanA).toBeCloseTo(4.5, 5); + expect(result.meanB).toBeCloseTo(2.5, 5); + expect(result.meanDiff).toBeCloseTo(2, 5); + // A clean 2-point separation on tight groups is highly significant. + expect(result.pValue).toBeLessThan(0.001); + } + }); + + it("returns a high p-value for two indistinguishable groups", () => { + const a = [3, 4, 3, 4, 3, 4, 3, 4]; + const b = [4, 3, 4, 3, 4, 3, 4, 3]; + const result = welchTTest(a, b); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(Math.abs(result.meanDiff)).toBeLessThan(0.001); + expect(result.pValue).toBeGreaterThan(0.5); + } + }); + + it("never returns NaN/Infinity on a one-sided constant group", () => { + const result = welchTTest([5, 5, 5, 5], [3, 4, 3, 4]); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(Number.isFinite(result.tStat)).toBe(true); + expect(Number.isFinite(result.df)).toBe(true); + expect(Number.isFinite(result.pValue)).toBe(true); + } + }); +}); diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 4cd610841..5d2f574c6 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -13,12 +13,20 @@ import { computeMoodStability, computeStructuredTagSummary, computeTagSummary, + computeTagInfluence, + computeBetterDays, + influenceConfidence, computeTimeOfDayAverages, computeWeekdayAverages, selectHeatmapWindow, + INFLUENCE_MIN_PRESENT_DAYS, + INFLUENCE_MIN_ABSENT_DAYS, + BETTER_DAYS_MAX_FACTORS, type CrossMetricMeasurement, type MoodAggregateEntry, + type MoodMetricCorrelation, type StructuredTagRef, + type TagInfluence, } from "../mood-aggregates"; const dayMs = 24 * 60 * 60 * 1000; @@ -462,3 +470,272 @@ describe("computeMoodStability", () => { expect(stability?.band).toBe("variable"); }); }); + +// ── F1 — computeTagInfluence (with-vs-without delta) ───────────────── + +describe("computeTagInfluence", () => { + function flatEntry(offset: number, score: number, tags: string[]): MoodAggregateEntry { + return entry(offset, score, tags); + } + + it("computes the with-vs-without daily-mean delta and direction", () => { + // 14 days WITH "exercise" averaging ~4.5, 14 days WITHOUT averaging ~2.5 + // — enough per group (>= 12) for a strong clean separation to read high. + const entries: MoodAggregateEntry[] = []; + for (let i = 0; i < 14; i++) entries.push(flatEntry(i, i % 2 === 0 ? 5 : 4, ["exercise"])); + for (let i = 14; i < 28; i++) entries.push(flatEntry(i, i % 2 === 0 ? 3 : 2, [])); + const result = computeTagInfluence(entries, NOW, 365); + const ex = result.flat.find((r) => r.tag === "exercise"); + expect(ex).toBeDefined(); + expect(ex!.withDays).toBe(14); + expect(ex!.withoutDays).toBe(14); + expect(ex!.withAvg).toBeCloseTo(4.5, 1); + expect(ex!.withoutAvg).toBeCloseTo(2.5, 1); + expect(ex!.delta).toBeCloseTo(2, 1); + expect(ex!.delta).toBeGreaterThan(0); + // strong clean separation on enough days → high confidence + expect(ex!.confidence).toBe("high"); + }); + + it("downgrades confidence to low on a small (but sufficient) sample", () => { + // exactly at the floor — 5 present / 5 absent — never reads high. + const entries: MoodAggregateEntry[] = []; + for (let i = 0; i < 5; i++) entries.push(flatEntry(i, 5, ["small"])); + for (let i = 5; i < 10; i++) entries.push(flatEntry(i, 2, [])); + const result = computeTagInfluence(entries, NOW); + const row = result.flat.find((r) => r.tag === "small"); + expect(row).toBeDefined(); + expect(row!.confidence).toBe("low"); + }); + + it("drops a tag below the present-days floor", () => { + const entries: MoodAggregateEntry[] = []; + // only 3 present days < INFLUENCE_MIN_PRESENT_DAYS + for (let i = 0; i < 3; i++) entries.push(flatEntry(i, 5, ["rare"])); + for (let i = 3; i < 15; i++) entries.push(flatEntry(i, 3, [])); + expect(INFLUENCE_MIN_PRESENT_DAYS).toBeGreaterThan(3); + const result = computeTagInfluence(entries, NOW); + expect(result.flat.find((r) => r.tag === "rare")).toBeUndefined(); + }); + + it("drops a tag below the absent-days floor", () => { + const entries: MoodAggregateEntry[] = []; + // tag present on 12 days, absent on only 3 < INFLUENCE_MIN_ABSENT_DAYS + for (let i = 0; i < 12; i++) entries.push(flatEntry(i, 4, ["ubiquitous"])); + for (let i = 12; i < 15; i++) entries.push(flatEntry(i, 3, [])); + expect(INFLUENCE_MIN_ABSENT_DAYS).toBeGreaterThan(3); + const result = computeTagInfluence(entries, NOW); + expect(result.flat.find((r) => r.tag === "ubiquitous")).toBeUndefined(); + }); + + it("uses the daily-mean convention (multi-entry days collapse to one observation)", () => { + const entries: MoodAggregateEntry[] = []; + // day 0 has two "calm" entries (5 and 3 → mean 4); make 5 more present days + entries.push(flatEntry(0, 5, ["calm"])); + entries.push(flatEntry(0, 3, ["calm"])); + for (let i = 1; i < 6; i++) entries.push(flatEntry(i, 4, ["calm"])); + for (let i = 6; i < 12; i++) entries.push(flatEntry(i, 2, [])); + const result = computeTagInfluence(entries, NOW); + const calm = result.flat.find((r) => r.tag === "calm"); + expect(calm).toBeDefined(); + // 6 distinct present days, not 7 entries + expect(calm!.withDays).toBe(6); + }); + + it("never returns NaN and drops a zero-delta tag", () => { + const entries: MoodAggregateEntry[] = []; + // identical means with and without → delta 0 → dropped + for (let i = 0; i < 6; i++) entries.push(flatEntry(i, 3, ["neutral"])); + for (let i = 6; i < 12; i++) entries.push(flatEntry(i, 3, [])); + const result = computeTagInfluence(entries, NOW); + expect(result.flat.find((r) => r.tag === "neutral")).toBeUndefined(); + for (const row of [...result.flat, ...result.structured]) { + expect(Number.isFinite(row.delta)).toBe(true); + expect(Number.isFinite(row.withAvg)).toBe(true); + expect(Number.isFinite(row.withoutAvg)).toBe(true); + expect(Number.isFinite(row.pValue)).toBe(true); + } + }); + + it("returns empty axes for a sparse history", () => { + const entries: MoodAggregateEntry[] = [flatEntry(0, 5, ["a"]), flatEntry(1, 4, ["b"])]; + const result = computeTagInfluence(entries, NOW); + expect(result.flat).toEqual([]); + expect(result.structured).toEqual([]); + }); + + it("handles structured tags with label metadata and ranks by |delta|", () => { + const tag = (key: string): StructuredTagRef => ({ + key, + categoryKey: "feelings", + labelKey: `mood.tag.${key}`, + icon: "Smile", + }); + const e = (offset: number, score: number, keys: string[]): MoodAggregateEntry => ({ + ...entry(offset, score, null), + structuredTags: keys.map(tag), + }); + const entries: MoodAggregateEntry[] = []; + // "social" lifts strongly, "chores" lifts mildly + for (let i = 0; i < 6; i++) entries.push(e(i, 5, ["social"])); + for (let i = 6; i < 12; i++) entries.push(e(i, 4, ["chores"])); + for (let i = 12; i < 18; i++) entries.push(e(i, 2, [])); + const result = computeTagInfluence(entries, NOW); + expect(result.structured[0].tag).toBe("social"); + expect(result.structured[0].labelKey).toBe("mood.tag.social"); + expect(result.structured[0].categoryKey).toBe("feelings"); + // ranked by absolute delta desc + expect(Math.abs(result.structured[0].delta)).toBeGreaterThanOrEqual( + Math.abs(result.structured[1].delta), + ); + }); + + it("caps each axis at the max-rows limit", () => { + const entries: MoodAggregateEntry[] = []; + // 12 distinct tags each present on 6 days, with a shared absent pool + for (let tagIdx = 0; tagIdx < 12; tagIdx++) { + for (let d = 0; d < 6; d++) { + entries.push(flatEntry(tagIdx * 6 + d, 5 - (tagIdx % 4), [`tag${tagIdx}`])); + } + } + const result = computeTagInfluence(entries, NOW, 365); + expect(result.flat.length).toBeLessThanOrEqual(8); + }); +}); + +describe("influenceConfidence", () => { + it("requires both a small p and a comfortable sample for high", () => { + expect(influenceConfidence(0.005, 12)).toBe("high"); + expect(influenceConfidence(0.005, 11)).toBe("medium"); // sample too small for high + expect(influenceConfidence(0.04, 10)).toBe("medium"); + expect(influenceConfidence(0.04, 7)).toBe("low"); // sample too small for medium + expect(influenceConfidence(0.2, 50)).toBe("low"); // p too high + }); +}); + +// ── F2 — computeBetterDays (unified board) ────────────────────────── + +describe("computeBetterDays", () => { + function corr( + n: number, + r: number, + strength: "stark" | "moderat" | "schwach" | "keine", + ): MoodMetricCorrelation { + return { result: { r, strength, n }, points: [], n }; + } + + const emptyCorr: MoodMetricCorrelation = { result: null, points: [], n: 0 }; + + it("merges tag influence and metric correlations, ranked by effect size", () => { + const tagInfluence: TagInfluence = { + flat: [ + { + tag: "exercise", + labelKey: null, + categoryKey: null, + icon: null, + withDays: 10, + withoutDays: 10, + withAvg: 4.4, + withoutAvg: 2.4, + delta: 2, // effect 1.0 (capped) + pValue: 0.001, + confidence: "high", + }, + ], + structured: [], + }; + const correlations = { + sleep: corr(40, 0.6, "moderat"), // effect 0.6 + steps: corr(40, 0.15, "keine"), // excluded (keine) + pulse: emptyCorr, // excluded (null) + weight: corr(3, 0.9, "stark"), // excluded (n < 5) + bloodPressureSystolic: corr(40, -0.45, "moderat"), // effect 0.45, down + }; + const board = computeBetterDays(tagInfluence, correlations); + expect(board.map((f) => f.key)).toEqual([ + "exercise", + "sleep", + "bloodPressureSystolic", + ]); + expect(board[0].source).toBe("tag"); + expect(board[0].direction).toBe("up"); + expect(board[2].direction).toBe("down"); + // excluded ones are absent + expect(board.find((f) => f.key === "steps")).toBeUndefined(); + expect(board.find((f) => f.key === "pulse")).toBeUndefined(); + expect(board.find((f) => f.key === "weight")).toBeUndefined(); + }); + + it("flags a negative tag delta as a down factor", () => { + const tagInfluence: TagInfluence = { + flat: [ + { + tag: "poor_sleep", + labelKey: null, + categoryKey: null, + icon: null, + withDays: 8, + withoutDays: 8, + withAvg: 2.2, + withoutAvg: 3.8, + delta: -1.6, + pValue: 0.01, + confidence: "medium", + }, + ], + structured: [], + }; + const board = computeBetterDays(tagInfluence, { + sleep: emptyCorr, + steps: emptyCorr, + pulse: emptyCorr, + weight: emptyCorr, + bloodPressureSystolic: emptyCorr, + }); + expect(board).toHaveLength(1); + expect(board[0].direction).toBe("down"); + expect(board[0].delta).toBe(-1.6); + }); + + it("returns an empty board when nothing clears the gates", () => { + const board = computeBetterDays( + { flat: [], structured: [] }, + { + sleep: corr(40, 0.1, "keine"), + steps: emptyCorr, + pulse: emptyCorr, + weight: emptyCorr, + bloodPressureSystolic: emptyCorr, + }, + ); + expect(board).toEqual([]); + }); + + it("caps the board length", () => { + const flat = Array.from({ length: 20 }, (_, i) => ({ + tag: `t${i}`, + labelKey: null, + categoryKey: null, + icon: null, + withDays: 6, + withoutDays: 6, + withAvg: 4, + withoutAvg: 3, + delta: 1 - i * 0.01, + pValue: 0.02, + confidence: "low" as const, + })); + const board = computeBetterDays( + { flat, structured: [] }, + { + sleep: emptyCorr, + steps: emptyCorr, + pulse: emptyCorr, + weight: emptyCorr, + bloodPressureSystolic: emptyCorr, + }, + ); + expect(board.length).toBe(BETTER_DAYS_MAX_FACTORS); + }); +}); diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index e5fd8dafb..33894f814 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -233,6 +233,104 @@ function twoSidedPFromT(absT: number, df: number): number { return regularizedIncompleteBeta(x, df / 2, 0.5); } +// ── Welch two-sample t-test (unequal variances) ──────────────────── + +export interface WelchOk { + status: "ok"; + /** Mean of the first sample ("with"). */ + meanA: number; + /** Mean of the second sample ("without"). */ + meanB: number; + /** meanA − meanB. */ + meanDiff: number; + /** Welch t-statistic for the difference of means. */ + tStat: number; + /** Welch–Satterthwaite degrees of freedom. */ + df: number; + /** Exact two-sided p-value via the incomplete beta. */ + pValue: number; + nA: number; + nB: number; +} + +export interface WelchInsufficient { + status: "insufficient"; + reason: "too_few_samples" | "no_variance"; + nA: number; + nB: number; +} + +export type WelchResult = WelchOk | WelchInsufficient; + +/** + * Welch's two-sample t-test for the difference of two group means under + * unequal variances (the correct test when the "with-tag" and + * "without-tag" day counts and spreads differ, which they almost always + * do). Reuses the exact Student-t survival (`twoSidedPFromT`) so the + * p-value is accurate at the small df the mood surface produces — the + * same rigour the Pearson cards use, not a normal approximation. + * + * Returns `insufficient` when either group is below `minPerGroup`, or + * when both groups are constant (zero pooled variance → no testable + * difference). Guards divide-by-zero and the all-equal degenerate case + * so callers never see NaN/Infinity. + * + * Formula (Welch 1947): + * t = (x̄_A − x̄_B) / sqrt(s²_A/n_A + s²_B/n_B) + * df = (s²_A/n_A + s²_B/n_B)² / + * ( (s²_A/n_A)²/(n_A−1) + (s²_B/n_B)²/(n_B−1) ) + * with s² the unbiased (n−1) sample variance. + */ +export function welchTTest( + groupA: readonly number[], + groupB: readonly number[], + options: { minPerGroup?: number } = {}, +): WelchResult { + const minPerGroup = options.minPerGroup ?? 2; + const nA = groupA.length; + const nB = groupB.length; + if (nA < minPerGroup || nB < minPerGroup) { + return { status: "insufficient", reason: "too_few_samples", nA, nB }; + } + + const meanA = groupA.reduce((s, v) => s + v, 0) / nA; + const meanB = groupB.reduce((s, v) => s + v, 0) / nB; + // Unbiased (n−1) sample variances. + const varA = groupA.reduce((s, v) => s + (v - meanA) ** 2, 0) / (nA - 1); + const varB = groupB.reduce((s, v) => s + (v - meanB) ** 2, 0) / (nB - 1); + + const seA = varA / nA; + const seB = varB / nB; + const seSum = seA + seB; + if (seSum === 0) { + // Both groups are perfectly constant — no testable spread. If their + // means differ the difference is real but has no variance estimate; + // we surface it as "no_variance" so the caller renders a deterministic + // band rather than a t-test that would divide by zero. + return { status: "insufficient", reason: "no_variance", nA, nB }; + } + + const meanDiff = meanA - meanB; + const tStat = meanDiff / Math.sqrt(seSum); + const dfDenomA = nA > 1 ? seA ** 2 / (nA - 1) : 0; + const dfDenomB = nB > 1 ? seB ** 2 / (nB - 1) : 0; + const dfDenom = dfDenomA + dfDenomB; + const df = dfDenom === 0 ? nA + nB - 2 : seSum ** 2 / dfDenom; + const pValue = twoSidedPFromT(Math.abs(tStat), df); + + return { + status: "ok", + meanA: roundTo(meanA, 3), + meanB: roundTo(meanB, 3), + meanDiff: roundTo(meanDiff, 3), + tStat: roundTo(tStat, 3), + df: roundTo(df, 2), + pValue, + nA, + nB, + }; +} + /** * Regularised incomplete beta function I_x(a, b). * diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index a35c0aaf3..5795e8519 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -30,6 +30,7 @@ import { computeMoodNarratives, type MoodNarrative, } from "@/lib/insights/mood-narratives"; +import { welchTTest } from "@/lib/insights/correlations"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -266,6 +267,389 @@ export function computeStructuredTagSummary( .sort((a, b) => b.count - a.count || a.labelKey.localeCompare(b.labelKey)); } +// --- Tag "Influence on Mood" — with-vs-without daily-mean delta (F1) --- + +/** + * Minimum days a tag must be PRESENT on, and minimum days it must be + * ABSENT on, before its with/without influence surfaces. Both floors + * apply: a tag seen on 4 days has no defensible "with" average, and a + * tag present on all-but-2 days has no defensible "without" baseline. + * Five days per side is the same order as the stability floor (7 daily + * points) but per-group — enough for a mean of daily means to mean + * something without surfacing a two-day fluke as a "factor". + */ +export const INFLUENCE_MIN_PRESENT_DAYS = 5; +export const INFLUENCE_MIN_ABSENT_DAYS = 5; + +/** + * Maximum influence rows surfaced per axis (flat / structured). Ranked by + * absolute delta so the strongest associations lead; the cap keeps the + * multiple-comparison surface bounded (the confidence band downgrades the + * rest honestly). + */ +export const INFLUENCE_MAX_ROWS = 8; + +/** Discrete confidence the UI chip renders for an influence row. */ +export type InfluenceConfidence = "low" | "medium" | "high"; + +export interface TagInfluenceRow { + /** Stable tag key. For flat tags this is the free-text string. */ + tag: string; + /** + * For structured tags, the i18n label key + parent category so the UI + * can render the localized label with its icon. `null` for flat tags + * (rendered verbatim). + */ + labelKey: string | null; + categoryKey: string | null; + icon: string | null; + /** Days the tag was present (daily-mean convention). */ + withDays: number; + /** Days the tag was absent over the same window. */ + withoutDays: number; + /** Mean of daily means on days the tag was present. */ + withAvg: number; + /** Mean of daily means on days the tag was absent (the counterfactual). */ + withoutAvg: number; + /** withAvg − withoutAvg, rounded. Positive = higher mood with the tag. */ + delta: number; + /** Welch two-sided p-value for the difference of means. */ + pValue: number; + /** Discrete confidence band derived from p-value + per-group sample size. */ + confidence: InfluenceConfidence; +} + +/** + * Map a Welch p-value + the smaller per-group day count to a discrete + * confidence band. Both inputs matter: a small p on tiny groups is still + * fragile, and a comfortable sample with a borderline p is only suggestive. + * + * - high : p < 0.01 AND both groups ≥ 12 days + * - medium : p < 0.05 AND both groups ≥ 8 days + * - low : everything else that cleared the surfacing floors + * + * The deterministic "no_variance" case (both groups perfectly constant but + * with a non-zero delta) is treated as `low` — a real but un-tested + * difference should never read as confident. + */ +export function influenceConfidence( + pValue: number, + minGroupDays: number, +): InfluenceConfidence { + if (pValue < 0.01 && minGroupDays >= 12) return "high"; + if (pValue < 0.05 && minGroupDays >= 8) return "medium"; + return "low"; +} + +/** + * Collapse raw mood rows to one observation per tz-anchored day: the + * day's mean score plus the union of every flat + structured tag key + * present on any entry that day. Daily-mean convention so a multi-entry + * day never over-weights an influence comparison (it matches the heatmap, + * distribution, and weekday axes). + */ +interface TaggedDay { + /** Day's mean mood (mean of the day's entry scores). */ + mean: number; + /** Flat free-text tag keys present that day. */ + flatTags: Set; + /** Structured tag refs present that day, keyed for de-dup. */ + structuredTags: Map; +} + +function collapseToTaggedDays( + entries: MoodAggregateEntry[], + now: Date, + windowDays: number, +): Map { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const byDay = new Map< + string, + { sum: number; count: number; flat: Set; structured: Map } + >(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + const day = byDay.get(entry.date) ?? { + sum: 0, + count: 0, + flat: new Set(), + structured: new Map(), + }; + day.sum += entry.score; + day.count += 1; + if (entry.tags && Array.isArray(entry.tags)) { + for (const tag of entry.tags as string[]) day.flat.add(tag); + } + if (entry.structuredTags) { + for (const ref of entry.structuredTags) day.structured.set(ref.key, ref); + } + byDay.set(entry.date, day); + } + const out = new Map(); + for (const [date, agg] of byDay) { + out.set(date, { + mean: agg.sum / agg.count, + flatTags: agg.flat, + structuredTags: agg.structured, + }); + } + return out; +} + +/** + * Compute the with-vs-without influence row for ONE candidate tag over a + * pre-collapsed day map. `presentOn(day)` decides membership. Returns null + * when either group is below its day floor (so the caller drops the tag) + * or when Welch reports no testable spread AND the means are equal. + */ +function influenceForTag( + days: TaggedDay[], + presentOn: (day: TaggedDay) => boolean, + meta: { tag: string; labelKey: string | null; categoryKey: string | null; icon: string | null }, +): TagInfluenceRow | null { + const withVals: number[] = []; + const withoutVals: number[] = []; + for (const day of days) { + if (presentOn(day)) withVals.push(day.mean); + else withoutVals.push(day.mean); + } + if ( + withVals.length < INFLUENCE_MIN_PRESENT_DAYS || + withoutVals.length < INFLUENCE_MIN_ABSENT_DAYS + ) { + return null; + } + + const welch = welchTTest(withVals, withoutVals); + + // Deterministic means even when Welch declines (no_variance) — the + // delta is still meaningful, we just cannot attach a p-value, so the + // band falls to `low`. + const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length; + const withoutAvg = + withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length; + const delta = withAvg - withoutAvg; + const minGroupDays = Math.min(withVals.length, withoutVals.length); + + const pValue = welch.status === "ok" ? welch.pValue : 1; + // A zero delta with no spread is not an "influence" — drop it so the + // board never shows a 0.0 row. + if (delta === 0) return null; + + return { + tag: meta.tag, + labelKey: meta.labelKey, + categoryKey: meta.categoryKey, + icon: meta.icon, + withDays: withVals.length, + withoutDays: withoutVals.length, + withAvg: round(withAvg, 2), + withoutAvg: round(withoutAvg, 2), + delta: round(delta, 2), + pValue, + confidence: influenceConfidence(pValue, minGroupDays), + }; +} + +export interface TagInfluence { + /** Flat free-text tag influence rows, ranked by |delta| desc. */ + flat: TagInfluenceRow[]; + /** Structured taxonomy tag influence rows, ranked by |delta| desc. */ + structured: TagInfluenceRow[]; +} + +/** + * Tag "Influence on Mood" (Daylio's flagship): for each frequent flat and + * 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 + * band from a Welch two-sample t-test. + * + * Gating: both groups must clear `INFLUENCE_MIN_PRESENT_DAYS` / + * `INFLUENCE_MIN_ABSENT_DAYS`; rows are ranked by absolute delta and capped + * at `INFLUENCE_MAX_ROWS` per axis. Observational only — the UI applies the + * standing "association, not cause" caption. Sparse tags, single-day tags, + * all-same-value days, and divide-by-zero are all handled cleanly (the row + * is dropped, never NaN). + */ +export function computeTagInfluence( + entries: MoodAggregateEntry[], + now: Date, + windowDays = 90, +): TagInfluence { + const dayMap = collapseToTaggedDays(entries, now, windowDays); + const days = Array.from(dayMap.values()); + + // Need at least one day per side to even attempt — the per-tag floor + // catches the real gate, this just short-circuits an empty history. + if (days.length < INFLUENCE_MIN_PRESENT_DAYS + INFLUENCE_MIN_ABSENT_DAYS) { + return { flat: [], structured: [] }; + } + + // Candidate flat tags: every distinct flat key seen in the window. + const flatKeys = new Set(); + for (const day of days) for (const k of day.flatTags) flatKeys.add(k); + + const flat: TagInfluenceRow[] = []; + for (const key of flatKeys) { + const row = influenceForTag(days, (d) => d.flatTags.has(key), { + tag: key, + labelKey: null, + categoryKey: null, + icon: null, + }); + if (row) flat.push(row); + } + + // Candidate structured tags: every distinct structured key + its meta. + const structuredMeta = new Map(); + for (const day of days) { + for (const [key, ref] of day.structuredTags) { + if (!structuredMeta.has(key)) structuredMeta.set(key, ref); + } + } + + const structured: TagInfluenceRow[] = []; + for (const [key, ref] of structuredMeta) { + const row = influenceForTag(days, (d) => d.structuredTags.has(key), { + tag: key, + labelKey: ref.labelKey, + categoryKey: ref.categoryKey, + icon: ref.icon, + }); + if (row) structured.push(row); + } + + const rank = (a: TagInfluenceRow, b: TagInfluenceRow) => + Math.abs(b.delta) - Math.abs(a.delta) || a.tag.localeCompare(b.tag); + + return { + flat: flat.sort(rank).slice(0, INFLUENCE_MAX_ROWS), + structured: structured.sort(rank).slice(0, INFLUENCE_MAX_ROWS), + }; +} + +// --- "What's associated with your better days" board (F2) --- + +/** + * One ranked factor on the unified board. Either a tag (`source: "tag"`, + * effect = the mood-point delta) or a health-metric correlation + * (`source: "metric"`, effect = Pearson r). Direction tells the UI whether + * the factor goes with higher or lower mood; the standing "association, not + * cause" caption is rendered once for the whole board. + */ +export interface BetterDayFactor { + source: "tag" | "metric"; + /** Tag key OR correlation channel key (sleep/steps/pulse/weight/bp). */ + key: string; + /** Flat tags / metrics: null. Structured tags: the i18n label key. */ + labelKey: string | null; + categoryKey: string | null; + icon: string | null; + /** "up" = associated with higher mood; "down" = with lower mood. */ + direction: "up" | "down"; + /** Sample count behind the factor (tag: smaller group; metric: paired n). */ + n: number; + /** Discrete confidence band. */ + confidence: InfluenceConfidence; + /** + * Unified ranking strength in [0,1]. For metrics this is |r|; for tags it + * is min(1, |delta| / 2) — a two-point mood swing reads as full strength. + * Ranking only; the UI surfaces the raw delta / r, not this number. + */ + effectSize: number; + /** Raw mood-point delta for a tag factor; null for a metric factor. */ + delta: number | null; + /** Raw Pearson r for a metric factor; null for a tag factor. */ + r: number | null; +} + +/** Max factors on the board so the headline surface stays scannable. */ +export const BETTER_DAYS_MAX_FACTORS = 8; + +/** Map a metric correlation strength label to the shared confidence band. */ +function metricConfidence( + strength: CorrelationResult["strength"], +): InfluenceConfidence { + if (strength === "stark") return "high"; + if (strength === "moderat") return "medium"; + return "low"; +} + +/** + * Merge the F1 tag influence rows and the mood × health-metric + * correlations into one effect-size-ranked, confidence-gated board. + * + * Inclusion gates (multiple-comparison aware): + * - Tag rows: already gated by `computeTagInfluence` (sample floors + + * Welch); included as-is. + * - Metric rows: only correlations that reached `n ≥ 5` AND carry a + * non-"keine" strength (|r| ≥ 0.2) are folded in — a near-zero r is not + * an association. + * + * Ranking is by `effectSize` desc (|r| for metrics, |delta|/2 capped for + * tags), then by confidence, then key for stability. Observational only. + */ +export function computeBetterDays( + tagInfluence: TagInfluence, + correlations: Record, +): BetterDayFactor[] { + const factors: BetterDayFactor[] = []; + + // Tag factors (both axes). Structured tags carry their label meta. + for (const row of [...tagInfluence.structured, ...tagInfluence.flat]) { + factors.push({ + source: "tag", + key: row.tag, + labelKey: row.labelKey, + categoryKey: row.categoryKey, + icon: row.icon, + direction: row.delta >= 0 ? "up" : "down", + n: Math.min(row.withDays, row.withoutDays), + confidence: row.confidence, + effectSize: Math.min(1, Math.abs(row.delta) / 2), + delta: row.delta, + r: null, + }); + } + + // Metric factors — only meaningful, sufficiently-sampled correlations. + for (const [key, corr] of Object.entries(correlations)) { + const result = corr.result; + if (!result || corr.n < 5) continue; + if (result.strength === "keine") continue; + factors.push({ + source: "metric", + key, + labelKey: null, + categoryKey: null, + icon: null, + // Mood is the x-axis: positive r = higher metric on higher-mood days, + // i.e. the metric is associated with higher mood. + direction: result.r >= 0 ? "up" : "down", + n: corr.n, + confidence: metricConfidence(result.strength), + effectSize: Math.min(1, Math.abs(result.r)), + delta: null, + r: result.r, + }); + } + + const confidenceRank: Record = { + high: 3, + medium: 2, + low: 1, + }; + + return factors + .sort( + (a, b) => + b.effectSize - a.effectSize || + confidenceRank[b.confidence] - confidenceRank[a.confidence] || + a.key.localeCompare(b.key), + ) + .slice(0, BETTER_DAYS_MAX_FACTORS); +} + // --- In-target % over the last 30 daily points (lifted from mood-status) --- /** @@ -691,6 +1075,17 @@ export interface MoodAggregates { tags: TagSummaryRow[]; /** v1.8.5 — structured-tag breakdown from the taxonomy join. */ structuredTags: StructuredTagRow[]; + /** + * v1.11.5 (F1) — tag "Influence on Mood": with-vs-without daily-mean + * delta + Welch confidence band for each frequent flat / structured tag. + */ + tagInfluence: TagInfluence; + /** + * v1.11.5 (F2) — unified "what's associated with your better days" board + * folding the F1 tag deltas and the mood × health-metric correlations + * into one effect-size-ranked, confidence-gated, observational list. + */ + betterDays: BetterDayFactor[]; /** * v1.8.6 — ranked, threshold-gated narrative takeaways. The "read * this first" layer above the charts; the same array feeds the LLM @@ -753,8 +1148,18 @@ export function computeMoodAggregates(args: { const stability = computeMoodStability(moodDaily); const tags = computeTagSummary(entries, now); const structuredTags = computeStructuredTagSummary(entries, now); + const tagInfluence = computeTagInfluence(entries, now); const inTargetPct = computeInTargetPct(moodDaily); + const correlations = Object.fromEntries( + Object.entries(CORRELATION_METRICS).map(([key, type]) => [ + key, + computeMoodMetricCorrelation(moodDaily, metricDaily(type), now), + ]), + ) as MoodAggregates["correlations"]; + + const betterDays = computeBetterDays(tagInfluence, correlations); + // Distinct day keys the user logged on — drives the streak takeaway. const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date))); @@ -782,6 +1187,8 @@ export function computeMoodAggregates(args: { stability, tags, structuredTags, + tagInfluence, + betterDays, narratives: computeMoodNarratives({ daily: moodDaily, weekday, @@ -792,12 +1199,7 @@ export function computeMoodAggregates(args: { loggedDayKeys, now, }), - correlations: Object.fromEntries( - Object.entries(CORRELATION_METRICS).map(([key, type]) => [ - key, - computeMoodMetricCorrelation(moodDaily, metricDaily(type), now), - ]), - ) as MoodAggregates["correlations"], + correlations, }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 050b54275..9e93b64d3 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -12,9 +12,12 @@ import { MOOD_GREEN_MIN, MOOD_ORANGE_MAX, MOOD_ORANGE_MIN, + computeBetterDays, computeInTargetPct, + computeMoodMetricCorrelation, computeMoodStability, computeStructuredTagSummary, + computeTagInfluence, computeTagSummary, computeTimeOfDayAverages, computeWeekdayAverages, @@ -268,6 +271,30 @@ export async function generateMoodStatusForUser( const tagSummary = computeTagSummary(entries, now); const structuredTagSummary = computeStructuredTagSummary(entries, now); + // v1.11.5 (F1/F2) — tag "Influence on Mood" + the unified "better days" + // board, computed from the same aggregates the visible mood page shows so + // the model's prose never contradicts the on-screen relations. The + // snapshot fetches only weight / systolic / pulse cross-metrics, so the + // board's metric factors are limited to those three channels (sleep / + // steps are absent here by design — the model sees only what it fetched). + const tagInfluence = computeTagInfluence(entries, now); + const emptyCorrelation = computeMoodMetricCorrelation([], [], now); + const betterDays = computeBetterDays(tagInfluence, { + sleep: emptyCorrelation, + steps: emptyCorrelation, + pulse: computeMoodMetricCorrelation(moodSeries.daily, pulseSeries.daily, now), + weight: computeMoodMetricCorrelation( + moodSeries.daily, + weightSeries.daily, + now, + ), + bloodPressureSystolic: computeMoodMetricCorrelation( + moodSeries.daily, + sysSeries.daily, + now, + ), + }); + // v1.8.6 — the same threshold-gated narrative feed the user sees on // the mood page, computed from the same aggregates so the prose the // model writes never contradicts the takeaways on screen (shown == @@ -320,6 +347,21 @@ export async function generateMoodStatusForUser( inTargetPctLast30DailyPoints, }, tags: tagSummary.length > 0 ? tagSummary : null, + // v1.11.5 (F1) — with-vs-without tag influence (only the populated + // axes are emitted so the prompt stays compact). + tagInfluence: + tagInfluence.flat.length > 0 || tagInfluence.structured.length > 0 + ? { + flat: tagInfluence.flat.length > 0 ? tagInfluence.flat : null, + structured: + tagInfluence.structured.length > 0 + ? tagInfluence.structured + : null, + } + : null, + // v1.11.5 (F2) — the ranked "better days" board the user sees, so the + // model's relations prose matches the on-screen list (shown == sent). + betterDays: betterDays.length > 0 ? betterDays : null, narratives: narratives.length > 0 ? narratives : null, // v1.9.0 — only emit the daypart pattern when it cleared its // spread/sample floors, so the model never reasons over a once-a-day