feat(mood-insights): promote mood to a discovery outcome and surface it

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:09:07 +02:00
1 parent f1edbd17d1
commit 4e6599d85f
6 files changed
+339 -13

No files matched your search

+17 -13
View File
@@ -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);
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodDiscoveredRelations />
</I18nProvider>
</QueryClientProvider>,
);
}
function pair(over: Partial<DiscoveredCorrelation>): DiscoveredCorrelation {
return {
behaviour: "TIME_IN_DAYLIGHT",
outcome: "MOOD",
n: 40,
r: 0.5,
pValue: 0.001,
qValue: 0.02,
interpretation: "",
lagDays: 1,
...over,
};
}
describe("<MoodDiscoveredRelations>", () => {
it("renders nothing without discovered data (loading / disabled surface)", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodDiscoveredRelations />
</I18nProvider>
</QueryClientProvider>,
);
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");
});
});
@@ -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<string, string> = {
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 (
<div className="space-y-2" data-slot="mood-discovered-relations">
<h3 className="text-base font-semibold">
{t("insights.mood.discovery.title")}
</h3>
<p className="text-muted-foreground text-sm">
{t("insights.mood.discovery.description")}
</p>
<ul className="divide-border divide-y">
{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 (
<li
key={`${pair.behaviour}->${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"}
>
<span className="text-foreground">
{t(sentenceKey, { factor: factorLabel })}
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{t("insights.mood.discovery.stat", {
n: pair.n,
r: pair.r.toFixed(2),
q: pair.qValue.toFixed(3),
})}
</span>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.discovery.footer", {
discovered: moodPairs.length,
tested: data.pairsTested,
})}
</p>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.discovery.disclaimer")}
</p>
</div>
);
}
@@ -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}
/>
</div>
{/* 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. */}
<MoodDiscoveredRelations />
</div>
);
}
@@ -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);
});
});
@@ -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