diff --git a/messages/de.json b/messages/de.json
index 9a3b498ee..2d7779d34 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2605,7 +2605,7 @@
},
"discovery": {
"title": "Was auffällt",
- "description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.",
+ "subheading": "Entdeckte Zusammenhänge",
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
"empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.",
"disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.",
diff --git a/messages/en.json b/messages/en.json
index ab9ebc9ec..f7ca2aa09 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2605,7 +2605,7 @@
},
"discovery": {
"title": "What stands out",
- "description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.",
+ "subheading": "Discovered relationships",
"footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.",
"empty": "No mood relationships cleared the statistical bar yet. Keep logging.",
"disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.",
diff --git a/messages/es.json b/messages/es.json
index 220243670..2fb005280 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2605,7 +2605,7 @@
},
"discovery": {
"title": "Lo que destaca",
- "description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.",
+ "subheading": "Relaciones descubiertas",
"footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.",
"empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.",
"disclaimer": "Filtrado estadísticamente, pero aún observacional: un patrón que vale la pena observar, no una causa.",
diff --git a/messages/fr.json b/messages/fr.json
index f84c2fe9d..fcc7105dc 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2605,7 +2605,7 @@
},
"discovery": {
"title": "Ce qui ressort",
- "description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.",
+ "subheading": "Relations découvertes",
"footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.",
"empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.",
"disclaimer": "Filtré statistiquement, mais toujours observationnel : une tendance à surveiller, pas une cause.",
diff --git a/messages/it.json b/messages/it.json
index 46aea4375..0483242c2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2605,7 +2605,7 @@
},
"discovery": {
"title": "Cosa risalta",
- "description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.",
+ "subheading": "Relazioni scoperte",
"footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.",
"empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.",
"disclaimer": "Filtrato statisticamente, ma sempre osservazionale: uno schema da tenere d'occhio, non una causa.",
diff --git a/messages/pl.json b/messages/pl.json
index acc63130d..73c9d03df 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2605,7 +2605,7 @@
},
"discovery": {
"title": "Co się wyróżnia",
- "description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.",
+ "subheading": "Wykryte relacje",
"footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.",
"empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.",
"disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.",
diff --git a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx
index 204510baa..63151a0f2 100644
--- a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx
+++ b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx
@@ -1,53 +1,30 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect } 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";
+import {
+ MoodDiscoveredRelations,
+ moodPairsOf,
+ type DiscoveredCorrelation,
+} from "../mood-discovered-relations";
-// 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;
-}
+/**
+ * v1.12.7 — the discovered-relations surface is now a header-less pure
+ * renderer embedded inside the merged "What stands out" card. The card owns
+ * the correlation-discovery fetch and passes in the mood pairs; this test
+ * exercises the renderer directly with `moodPairsOf` doing the mood filtering
+ * the card performs.
+ */
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,
- });
+ const pairs = moodPairsOf(discovered);
return renderToStaticMarkup(
-
-
-
-
- ,
+
+
+ ,
);
}
@@ -66,16 +43,11 @@ function pair(over: Partial): DiscoveredCorrelation {
}
describe("", () => {
- it("renders nothing without discovered data (loading / disabled surface)", () => {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false } },
- });
+ it("renders nothing with no pairs", () => {
const html = renderToStaticMarkup(
-
-
-
-
- ,
+
+
+ ,
);
expect(html).toBe("");
});
@@ -91,15 +63,15 @@ describe("", () => {
const html = renderWith([
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD", r: 0.5 }),
]);
+ expect(html).toContain('data-slot="mood-discovered-relations"');
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");
- // v1.12.4 (C4) — the n/r/q detail moved off the visible sub-row into a
- // focusable explainer icon (its tooltip content is portalled on open, so
- // it is absent from the static markup). Assert the icon is wired per pair
- // instead of the inline stat string.
+ // The n/r/q detail rides a focusable explainer icon (its tooltip content
+ // is portalled on open, so it is absent from the static markup). Assert
+ // the icon is wired per pair instead of the inline stat string.
expect(html).toContain('aria-label="How this was computed"');
});
@@ -113,7 +85,7 @@ describe("", () => {
expect(html).toContain("lower next-day");
});
- it("filters to mood pairs and folds the full-family footer into the header explainer", () => {
+ it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -124,16 +96,14 @@ describe("", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
- // v1.12.4 (C3) — the false-discovery footer (denominator = the full
- // behaviour×outcome family the engine tested, i.e. 12, not the mood-only
- // subset) + the observational disclaimer were two full-width footnote
- // rows; both fold into a single header explainer icon now. Its tooltip
- // content is portalled on open, so assert the trigger exists rather than
- // the (now hidden) footer string.
+ // The false-discovery footer + the observational disclaimer fold into the
+ // subsection explainer icon; its tooltip content is portalled on open, so
+ // assert the trigger exists rather than the (now hidden) footer string.
expect(
(html.match(/aria-label="How this was computed"/g) ?? []).length,
).toBeGreaterThanOrEqual(1);
- // The two old inline footnote rows are gone from the static markup.
+ // The former muted explainer paragraph + inline footnote rows are gone.
expect(html).not.toContain("day-to-day pairs tested");
+ expect(html).not.toContain("hold up after correcting");
});
});
diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts
index ca89bd9e3..11eb0d561 100644
--- a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts
+++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts
@@ -36,9 +36,21 @@ describe("mood notes timeline removal", () => {
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
"utf8",
);
- expect(src).toContain("MoodNarrativeFeed");
+ // v1.12.7 — the narrative one-liners now ride the merged "What stands out"
+ // card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally.
+ // The structural guard tracks the mount point on the sections surface.
+ expect(src).toContain("MoodWhatStandsOut");
expect(src).toContain("MoodInTargetTile");
});
+
+ it("renders the narrative feed inside the merged what-stands-out card", () => {
+ const src = readFileSync(
+ join(COMPONENT_DIR, "mood-what-stands-out.tsx"),
+ "utf8",
+ );
+ expect(src).toContain("MoodNarrativeFeed");
+ expect(src).toContain("MoodDiscoveredRelations");
+ });
});
describe("mood entries table — long-note truncation", () => {
diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx
index 6e087b84c..4b2283d96 100644
--- a/src/components/insights/mood/mood-discovered-relations.tsx
+++ b/src/components/insights/mood/mood-discovered-relations.tsx
@@ -1,12 +1,5 @@
"use client";
-import { useQuery } from "@tanstack/react-query";
-import { Compass } from "lucide-react";
-
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { TileHeader } from "@/components/insights/tile-header";
-import { useAuth } from "@/hooks/use-auth";
-import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodExplainerIcon } from "./mood-explainer-icon";
@@ -21,13 +14,17 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
* 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.
+ * v1.12.7 — the surface is now a header-less subsection rendered INSIDE the
+ * merged "What stands out" card (see `mood-insights-sections.tsx`). The owning
+ * card runs the correlation-discovery fetch once and passes the mood pairs in;
+ * this component is a pure renderer that paints the list (or returns nothing
+ * when no pair cleared the bar). The former muted explainer paragraph is gone:
+ * the statistical caveat rides a single info-icon tooltip beside the
+ * subsection heading, and every finding renders in the standard foreground
+ * text colour. Observational only.
*/
-interface DiscoveredCorrelation {
+export interface DiscoveredCorrelation {
behaviour: string;
outcome: string;
n: number;
@@ -38,7 +35,7 @@ interface DiscoveredCorrelation {
lagDays: number;
}
-interface CorrelationDiscoveryResponse {
+export interface CorrelationDiscoveryResponse {
discovered: DiscoveredCorrelation[];
pairsTested: number;
fdrQ: number;
@@ -57,60 +54,44 @@ const CHANNEL_LABEL_KEY: Record = {
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(
+/** Keep only the discovered pairs that involve the mood channel. */
+export function moodPairsOf(
+ discovered: DiscoveredCorrelation[],
+): DiscoveredCorrelation[] {
+ return discovered.filter(
(pair) => pair.behaviour === "MOOD" || pair.outcome === "MOOD",
);
- if (moodPairs.length === 0) return null;
+}
+
+export function MoodDiscoveredRelations({
+ pairs,
+ pairsTested,
+}: {
+ pairs: DiscoveredCorrelation[];
+ pairsTested: number;
+}) {
+ const { t } = useTranslations();
+
+ if (pairs.length === 0) return null;
return (
-
-
- {/* v1.12.4 (C3) — the false-discovery footer + observational
- disclaimer were two full-width footnote rows. Fold both into a
- single explainer icon so the explanation is one hover/focus away
- instead of a low-density block under the list. v1.12.6 — the
- heading now rides the canonical `TileHeader`, with the explainer
- icon pinned to its trailing slot. */}
-
- }
+ // v1.12.7 — header-less subsection. The merged "What stands out" card owns
+ // the single TileHeader; this block leads with a compact subsection label
+ // plus the statistical-caveat explainer icon (the false-discovery footer +
+ // observational disclaimer fold into the one tooltip) so the surface stops
+ // spending a muted paragraph on a footnote.
+
+ {pairs.map((pair) => {
const moodIsOutcome = pair.outcome === "MOOD";
const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome;
const factorLabel = t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey);
@@ -130,10 +111,10 @@ export function MoodDiscoveredRelations() {
data-mood-role={moodIsOutcome ? "outcome" : "behaviour"}
data-direction={up ? "up" : "down"}
>
- {/* v1.12.4 (C4) — the {n} paired days · r · q line was a
- low-density sub-row under each sentence. Move the numeric
- detail behind an explainer icon on the same line as the
- finding so the list stays one row per pair. */}
+ {/* The {n} paired days · r · q numeric detail rides an explainer
+ icon on the same line as the finding so the list stays one
+ row per pair. The finding itself reads in the standard
+ foreground colour. */}
{t(sentenceKey, { factor: factorLabel })}
);
})}
-
-
-
+
+
);
}
diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx
index 061871ca1..d1f867529 100644
--- a/src/components/insights/mood/mood-insights-sections.tsx
+++ b/src/components/insights/mood/mood-insights-sections.tsx
@@ -81,10 +81,8 @@ import {
MoodStructuredTagBreakdown,
type MoodStructuredTagRow,
} from "./mood-structured-tag-breakdown";
-import {
- MoodNarrativeFeed,
- type MoodNarrativeItem,
-} from "./mood-narrative-feed";
+import { type MoodNarrativeItem } from "./mood-narrative-feed";
+import { MoodWhatStandsOut } from "./mood-what-stands-out";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodStabilityTile,
@@ -102,7 +100,6 @@ import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
-import { MoodDiscoveredRelations } from "./mood-discovered-relations";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -257,21 +254,34 @@ export function MoodInsightsSections() {
{/* "Was zu deinen besseren Tagen passt" — the better-days assessment,
ranked by effect size. The substance of the Einschätzung sits here,
- directly under the classification. */}
+ directly under the classification. v1.12.7 — it now carries the same
+ assessment-card weight the per-metric `` uses on
+ the other subpages (tighter `gap`/`py`, the `insight-in` entry, and
+ the `insight-assessment` hook) so it reads as THE mood assessment,
+ consistent across the app. */}
{hasBetterDays && (
-
-
-
+
+
+
+
+
+
+
)}
- {narratives.length > 0 && (
-
-
-
- )}
+ {/* v1.12.7 — the single "What stands out" card folds the narrative
+ one-liners AND the FDR-controlled discovered relations into one tile
+ (was two separate cards). Self-fetches the discovery surface and
+ renders nothing when both halves are empty. */}
+
-
- {/* 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/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx
new file mode 100644
index 000000000..991b782c1
--- /dev/null
+++ b/src/components/insights/mood/mood-what-stands-out.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { TrendingUp } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { TileHeader } from "@/components/insights/tile-header";
+import { useAuth } from "@/hooks/use-auth";
+import { queryKeys } from "@/lib/query-keys";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ MoodNarrativeFeed,
+ type MoodNarrativeItem,
+} from "./mood-narrative-feed";
+import {
+ MoodDiscoveredRelations,
+ moodPairsOf,
+ type CorrelationDiscoveryResponse,
+} from "./mood-discovered-relations";
+
+/**
+ * v1.12.7 — the single "What stands out" card.
+ *
+ * The mood page used to carry TWO separate "what stands out" surfaces: the
+ * narrative takeaway feed up top, and the FDR-controlled discovered relations
+ * lower down — both titled "What stands out", both taking a full card. This
+ * folds them into ONE card: one `TileHeader`, the narrative one-liners, then
+ * the discovered day-to-day relations as a labelled subsection. Each half
+ * gates independently — the card renders whatever has content and degrades to
+ * nothing when both are empty, so it never paints an empty shell.
+ *
+ * The discovered-relations fetch lives here (the correlation-discovery surface
+ * is optional and self-degrades on a 403); the narrative items arrive from the
+ * mood-insights aggregate the parent already holds.
+ */
+export function MoodWhatStandsOut({
+ narratives,
+}: {
+ narratives: MoodNarrativeItem[];
+}) {
+ 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,
+ });
+
+ const moodPairs =
+ isLoading || isError || !data ? [] : moodPairsOf(data.discovered);
+ const hasNarratives = narratives.length > 0;
+ const hasDiscovered = moodPairs.length > 0;
+
+ // Both halves empty → render nothing rather than an empty card.
+ if (!hasNarratives && !hasDiscovered) return null;
+
+ return (
+
+
+
+
+
+ {hasNarratives && }
+ {hasDiscovered && (
+
+ )}
+
+
+ );
+}