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