diff --git a/messages/de.json b/messages/de.json index fffbd59f3..bc1b7c786 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2645,6 +2645,26 @@ "detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.", "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst." }, + "factorCrosstab": { + "title": "Deine bewerteten Faktoren und deine Vitalwerte", + "description": "Wie sich ein Vitalwert an den Tagen unterscheidet, an denen du einen Faktor niedrig bewertet hast, verglichen mit den Tagen mit hoher Bewertung.", + "pairLabel": "{factor} · {metric}", + "metricSleepDuration": "Schlaf", + "metricSteps": "Schritte", + "metricRestingHeartRate": "Ruhepuls", + "metricHrv": "Herzfrequenzvariabilität", + "metricWeight": "Gewicht", + "metricBloodPressureSys": "Systolisch", + "unitHours": "Std.", + "unitScore": "Pkt.", + "unitSteps": "Schritte", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitMmHg": "mmHg", + "detail": "Durchschnittlich {lowAvg} {unit} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} {unit} an den Tagen mit hoher Bewertung.", + "detailInverse": "Durchschnittlich {lowAvg} {unit} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} {unit} an deinen besseren." + }, "discovery": { "title": "Was auffällt", "subheading": "Entdeckte Zusammenhänge", diff --git a/messages/en.json b/messages/en.json index ae5cfcb9a..8503c9d99 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2645,6 +2645,26 @@ "detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.", "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise." }, + "factorCrosstab": { + "title": "Your rated factors and your vitals", + "description": "How a vital differs on the days you rated each factor low compared with the days you rated it high.", + "pairLabel": "{factor} · {metric}", + "metricSleepDuration": "Sleep", + "metricSteps": "Steps", + "metricRestingHeartRate": "Resting heart rate", + "metricHrv": "Heart-rate variability", + "metricWeight": "Weight", + "metricBloodPressureSys": "Systolic", + "unitHours": "h", + "unitScore": "pts", + "unitSteps": "steps", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitMmHg": "mmHg", + "detail": "Averages {lowAvg} {unit} on the {lowDays} days you rated {factor} low vs {highAvg} {unit} on the days you rated it high.", + "detailInverse": "Averages {lowAvg} {unit} on your {lowDays} worse {factor} days vs {highAvg} {unit} on your better ones." + }, "discovery": { "title": "What stands out", "subheading": "Discovered relationships", diff --git a/messages/es.json b/messages/es.json index 8ba11e396..d5b8544cd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2645,6 +2645,26 @@ "detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.", "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto." }, + "factorCrosstab": { + "title": "Tus factores valorados y tus constantes", + "description": "Cómo difiere una constante en los días en que valoraste bajo cada factor frente a los días en que lo valoraste alto.", + "pairLabel": "{factor} · {metric}", + "metricSleepDuration": "Sueño", + "metricSteps": "Pasos", + "metricRestingHeartRate": "Frecuencia cardíaca en reposo", + "metricHrv": "Variabilidad de la frecuencia cardíaca", + "metricWeight": "Peso", + "metricBloodPressureSys": "Sistólica", + "unitHours": "h", + "unitScore": "pts", + "unitSteps": "pasos", + "unitBpm": "ppm", + "unitMs": "ms", + "unitKg": "kg", + "unitMmHg": "mmHg", + "detail": "Promedia {lowAvg} {unit} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} {unit} en los días que lo valoraste alto.", + "detailInverse": "Promedia {lowAvg} {unit} en tus {lowDays} peores días de {factor} frente a {highAvg} {unit} en los mejores." + }, "discovery": { "title": "Lo que destaca", "subheading": "Relaciones descubiertas", diff --git a/messages/fr.json b/messages/fr.json index 260ca6483..ab2f5ceeb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2645,6 +2645,26 @@ "detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.", "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon." }, + "factorCrosstab": { + "title": "Vos facteurs notés et vos constantes", + "description": "Comment une constante diffère les jours où vous avez noté un facteur bas par rapport aux jours où vous l'avez noté haut.", + "pairLabel": "{factor} · {metric}", + "metricSleepDuration": "Sommeil", + "metricSteps": "Pas", + "metricRestingHeartRate": "Fréquence cardiaque au repos", + "metricHrv": "Variabilité de la fréquence cardiaque", + "metricWeight": "Poids", + "metricBloodPressureSys": "Systolique", + "unitHours": "h", + "unitScore": "pts", + "unitSteps": "pas", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitMmHg": "mmHg", + "detail": "En moyenne {lowAvg} {unit} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} {unit} les jours où vous l'avez noté haut.", + "detailInverse": "En moyenne {lowAvg} {unit} sur vos {lowDays} pires jours de {factor} contre {highAvg} {unit} sur les meilleurs." + }, "discovery": { "title": "Ce qui ressort", "subheading": "Relations découvertes", diff --git a/messages/it.json b/messages/it.json index 0e8e97df1..9997c4aa9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2645,6 +2645,26 @@ "detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.", "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti." }, + "factorCrosstab": { + "title": "I tuoi fattori valutati e i tuoi parametri vitali", + "description": "Come cambia un parametro vitale nei giorni in cui hai valutato basso un fattore rispetto ai giorni in cui lo hai valutato alto.", + "pairLabel": "{factor} · {metric}", + "metricSleepDuration": "Sonno", + "metricSteps": "Passi", + "metricRestingHeartRate": "Frequenza cardiaca a riposo", + "metricHrv": "Variabilità della frequenza cardiaca", + "metricWeight": "Peso", + "metricBloodPressureSys": "Sistolica", + "unitHours": "h", + "unitScore": "pti", + "unitSteps": "passi", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitMmHg": "mmHg", + "detail": "In media {lowAvg} {unit} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} {unit} nei giorni in cui lo hai valutato alto.", + "detailInverse": "In media {lowAvg} {unit} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} {unit} in quelli migliori." + }, "discovery": { "title": "Cosa risalta", "subheading": "Relazioni scoperte", diff --git a/messages/pl.json b/messages/pl.json index 23b7da3e8..935bea4fb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2645,6 +2645,26 @@ "detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.", "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni." }, + "factorCrosstab": { + "title": "Twoje ocenione czynniki i parametry życiowe", + "description": "Jak parametr życiowy różni się w dniach, w których oceniłeś dany czynnik nisko, w porównaniu z dniami, w których oceniłeś go wysoko.", + "pairLabel": "{factor} · {metric}", + "metricSleepDuration": "Sen", + "metricSteps": "Kroki", + "metricRestingHeartRate": "Tętno spoczynkowe", + "metricHrv": "Zmienność rytmu serca", + "metricWeight": "Waga", + "metricBloodPressureSys": "Skurczowe", + "unitHours": "godz.", + "unitScore": "pkt", + "unitSteps": "kroki", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitMmHg": "mmHg", + "detail": "Średnio {lowAvg} {unit} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} {unit} w dniach z wysoką oceną.", + "detailInverse": "Średnio {lowAvg} {unit} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} {unit} w lepszych." + }, "discovery": { "title": "Co się wyróżnia", "subheading": "Wykryte relacje", diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx new file mode 100644 index 000000000..9b9101d01 --- /dev/null +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { moodTagIcon } from "@/components/mood/mood-tag-icons"; +import { cn } from "@/lib/utils"; +import type { MoodInfluenceConfidence } from "./mood-tag-influence"; + +/** + * v1.14.0 — RATED-factor × vital crosstab card. + * + * The cross-domain bridge: for each factor the user scores per entry (work / + * sleep-quality / stress …), a vital's mean on the days the factor was rated + * LOW (below its median) vs HIGH, the delta, and a confidence chip. The math + * is pre-computed in `mood-aggregates.ts` (`computeFactorMetricCrosstab`) — + * the same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the + * relations surface uses; only FDR-surviving rows reach here. An inverse + * factor (stress / conflict) already runs its split on the flipped series, so + * "low" always means a worse day; the phrasing reads "your worse + * days". Observational only: the page-level "associations, not causes" footer + * carries the caveat once. + */ + +export type MoodFactorCrosstabDisplay = + | "hours" + | "score" + | "steps" + | "bpm" + | "ms" + | "kg" + | "mmHg"; +export type MoodFactorCrosstabMode = "sameDay" | "nextDay"; + +export interface MoodFactorMetricCrosstabRow { + factor: string; + labelKey: string; + categoryKey: string; + icon: string | null; + inverse: boolean; + metricKey: string; + display: MoodFactorCrosstabDisplay; + mode: MoodFactorCrosstabMode; + lowDays: number; + highDays: number; + lowAvg: number; + highAvg: number; + delta: number; + pValue: number; + qValue: number; + confidence: MoodInfluenceConfidence; +} + +const METRIC_LABEL_KEY: Record = { + sleepDuration: "insights.mood.factorCrosstab.metricSleepDuration", + steps: "insights.mood.factorCrosstab.metricSteps", + restingHeartRate: "insights.mood.factorCrosstab.metricRestingHeartRate", + heartRateVariability: "insights.mood.factorCrosstab.metricHrv", + weight: "insights.mood.factorCrosstab.metricWeight", + bloodPressureSystolic: "insights.mood.factorCrosstab.metricBloodPressureSys", +}; + +const UNIT_KEY: Record = { + hours: "insights.mood.factorCrosstab.unitHours", + score: "insights.mood.factorCrosstab.unitScore", + steps: "insights.mood.factorCrosstab.unitSteps", + bpm: "insights.mood.factorCrosstab.unitBpm", + ms: "insights.mood.factorCrosstab.unitMs", + kg: "insights.mood.factorCrosstab.unitKg", + mmHg: "insights.mood.factorCrosstab.unitMmHg", +}; + +const CONFIDENCE_KEY: Record = { + 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-info/15 text-info", + high: "bg-success/15 text-success", +}; + +/** Whole numbers for steps; one decimal otherwise. */ +function fmt(value: number, display: MoodFactorCrosstabDisplay): string { + return display === "steps" ? Math.round(value).toString() : value.toFixed(1); +} + +export function MoodFactorMetricCrosstab({ + rows, +}: { + rows: MoodFactorMetricCrosstabRow[]; +}) { + const { t } = useTranslations(); + if (rows.length === 0) return null; + + return ( +
+

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

+
    + {rows.map((row) => { + const Icon = moodTagIcon(row.icon); + const factorLabel = t(row.labelKey); + const metricLabel = t( + METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, + ); + const unit = t(UNIT_KEY[row.display]); + // `delta` = lowAvg − highAvg: negative = vital runs lower on the + // low-factor (worse) days. The read-out is colored by sign. + const up = row.delta >= 0; + const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + const deltaColor = up ? "var(--success)" : "var(--destructive)"; + return ( +
  • +
    + {Icon && ( +
    +

    + {t( + row.inverse + ? "insights.mood.factorCrosstab.detailInverse" + : "insights.mood.factorCrosstab.detail", + { + factor: factorLabel, + metric: metricLabel, + lowAvg: fmt(row.lowAvg, row.display), + highAvg: fmt(row.highAvg, row.display), + unit, + lowDays: row.lowDays, + }, + )} +

    +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index d8681b01e..137b194eb 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -9,6 +9,7 @@ import { CalendarDays, CalendarRange, Clock, + Gauge, Grid3x3, Link2, Sparkles, @@ -94,6 +95,10 @@ import { MoodTagMetricCrosstab, type MoodTagMetricCrosstabRow, } from "./mood-tag-metric-crosstab"; +import { + MoodFactorMetricCrosstab, + type MoodFactorMetricCrosstabRow, +} from "./mood-factor-metric-crosstab"; /** * v1.8.5 — additional Mood Insights sections. @@ -130,6 +135,9 @@ interface MoodInsightsResponse { // Optional only to tolerate a stale pre-v1.12.0 cached payload during a // rollout; the live endpoint always populates it. tagMetricCrosstab?: MoodTagMetricCrosstabRow[]; + // Optional only to tolerate a stale pre-v1.14.0 cached payload during a + // rollout; the live endpoint always populates it. + factorCrosstab?: MoodFactorMetricCrosstabRow[]; narratives: MoodNarrativeItem[]; correlations: { sleep: MoodMetricCorrelationData; @@ -244,6 +252,8 @@ export function MoodInsightsSections({ const hasBetterDays = betterDays.length > 0; const crosstabRows = data.tagMetricCrosstab ?? []; const hasCrosstab = crosstabRows.length > 0; + const factorCrosstabRows = data.factorCrosstab ?? []; + const hasFactorCrosstab = factorCrosstabRows.length > 0; // v1.12.7 — the Stimmungskalender is lifted above the line chart on the page, // so it renders as its own region here. @@ -368,7 +378,17 @@ export function MoodInsightsSections({ )} + {hasFactorCrosstab && ( + + + + )} + +

{t("insights.mood.correlationsDescription")}

diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index 131168053..012c1b9fa 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -94,6 +94,28 @@ describe("discoverCorrelations", () => { expect(result.pairsTested).toBe(1); expect(result.discovered).toHaveLength(0); }); + + it("surfaces a RATED-factor channel and humanises its namespace prefix", () => { + // A `FACTOR:work` behaviour whose next-day sleep tracks it linearly. + const n = 30; + const factorVals = Array.from({ length: n }, (_, i) => (i % 5) + 1); + const sleepVals = [400, ...factorVals.slice(0, n - 1).map((v) => v * 30 + 300)]; + const result = discoverCorrelations( + [ + { key: "FACTOR:work", role: "behaviour", points: series(factorVals) }, + { key: "SLEEP_DURATION", role: "outcome", points: series(sleepVals) }, + ], + { fdrQ: 0.1 }, + ); + expect(result.discovered).toHaveLength(1); + const pair = result.discovered[0]; + expect(pair.behaviour).toBe("FACTOR:work"); + // The interpretation strips the namespace and reads "rated work", never + // the raw key, and stays causation-banned. + expect(pair.interpretation).toContain("rated work"); + expect(pair.interpretation).not.toContain("FACTOR:"); + expect(pair.interpretation).toMatch(/not a cause/); + }); }); // ── F3 — MOOD as a discoverable OUTCOME channel ───────────────────── diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 47bf54902..4acf39706 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -16,6 +16,8 @@ import { computeTagInfluence, computeBetterDays, computeTagMetricCrosstab, + computeFactorMetricCrosstab, + buildFactorDailySeries, CROSSTAB_MIN_PRESENT_DAYS, CROSSTAB_MIN_ABSENT_DAYS, CROSSTAB_MAX_ROWS, @@ -29,6 +31,7 @@ import { type CrossMetricMeasurement, type MoodAggregateEntry, type MoodMetricCorrelation, + type RatedFactorScore, type StructuredTagRef, type TagInfluence, } from "../mood-aggregates"; @@ -1094,3 +1097,158 @@ describe("computeTagMetricCrosstab", () => { expect(row!.withoutAvg).toBeCloseTo(6, 1); // 3 × 120 = 360 min = 6 h }); }); + +describe("buildFactorDailySeries", () => { + function factorEntry( + offset: number, + score: number, + factors: RatedFactorScore[], + ): MoodAggregateEntry { + return { ...entry(offset, score, null), ratedFactors: factors }; + } + + const WORK = (rating: number, inverse = false): RatedFactorScore => ({ + key: "work", + categoryKey: "life", + labelKey: "mood.tag.work", + icon: "Briefcase", + rating, + scaleMin: 1, + scaleMax: 5, + inverse, + }); + + it("means a factor's ratings per day", () => { + const entries = [ + // two entries same day → mean of (4, 2) = 3. + { ...factorEntry(0, 4, [WORK(4)]) }, + { ...factorEntry(0, 2, [WORK(2)]) }, + factorEntry(1, 3, [WORK(5)]), + ]; + const series = buildFactorDailySeries(entries, NOW, 365); + const work = series.get("work"); + expect(work).toBeDefined(); + expect(work!.byDay.get(dayKey(0))).toBeCloseTo(3, 5); + expect(work!.byDay.get(dayKey(1))).toBeCloseTo(5, 5); + expect(work!.ref.inverse).toBe(false); + }); + + it("flips an inverse factor across its scale midpoint so up = better", () => { + // inverse stress rated 5 (worst) maps to (1+5)-5 = 1 (worst on the + // flipped axis); rated 1 (best) maps to 5. + const entries = [ + factorEntry(0, 2, [WORK(5, true)]), + factorEntry(1, 4, [WORK(1, true)]), + ]; + const series = buildFactorDailySeries(entries, NOW, 365); + const work = series.get("work")!; + expect(work.byDay.get(dayKey(0))).toBeCloseTo(1, 5); + expect(work.byDay.get(dayKey(1))).toBeCloseTo(5, 5); + expect(work.ref.inverse).toBe(true); + }); +}); + +describe("computeFactorMetricCrosstab", () => { + function factorEntry( + offset: number, + score: number, + factors: RatedFactorScore[], + ): MoodAggregateEntry { + return { ...entry(offset, score, null), ratedFactors: factors }; + } + + function meas(offset: number, type: string, value: number): CrossMetricMeasurement { + return { type, value, measuredAt: new Date(NOW.getTime() - offset * dayMs) }; + } + + const work = (rating: number, inverse = false): RatedFactorScore => ({ + key: "work", + categoryKey: "life", + labelKey: "mood.tag.work", + icon: "Briefcase", + rating, + scaleMin: 1, + scaleMax: 5, + inverse, + }); + + it("surfaces a low-factor-day vital deviation via the median split", () => { + const entries: MoodAggregateEntry[] = []; + const measurements: CrossMetricMeasurement[] = []; + // 12 LOW-work days (rating 2, below the median of 3.5) with short sleep + // (~6 h = 360 min); 12 HIGH-work days (rating 5) with long sleep (~8 h). + for (let i = 0; i < 12; i++) { + entries.push(factorEntry(i, 3, [work(2)])); + measurements.push(meas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6))); + } + for (let i = 12; i < 24; i++) { + entries.push(factorEntry(i, 4, [work(5)])); + measurements.push(meas(i, "SLEEP_DURATION", 480 + (i % 2 === 0 ? 6 : -6))); + } + const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW }); + const row = rows.find((r) => r.factor === "work" && r.metricKey === "sleepDuration"); + expect(row).toBeDefined(); + expect(row!.display).toBe("hours"); + expect(row!.lowDays).toBe(12); + expect(row!.highDays).toBe(12); + expect(row!.lowAvg).toBeCloseTo(6, 1); + expect(row!.highAvg).toBeCloseTo(8, 1); + // delta = lowAvg − highAvg → negative: sleep runs lower on low-work days. + expect(row!.delta).toBeLessThan(0); + expect(row!.delta).toBeCloseTo(-2, 1); + expect(row!.confidence).toBe("high"); + expect(row!.qValue).toBeLessThanOrEqual(0.1); + expect(row!.inverse).toBe(false); + }); + + it("carries the inverse flag through and still splits worse-vs-better on the flipped axis", () => { + const entries: MoodAggregateEntry[] = []; + const measurements: CrossMetricMeasurement[] = []; + // stress is inverse: rating 5 = worst day. On the flipped axis it becomes + // 1 (low/worse). Worse-stress days carry higher next-day RHR (D → D+1). + for (let i = 1; i <= 12; i++) { + entries.push(factorEntry(i, 3, [work(5, true)])); // worse → flipped low + measurements.push(meas(i - 1, "RESTING_HEART_RATE", 64 + (i % 2 === 0 ? 1 : -1))); + } + for (let i = 13; i <= 24; i++) { + entries.push(factorEntry(i, 4, [work(1, true)])); // better → flipped high + measurements.push(meas(i - 1, "RESTING_HEART_RATE", 56 + (i % 2 === 0 ? 1 : -1))); + } + const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW }); + const row = rows.find((r) => r.metricKey === "restingHeartRate"); + expect(row).toBeDefined(); + expect(row!.mode).toBe("nextDay"); + expect(row!.inverse).toBe(true); + // worse-stress (low flipped) days → higher RHR; delta = low − high > 0. + expect(row!.lowAvg).toBeCloseTo(64, 0); + expect(row!.highAvg).toBeCloseTo(56, 0); + expect(row!.delta).toBeGreaterThan(0); + }); + + it("drops a factor with too few rated days to split defensibly", () => { + const entries: MoodAggregateEntry[] = []; + const measurements: CrossMetricMeasurement[] = []; + // only 6 rated days < 5 + 5 floor for a clean median split. + for (let i = 0; i < 6; i++) { + entries.push(factorEntry(i, 3, [work(i < 3 ? 2 : 5)])); + measurements.push(meas(i, "SLEEP_DURATION", i < 3 ? 360 : 480)); + } + expect(CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS).toBeGreaterThan(6); + const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW }); + expect(rows.find((r) => r.factor === "work")).toBeUndefined(); + }); + + it("returns nothing when there are no rated factors at all", () => { + const entries: MoodAggregateEntry[] = []; + const measurements: CrossMetricMeasurement[] = []; + for (let i = 0; i < 24; i++) { + entries.push(entry(i, 3, null)); + measurements.push(meas(i, "SLEEP_DURATION", 420)); + } + expect(computeFactorMetricCrosstab({ entries, measurements, now: NOW })).toEqual([]); + }); + + it("caps the surface at CROSSTAB_MAX_ROWS", () => { + expect(CROSSTAB_MAX_ROWS).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 959cb809e..318dc23c4 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -161,11 +161,24 @@ function interpret(behaviour: string, outcome: string, r: number): string { return `${b} and next-day ${o} move together only weakly in this window.`; } -/** Lower-case, space-separated label from a channel key. */ +/** + * Lower-case, space-separated label from a channel key. + * + * v1.14.0 — a `FACTOR:` channel (a RATED mood factor folded into the + * matrix) strips its namespace prefix so the phrasing reads "rated " + * rather than leaking the raw key. The prefix exists only to keep a factor + * key from colliding with a `MeasurementType` in the channel set. + */ function humanise(key: string): string { + if (key.startsWith("FACTOR:")) { + return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`; + } return key.replace(/_/g, " ").toLowerCase(); } +/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */ +export const FACTOR_CHANNEL_PREFIX = "FACTOR:"; + /** * Run the FDR-controlled discovery over the behaviour × outcome matrix. * diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 3570fe17c..cf43fabff 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -109,6 +109,44 @@ export interface MoodAggregateEntry { * aggregate tests (flat tags only) keep their narrow fixtures. */ structuredTags?: StructuredTagRef[]; + /** + * v1.14.0 — per-entry RATED factor scores (work / sleep-quality / + * stress …) from the same `mood_entry_tag_links` join, but carrying the + * numeric `rating` instead of a present/absent flag. Optional so legacy + * fixtures keep their narrow shape. A RATED factor is a *continuous* + * daily signal, fed into the factor crosstab (low-vs-high-day vital + * deviation) and the discovery matrix — distinct from BINARY structured + * tags, which carry no `rating`. + */ + ratedFactors?: RatedFactorScore[]; +} + +/** + * A RATED mood factor's score on a single entry, resolved from the + * catalog. Carries the scale + `inverse` flag so the analytics layer can + * apply the documented sign-flip ("higher rating = worse day" → flip so + * "up" always reads as better) once, at the series boundary. + */ +export interface RatedFactorScore { + /** Stable factor key (e.g. `work`). */ + key: string; + /** Parent category key. */ + categoryKey: string; + /** i18n message key for the factor label. */ + labelKey: string; + /** Lucide icon name, or null. */ + icon: string | null; + /** The per-entry rating, bounded to `scaleMin..scaleMax` at ingest. */ + rating: number; + /** Inclusive scale bounds (default 1..5). */ + scaleMin: number; + scaleMax: number; + /** + * `true` when a higher rating means a WORSE day (stress / conflict). + * The series builder flips an inverse factor's rating to + * `(scaleMin + scaleMax) - rating` so "up" always reads as better. + */ + inverse: boolean; } /** @@ -1043,6 +1081,313 @@ export function computeTagMetricCrosstab(args: { .slice(0, CROSSTAB_MAX_ROWS); } +// --- RATED factor × health-metric crosstab (low- vs high-factor days) --- + +/** + * v1.14.0 — the flagship cross-domain insight: "on days you rated + * low, your ran X below baseline". + * + * A RATED mood factor (work / sleep-quality / stress …) is a CONTINUOUS + * per-day score, unlike the BINARY structured tags `computeTagMetricCrosstab` + * handles. To reuse the EXACT same Welch + FDR engine, this thresholds the + * factor's daily mean into a binary membership — a day is "low" when its + * factor mean sits BELOW the factor's own median over the window, "high" at + * or above. The median split is robust + self-calibrating (always two + * non-empty groups when the factor has spread), and invents no fixed cutoff. + * + * `inverse` is applied at the SERIES boundary (the factor's per-day mean is + * flipped to `(scaleMin + scaleMax) - raw` for an inverse factor like + * stress), so the median split — and therefore the "low day" label — always + * means "a worse day for this factor", honestly, without per-call casing. + * + * Same honesty discipline as the tag crosstab: both sides need + * `CROSSTAB_MIN_PRESENT_DAYS` / `CROSSTAB_MIN_ABSENT_DAYS` paired metric + * days, the family is BH-FDR corrected as one (across every factor × metric + * × direction pair), survivors clear p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`, and + * the surface is capped + ranked. Observational only — the card renders the + * standing "association, not cause" caption. + */ + +/** + * The metric channels the factor crosstab pairs each RATED factor against. + * Broader than the tag crosstab (which is activity-focused) because the + * value of a RATED factor is its bridge from a SUBJECTIVE score to an + * OBJECTIVE vital. `sameDay` for same-night/same-day metrics (sleep, + * steps); `nextDay` (D → D+1) for overnight-recovery metrics (RHR / HRV), + * matching the plausible "today's factor → tomorrow's body" direction. + */ +export const FACTOR_CROSSTAB_METRICS: Record< + string, + { + type: MeasurementType; + mode: CrosstabMode; + display: "hours" | "score" | "steps" | "bpm" | "ms" | "kg" | "mmHg"; + } +> = { + sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" }, + steps: { type: "ACTIVITY_STEPS", mode: "sameDay", display: "steps" }, + restingHeartRate: { + type: "RESTING_HEART_RATE", + mode: "nextDay", + display: "bpm", + }, + heartRateVariability: { + type: "HEART_RATE_VARIABILITY", + mode: "nextDay", + display: "ms", + }, + weight: { type: "WEIGHT", mode: "sameDay", display: "kg" }, + bloodPressureSystolic: { + type: "BLOOD_PRESSURE_SYS", + mode: "sameDay", + display: "mmHg", + }, +} as const; + +export type FactorCrosstabMetricKey = keyof typeof FACTOR_CROSSTAB_METRICS; + +/** Distinct measurement types the factor crosstab reads — single-sourced. */ +export const FACTOR_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( + new Set(Object.values(FACTOR_CROSSTAB_METRICS).map((m) => m.type)), +); + +export interface FactorMetricCrosstabRow { + /** Stable RATED-factor key. */ + factor: string; + /** i18n label key for the factor. */ + labelKey: string; + /** Parent category key, for grouping/icon. */ + categoryKey: string; + /** Lucide icon name, or null. */ + icon: string | null; + /** + * `true` when the factor is inverse-scaled (stress / conflict). The + * UI flips the phrasing — "your worse days" — but the split + * itself already runs on the flipped series, so a "low" row always + * means a worse day regardless. + */ + inverse: boolean; + /** Which metric channel this row compares against. */ + metricKey: FactorCrosstabMetricKey; + /** Display unit hint for the client formatter. */ + display: FactorMetricDisplay; + /** Pairing mode used (echoed so the UI can caption "next-day"). */ + mode: CrosstabMode; + /** Days the factor was rated LOW (below its median) with a paired metric. */ + lowDays: number; + /** Days the factor was rated HIGH (at/above its median) with a paired metric. */ + highDays: number; + /** Mean metric on low-factor days (display unit). */ + lowAvg: number; + /** Mean metric on high-factor days (display unit). */ + highAvg: number; + /** lowAvg − highAvg (display unit). Negative = vital runs lower on low days. */ + delta: number; + /** Welch two-sided p-value for the difference of means. */ + pValue: number; + /** Benjamini-Hochberg adjusted q-value across the tested family. */ + qValue: number; + /** Discrete confidence band (p + min per-group day count). */ + confidence: InfluenceConfidence; +} + +type FactorMetricDisplay = (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]["display"]; + +/** Convert a raw metric value to its factor-crosstab display unit. */ +function toFactorDisplayUnit(value: number, display: FactorMetricDisplay): number { + return display === "hours" ? value / 60 : value; +} + +/** Median of a numeric array (sorted-copy, mean-of-two for even length). */ +function median(values: number[]): number { + if (values.length === 0) return Number.NaN; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Reference data for one RATED factor's daily series + its meta. */ +interface FactorDailySeries { + ref: Pick; + /** Day key → factor's daily-mean score, inverse-flipped if needed. */ + byDay: Map; +} + +/** + * Build a per-factor daily-mean series, applying the `inverse` sign-flip + * once. For each tz-anchored day, the mean of the factor's ratings across + * the day's entries; an inverse factor's rating `r` maps to + * `(scaleMin + scaleMax) - r` BEFORE averaging so "up" always reads as a + * better day. Pure. Exported for the discovery-channel path (period + * narrative) so the two consumers can never diverge on the flip. + */ +export function buildFactorDailySeries( + entries: MoodAggregateEntry[], + now: Date, + windowDays: number, +): Map { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const acc = new Map< + string, + { + ref: FactorDailySeries["ref"]; + byDay: Map; + } + >(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + if (!entry.ratedFactors) continue; + for (const f of entry.ratedFactors) { + if (!Number.isFinite(f.rating)) continue; + // The documented sign-flip: an inverse factor's rating is mirrored + // across its scale midpoint so a higher series value always means a + // better day. Done here, once, at the series boundary. + const value = f.inverse + ? f.scaleMin + f.scaleMax - f.rating + : f.rating; + const slot = + acc.get(f.key) ?? + { + ref: { + key: f.key, + labelKey: f.labelKey, + categoryKey: f.categoryKey, + icon: f.icon, + inverse: f.inverse, + }, + byDay: new Map(), + }; + const cur = slot.byDay.get(entry.date) ?? { sum: 0, count: 0 }; + cur.sum += value; + cur.count += 1; + slot.byDay.set(entry.date, cur); + acc.set(f.key, slot); + } + } + const out = new Map(); + for (const [key, slot] of acc) { + const byDay = new Map(); + for (const [day, agg] of slot.byDay) byDay.set(day, agg.sum / agg.count); + out.set(key, { ref: slot.ref, byDay }); + } + return out; +} + +interface FactorCrosstabCandidate { + row: Omit; +} + +/** + * Compute the RATED-factor × metric crosstab. Pure over already-fetched + * rows. Mirrors `computeTagMetricCrosstab` but splits each factor's + * continuous daily score into low/high by its own median, then runs the + * same Welch + FDR engine. + */ +export function computeFactorMetricCrosstab(args: { + entries: MoodAggregateEntry[]; + measurements: CrossMetricMeasurement[]; + now: Date; + windowDays?: number; + userPriorityJson?: unknown; +}): FactorMetricCrosstabRow[] { + const { entries, measurements, now } = args; + const windowDays = args.windowDays ?? 365; + const userPriorityJson = args.userPriorityJson ?? null; + + const factorSeries = buildFactorDailySeries(entries, now, windowDays); + if (factorSeries.size === 0) return []; + + const candidates: FactorCrosstabCandidate[] = []; + + for (const [metricKey, cfg] of Object.entries(FACTOR_CROSSTAB_METRICS) as Array< + [FactorCrosstabMetricKey, (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]] + >) { + const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson); + if (metricByDay.size === 0) continue; + + for (const [factorKey, series] of factorSeries) { + // The median split is over the factor's own rated days only, so a + // sparse factor never borrows another's threshold. + const ratedDays = [...series.byDay.values()]; + if (ratedDays.length < CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS) { + continue; + } + const split = median(ratedDays); + + const lowVals: number[] = []; + const highVals: number[] = []; + for (const [dayKey, score] of series.byDay) { + const metricDayKey = + cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey; + const metricValue = metricByDay.get(metricDayKey); + if (metricValue == null || !Number.isFinite(metricValue)) continue; + const display = toFactorDisplayUnit(metricValue, cfg.display); + // Below the median is a "low" (worse, after the inverse flip) day; + // at or above is "high". A perfectly bimodal factor with all days + // exactly on the median falls entirely into "high" and fails the + // low-side floor below — honest: no contrast, no row. + if (score < split) lowVals.push(display); + else highVals.push(display); + } + + if ( + lowVals.length < CROSSTAB_MIN_PRESENT_DAYS || + highVals.length < CROSSTAB_MIN_ABSENT_DAYS + ) { + continue; + } + + const welch = welchTTest(lowVals, highVals); + const lowAvg = lowVals.reduce((s, v) => s + v, 0) / lowVals.length; + const highAvg = highVals.reduce((s, v) => s + v, 0) / highVals.length; + const delta = lowAvg - highAvg; + if (delta === 0) continue; + + const pValue = welch.status === "ok" ? welch.pValue : 1; + const minGroupDays = Math.min(lowVals.length, highVals.length); + + candidates.push({ + row: { + factor: factorKey, + labelKey: series.ref.labelKey, + categoryKey: series.ref.categoryKey, + icon: series.ref.icon, + inverse: series.ref.inverse, + metricKey, + display: cfg.display, + mode: cfg.mode, + lowDays: lowVals.length, + highDays: highVals.length, + lowAvg: round(lowAvg, 2), + highAvg: round(highAvg, 2), + delta: round(delta, 2), + pValue, + confidence: influenceConfidence(pValue, minGroupDays), + }, + }); + } + } + + if (candidates.length === 0) return []; + + // One BH family across every factor × metric pair tested — the same + // step-up the tag crosstab + discovery engine run. + const qValues = fdrAdjust(candidates.map((c) => c.row.pValue)); + + return candidates + .map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 })) + .filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q) + .sort( + (a, b) => + a.qValue - b.qValue || + Math.abs(b.delta) - Math.abs(a.delta) || + a.factor.localeCompare(b.factor), + ) + .slice(0, CROSSTAB_MAX_ROWS); +} + // --- In-target % over the last 30 daily points (lifted from mood-status) --- /** @@ -1486,6 +1831,15 @@ export interface MoodAggregates { * renders the standing "association, not cause" caption. */ tagMetricCrosstab: TagMetricCrosstabRow[]; + /** + * v1.14.0 — per-RATED-factor × health-metric crosstab: for each factor + * the user scores per entry (work / sleep-quality / stress …), a vital's + * mean on the days the factor was rated LOW vs HIGH (median split, same-day + * or D→D+1), Welch-tested + FDR-corrected. The cross-domain bridge from a + * subjective score to an objective vital. Observational; the card renders + * the standing "association, not cause" caption. + */ + factorCrosstab: FactorMetricCrosstabRow[]; /** * v1.8.6 — ranked, threshold-gated narrative takeaways. The "read * this first" layer above the charts; the same array feeds the LLM @@ -1572,6 +1926,12 @@ export function computeMoodAggregates(args: { now, userPriorityJson, }); + const factorCrosstab = computeFactorMetricCrosstab({ + entries, + measurements, + now, + userPriorityJson, + }); // Distinct day keys the user logged on — drives the streak takeaway. const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date))); @@ -1603,6 +1963,7 @@ export function computeMoodAggregates(args: { tagInfluence, betterDays, tagMetricCrosstab, + factorCrosstab, narratives: computeMoodNarratives({ daily: moodDaily, weekday, @@ -1646,13 +2007,20 @@ export async function fetchMoodAggregates( tz: true, // v1.8.5 — pull the structured-tag links + their catalog rows so // the breakdown can fold structured tags next to the flat ones. + // v1.14.0 — also pull the per-link `rating` + the factor's + // kind/scale/inverse so RATED factors feed the factor crosstab. tagLinks: { select: { + rating: true, moodTag: { select: { key: true, labelKey: true, icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, category: { select: { key: true } }, }, }, @@ -1667,12 +2035,31 @@ export async function fetchMoodAggregates( tags: parseTags(row.tags), moodLoggedAt: row.moodLoggedAt, tz: row.tz, - structuredTags: row.tagLinks.map((link) => ({ - key: link.moodTag.key, - categoryKey: link.moodTag.category.key, - labelKey: link.moodTag.labelKey, - icon: link.moodTag.icon, - })), + // BINARY links carry the structured-tag breakdown; RATED links + // (a non-null `rating`) carry the factor score. One join, two axes. + structuredTags: row.tagLinks + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => ({ + key: link.moodTag.key, + categoryKey: link.moodTag.category.key, + labelKey: link.moodTag.labelKey, + icon: link.moodTag.icon, + })), + ratedFactors: row.tagLinks + .filter( + (link): link is typeof link & { rating: number } => + link.moodTag.kind === "RATED" && link.rating != null, + ) + .map((link) => ({ + key: link.moodTag.key, + categoryKey: link.moodTag.category.key, + labelKey: link.moodTag.labelKey, + icon: link.moodTag.icon, + rating: link.rating, + scaleMin: link.moodTag.scaleMin, + scaleMax: link.moodTag.scaleMax, + inverse: link.moodTag.inverse, + })), })), ); @@ -1691,6 +2078,10 @@ export async function fetchMoodAggregates( new Set([ ...(Object.values(CORRELATION_METRICS) as MeasurementType[]), ...CROSSTAB_METRIC_TYPES, + // v1.14.0 — union the factor-crosstab vital channels (RHR / + // HRV / steps / sleep / weight / BP-sys) so the factor board + // has its measurements without a second query. + ...FACTOR_CROSSTAB_METRIC_TYPES, ]), ), }, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 02ab43694..d837e6df5 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -18,7 +18,9 @@ import { MOOD_GREEN_MIN, MOOD_ORANGE_MAX, MOOD_ORANGE_MIN, + type FactorMetricCrosstabRow, computeBetterDays, + computeFactorMetricCrosstab, computeInTargetPct, computeMoodMetricCorrelation, computeMoodStability, @@ -64,6 +66,21 @@ function stripRankingOnly(row: TagInfluenceRow): Omit { + const { icon: _icon, ...rest } = row; + void _icon; + return rest; +} + export async function generateMoodStatusForUser( userId: string, options?: { @@ -151,13 +168,20 @@ export async function generateMoodStatusForUser( // v1.8.6 — pull the structured-tag links so the snapshot feeds // `computeMoodNarratives` the same structured-tag pool the visible // mood page does (shown == sent for the tag→mood takeaways). + // v1.14.0 — also pull the per-link `rating` + factor kind/scale/inverse + // so RATED factors feed the factor crosstab in the snapshot. tagLinks: { select: { + rating: true, moodTag: { select: { key: true, labelKey: true, icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, category: { select: { key: true } }, }, }, @@ -172,12 +196,29 @@ export async function generateMoodStatusForUser( tags: row.tags, moodLoggedAt: row.moodLoggedAt, tz: row.tz, - structuredTags: (row.tagLinks ?? []).map((link) => ({ - key: link.moodTag.key, - categoryKey: link.moodTag.category.key, - labelKey: link.moodTag.labelKey, - icon: link.moodTag.icon, - })), + structuredTags: (row.tagLinks ?? []) + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => ({ + key: link.moodTag.key, + categoryKey: link.moodTag.category.key, + labelKey: link.moodTag.labelKey, + icon: link.moodTag.icon, + })), + ratedFactors: (row.tagLinks ?? []) + .filter( + (link): link is typeof link & { rating: number } => + link.moodTag.kind === "RATED" && link.rating != null, + ) + .map((link) => ({ + key: link.moodTag.key, + categoryKey: link.moodTag.category.key, + labelKey: link.moodTag.labelKey, + icon: link.moodTag.icon, + rating: link.rating, + scaleMin: link.moodTag.scaleMin, + scaleMax: link.moodTag.scaleMax, + inverse: link.moodTag.inverse, + })), })), ); @@ -225,15 +266,36 @@ export async function generateMoodStatusForUser( // 1095 = 365 d × 3 channels so power users do not pull unbounded // rows. Order desc + reverse so the downstream filters see oldest- // first as before. + // v1.14.0 — union the factor-crosstab vital channels (RHR / HRV / steps / + // sleep, on top of weight / BP-sys) so the factor board the snapshot carries + // has its measurements; cap lifted to 365 d × 8 channels. `source` / + // `deviceType` ride along so the crosstab's per-day canonical-source pick + // de-dups a cumulative metric reported by two sources on the same day. const measurements = await prisma.measurement .findMany({ where: { userId, - type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] }, + type: { + in: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "ACTIVITY_STEPS", + "SLEEP_DURATION", + ], + }, }, orderBy: { measuredAt: "desc" }, - take: 1095, - select: { type: true, value: true, measuredAt: true }, + take: 50_000, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, }) .then((rows) => rows.reverse()); @@ -312,6 +374,17 @@ export async function generateMoodStatusForUser( ), }); + // v1.14.0 — RATED-factor × vital crosstab: "on days you rated low, + // your ran X below baseline". Computed from the same entries + + // measurements the snapshot already holds so the model's prose matches the + // visible factor board (shown == sent). FDR-controlled + min-N-gated inside + // `computeFactorMetricCrosstab` — never fabricated on thin data. + const factorCrosstab = computeFactorMetricCrosstab({ + entries, + measurements, + 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 == @@ -382,6 +455,13 @@ export async function generateMoodStatusForUser( // 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, + // v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch + // delta, FDR-gated). Lets the model say "your data shows sleep runs ~X + // shorter on days you rate work low (n=N, association not cause)". Only + // the surviving rows reach the prompt; an empty board emits null. + factorCrosstab: factorCrosstab.length > 0 + ? factorCrosstab.map(stripFactorCrosstab) + : 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 diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index d0cd5c65a..3e0b7aa26 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -213,6 +213,41 @@ describe("assemblePeriodNarrativeContext — drivers (FDR-controlled)", () => { const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); expect(ctx.drivers).toEqual([]); }); + + it("surfaces a RATED-factor channel as a driver with humanised prose", () => { + // A `FACTOR:work` daily score whose next-day sleep tracks it linearly. + const fVals = Array.from({ length: 31 }, (_, i) => (i % 5) + 1); + const sVals = Array.from({ length: 31 }, (_, i) => + i === 0 ? 400 : ((i - 1) % 5) * 40 + 360 + (i % 2 === 0 ? 1 : -1), + ); + const factor = seriesEndingAt(fVals, "2026-04-30"); + const sleep = seriesEndingAt(sVals, "2026-04-30"); + // Two real vitals with current-period coverage so the availability gate + // (≥ 2 DELTA metrics covered) is satisfied — a factor channel is not a + // vital delta and does not count toward coverage by design. + const weight = seriesEndingAt(Array.from({ length: 31 }, () => 80), "2026-04-30"); + const steps = seriesEndingAt(Array.from({ length: 31 }, (_, i) => 8000 + i), "2026-04-30"); + const discoverySeries: NamedSeries[] = [ + { key: "FACTOR:work", role: "behaviour", points: factor }, + { key: "SLEEP_DURATION", role: "outcome", points: sleep }, + ]; + const m = new Map(); + m.set("FACTOR:work", factor); + m.set("SLEEP_DURATION", sleep); + m.set("WEIGHT", weight); + m.set("ACTIVITY_STEPS", steps); + + const ctx = assertReady( + assemblePeriodNarrativeContext(monthInput(m, discoverySeries)), + ); + const driver = ctx.drivers.find((d) => d.behaviour === "FACTOR:work"); + expect(driver).toBeDefined(); + // The prose strips the namespace prefix and reads "rated work", stays + // causation-banned. + expect(driver!.interpretation).toContain("rated work"); + expect(driver!.interpretation).not.toContain("FACTOR:"); + expect(driver!.interpretation.toLowerCase()).toContain("not a cause"); + }); }); describe("assemblePeriodNarrativeContext — coincident flags", () => { diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 1641b2314..e363d4852 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -36,6 +36,7 @@ import { discoverCorrelations, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + FACTOR_CHANNEL_PREFIX, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -199,6 +200,57 @@ function toDailyMeans( .sort((a, b) => (a.day < b.day ? -1 : 1)); } +/** + * A RATED mood factor link the period read pulls alongside the score. + * Carries the scale + `inverse` so the series build can apply the + * documented sign-flip once, at the boundary. + */ +interface FactorLink { + key: string; + rating: number; + scaleMin: number; + scaleMax: number; + inverse: boolean; + at: Date; +} + +/** + * v1.14.0 — collapse RATED-factor links to one inverse-flipped daily-mean + * series per factor, tz-day-keyed exactly like `toDailyMeans` so a factor + * channel joins the discovery matrix on the same day grid as every vital. + * An inverse factor's rating `r` maps to `(scaleMin + scaleMax) - r` BEFORE + * averaging so "up" always reads as a better day — the same flip the mood + * aggregates apply, kept in lock-step. Returns one `FACTOR:` series per + * factor the user actually rated. Pure. + */ +function factorDailyMeans( + links: FactorLink[], + tz: string, +): Map { + const byFactor = new Map>(); + for (const l of links) { + if (!Number.isFinite(l.rating)) continue; + const value = l.inverse ? l.scaleMin + l.scaleMax - l.rating : l.rating; + const day = tzDayKey(l.at, tz); + const days = byFactor.get(l.key) ?? new Map(); + const acc = days.get(day) ?? { sum: 0, count: 0 }; + acc.sum += value; + acc.count += 1; + days.set(day, acc); + byFactor.set(l.key, days); + } + const out = new Map(); + for (const [key, days] of byFactor) { + out.set( + `${FACTOR_CHANNEL_PREFIX}${key}`, + [...days.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)), + ); + } + return out; +} + /** Round to 2 decimals. */ function round2(n: number): number { return Math.round(n * 100) / 100; @@ -501,7 +553,29 @@ export async function buildPeriodNarrativeContext( where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, orderBy: { moodLoggedAt: "asc" }, take: 5000, - select: { score: true, moodLoggedAt: true }, + select: { + score: true, + moodLoggedAt: true, + // v1.14.0 — pull RATED-factor links so each factor the user scores + // (work / sleep-quality / stress …) joins the discovery matrix as a + // `FACTOR:` channel. One extra select on the existing mood read + // — no new round-trip. BINARY links carry a null `rating` and are + // dropped below. + tagLinks: { + where: { moodTag: { kind: "RATED" }, rating: { not: null } }, + select: { + rating: true, + moodTag: { + select: { + key: true, + scaleMin: true, + scaleMax: true, + inverse: true, + }, + }, + }, + }, + }, }), ]); @@ -522,6 +596,25 @@ export async function buildPeriodNarrativeContext( ); if (moodPoints.length > 0) seriesByMetric.set("MOOD", moodPoints); + // v1.14.0 — RATED-factor channels. Flatten every entry's RATED links into + // one inverse-flipped daily-mean series per factor, keyed `FACTOR:`. + const factorLinks: FactorLink[] = []; + for (const e of moodEntries) { + for (const link of e.tagLinks) { + if (link.rating == null) continue; + factorLinks.push({ + key: link.moodTag.key, + rating: link.rating, + scaleMin: link.moodTag.scaleMin, + scaleMax: link.moodTag.scaleMax, + inverse: link.moodTag.inverse, + at: e.moodLoggedAt, + }); + } + } + const factorSeries = factorDailyMeans(factorLinks, tz); + for (const [key, points] of factorSeries) seriesByMetric.set(key, points); + // Discovery matrix over the same window. const discoverySeries: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { @@ -538,6 +631,15 @@ export async function buildPeriodNarrativeContext( points: seriesByMetric.get(key) ?? [], }); } + // A factor is plausibly both a lag source ("rated work today → next-day + // sleep") and a lag target ("steps today → next-day rated energy"), so it + // enters as both roles. The engine skips self-pairs and BH-FDR-controls the + // wider family this opens. Factors the user rated on < the paired-day floor + // simply never get tested — no new statistical machinery, same honesty. + for (const [key, points] of factorSeries) { + discoverySeries.push({ key, role: "behaviour", points }); + discoverySeries.push({ key, role: "outcome", points }); + } return assemblePeriodNarrativeContext({ period,