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