refactor(insights): tighten the mood page — consolidate subtexts, icon-ify explainers, unify palette

Lay the narrative takeaways two-up instead of a single full-width column so
short one-liners (best daypart, logging streak, weekday peak) stop stacking
into a long ladder of half-empty rows.

Fold the low-density footnotes behind a focusable ring/info explainer icon
with a tooltip: the per-pair paired-day/r/q stat under each discovered
relation, the per-card paired-day/r line under each mood-correlation scatter,
and the false-discovery footer + observational disclaimer that sat as two
full-width rows under the discovered-relations list. The trigger is a real
button carrying an aria-label so the explanation is keyboard- and
screen-reader-reachable, not hover-only.

Charts stay Recharts and visually identical — the mood distribution and
mood-by-time-of-day charts already share one Dracula band palette
(red/orange/yellow/green over the same thresholds, --dracula-fg ticks,
--dracula-comment axes, --card/--border tooltips), so the surface palette is
already consistent and left untouched.

New explainer keys land in all six locales.

(cherry picked from commit afc38491ebfae3beed6476a79439d2b87f33f823)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 10:42:46 +02:00
1 parent 9cc739f79d
commit 89d51c5380
11 files changed
+153 -49

No files matched your search

+5 -2
View File
@@ -2558,7 +2558,8 @@
"weightTitle": "Stimmung vs. Gewicht",
"bloodPressureTitle": "Stimmung vs. Blutdruck",
"weightAxis": "Gewicht",
"bloodPressureAxis": "Systolisch (mmHg)"
"bloodPressureAxis": "Systolisch (mmHg)",
"sourceLabel": "Wie das berechnet wurde"
},
"structuredTagsTitle": "Tag-Auswertung",
"inTargetValue": "{pct} %",
@@ -2629,7 +2630,9 @@
"moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.",
"outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.",
"outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.",
"stat": "{n} gepaarte Tage · r = {r} · q = {q}"
"stat": "{n} gepaarte Tage · r = {r} · q = {q}",
"statLabel": "Wie das berechnet wurde",
"explainerLabel": "Wie das berechnet wurde"
}
},
"derived": {
+4 -1
View File
@@ -2553,6 +2553,7 @@
"strengthWeak": "Weak",
"strengthNone": "None",
"source": "{n} paired days · r = {r}",
"sourceLabel": "How this was computed",
"emptyTitle": "Not enough paired data yet",
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
"weightTitle": "Mood vs. weight",
@@ -2629,7 +2630,9 @@
"moodDown": "Higher {factor} tends to go with lower next-day mood in your data.",
"outcomeUp": "Higher mood tends to go with higher next-day {factor} in your data.",
"outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.",
"stat": "{n} paired days · r = {r} · q = {q}"
"stat": "{n} paired days · r = {r} · q = {q}",
"statLabel": "How this was computed",
"explainerLabel": "How this was computed"
}
},
"derived": {
+5 -2
View File
@@ -2558,7 +2558,8 @@
"weightTitle": "Ánimo vs. peso",
"bloodPressureTitle": "Ánimo vs. presión arterial",
"weightAxis": "Peso",
"bloodPressureAxis": "Sistólica (mmHg)"
"bloodPressureAxis": "Sistólica (mmHg)",
"sourceLabel": "Cómo se calculó esto"
},
"structuredTagsTitle": "Desglose de etiquetas",
"inTargetValue": "{pct} %",
@@ -2629,7 +2630,9 @@
"moodDown": "Un mayor {factor} suele coincidir con peor ánimo al día siguiente en tus datos.",
"outcomeUp": "Mejor ánimo suele coincidir con un {factor} más alto al día siguiente en tus datos.",
"outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.",
"stat": "{n} días emparejados · r = {r} · q = {q}"
"stat": "{n} días emparejados · r = {r} · q = {q}",
"statLabel": "Cómo se calculó esto",
"explainerLabel": "Cómo se calculó esto"
}
},
"derived": {
+5 -2
View File
@@ -2558,7 +2558,8 @@
"weightTitle": "Humeur vs. poids",
"bloodPressureTitle": "Humeur vs. tension artérielle",
"weightAxis": "Poids",
"bloodPressureAxis": "Systolique (mmHg)"
"bloodPressureAxis": "Systolique (mmHg)",
"sourceLabel": "Comment ceci est calculé"
},
"structuredTagsTitle": "Répartition des étiquettes",
"inTargetValue": "{pct} %",
@@ -2629,7 +2630,9 @@
"moodDown": "Un {factor} plus élevé tend à coïncider avec une moins bonne humeur le lendemain dans vos données.",
"outcomeUp": "Une meilleure humeur tend à coïncider avec un {factor} plus élevé le lendemain dans vos données.",
"outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.",
"stat": "{n} jours appariés · r = {r} · q = {q}"
"stat": "{n} jours appariés · r = {r} · q = {q}",
"statLabel": "Comment ceci est calculé",
"explainerLabel": "Comment ceci est calculé"
}
},
"derived": {
+5 -2
View File
@@ -2558,7 +2558,8 @@
"weightTitle": "Umore vs. peso",
"bloodPressureTitle": "Umore vs. pressione sanguigna",
"weightAxis": "Peso",
"bloodPressureAxis": "Sistolica (mmHg)"
"bloodPressureAxis": "Sistolica (mmHg)",
"sourceLabel": "Come è stato calcolato"
},
"structuredTagsTitle": "Analisi etichette",
"inTargetValue": "{pct} %",
@@ -2629,7 +2630,9 @@
"moodDown": "Un {factor} più alto tende a coincidere con un umore peggiore il giorno dopo nei tuoi dati.",
"outcomeUp": "Un umore migliore tende a coincidere con un {factor} più alto il giorno dopo nei tuoi dati.",
"outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.",
"stat": "{n} giorni appaiati · r = {r} · q = {q}"
"stat": "{n} giorni appaiati · r = {r} · q = {q}",
"statLabel": "Come è stato calcolato",
"explainerLabel": "Come è stato calcolato"
}
},
"derived": {
+5 -2
View File
@@ -2558,7 +2558,8 @@
"weightTitle": "Nastrój vs. waga",
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
"weightAxis": "Waga",
"bloodPressureAxis": "Skurczowe (mmHg)"
"bloodPressureAxis": "Skurczowe (mmHg)",
"sourceLabel": "Jak to obliczono"
},
"structuredTagsTitle": "Podział tagów",
"inTargetValue": "{pct}%",
@@ -2629,7 +2630,9 @@
"moodDown": "Wyższy {factor} zwykle wiąże się z gorszym nastrojem następnego dnia w Twoich danych.",
"outcomeUp": "Lepszy nastrój zwykle wiąże się z wyższym {factor} następnego dnia w Twoich danych.",
"outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.",
"stat": "{n} sparowanych dni · r = {r} · q = {q}"
"stat": "{n} sparowanych dni · r = {r} · q = {q}",
"statLabel": "Jak to obliczono",
"explainerLabel": "Jak to obliczono"
}
},
"derived": {
@@ -96,9 +96,11 @@ describe("<MoodDiscoveredRelations>", () => {
// 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");
// 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.
expect(html).toContain('aria-label="How this was computed"');
});
it("phrases a mood → next-day outcome pair", () => {
@@ -111,7 +113,7 @@ describe("<MoodDiscoveredRelations>", () => {
expect(html).toContain("lower next-day");
});
it("filters to mood pairs and shows the honest full-family footer", () => {
it("filters to mood pairs and folds the full-family footer into the header explainer", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -122,8 +124,16 @@ describe("<MoodDiscoveredRelations>", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
// Footer's denominator is the full behaviour×outcome family the engine
// tested, not the mood-only subset — so it must report 12, not 1 of 1.
expect(html).toContain("from 12 day-to-day pairs");
// 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.
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.
expect(html).not.toContain("day-to-day pairs tested");
});
});
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/analytics/correlations";
import { MoodExplainerIcon } from "./mood-explainer-icon";
/**
* v1.8.5 — mood × metric correlation cards (Apple "Life Factors" parity).
@@ -96,9 +97,21 @@ function MoodCorrelationCard({
{t(TITLE_KEY[kind])}
</CardTitle>
{hasResult && data.result && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
{/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the
scatter as a half-empty row. Move it into an explainer icon
in the header so the card footprint stays tight. */}
<MoodExplainerIcon
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
/>
</div>
)}
</div>
</CardHeader>
@@ -117,12 +130,6 @@ function MoodCorrelationCard({
yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }}
height={180}
/>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
</p>
</>
) : (
<EmptyState
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodExplainerIcon } from "./mood-explainer-icon";
/**
* v1.11.5 (F3) — FDR-controlled discovered mood relations.
@@ -81,9 +82,21 @@ export function MoodDiscoveredRelations() {
return (
<div className="space-y-2" data-slot="mood-discovered-relations">
<h3 className="text-base font-semibold">
{t("insights.mood.discovery.title")}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="text-base font-semibold">
{t("insights.mood.discovery.title")}
</h3>
{/* 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. */}
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
detail={`${t("insights.mood.discovery.footer", {
tested: data.pairsTested,
})} ${t("insights.mood.discovery.disclaimer")}`}
/>
</div>
<p className="text-muted-foreground text-sm">
{t("insights.mood.discovery.description")}
</p>
@@ -108,28 +121,25 @@ export function MoodDiscoveredRelations() {
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),
})}
{/* 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. */}
<span className="text-foreground flex items-center gap-1.5">
<span>{t(sentenceKey, { factor: factorLabel })}</span>
<MoodExplainerIcon
label={t("insights.mood.discovery.statLabel")}
detail={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", {
tested: data.pairsTested,
})}
</p>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.discovery.disclaimer")}
</p>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* v1.12.4 (C3/C4) — compact explainer affordance for the mood surface.
*
* Replaces the low-density inline "how this was computed" text rows
* (paired-day counts, the false-discovery footer, the observational
* disclaimer) with a single ring/info icon. The detail moves into a
* hover/focus tooltip so the surface stops spending a full-width row on a
* footnote. The trigger is a real focusable `button` carrying an
* `aria-label`, so the explanation is reachable by keyboard and screen
* readers — not hover-only. Self-contained `TooltipProvider` so callers
* don't need an ambient one.
*/
export function MoodExplainerIcon({
label,
detail,
className,
}: {
/** Accessible name for the trigger (what the icon explains). */
label: string;
/** The explanation shown in the tooltip. */
detail: string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-current/40 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
{detail}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -64,8 +64,12 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
if (items.length === 0) return null;
return (
// v1.12.4 (C2) — the takeaways are short one-liners, so a single
// full-width column wasted horizontal space and stacked into a long
// ladder. Lay them two-up on anything wider than a phone; a lone
// takeaway still spans the row rather than orphaning half of it.
<ul
className="space-y-2"
className="grid gap-2 sm:grid-cols-2"
data-slot="mood-narrative-feed"
aria-label={t("insights.mood.narrative.title")}
>
@@ -82,7 +86,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
return (
<li
key={`${item.kind}-${index}`}
className="bg-card border-border flex items-start gap-3 rounded-lg border p-3"
className="bg-card border-border flex h-full items-start gap-3 rounded-lg border p-3"
>
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<p className="text-foreground/90 text-sm leading-snug">