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. +
+
+ {t("insights.mood.discovery.subheading")} + - - -

- {t("insights.mood.discovery.description")} -

-
    - {moodPairs.map((pair) => { +
+
    + {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 && ( + + )} + + + ); +}