mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
f1edbd17d1
commit
4e6599d85f
6 files changed
+339
-13
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user