From 4e6599d85fee91811297b5c79b36e113d12d5ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 18:05:41 +0200 Subject: [PATCH] feat(mood-insights): promote mood to a discovery outcome and surface it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that turn the descriptive relations surfaces into a statistically-gated discovery view on the mood page. Mood as an outcome: the FDR-controlled all-pairs correlation discovery engine treated mood only as a behaviour (a lag source). Promote it to an outcome channel too, so the scan also surfaces "behaviour today → next-day mood" relations (e.g. more daylight today → better mood tomorrow) alongside the existing "mood today → next-day outcome" direction. The self-pair is skipped and Benjamini-Hochberg already controls the larger pair family this opens up. The discovery route now feeds the shared mood daily-mean series into both roles. Surface the discovery slice: the engine was computed but never rendered. The mood Insights page now shows the mood-involving pairs that survive the false-discovery correction, each phrased observationally with its paired-day count, r, and BH q-value, plus the honest "X of N tested" footer. The surface self-fetches the correlation route and degrades to nothing when the operator has disabled it. Observational only, with the standing disclaimer. --- src/app/api/insights/correlations/route.ts | 30 ++-- .../mood-discovered-relations.test.tsx | 128 +++++++++++++++++ .../mood/mood-discovered-relations.tsx | 136 ++++++++++++++++++ .../insights/mood/mood-insights-sections.tsx | 6 + .../__tests__/correlation-discovery.test.ts | 45 ++++++ src/lib/insights/correlation-discovery.ts | 7 + 6 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx create mode 100644 src/components/insights/mood/mood-discovered-relations.tsx diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index df48026fb..d628e588f 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -75,11 +75,15 @@ export const GET = apiHandler(async () => { const tz = profile?.timezone ?? "Europe/Berlin"; const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); - // Behaviour channels are measurement-backed except MOOD (mood entries). + // MOOD is backed by mood entries, not measurements — it appears as both a + // behaviour and (v1.11.5 F3) an outcome channel, so filter it out of the + // measurement type list on either side. const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( (k) => k !== "MOOD", ) as MeasurementType[]; - const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[]; + const outcomeTypes = DISCOVERY_OUTCOMES.filter( + (k) => k !== "MOOD", + ) as MeasurementType[]; const [measurements, moodEntries] = await Promise.all([ prisma.measurement.findMany({ @@ -108,23 +112,23 @@ export const GET = apiHandler(async () => { measurementsByType.set(m.type, list); } + // MOOD's daily-mean series is shared between its behaviour and outcome + // roles (computed once). + const moodDaily = toDailyMeans( + moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), + tz, + ); + const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { const points = - key === "MOOD" - ? toDailyMeans( - moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), - tz, - ) - : toDailyMeans(measurementsByType.get(key) ?? [], tz); + key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz); series.push({ key, role: "behaviour", points }); } for (const key of DISCOVERY_OUTCOMES) { - series.push({ - key, - role: "outcome", - points: toDailyMeans(measurementsByType.get(key) ?? [], tz), - }); + const points = + key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz); + series.push({ key, role: "outcome", points }); } const result = discoverCorrelations(series); diff --git a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx new file mode 100644 index 000000000..e451048d6 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +// Signed-in stub so the `enabled: isAuthenticated` gate opens without a fetch. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1" }, + isLoading: false, + isAuthenticated: true, + error: null, + refetch: () => {}, + }), +})); + +import { MoodDiscoveredRelations } from "../mood-discovered-relations"; + +interface DiscoveredCorrelation { + behaviour: string; + outcome: string; + n: number; + r: number; + pValue: number; + qValue: number; + interpretation: string; + lagDays: number; +} + +function renderWith( + discovered: DiscoveredCorrelation[], + pairsTested = discovered.length, +) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData(queryKeys.insightsCorrelations(), { + discovered, + pairsTested, + fdrQ: 0.1, + minPairs: 20, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +function pair(over: Partial): DiscoveredCorrelation { + return { + behaviour: "TIME_IN_DAYLIGHT", + outcome: "MOOD", + n: 40, + r: 0.5, + pValue: 0.001, + qValue: 0.02, + interpretation: "", + lagDays: 1, + ...over, + }; +} + +describe("", () => { + it("renders nothing without discovered data (loading / disabled surface)", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const html = renderToStaticMarkup( + + + + + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when no discovered pair involves mood", () => { + const html = renderWith([ + pair({ behaviour: "ACTIVITY_STEPS", outcome: "SLEEP_DURATION" }), + ]); + expect(html).toBe(""); + }); + + it("phrases a behaviour → next-day mood pair (mood as outcome)", () => { + const html = renderWith([ + pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD", r: 0.5 }), + ]); + expect(html).toContain('data-mood-role="outcome"'); + expect(html).toContain('data-direction="up"'); + // factor label resolves to the localized measurement name + expect(html).toContain("Time in Daylight"); + expect(html).toContain("higher next-day mood"); + // stat line carries n, r, q + expect(html).toContain("r = 0.50"); + expect(html).toContain("q = 0.020"); + }); + + it("phrases a mood → next-day outcome pair", () => { + const html = renderWith([ + pair({ behaviour: "MOOD", outcome: "SLEEP_DURATION", r: -0.4 }), + ]); + expect(html).toContain('data-mood-role="behaviour"'); + expect(html).toContain('data-direction="down"'); + expect(html).toContain("Sleep"); + expect(html).toContain("lower next-day"); + }); + + it("filters to mood pairs and shows the honest tested footer", () => { + const html = renderWith( + [ + pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }), + pair({ behaviour: "ACTIVITY_STEPS", outcome: "WEIGHT" }), // non-mood + ], + 12, + ); + expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe( + 1, + ); + // footer reports 1 mood pair of 12 tested + expect(html).toContain("1 of 12"); + }); +}); diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx new file mode 100644 index 000000000..d9168158f --- /dev/null +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.11.5 (F3) — FDR-controlled discovered mood relations. + * + * Surfaces the slice of the all-pairs correlation-discovery engine + * (`/api/insights/correlations`) that involves mood — both + * "behaviour today → next-day mood" (mood promoted to an OUTCOME channel in + * this release) and "mood today → next-day outcome". Only pairs that survive + * the Benjamini-Hochberg false-discovery correction the engine already + * applies are returned, so this is the statistically-defensible complement + * to the descriptive influence / better-days surfaces. + * + * Renders nothing when the operator has disabled the correlations surface + * (the route 403s — we degrade silently), while loading, or when no mood + * pair cleared the bar. Observational only; the standing disclaimer rides + * the card. + */ + +interface DiscoveredCorrelation { + behaviour: string; + outcome: string; + n: number; + r: number; + pValue: number; + qValue: number; + interpretation: string; + lagDays: number; +} + +interface CorrelationDiscoveryResponse { + discovered: DiscoveredCorrelation[]; + pairsTested: number; + fdrQ: number; + minPairs: number; +} + +/** Map a discovery channel key to its localized measurement-type label. */ +const CHANNEL_LABEL_KEY: Record = { + TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight", + BLOOD_GLUCOSE: "measurements.typeBloodGlucose", + BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure", + ACTIVITY_STEPS: "measurements.typeSteps", + SLEEP_DURATION: "measurements.typeSleep", + HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", + RESTING_HEART_RATE: "measurements.typeRestingHeartRate", + WEIGHT: "measurements.typeWeight", +}; + +export function MoodDiscoveredRelations() { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.insightsCorrelations(), + queryFn: async () => { + const res = await fetch("/api/insights/correlations"); + // 403 = operator disabled the surface; any non-OK degrades to nothing. + if (!res.ok) throw new Error("unavailable"); + const json = await res.json(); + return json.data as CorrelationDiscoveryResponse; + }, + enabled: isAuthenticated, + staleTime: 60_000, + // The surface is optional — don't retry a deliberate 403 into noise. + retry: false, + }); + + if (isLoading || isError || !data) return null; + + const moodPairs = data.discovered.filter( + (pair) => pair.behaviour === "MOOD" || pair.outcome === "MOOD", + ); + if (moodPairs.length === 0) return null; + + return ( +
+

+ {t("insights.mood.discovery.title")} +

+

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

+
    + {moodPairs.map((pair) => { + const moodIsOutcome = pair.outcome === "MOOD"; + const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome; + const factorLabel = t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + const up = pair.r >= 0; + const sentenceKey = moodIsOutcome + ? up + ? "insights.mood.discovery.moodUp" + : "insights.mood.discovery.moodDown" + : up + ? "insights.mood.discovery.outcomeUp" + : "insights.mood.discovery.outcomeDown"; + return ( +
  • ${pair.outcome}`} + className="flex flex-col gap-1 py-2 text-sm" + data-slot="mood-discovered-pair" + data-mood-role={moodIsOutcome ? "outcome" : "behaviour"} + data-direction={up ? "up" : "down"} + > + + {t(sentenceKey, { factor: factorLabel })} + + + {t("insights.mood.discovery.stat", { + n: pair.n, + r: pair.r.toFixed(2), + q: pair.qValue.toFixed(3), + })} + +
  • + ); + })} +
+

+ {t("insights.mood.discovery.footer", { + discovered: moodPairs.length, + tested: data.pairsTested, + })} +

+

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

+
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 56f705271..073d53f60 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -50,6 +50,7 @@ import { MoodBetterDays, type MoodBetterDayFactor, } from "./mood-better-days"; +import { MoodDiscoveredRelations } from "./mood-discovered-relations"; /** * v1.8.5 — additional Mood Insights sections. @@ -250,6 +251,11 @@ export function MoodInsightsSections() { bloodPressureSystolic={data.correlations.bloodPressureSystolic} /> + + {/* F3 — the FDR-controlled discovered mood relations. Self-fetches the + correlation-discovery surface and renders nothing when the operator + disabled it, while loading, or when no mood pair cleared the bar. */} + ); } diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index d472e2863..131168053 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -95,3 +95,48 @@ describe("discoverCorrelations", () => { expect(result.discovered).toHaveLength(0); }); }); + +// ── F3 — MOOD as a discoverable OUTCOME channel ───────────────────── + +import { DISCOVERY_OUTCOMES } from "../correlation-discovery"; + +describe("MOOD as outcome (F3)", () => { + it("registers MOOD in the outcome matrix", () => { + expect(DISCOVERY_OUTCOMES).toContain("MOOD"); + }); + + it("surfaces a behaviour → next-day MOOD relation", () => { + // 30 contiguous days; daylight on day D drives mood on day D+1. + const n = 30; + const daylight = Array.from({ length: n }, (_, i) => 60 + (i % 10) * 5); + // mood[D+1] tracks daylight[D] with light noise → strong lagged r. + const mood = Array.from({ length: n }, (_, i) => { + const driver = i === 0 ? 60 : 60 + ((i - 1) % 10) * 5; + return 1 + (driver - 60) / 45 + (i % 2 === 0 ? 0.02 : -0.02); + }); + const result = discoverCorrelations([ + { key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(daylight) }, + { key: "MOOD", role: "outcome", points: series(mood) }, + ]); + const pair = result.discovered.find( + (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "MOOD", + ); + expect(pair).toBeDefined(); + expect(pair!.r).toBeGreaterThan(0.5); + }); + + it("never tests the MOOD → MOOD self-pair", () => { + const n = 30; + const mood = Array.from({ length: n }, (_, i) => 3 + (i % 3)); + const result = discoverCorrelations([ + { key: "MOOD", role: "behaviour", points: series(mood) }, + { key: "MOOD", role: "outcome", points: series(mood) }, + ]); + expect( + result.discovered.find( + (p) => p.behaviour === "MOOD" && p.outcome === "MOOD", + ), + ).toBeUndefined(); + expect(result.pairsTested).toBe(0); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index da9881ff6..959cb809e 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -249,6 +249,13 @@ export const DISCOVERY_OUTCOMES = [ "HEART_RATE_VARIABILITY", "RESTING_HEART_RATE", "WEIGHT", + // v1.11.5 (F3) — mood is now an OUTCOME channel too, not only a behaviour. + // The discovery loop skips the MOOD→MOOD self-pair (`b.key === o.key`), so + // promoting it lets the FDR scan surface "behaviour today → next-day mood" + // relations (e.g. more daylight today → better mood tomorrow) in addition + // to the existing "mood today → next-day outcome" direction. BH-FDR + // already controls the larger pair family this opens up. + "MOOD", // Wrist temperature is a credible future OUTCOME channel (near-daily, so // n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night // temperature?"), but it is deliberately NOT a channel yet: it is