mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(mood-insights): add tag influence and the "better days" relations board
Two new observational surfaces on the mood Insights page, both computed in the single mood-aggregates engine so the charts and the coach snapshot stay in sync. Influence on mood (per tag): for each frequent flat and structured tag, compare the average daily mood on days the tag is present against the counterfactual baseline of days it is absent, with the delta and a confidence band. The band comes from a Welch two-sample t-test reusing the exact Student-t p-value already behind the correlation cards, not a naive approximation. Both groups must clear a five-day floor; rows rank by absolute delta and cap per axis. Sparse tags, single-day tags, all-same-value days, and zero-delta tags are dropped cleanly — never NaN. Better days board: one ranked list folding the tag deltas and the mood x health-metric correlations (sleep, steps, pulse, weight, blood pressure) into a single effect-size-ranked, confidence-gated surface — the headline answer to "what goes with my good days?". Near-zero and under-sampled correlations are excluded; the list is capped and observational only, with the standing "association, not cause" caption. Both surfaces ride the mood-status snapshot too so the assessment prose never contradicts the on-screen relations. New copy in all six locales.
This commit is contained in:
1 parent
528ce93c71
commit
f1edbd17d1
16 files changed
+1518
-6
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MoodBetterDays,
|
||||
type MoodBetterDayFactor,
|
||||
} from "../mood-better-days";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const tagFactor: MoodBetterDayFactor = {
|
||||
source: "tag",
|
||||
key: "exercise",
|
||||
labelKey: null,
|
||||
categoryKey: null,
|
||||
icon: null,
|
||||
direction: "up",
|
||||
n: 14,
|
||||
confidence: "high",
|
||||
effectSize: 1,
|
||||
delta: 2,
|
||||
r: null,
|
||||
};
|
||||
|
||||
const metricFactor: MoodBetterDayFactor = {
|
||||
source: "metric",
|
||||
key: "sleep",
|
||||
labelKey: null,
|
||||
categoryKey: null,
|
||||
icon: null,
|
||||
direction: "up",
|
||||
n: 40,
|
||||
confidence: "medium",
|
||||
effectSize: 0.6,
|
||||
delta: null,
|
||||
r: 0.6,
|
||||
};
|
||||
|
||||
describe("<MoodBetterDays>", () => {
|
||||
it("renders nothing for an empty board", () => {
|
||||
expect(render(<MoodBetterDays factors={[]} />)).toBe("");
|
||||
});
|
||||
|
||||
it("shows a tag factor with its mood delta", () => {
|
||||
const html = render(<MoodBetterDays factors={[tagFactor]} />);
|
||||
expect(html).toContain("exercise");
|
||||
expect(html).toContain("+2.0");
|
||||
expect(html).toContain("High confidence");
|
||||
expect(html).toContain('data-source="tag"');
|
||||
expect(html).toContain('data-direction="up"');
|
||||
});
|
||||
|
||||
it("shows a metric factor with its labelled name and r value", () => {
|
||||
const html = render(<MoodBetterDays factors={[metricFactor]} />);
|
||||
// sleep maps to the localized correlation title, not the raw key
|
||||
expect(html).toContain("Mood vs. sleep");
|
||||
expect(html).toContain("r 0.60");
|
||||
expect(html).toContain('data-source="metric"');
|
||||
});
|
||||
|
||||
it("renders a down factor", () => {
|
||||
const html = render(
|
||||
<MoodBetterDays
|
||||
factors={[
|
||||
{
|
||||
...metricFactor,
|
||||
key: "bloodPressureSystolic",
|
||||
direction: "down",
|
||||
r: -0.45,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-direction="down"');
|
||||
expect(html).toContain("r -0.45");
|
||||
});
|
||||
|
||||
it("renders the description and disclaimer once each", () => {
|
||||
const html = render(
|
||||
<MoodBetterDays factors={[tagFactor, metricFactor]} />,
|
||||
);
|
||||
expect((html.match(/Associations only/g) ?? []).length).toBe(1);
|
||||
expect((html.match(/ranked by how strongly/g) ?? []).length).toBe(1);
|
||||
// one row per factor
|
||||
expect((html.match(/data-slot="mood-better-day-factor"/g) ?? []).length).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MoodTagInfluence,
|
||||
type MoodTagInfluenceRow,
|
||||
} from "../mood-tag-influence";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function flatRow(over: Partial<MoodTagInfluenceRow> = {}): MoodTagInfluenceRow {
|
||||
return {
|
||||
tag: "exercise",
|
||||
labelKey: null,
|
||||
categoryKey: null,
|
||||
icon: null,
|
||||
withDays: 14,
|
||||
withoutDays: 14,
|
||||
withAvg: 4.5,
|
||||
withoutAvg: 2.5,
|
||||
delta: 2,
|
||||
pValue: 0.001,
|
||||
confidence: "high",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<MoodTagInfluence>", () => {
|
||||
it("renders nothing for an empty list", () => {
|
||||
expect(render(<MoodTagInfluence rows={[]} />)).toBe("");
|
||||
});
|
||||
|
||||
it("shows a flat tag verbatim with a signed delta and confidence chip", () => {
|
||||
const html = render(<MoodTagInfluence rows={[flatRow()]} />);
|
||||
expect(html).toContain("exercise");
|
||||
expect(html).toContain("+2.0");
|
||||
expect(html).toContain("High confidence");
|
||||
// with/without detail uses the real averages + present-day count
|
||||
expect(html).toContain("4.5");
|
||||
expect(html).toContain("2.5");
|
||||
expect(html).toContain("14");
|
||||
expect(html).toContain('data-slot="mood-tag-influence"');
|
||||
expect(html).toContain('data-direction="up"');
|
||||
});
|
||||
|
||||
it("renders a negative delta as a down row without a plus sign", () => {
|
||||
const html = render(
|
||||
<MoodTagInfluence
|
||||
rows={[
|
||||
flatRow({ tag: "poor_sleep", delta: -1.4, confidence: "medium" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("-1.4");
|
||||
expect(html).not.toContain("+-1.4");
|
||||
expect(html).toContain('data-direction="down"');
|
||||
expect(html).toContain("Medium confidence");
|
||||
});
|
||||
|
||||
it("resolves a structured tag label key", () => {
|
||||
const html = render(
|
||||
<MoodTagInfluence
|
||||
rows={[
|
||||
flatRow({
|
||||
tag: "happy",
|
||||
labelKey: "charts.weekdaysFull.mon",
|
||||
categoryKey: "feelings",
|
||||
icon: "Smile",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Monday");
|
||||
expect(html).not.toContain("charts.weekdaysFull.mon");
|
||||
});
|
||||
|
||||
it("renders the observational disclaimer once", () => {
|
||||
const html = render(
|
||||
<MoodTagInfluence rows={[flatRow(), flatRow({ tag: "social" })]} />,
|
||||
);
|
||||
expect((html.match(/not proof of cause/g) ?? []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
|
||||
|
||||
/**
|
||||
* v1.11.5 (F2) — "What's associated with your better days" board.
|
||||
*
|
||||
* One ranked, confidence-gated list folding the F1 tag deltas and the
|
||||
* mood × health-metric correlations into a single effect-size-ranked
|
||||
* surface — the headline "relations" answer to "what goes with my good
|
||||
* days?". The merge + ranking is pre-computed in `mood-aggregates.ts`
|
||||
* (`computeBetterDays`); this component only paints. Observational only;
|
||||
* the standing disclaimer is rendered once below the board.
|
||||
*/
|
||||
|
||||
export interface MoodBetterDayFactor {
|
||||
source: "tag" | "metric";
|
||||
key: string;
|
||||
labelKey: string | null;
|
||||
categoryKey: string | null;
|
||||
icon: string | null;
|
||||
direction: "up" | "down";
|
||||
n: number;
|
||||
confidence: MoodInfluenceConfidence;
|
||||
effectSize: number;
|
||||
delta: number | null;
|
||||
r: number | null;
|
||||
}
|
||||
|
||||
const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
sleep: "insights.mood.correlation.sleepTitle",
|
||||
steps: "insights.mood.correlation.stepsTitle",
|
||||
pulse: "insights.mood.correlation.pulseTitle",
|
||||
weight: "insights.mood.correlation.weightTitle",
|
||||
bloodPressureSystolic: "insights.mood.correlation.bloodPressureTitle",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "insights.mood.influence.confidenceLow",
|
||||
medium: "insights.mood.influence.confidenceMedium",
|
||||
high: "insights.mood.influence.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
|
||||
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
|
||||
};
|
||||
|
||||
export function MoodBetterDays({
|
||||
factors,
|
||||
}: {
|
||||
factors: MoodBetterDayFactor[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (factors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="mood-better-days">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("insights.mood.betterDays.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{factors.map((factor) => {
|
||||
const up = factor.direction === "up";
|
||||
|
||||
let label: string;
|
||||
let Icon: ReturnType<typeof moodTagIcon> | null = null;
|
||||
if (factor.source === "metric") {
|
||||
label = t(METRIC_LABEL_KEY[factor.key] ?? factor.key);
|
||||
} else if (factor.labelKey) {
|
||||
label = t(factor.labelKey);
|
||||
Icon = moodTagIcon(factor.icon);
|
||||
} else {
|
||||
label = factor.key;
|
||||
}
|
||||
|
||||
const DirectionIcon = up ? ArrowUpRight : ArrowDownRight;
|
||||
const directionColor = up
|
||||
? "var(--dracula-green)"
|
||||
: "var(--dracula-red)";
|
||||
|
||||
// The effect read-out: tag factors show the mood-point delta,
|
||||
// metric factors show the correlation coefficient.
|
||||
const effectText =
|
||||
factor.source === "tag" && factor.delta != null
|
||||
? `${factor.delta >= 0 ? "+" : ""}${factor.delta.toFixed(1)}`
|
||||
: factor.r != null
|
||||
? `r ${factor.r.toFixed(2)}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${factor.source}:${factor.key}`}
|
||||
className="flex items-center gap-2 py-2 text-sm"
|
||||
data-slot="mood-better-day-factor"
|
||||
data-source={factor.source}
|
||||
data-direction={factor.direction}
|
||||
data-confidence={factor.confidence}
|
||||
>
|
||||
<DirectionIcon
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: directionColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-xs font-semibold tabular-nums"
|
||||
style={{ color: directionColor }}
|
||||
>
|
||||
{effectText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[factor.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[factor.confidence])}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-3 text-[11px]">
|
||||
{t("insights.mood.betterDays.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,14 @@ import {
|
||||
MoodStabilityTile,
|
||||
type MoodStabilityData,
|
||||
} from "./mood-stability-tile";
|
||||
import {
|
||||
MoodTagInfluence,
|
||||
type MoodTagInfluenceRow,
|
||||
} from "./mood-tag-influence";
|
||||
import {
|
||||
MoodBetterDays,
|
||||
type MoodBetterDayFactor,
|
||||
} from "./mood-better-days";
|
||||
|
||||
/**
|
||||
* v1.8.5 — additional Mood Insights sections.
|
||||
@@ -68,6 +76,13 @@ interface MoodInsightsResponse {
|
||||
stability: MoodStabilityData | null;
|
||||
tags: MoodTagRow[];
|
||||
structuredTags: MoodStructuredTagRow[];
|
||||
// Optional only to tolerate a stale pre-v1.11.5 cached payload during a
|
||||
// rollout; the live endpoint always populates both.
|
||||
tagInfluence?: {
|
||||
flat: MoodTagInfluenceRow[];
|
||||
structured: MoodTagInfluenceRow[];
|
||||
};
|
||||
betterDays?: MoodBetterDayFactor[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
@@ -138,6 +153,19 @@ export function MoodInsightsSections() {
|
||||
const hasStabilityTile = data.stability != null;
|
||||
const hasInTargetTile = data.summary.inTargetPct != null;
|
||||
|
||||
// F1 — fold the structured + flat influence axes into one list ranked by
|
||||
// absolute delta, so the strongest "this tag moves my mood" rows lead
|
||||
// regardless of which axis they came from. Defensive against a stale
|
||||
// server-cache payload minted before the v1.11.5 shape landed (the
|
||||
// aggregate is cached up to 60 s; a rollout can serve one old read).
|
||||
const influenceRows = [
|
||||
...(data.tagInfluence?.structured ?? []),
|
||||
...(data.tagInfluence?.flat ?? []),
|
||||
].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
||||
const hasInfluence = influenceRows.length > 0;
|
||||
const betterDays = data.betterDays ?? [];
|
||||
const hasBetterDays = betterDays.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(hasInTargetTile || hasStabilityTile) && (
|
||||
@@ -160,6 +188,12 @@ export function MoodInsightsSections() {
|
||||
|
||||
<MoodNarrativeFeed items={narratives} />
|
||||
|
||||
{hasBetterDays && (
|
||||
<SectionCard title={t("insights.mood.betterDays.title")}>
|
||||
<MoodBetterDays factors={betterDays} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title={t("insights.mood.heatmapTitle")}>
|
||||
<MoodHeatmap
|
||||
cells={heatmapCells}
|
||||
@@ -195,6 +229,12 @@ export function MoodInsightsSections() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasInfluence && (
|
||||
<SectionCard title={t("insights.mood.influence.title")}>
|
||||
<MoodTagInfluence rows={influenceRows} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("insights.mood.correlationsTitle")}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.11.5 (F1) — tag "Influence on Mood".
|
||||
*
|
||||
* For each frequent flat / structured tag, the average daily mood on days
|
||||
* the tag is PRESENT vs the counterfactual baseline of days it is ABSENT,
|
||||
* the delta, and a confidence chip. The math is pre-computed in
|
||||
* `mood-aggregates.ts` (shared with the LLM snapshot), gated on per-group
|
||||
* sample floors — sparse tags never reach here. Observational only: the
|
||||
* "association, not cause" caption is rendered once below the list.
|
||||
*/
|
||||
|
||||
export type MoodInfluenceConfidence = "low" | "medium" | "high";
|
||||
|
||||
export interface MoodTagInfluenceRow {
|
||||
tag: string;
|
||||
labelKey: string | null;
|
||||
categoryKey: string | null;
|
||||
icon: string | null;
|
||||
withDays: number;
|
||||
withoutDays: number;
|
||||
withAvg: number;
|
||||
withoutAvg: number;
|
||||
delta: number;
|
||||
pValue: number;
|
||||
confidence: MoodInfluenceConfidence;
|
||||
}
|
||||
|
||||
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "insights.mood.influence.confidenceLow",
|
||||
medium: "insights.mood.influence.confidenceMedium",
|
||||
high: "insights.mood.influence.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
|
||||
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
|
||||
};
|
||||
|
||||
function deltaColor(delta: number): string {
|
||||
return delta >= 0 ? "var(--dracula-green)" : "var(--dracula-red)";
|
||||
}
|
||||
|
||||
export function MoodTagInfluence({
|
||||
rows,
|
||||
}: {
|
||||
rows: MoodTagInfluenceRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="mood-tag-influence">
|
||||
<ul className="divide-border divide-y">
|
||||
{rows.map((row) => {
|
||||
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
|
||||
const label = row.labelKey ? t(row.labelKey) : row.tag;
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
|
||||
return (
|
||||
<li
|
||||
key={`${row.labelKey ?? "flat"}:${row.tag}`}
|
||||
className="flex flex-col gap-1.5 py-2"
|
||||
data-slot="mood-influence-row"
|
||||
data-direction={up ? "up" : "down"}
|
||||
data-confidence={row.confidence}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-sm font-semibold tabular-nums"
|
||||
style={{ color: deltaColor(row.delta) }}
|
||||
>
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.influence.detail", {
|
||||
withAvg: row.withAvg.toFixed(1),
|
||||
withoutAvg: row.withoutAvg.toFixed(1),
|
||||
withDays: row.withDays,
|
||||
})}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-3 text-[11px]">
|
||||
{t("insights.mood.influence.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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<string>;
|
||||
/** Structured tag refs present that day, keyed for de-dup. */
|
||||
structuredTags: Map<string, StructuredTagRef>;
|
||||
}
|
||||
|
||||
function collapseToTaggedDays(
|
||||
entries: MoodAggregateEntry[],
|
||||
now: Date,
|
||||
windowDays: number,
|
||||
): Map<string, TaggedDay> {
|
||||
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
|
||||
const byDay = new Map<
|
||||
string,
|
||||
{ sum: number; count: number; flat: Set<string>; structured: Map<string, StructuredTagRef> }
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
if (entry.moodLoggedAt.getTime() < cutoff) continue;
|
||||
const day = byDay.get(entry.date) ?? {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
flat: new Set<string>(),
|
||||
structured: new Map<string, StructuredTagRef>(),
|
||||
};
|
||||
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<string, TaggedDay>();
|
||||
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<string>();
|
||||
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<string, StructuredTagRef>();
|
||||
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<CorrelationKey, MoodMetricCorrelation>,
|
||||
): 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<InfluenceConfidence, number> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user