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

@@ -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>
);
}