mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(insights): merge the two mood "what stands out" surfaces into one card
The mood page carried two separate "what stands out" tiles: the narrative takeaway feed and the FDR-controlled discovered relations, each a full card under the same heading. Fold them into a single card — one header, the narrative one-liners, then the discovered relations as a labelled subsection. Each half gates independently; the card degrades to nothing when both are empty. Drop the muted FDR explainer paragraph: the statistical caveat rides the subsection info-icon tooltip, and every finding now reads in the standard foreground text colour. Remove the orphaned discovery.description string from all six locales and add the subsection label. Give the better-days board the assessment-card treatment (insight-assessment hook, tighter gap/padding, the insight-in entry) so it reads as the mood assessment, consistent with the per-metric subpages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f25f7f3597
commit
68ab78d6d8
11 files changed
+208
-158
No files matched your search
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodDiscoveredRelations />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodDiscoveredRelations pairs={pairs} pairsTested={pairsTested} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,16 +43,11 @@ function pair(over: Partial<DiscoveredCorrelation>): DiscoveredCorrelation {
|
||||
}
|
||||
|
||||
describe("<MoodDiscoveredRelations>", () => {
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodDiscoveredRelations />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodDiscoveredRelations pairs={[]} pairsTested={0} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
@@ -91,15 +63,15 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
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("<MoodDiscoveredRelations>", () => {
|
||||
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("<MoodDiscoveredRelations>", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<Card data-slot="mood-discovered-relations">
|
||||
<CardHeader className="pb-2">
|
||||
{/* 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. */}
|
||||
<TileHeader
|
||||
icon={Compass}
|
||||
title={t("insights.mood.discovery.title")}
|
||||
right={
|
||||
<MoodExplainerIcon
|
||||
label={t("insights.mood.discovery.explainerLabel")}
|
||||
detail={`${t("insights.mood.discovery.footer", {
|
||||
tested: data.pairsTested,
|
||||
})} ${t("insights.mood.discovery.disclaimer")}`}
|
||||
/>
|
||||
}
|
||||
// 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.
|
||||
<div data-slot="mood-discovered-relations" className="space-y-1">
|
||||
<div className="text-foreground flex items-center gap-1.5 text-sm font-medium">
|
||||
<span>{t("insights.mood.discovery.subheading")}</span>
|
||||
<MoodExplainerIcon
|
||||
label={t("insights.mood.discovery.explainerLabel")}
|
||||
detail={`${t("insights.mood.discovery.footer", {
|
||||
tested: pairsTested,
|
||||
})} ${t("insights.mood.discovery.disclaimer")}`}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("insights.mood.discovery.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{moodPairs.map((pair) => {
|
||||
</div>
|
||||
<ul className="divide-border divide-y">
|
||||
{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. */}
|
||||
<span className="text-foreground flex items-center gap-1.5">
|
||||
<span>{t(sentenceKey, { factor: factorLabel })}</span>
|
||||
<MoodExplainerIcon
|
||||
@@ -148,8 +129,7 @@ export function MoodDiscoveredRelations() {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<InsightStatusCard>` 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 && (
|
||||
<SectionCard
|
||||
title={t("insights.mood.betterDays.title")}
|
||||
icon={Sparkles}
|
||||
<Card
|
||||
aria-live="polite"
|
||||
data-slot="insight-assessment"
|
||||
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
|
||||
>
|
||||
<MoodBetterDays factors={betterDays} />
|
||||
</SectionCard>
|
||||
<CardHeader className="pb-2">
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.mood.betterDays.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MoodBetterDays factors={betterDays} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{narratives.length > 0 && (
|
||||
<SectionCard title={t("insights.mood.narrative.title")} icon={TrendingUp}>
|
||||
<MoodNarrativeFeed items={narratives} />
|
||||
</SectionCard>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<MoodWhatStandsOut narratives={narratives} />
|
||||
|
||||
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
|
||||
<MoodHeatmap
|
||||
@@ -344,11 +354,6 @@ export function MoodInsightsSections() {
|
||||
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card data-slot="mood-what-stands-out">
|
||||
<CardHeader className="pb-2">
|
||||
<TileHeader icon={TrendingUp} title={t("insights.mood.narrative.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasNarratives && <MoodNarrativeFeed items={narratives} />}
|
||||
{hasDiscovered && (
|
||||
<MoodDiscoveredRelations
|
||||
pairs={moodPairs}
|
||||
pairsTested={data?.pairsTested ?? moodPairs.length}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user