From d434a6e24bd6b781108487e41790afb413bc8074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:20:48 +0200 Subject: [PATCH 01/17] insights: add canonical TileHeader and route shared tiles through it Introduce a single TileHeader primitive (foreground icon + CardTitle, matching the assessment-card header) and route the assessment card, the target reference panel, and the stat strip through it so every Insights tile leads with the same header language. The target panel now leads with a single-word "Ziel"/"Target" heading and drops the redundant range string (the band numbers already live on the range bar); the stat strip labels each series via a header ("Systolisch"/"Diastolisch" for blood pressure, the metric name for single-series metrics). --- messages/de.json | 9 ++- messages/en.json | 9 ++- messages/es.json | 9 ++- messages/fr.json | 9 ++- messages/it.json | 9 ++- messages/pl.json | 9 ++- .../__tests__/metric-target-summary.test.tsx | 62 +++++++++++------ .../insights/healthkit-metric-page.tsx | 20 +++++- .../insights/insight-status-card.tsx | 42 +++++++----- src/components/insights/metric-stat-strip.tsx | 36 +++++++--- .../insights/metric-target-summary.tsx | 68 ++++++------------- src/components/insights/tile-header.tsx | 68 +++++++++++++++++++ 12 files changed, 232 insertions(+), 118 deletions(-) create mode 100644 src/components/insights/tile-header.tsx diff --git a/messages/de.json b/messages/de.json index 73279398e..73e41890e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Chronologisches GLP-1-Therapieprotokoll — Dosis-Änderungen, Injektionen, Bestand, Nebenwirkungen.", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", "target": { - "range": "Ziel: {min}–{max} {unit}", - "bpRange": "Ziel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% der erfassten Tage im Zielbereich", "adjustLink": "Zielbereich anpassen" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Stimmung über 30 Tage leicht verbessert.", "moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.", "moodStable": "Stimmung über die letzten 30 Tage stabil." + }, + "target": { + "heading": "Ziel" + }, + "disclaimer": { + "footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt." } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index ff734fe34..140470549 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", "target": { - "range": "Target: {min}–{max} {unit}", - "bpRange": "Target: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% of logged days within target", "adjustLink": "Adjust target range" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Mood trended slightly higher over 30 days.", "moodDeclined": "Mood trended slightly lower over 30 days.", "moodStable": "Mood held steady over the last 30 days." + }, + "target": { + "heading": "Target" + }, + "disclaimer": { + "footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you." } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 4d6f06a78..754212920 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Protocolo cronológico de terapia GLP-1 — cambios de dosis, inyecciones, existencias, efectos secundarios.", "bmiDescription": "IMC derivado de tu peso y altura, con valoración.", "target": { - "range": "Objetivo: {min}–{max} {unit}", - "bpRange": "Objetivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% de los días registrados dentro del objetivo", "adjustLink": "Ajustar el rango objetivo" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.", "moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.", "moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días." + }, + "target": { + "heading": "Objetivo" + }, + "disclaimer": { + "footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe." } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 13c542728..f4579f8b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Journal chronologique de la thérapie GLP-1 — changements de dose, injections, stock, effets secondaires.", "bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.", "target": { - "range": "Cible : {min}–{max} {unit}", - "bpRange": "Cible : {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% des jours enregistrés dans la cible", "adjustLink": "Ajuster la plage cible" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Humeur en légère hausse sur 30 jours.", "moodDeclined": "Humeur en légère baisse sur 30 jours.", "moodStable": "Humeur stable sur les 30 derniers jours." + }, + "target": { + "heading": "Objectif" + }, + "disclaimer": { + "footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe." } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 5245295ef..11dfd526e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Protocollo cronologico della terapia GLP-1 — cambi di dose, iniezioni, scorte, effetti collaterali.", "bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.", "target": { - "range": "Obiettivo: {min}–{max} {unit}", - "bpRange": "Obiettivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% dei giorni registrati nell'obiettivo", "adjustLink": "Modifica l'intervallo obiettivo" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Umore in lieve miglioramento su 30 giorni.", "moodDeclined": "Umore in lieve calo su 30 giorni.", "moodStable": "Umore stabile negli ultimi 30 giorni." + }, + "target": { + "heading": "Obiettivo" + }, + "disclaimer": { + "footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi." } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index d4b5efcff..9cd479969 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Chronologiczny protokół terapii GLP-1 — zmiany dawki, iniekcje, stan, działania niepożądane.", "bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.", "target": { - "range": "Cel: {min}–{max} {unit}", - "bpRange": "Cel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu", "adjustLink": "Dostosuj zakres docelowy" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.", "moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.", "moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni." + }, + "target": { + "heading": "Cel" + }, + "disclaimer": { + "footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi." } }, "thresholds": { diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 10faa398e..18e781bb6 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -6,16 +6,16 @@ import { I18nProvider } from "@/lib/i18n/context"; /** * v1.8.0 → v1.8.5 — `` unit tests. * - * The panel surfaces the numeric target range, the verbal status pill, - * the guideline source, the in-target share, the 30-day average, the - * range bar and the 7-day consistency strip on each insights category - * page, reading the same `/api/insights/targets` payload that powers - * `/targets`. The component depends on `useAuth` (gate) and TanStack - * Query (`useQuery`) for the payload, so both are mocked here and the - * assertions run through SSR — the load-bearing behaviour is the - * range/share/source rendering + the slug→type mapping (including the - * blood-glucose per-context split + unit conversion), not the live - * fetch. + * The panel surfaces the canonical "Target" tile header, the verbal + * status pill, the guideline source, the 30-day average (BP only), the + * range bar (whose axis labels carry the band endpoints) and the 7-day + * consistency strip on each insights category page, reading the same + * `/api/insights/targets` payload that powers `/targets`. The component + * depends on `useAuth` (gate) and TanStack Query (`useQuery`) for the + * payload, so both are mocked here and the assertions run through SSR — + * the load-bearing behaviour is the header/bar/source rendering + the + * slug→type mapping (including the blood-glucose per-context split + unit + * conversion), not the live fetch. */ vi.mock("@/hooks/use-auth", () => ({ @@ -154,10 +154,17 @@ const GLUCOSE_DATA: TargetsFixture = { }; describe("", () => { - it("renders the numeric range for a simple metric", () => { + it("renders the canonical Target tile header and the band endpoints on the bar", () => { const html = renderWith("weight", WEIGHT_DATA); - // Trailing `.0` trimmed, single-decimal kept. - expect(html).toContain("Target: 60.1–80.9 kg"); + // v1.12.6 — the heading is the single-word canonical ``; + // the band numbers live on the range bar's axis labels rather than a + // redundant "Target: 60.1–80.9 kg" string above it. + expect(html).toContain('data-slot="tile-header"'); + expect(html).toContain("Target"); + expect(html).not.toContain("Target: 60.1–80.9 kg"); + // The min / max endpoints surface as the range-bar axis labels. + expect(html).toContain("60.1 kg"); + expect(html).toContain("80.9 kg"); // v1.12.0 — the in-target share moved up to ``, // the canonical home for the headline + 30-day average + in-range // bar. The band-reference panel no longer repeats it for non-BP @@ -188,9 +195,13 @@ describe("", () => { expect(html).not.toContain("30-day average: 71.4 kg"); }); - it("stitches the systolic + diastolic bands for blood pressure", () => { + it("renders the systolic + diastolic bands for blood pressure", () => { const html = renderWith("blood-pressure", BP_DATA); - expect(html).toContain("Target: 120–129 / 70–79 mmHg"); + // v1.12.6 — the stitched "Target: 120–129 / 70–79 mmHg" string is gone; + // the canonical "Target" header leads and each band's endpoints surface + // on its own range bar. + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("Target: 120–129 / 70–79 mmHg"); // v1.12.0 — the in-target share moved up to the primary tile; the BP // panel keeps only the stitched 30-day average inline. expect(html).not.toContain("of logged days within target"); @@ -223,7 +234,9 @@ describe("", () => { ...WEIGHT_DATA, targets: [{ ...WEIGHT_ITEM, insufficientData: true }], }); - expect(html).toContain("Target: 60.1–80.9 kg"); + // v1.12.6 — the canonical Target header still renders; the share + + // consistency strip suppress on insufficient data. + expect(html).toContain('data-slot="tile-header"'); expect(html).not.toContain("of logged days within target"); expect(html).not.toContain('data-slot="consistency-strip"'); }); @@ -253,9 +266,12 @@ describe("", () => { html.match(/data-slot="metric-target-summary"/g) ?? [] ).length; expect(panelCount).toBe(2); + // v1.12.6 — the per-context band numbers live on each panel's range + // bar (the redundant "Target: 70–100 mg/dL" string was removed). The // mg/dL display unit keeps the canonical values unchanged. - expect(html).toContain("Target: 70–100 mg/dL"); - expect(html).toContain("Target: 70–140 mg/dL"); + expect(html).toContain("70 mg/dL"); + expect(html).toContain("100 mg/dL"); + expect(html).toContain("140 mg/dL"); // ADA / DDG source surfaces for each panel. expect(html).toContain("Source: ADA 2024 / DDG"); // The i18n label keys resolve to EN context headings (rendered @@ -270,10 +286,12 @@ describe("", () => { profile: { glucoseUnit: "mmol/L" }, }); // 70 mg/dL → 3.9, 100 mg/dL → 5.5, 140 mg/dL → 7.8 mmol/L - // (the /targets converter rounds to one decimal). - expect(html).toContain("Target: 3.9–5.5 mmol/L"); - expect(html).toContain("Target: 3.9–7.8 mmol/L"); - expect(html).toContain("mmol/L"); + // (the /targets converter rounds to one decimal). v1.12.6 — the + // converted endpoints surface on the range bar, not a "Target: …" + // string. + expect(html).toContain("3.9 mmol/L"); + expect(html).toContain("5.5 mmol/L"); + expect(html).toContain("7.8 mmol/L"); }); it("renders nothing when no glucose context has a band", () => { diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index aa86c5c5c..4732f4e7c 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps, ComponentType, ReactNode } from "react"; import { Sparkles } from "lucide-react"; @@ -77,6 +77,14 @@ export interface HealthKitMetricPageProps { emptyStateCtaType?: string | null; /** Icon node mounted in the empty-state card. */ emptyStateIcon: ReactNode; + /** + * v1.12.6 — leading glyph for the stat strip's `` (the + * numbers-first block above the chart). Pass the metric's icon component + * (e.g. `Activity`, `Droplet`); when omitted the strip falls back to a + * generic stats glyph so every HealthKit page still leads with a complete + * header carrying the metric name. + */ + statIcon?: ComponentType<{ className?: string }>; /** * Optional Coach prefill for the empty-state launch. Falls back to a * generic onboarding prompt threaded through ``'s @@ -148,6 +156,7 @@ export function HealthKitMetricPage({ explainerMetric, targetSummarySlug, statusMetric, + statIcon, forecast = false, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); @@ -210,7 +219,14 @@ export function HealthKitMetricPage({ title={title} description={description} explainerMetric={explainerMetric} - statStrip={} + statStrip={ + + } diversityNudge={ `. Callers still pass a pre-sized icon NODE (e.g. + * ``) because the bespoke metric pages + * own that prop; `` drives its icon off a component, so wrap + * the node in a thin component that renders it verbatim. The node already + * carries the `h-5 w-5` foreground sizing `` would apply, so + * the rendered row is byte-identical to the previous hand-rolled `flex + * items-center gap-2` header. + */ +function nodeIcon(icon: React.ReactNode): React.ComponentType { + return function StatusIcon() { + return <>{icon}; + }; +} + interface InsightStatusCardProps { title: string; icon: React.ReactNode; @@ -59,10 +76,7 @@ export function InsightStatusCard({ className="gap-2 py-4 md:gap-3 md:py-5" > -
- {icon} - {title} -
+
@@ -115,10 +129,7 @@ export function InsightStatusCard({ return ( -
- {icon} - {title} -
+

@@ -133,10 +144,7 @@ export function InsightStatusCard({ return ( -

- {icon} - {title} -
+

@@ -175,11 +183,9 @@ export function InsightStatusCard({ {/* v1.11.5 — the top-right "cached" label was removed: it surfaced an implementation detail and devalued the assessment. The card - still consumes the warm cache; it just no longer announces it. */} -

- {icon} - {title} -
+ still consumes the warm cache; it just no longer announces it. + v1.12.6 — the icon + title row is the canonical ``. */} + {/* v1.4.27 — defence-in-depth strip. Cached status text from diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 75af6b489..c49964c68 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -1,6 +1,10 @@ "use client"; +import type { ComponentType } from "react"; +import { Sigma } from "lucide-react"; + import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -41,12 +45,26 @@ interface MetricStatStripProps { */ fractionDigits?: number; /** - * v1.12.4 — optional series caption rendered above the grid. Single-series - * metrics omit it; blood pressure stacks two strips (systolic / diastolic) - * and labels each so the unified strip covers both halves rather than - * silently dropping one. Also feeds the `aria-label` for the section. + * v1.12.4 — series caption rendered above the grid. Single-series metrics + * pass the metric name (e.g. "Gewicht"); blood pressure stacks two strips + * (systolic / diastolic) and labels each so the unified strip covers both + * halves rather than silently dropping one. Also feeds the `aria-label` + * for the section. + * + * v1.12.6 — the caption is now rendered through the canonical + * `` (white heading + white icon, matching the Einschätzung + * card) rather than a small muted uppercase line, so every series block + * leads with the same header language as the rest of the surface. */ seriesLabel?: string; + /** + * v1.12.6 — leading glyph for the series ``. Pass the metric's + * icon component (e.g. `Scale`, `Heart`); blood pressure passes a + * directional glyph per series. Defaults to a generic stats glyph so a + * caller that supplies a `seriesLabel` without an icon still renders a + * complete header. + */ + icon?: ComponentType<{ className?: string }>; } export function MetricStatStrip({ @@ -54,6 +72,7 @@ export function MetricStatStrip({ unit, fractionDigits = 1, seriesLabel, + icon, }: MetricStatStripProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -88,15 +107,10 @@ export function MetricStatStrip({ ? `${t("insights.subPage.stats.label")} — ${seriesLabel}` : t("insights.subPage.stats.label") } - className="bg-card border-border space-y-2 rounded-xl border p-4" + className="bg-card border-border space-y-3 rounded-xl border p-4" > {seriesLabel ? ( -

- {seriesLabel} -

+ ) : null}
{cells.map((cell) => ( diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index bda5faf12..fa32de60f 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -11,6 +11,7 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { RangeBar } from "@/components/targets/range-bar"; import { ConsistencyStrip } from "@/components/targets/consistency-strip"; import { TargetStatusPill } from "@/components/targets/target-status-pill"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; import { getTargetSourceLink } from "@/lib/targets/source-link"; @@ -121,11 +122,6 @@ interface MetricTargetSummaryProps { slug: string; } -/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */ -function fmt(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -275,27 +271,6 @@ function TargetReferencePanel({ const isBp = target.type === "BLOOD_PRESSURE"; const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE"; - // Compose the range string. Blood pressure stitches the systolic band - // (on this target) with the diastolic band (on `bpDiastolic`) so the - // user reads the familiar `S/D` pair. - let rangeLabel: string; - if (isBp && bpDiastolic?.range) { - const dia = bpDiastolic.range; - rangeLabel = t("insights.subPage.target.bpRange", { - sysMin: fmt(range.min), - sysMax: fmt(range.max), - diaMin: fmt(dia.min), - diaMax: fmt(dia.max), - unit, - }); - } else { - rangeLabel = t("insights.subPage.target.range", { - min: fmt(range.min), - max: fmt(range.max), - unit, - }); - } - const sourceLink = getTargetSourceLink(target); // v1.12.0 — the in-target share (% of logged days in band) and the @@ -327,33 +302,32 @@ function TargetReferencePanel({ data-target-type={target.type} className="bg-card border-border space-y-3 rounded-xl border p-4" > - {/* Row 1: target range string + status pill + source link. */} -
-
-
-
- {target.classification ? ( + {/* Row 1: the canonical tile header — a single-word "Ziel" / "Target" + heading (the range numbers live on the range bar below, so the + old "Target: 120–129 mmHg" string would just duplicate them) with + the status pill pinned to the trailing edge. For glucose the + per-context label (Fasting / Postprandial / …) carries real, + non-duplicative info, so it stays as a sub-caption under the + header. */} + - ) : null} -
-
+ ) : null + } + /> + {heading ? ( +

+ {heading} +

+ ) : null} {/* Row 2: range bar — where today's value sits inside the band. */} {target.current != null ? ( diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx new file mode 100644 index 000000000..5d0428e64 --- /dev/null +++ b/src/components/insights/tile-header.tsx @@ -0,0 +1,68 @@ +import type { ComponentType, ReactNode } from "react"; + +import { CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +/** + * v1.12.6 — the one canonical tile header used by every Insights tile. + * + * Pre-v1.12.6 each tile hand-rolled its own `icon + heading` row at a + * slightly different size, weight, and colour: the "Einschätzung" card + * led with a foreground-colour icon + `CardTitle`, the target panel led + * with a small muted `Target` glyph + a range string, and the stat strip + * led with a tiny uppercase muted caption. Side-by-side on the same + * subpage they read as three different card languages. + * + * `TileHeader` pins the single header contract to the "Einschätzung" + * reference (the calmest, most legible of the three): a `flex + * items-center gap-2` row with the icon and the `CardTitle` BOTH in the + * foreground colour (white in dark mode) — never `text-muted-foreground` + * — at `text-base`, icon `h-5 w-5 shrink-0`. An optional `right` slot + * carries a trailing affordance (a provenance / tooltip glyph, a status + * pill) without breaking the row's alignment. + * + * RSC-safe: no hooks, no browser API, so it renders on the server inside + * any client or server tile. + */ + +interface TileHeaderProps { + /** + * Leading glyph. Accepts a Lucide icon component (or any component that + * takes a `className`) so the caller passes the component itself + * (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size + * and colour so every tile header matches. + */ + icon: ComponentType<{ className?: string }>; + /** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */ + title: ReactNode; + /** Optional trailing affordance pinned to the right edge of the row. */ + right?: ReactNode; + className?: string; + titleClassName?: string; + /** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */ + id?: string; +} + +export function TileHeader({ + icon: Icon, + title, + right, + className, + titleClassName, + id, +}: TileHeaderProps) { + return ( +
+ {/* Icon and heading share the foreground colour — the calm, + high-contrast read the "Einschätzung" card established. */} +
+ ); +} From ea2ce8697ed20f97c84bd70f7663a90d85cd97fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:20:54 +0200 Subject: [PATCH 02/17] insights: lead every metric subpage with the stat strip above the chart Render the Min/Max/Median/Mittelwert stat strip directly above the chart on the canonical spine (intro -> stats -> chart -> target -> assessment), the numbers-first detail-screen convention. Wire the series headings on each subpage: blood pressure shows the full four-stat grid for both systolic and diastolic as labelled blocks, single-series pages pass the metric name and icon. Leave a marker for chart-brush-reactive stats as a follow-up. --- .../__tests__/assessment-spine-order.test.ts | 11 +++--- src/app/insights/blood-pressure/page.tsx | 4 ++- src/app/insights/pulse/page.tsx | 9 ++++- src/app/insights/weight/page.tsx | 9 ++++- src/components/insights/sub-page-shell.tsx | 36 ++++++++++++------- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index d73973512..acde8bac2 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -5,10 +5,13 @@ import { join } from "node:path"; /** * v1.12.4 — canonical metric-detail spine guard. * - * The unified subpage spine is: intro → chart → target card → assessment → - * stat strip. The assessment is the LAST content block the page hands to - * `` as `children`; the shell appends the Min/Max/Median/Mean - * stat strip below it. The generic `HealthKitMetricPage` scaffold enforces + * v1.12.6 — the unified subpage spine is: intro → stat strip → chart → + * target card → assessment. The stat strip moved ABOVE the chart (the + * shell renders the `statStrip` prop before `children`); the assessment + * stays the LAST content block the page hands to `` as + * `children`. This source-order guard is unaffected by the render-position + * move because `statStrip` is a prop on the opening tag and the assessment + * is still the final child element. The generic `HealthKitMetricPage` scaffold enforces * this for the ~29 HealthKit pages structurally; the bespoke pages each * hand-write their body, so a future edit can silently slot a card after the * assessment (as `mood` and `medications` did before). diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 875cdb12b..e6369be8e 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { HeartPulse } from "lucide-react"; +import { ArrowDownRight, ArrowUpRight, HeartPulse } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -112,12 +112,14 @@ export default function InsightsBlutdruckPage() { unit="mmHg" fractionDigits={0} seriesLabel={t("charts.systolic")} + icon={ArrowUpRight} />
} diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 5ee3b6531..a6ce89b51 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -113,7 +113,14 @@ export default function InsightsPulsPage() { title={t("insights.pulseSectionTitle")} description={t("insights.subPage.pulsDescription")} explainerMetric="pulse" - statStrip={} + statStrip={ + + } diversityNudge={ } + statStrip={ + + } diversityNudge={ `; the strip + * v1.12.6 — the Min / Max / Median / Mittelwert stat strip. It now leads + * the canonical spine, directly ABOVE the chart, so every metric subpage + * reads the same: intro → stat strip → chart → target → assessment. + * Numbers-first matches how Apple Health / Withings / Oura lead a detail + * screen. Sub-pages pass ``; the strip * self-gates (renders nothing without data), so the empty-state and * brand-new-metric paths stay clean. Omitted on the empty-state branch. */ @@ -255,17 +256,26 @@ export function SubPageShell({

{description}

) : null} - {/* v1.12.4 — the canonical spine body: intro (header, above) → - chart → target card → assessment, all rendered by the page as - `children`. */} - {children} - {/* v1.12.4 — the Min / Max / Median / Mittelwert stat strip closes - the spine, below the assessment. Self-gating (renders nothing + {/* v1.12.6 — the Min / Max / Median / Mittelwert stat strip LEADS + the spine, directly above the chart. Self-gating (renders nothing without data), so the spacing rhythm holds on the empty-state and - brand-new-metric paths. The diversity nudge rides the heading row; - the headline tile and the "Letzte Messung" card are retired so the - subpage is identical by construction across every metric. */} + brand-new-metric paths. Numbers-first is the Apple-Health / + Withings / Oura detail-screen convention. + + TODO(v1.12.7): chart-brush-reactive stats. When the user brushes / + selects a window in the chart below, recompute the stat strip's + Min/Max/Median/Mean for the visible domain. Deferred: the shared + `` (~1900 LOC, also driving the dashboard) has no + Recharts `` or lifted domain-change callback today, and its + range state lives internally; wiring a stable brush + lifting the + selected domain into this shell so both the chart and the strip read + it is a larger change than this release's scope warrants without + destabilising the chart. */} {statStrip} + {/* v1.12.6 — the canonical spine body: intro (header) → stat strip + (above) → chart → target card → assessment. The page renders + chart → target → assessment as `children`. */} + {children} {/* v1.8.5 W4b — "show all readings" entry at the foot, linking to the dedicated per-metric values subpage. v1.8.6 — normalised to the `h-10` secondary-button height so it From f3f358b208887450b6b3ec072bde363f950034b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:20:59 +0200 Subject: [PATCH 03/17] insights: consolidate the generic disclaimer to one page-level footer Render the generic "describes your own data, not a clinical assessment or diagnosis" line once, as a footer under every /insights route in the layout shell, instead of repeating it across tiles and pages. Add the insights.disclaimer.footer key in all six locales. Metric-specific caveats stay on their own cards. --- .../insights/insights-layout-shell.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index cf7f2e4e9..c04fa0a26 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -3,6 +3,7 @@ import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useWorkouts } from "@/hooks/use-workouts"; @@ -39,6 +40,7 @@ interface ComprehensivePayload { } export function InsightsLayoutShell({ children }: { children: ReactNode }) { + const { t } = useTranslations(); const { isAuthenticated } = useAuth(); // v1.4.33 F18 — gate the advisor POST on the operator's assistant // feature flag. Pre-fix, every /insights mount fired POST @@ -108,6 +110,21 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { availability={availability} /> {children} + {/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer. + The "describes your own data, not a clinical assessment / diagnosis" + sentence used to repeat across the overview, the metric tiles, the + mood page, and the medications page. It now lives here once, as a + page-level footer under every `/insights/*` route. Metric-SPECIFIC + caveats (health score, correlations, mood relations, derived cards, + rhythm events) stay on their own cards — those convey real, + non-generic information. Other waves remove the generic line from the + overview / mood / medications pages; this footer is where it lives. */} +

+ {t("insights.disclaimer.footer")} +

); } From a0883bc269246f4ada1f276df3bf7a6402d72026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:33:00 +0200 Subject: [PATCH 04/17] insights: render the wellness score number in the foreground colour The centred score number painted off the band token (success/warning/ destructive), which cleared only ~1.5:1 against the dark wellness card and read as illegible. Switch the number to the foreground colour so it is legible on both the dark and the light card. The band semantic stays carried by the ring arc fill, the data-band attribute, and the aria-label, so the colour change costs no information. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/insights/derived/score-ring.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 0f75c40e7..ccd2e647c 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -12,7 +12,6 @@ import { cn } from "@/lib/utils"; import { useCountUp } from "@/hooks/use-count-up"; import { bandForScore, - BAND_NUMBER_CLASS, BAND_VAR, clampScore, type ScoreBand, @@ -156,12 +155,16 @@ export function ScoreRing({ className={cn( "font-semibold tabular-nums", dims.numberClass, - hasScore - ? BAND_NUMBER_CLASS[resolvedBand].replace( - "text-", - "fill-", - ) - : "fill-muted-foreground", + // v1.12.6 — the centred number reads in the + // foreground colour (white on the dark wellness + // card, near-black on the light card), never the + // band tint. The band-coloured number cleared only + // ~1.5:1 on `--card` and read as illegible. The + // band semantic is carried by the ring arc + // (`fill` = BAND_VAR) + the `data-band` attribute + + // the aria-label, so dropping it from the number + // costs no information while clearing AA. + hasScore ? "fill-foreground" : "fill-muted-foreground", )} > {hasScore ? displayedRounded : "—"} From b92728d5e86d70a7dde89370acb4a242e80277d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:33:10 +0200 Subject: [PATCH 05/17] insights: lift the wellness strip above the daily briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wellness-score strip rendered inside the vitals dashboard, below the daily briefing. Lift it out so it sits as its own full-width section directly under the hero and above the briefing — the overview spine is now hero, wellness, briefing, vitals, trends, retrospective, signals. The page now owns the one shared derived batch and feeds the same query instance to both the wellness strip and the vitals grid, so the lift adds no second request. Each wellness tile and the section heading lead with the canonical TileHeader (a per-metric icon plus the metric name in the foreground colour), matching the rest of the Insights card language. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/page.tsx | 58 +++++++--- .../__tests__/vitals-dashboard.test.tsx | 82 ++++++-------- .../__tests__/wellness-scores.test.tsx | 105 ++++++++++++++++++ .../insights/derived/use-dashboard-derived.ts | 88 +++++++++++++++ .../insights/derived/vitals-dashboard.tsx | 85 +++----------- .../insights/derived/wellness-scores.tsx | 42 ++++--- 6 files changed, 314 insertions(+), 146 deletions(-) create mode 100644 src/components/insights/derived/__tests__/wellness-scores.test.tsx create mode 100644 src/components/insights/derived/use-dashboard-derived.ts diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a98cfe275..19b1f2235 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -18,6 +18,7 @@ import { HeroStrip } from "@/components/insights/hero-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived"; // v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as // `InsightsAnalyticsData`; aliased back to the local name to keep the // rest of this file readable. @@ -78,6 +79,20 @@ const TrendsRow = dynamic( loading: () => , }, ); +// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so +// it renders as its own full-width section directly above the daily briefing. +// Both this strip and the vitals grid read the ONE shared derived batch the +// page owns (passed in via `batch`), so the lift adds no second request. +const WellnessScores = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.WellnessScores, + })), + { + ssr: false, + loading: () => , + }, +); // v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of // personal-typical-range + passthrough-reframe tiles). Deferred behind // `next/dynamic` like the other below-the-fold blocks; each tile owns its @@ -224,6 +239,11 @@ export default function InsightsPage() { const analyticsQuery = useAnalyticsQuery(); const analytics = analyticsQuery.data as AnalyticsData | undefined; + // v1.12.6 — the page owns the ONE overview derived batch. The wellness + // strip (above the briefing) and the vitals grid (below it) both read this + // single query instance, so the wellness lift adds no second request. + const dashboardDerived = useDashboardDerived(isAuthenticated); + // Empty-state shortcut — only paint once the comprehensive query has // resolved AND reported zero measurements. While it's in-flight we // fall through to the streamed shell so the user gets the hero + @@ -265,22 +285,25 @@ export default function InsightsPage() { // server children would require a Server-Component refactor; that's // a v1.5.x track. - // v1.12.4 — overview section order, top → bottom: - // 1. "Guten Morgen" Hero + Coach questions (HeroStrip) + the detailed - // "Heute auf einen Blick" briefing right below it (same briefing - // cluster, stays at the top). - // 2. Wellnesswerte ┐ both live inside , which renders - // 3. Vitalwerte ┘ the wellness-score strip first, then the vitals - // grid — so a single mount already gives the 2 → 3 sequence. - // 4. Trends (TrendsRow). - // 5. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). - // 6. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus + // v1.12.6 — overview section order, top → bottom: + // 1. "Guten Morgen" Hero + Coach questions (HeroStrip). + // 2. Wellnesswerte — the wellness-score strip (lifted OUT of + // so it sits ABOVE the briefing as its own + // full-width section). + // 3. "Heute auf einen Blick" briefing (DailyBriefing). + // 4. Vitalwerte — the vitals grid (+ mobility) inside , + // which no longer renders the wellness strip. + // 5. Trends (TrendsRow). + // 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). + // 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus // the rhythm-events alert timeline, kept out of the AI gate so a // health alert is never hidden. - // The per-metric correlation cards moved onto the metric pages, so the - // overview renders no correlation row. The duplicate footer "prepare - // assessments" control was removed in v1.12.4 — the tab-strip regenerate - // button is the single affordance. + // The wellness strip and the vitals grid share the ONE `dashboardDerived` + // batch the page owns — one request, two sections. The per-metric + // correlation cards moved onto the metric pages, so the overview renders no + // correlation row. The duplicate footer "prepare assessments" control was + // removed in v1.12.4 — the tab-strip regenerate button is the single + // affordance. The generic disclaimer lives once in the layout-shell footer. return (
+ + {flags.briefing && ( )} - + ({ - useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a), -})); -// The auth hook gates the (mocked-away) query — stub it authenticated. -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), -})); +import type { DashboardDerived } from "../use-dashboard-derived"; import { VitalsDashboard } from "../vitals-dashboard"; @@ -58,33 +47,36 @@ function insufficient(reason: string, historyDays = 0): Resp { } /** - * Install a batch mock that resolves each requested token from `resolve`, - * defaulting unmatched tokens to the absent state so a scenario only has to - * name the tiles it cares about. + * Build a shared-batch handle that resolves each requested token from + * `resolve`, defaulting unmatched tokens to the absent state so a scenario + * only has to name the tiles it cares about. The page owns this batch in + * production; the dashboard now receives it via the `batch` prop, so the test + * passes a stub handle directly instead of mocking the query hook. */ function mockBatch( resolve: (token: DerivedBatchToken) => Resp, extra: { isLoading?: boolean; isError?: boolean; refetch?: () => void } = {}, -) { - useDerivedBatch.mockReturnValue({ +): DashboardDerived { + return { isLoading: extra.isLoading ?? false, isError: extra.isError ?? false, refetch: extra.refetch ?? (() => {}), - read: (token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse, - }); + read: (token: DerivedBatchToken) => + resolve(token) as unknown as DerivedMetricResponse, + } as unknown as DashboardDerived; } beforeEach(() => vi.clearAllMocks()); describe("", () => { it("renders an available baseline vital as an ok tile", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") { return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-dashboard"'); expect(html).toContain('data-metric="RESTING_HEART_RATE"'); expect(html).toContain('data-state="ok"'); @@ -93,8 +85,8 @@ describe("", () => { }); it("hides the whole section (heading + grid) when no vital has content", () => { - mockBatch(() => insufficient("no_readings_in_window")); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window")); + const html = render(); // No content under the heading → the heading must not strand over blank // space. The whole section un-mounts. expect(html).not.toContain('data-slot="vitals-dashboard"'); @@ -104,8 +96,8 @@ describe("", () => { }); it("renders a skeleton row (no tiles, no heading strand) while loading", () => { - mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); + const html = render(); // Heading + grid present so the section reserves its final height, with a // busy/live region and skeleton placeholders — but no real tiles yet. expect(html).toContain('data-slot="vitals-dashboard"'); @@ -116,8 +108,8 @@ describe("", () => { }); it("shows an inline error + retry when the batch fails", () => { - mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); + const html = render(); // The surface must not silently vanish — a compact error + a Retry button. expect(html).toContain('data-slot="vitals-dashboard-error"'); expect(html).toContain('role="alert"'); @@ -127,13 +119,13 @@ describe("", () => { }); it("shows a provisional baseline tile with the coverage meter, not a headline", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") { return insufficient("insufficient_history_for_band", 4); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="WEIGHT"'); expect(html).toContain('data-state="provisional"'); expect(html).toContain('data-slot="coverage-meter"'); @@ -141,40 +133,40 @@ describe("", () => { }); it("renders the fitness-age tile with its age framing", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "FITNESS_AGE") { return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="FITNESS_AGE"'); expect(html).toContain("yr younger"); }); it("renders the BMI tile with its WHO category", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "BMI") { return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="BMI"'); expect(html).toContain("Normal range (WHO)"); }); it("hides BMI when there is no height/weight", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "BMI") return insufficient("no_height_on_profile"); return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).not.toContain('data-metric="BMI"'); }); it("renders the estimated 6-minute-walk band tile with its percent framing", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "SIX_MINUTE_WALK_BAND") { return ok({ distanceM: 540, @@ -188,14 +180,14 @@ describe("", () => { } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-mobility"'); expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"'); expect(html).toContain("90% of predicted"); }); it("renders the 6-minute-walk tile distance-only when demographics are absent", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "SIX_MINUTE_WALK_BAND") { return ok({ distanceM: 540, @@ -209,28 +201,28 @@ describe("", () => { } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"'); // Honest prompt, never a fabricated placement. expect(html).toContain("Add your age, height, weight and sex"); }); it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") { return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-mobility"'); expect(html).toContain('data-metric="STAIR_ASCENT_SPEED_BASELINE"'); expect(html).toContain("Mobility & body"); }); it("hides the whole Mobility & body section when no mobility metric has content", () => { - mockBatch(() => insufficient("no_readings_in_window")); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window")); + const html = render(); expect(html).not.toContain('data-slot="vitals-mobility"'); expect(html).not.toContain("Mobility & body"); expect(html).not.toContain('data-metric="SIX_MINUTE_WALK_BAND"'); @@ -241,11 +233,11 @@ describe("", () => { // The flag moved to the dedicated `CoincidentDeviationCard`; the dashboard // batch no longer requests it and the grid never paints it. let requestedCoincident = false; - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "COINCIDENT_DEVIATION") requestedCoincident = true; return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(requestedCoincident).toBe(false); expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"'); }); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx new file mode 100644 index 000000000..54e66eb52 --- /dev/null +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { + DerivedMetricResponse, + DerivedBatchToken, +} from "../use-derived-metric"; +import { WellnessScores } from "../wellness-scores"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +type Resp = DerivedMetricResponse>; + +function ok(value: Record): Resp { + return { + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, + }; +} + +function absent(): Resp { + return { + metric: "X", + status: "insufficient", + value: null, + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason: "no_readings_in_window", + }; +} + +function readFrom(map: Record): ( + token: DerivedBatchToken, +) => DerivedMetricResponse | null { + return (token: DerivedBatchToken) => + (map[token.metric] ?? absent()) as unknown as DerivedMetricResponse; +} + +describe("", () => { + it("un-mounts the whole strip when no score has data", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing while loading (the page reserves the row)", () => { + const html = render( + , + ); + // While loading no tiles resolve, so the strip stays un-mounted. + expect(html).toBe(""); + }); + + it("renders a ring tile with a canonical TileHeader icon + heading", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="wellness-scores"'); + expect(html).toContain('data-metric="READINESS"'); + // Each tile leads with the shared TileHeader (icon + heading row), with + // the metric name in the foreground-colour CardTitle. + expect(html).toContain('data-slot="tile-header"'); + expect(html).toContain('data-slot="card-title"'); + expect(html).toContain("Readiness"); + // The ring carries its band on the arc + data-attribute (not the number). + expect(html).toContain('data-band="green"'); + // The band word renders under the ring. + expect(html).toContain('data-slot="wellness-score-band-word"'); + }); + + it("renders one tile per available score and hides absent ones", () => { + const html = render( + , + ); + expect(html).toContain('data-metric="READINESS"'); + expect(html).toContain('data-metric="SLEEP_SCORE"'); + expect(html).not.toContain('data-metric="RECOVERY_SCORE"'); + expect(html).not.toContain('data-metric="STRESS_SCORE"'); + expect(html).not.toContain('data-metric="STRAIN_SCORE"'); + }); +}); diff --git a/src/components/insights/derived/use-dashboard-derived.ts b/src/components/insights/derived/use-dashboard-derived.ts new file mode 100644 index 000000000..c86c1f8ce --- /dev/null +++ b/src/components/insights/derived/use-dashboard-derived.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useMemo } from "react"; + +import { + useDerivedBatch, + type DerivedBatchToken, +} from "./use-derived-metric"; +// Type-only — keeps the derived registry's server graph out of the bundle. +import type { DerivedMetricId } from "@/lib/insights/derived/registry"; + +/** + * v1.12.6 — the shared derived-metric batch for the Insights overview. + * + * The overview paints the wellness-score strip and the vitals grid as two + * separate, full-width sections (wellness lifted above the daily briefing, + * vitals below it). Both surfaces read the same already-computed derived + * values, so this hook owns the ONE batched `/api/insights/derived/batch` + * request and hands its `read`/`isLoading`/`isError`/`refetch` handle to + * both. A single cache entry, one network round-trip — never a per-section + * fan-out (the v1.9.1 "hangs then recovers" symptom). + * + * The page mounts this once and passes the handle down, so the wellness + * strip and the vitals dashboard share the same query instance. + */ + +/** The per-vital baseline tiles the dashboard renders (HRV has its own tile). */ +export const SECTION_VITALS: string[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + "WEIGHT", +]; + +/** + * The any-user HealthKit baseline bands (Mobility section). The `metric` is + * the batch token + provenance key; `type` drives icon/label/unit. + */ +export const SECTION_MOBILITY: { metric: DerivedMetricId; type: string }[] = [ + { metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" }, + { metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" }, + { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" }, +]; + +/** + * The full set of tokens the overview reads in one batch — the five wellness + * scores + the four derived re-frames + one baseline per vital (minus HRV, + * which has its own balance tile) + the mobility/body metrics. The + * coincident-deviation flag is read by the dedicated "Today's signal" card, + * not here. + */ +export function dashboardTokens(): DerivedBatchToken[] { + const tokens: DerivedBatchToken[] = [ + { metric: "READINESS" }, + { metric: "SLEEP_SCORE" }, + { metric: "RECOVERY_SCORE" }, + { metric: "STRESS_SCORE" }, + { metric: "STRAIN_SCORE" }, + { metric: "FITNESS_AGE" }, + { metric: "VASCULAR_AGE_DELTA" }, + { metric: "HRV_BALANCE" }, + { metric: "BMI" }, + { metric: "SIX_MINUTE_WALK_BAND" }, + ]; + for (const type of SECTION_VITALS) { + if (type === "HEART_RATE_VARIABILITY") continue; + tokens.push({ metric: "VITALS_BASELINE", type }); + } + for (const { metric } of SECTION_MOBILITY) { + tokens.push({ metric }); + } + return tokens; +} + +/** The shared batch handle both the wellness strip and the vitals grid read. */ +export type DashboardDerived = ReturnType; + +/** + * Mount the one overview-wide derived batch. `enabled` gates the fetch on the + * page's auth state; the hook itself also gates on the session internally. + */ +export function useDashboardDerived(enabled: boolean): DashboardDerived { + const tokens = useMemo(() => dashboardTokens(), []); + return useDerivedBatch(tokens, { enabled }); +} diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 0bfb8ccb0..7eb93abad 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -5,7 +5,6 @@ import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; import { @@ -14,14 +13,14 @@ import { } from "@/components/measurements/measurement-list-meta"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { CoverageMeter } from "./coverage-meter"; -import { WellnessScores } from "./wellness-scores"; import { ProvenanceExplainer } from "./provenance-explainer"; import { METRIC_PROVENANCE } from "./standards"; +import { type DerivedBatchRead } from "./use-derived-metric"; import { - useDerivedBatch, - type DerivedBatchRead, - type DerivedBatchToken, -} from "./use-derived-metric"; + SECTION_VITALS, + SECTION_MOBILITY, + type DashboardDerived, +} from "./use-dashboard-derived"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; @@ -53,29 +52,9 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types"; * the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1). */ -const SECTION_VITALS: string[] = [ - "RESTING_HEART_RATE", - "HEART_RATE_VARIABILITY", - "RESPIRATORY_RATE", - "OXYGEN_SATURATION", - "BODY_TEMPERATURE", - "BLOOD_GLUCOSE", - "WEIGHT", -]; - -/** - * v1.10.3 — the any-user HealthKit baseline bands, each pinning one input and - * framing as a personal typical range (median ± k·MAD). The `metric` is the - * batch token + provenance key; `type` drives icon/label/unit. The estimated - * 6-minute-walk re-frame rides the same Mobility section but reads its own - * value shape (`SixMinuteWalkTile`). Most users carry none of these, so the - * tiles simply do not appear (the per-tile absent gate). - */ -const SECTION_MOBILITY: { metric: keyof typeof METRIC_PROVENANCE; type: string }[] = [ - { metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" }, - { metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" }, - { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" }, -]; +// v1.12.6 — `SECTION_VITALS`, `SECTION_MOBILITY` and the batch token list +// moved to `use-dashboard-derived.ts` so the page can own the one shared batch +// and feed it to both the wellness strip and this grid. Imported above. /** Up-is-bad for the vitals where a rise is unfavourable. */ const UP_BAD_VITALS = new Set([ @@ -111,7 +90,13 @@ function vitalSentiment(type: string): TrendDirectionSentiment { } interface DashboardProps { - enabled?: boolean; + /** + * The shared overview derived batch (owned by the page so the wellness + * strip and this vitals grid read the same one request). Lifted out of + * this component in v1.12.6 — the wellness strip now renders as its own + * full-width section above the daily briefing. + */ + batch: DashboardDerived; className?: string; } @@ -521,47 +506,10 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean { return false; } -/** - * The full set of tokens the dashboard reads in one batch — the five - * wellness scores + the four derived re-frames + one baseline per vital - * (minus HRV, which has its own balance tile) + the four v1.10.3 mobility/body - * metrics (estimated 6-minute-walk band + the three HealthKit baseline bands). - * The coincident-deviation flag is no longer read here: it is now the - * dedicated "Today's signal" card at the top of the overview - * (`CoincidentDeviationCard`). - */ -function dashboardTokens(): DerivedBatchToken[] { - const tokens: DerivedBatchToken[] = [ - { metric: "READINESS" }, - { metric: "SLEEP_SCORE" }, - { metric: "RECOVERY_SCORE" }, - { metric: "STRESS_SCORE" }, - { metric: "STRAIN_SCORE" }, - { metric: "FITNESS_AGE" }, - { metric: "VASCULAR_AGE_DELTA" }, - { metric: "HRV_BALANCE" }, - { metric: "BMI" }, - { metric: "SIX_MINUTE_WALK_BAND" }, - ]; - for (const type of SECTION_VITALS) { - if (type === "HEART_RATE_VARIABILITY") continue; - tokens.push({ metric: "VITALS_BASELINE", type }); - } - for (const { metric } of SECTION_MOBILITY) { - tokens.push({ metric }); - } - return tokens; -} - -export function VitalsDashboard({ enabled = true, className }: DashboardProps) { +export function VitalsDashboard({ batch, className }: DashboardProps) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); const vitals = useMemo(() => SECTION_VITALS, []); - const tokens = useMemo(() => dashboardTokens(), []); - const batch = useDerivedBatch(tokens, { - enabled: enabled && isAuthenticated, - }); const read = batch.read; const isLoading = batch.isLoading; const isError = batch.isError; @@ -618,7 +566,6 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) { return (
- {showSection && (
; }) { return ( -
- {/* The metric title rides an HTML caption below the ring, not the - in-SVG `label` slot: a long localised title ("Schlaf-Score", - "Bereitschaft") overflowed the small ring's centred SVG text and - the recharts `overflow:hidden` clipped its leading glyph. As HTML - it wraps within the tile and never truncates. The ring keeps just - the number centred. */} + {/* v1.12.6 — every tile leads with the canonical icon + heading + (foreground colour, matching the Einschätzung reference) so the + wellness strip reads as the same card language as the rest of + Insights. The metric name no longer rides an under-ring caption. */} + +
+ {/* The ring keeps just the number centred; the metric name lives in + the TileHeader above (a long localised title would overflow the + small ring's centred SVG text and clip under recharts' + `overflow:hidden`). */} - - {label} - , ); } @@ -121,6 +124,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(recovery.value.band)} href="/insights/scores/recovery" metricSlot="RECOVERY_SCORE" + icon={HeartPulse} />, ); } @@ -134,6 +138,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(sleep.value.band)} href="/insights/scores/sleep" metricSlot="SLEEP_SCORE" + icon={Moon} />, ); } @@ -147,6 +152,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(stress.value.band)} href="/insights/scores/stress" metricSlot="STRESS_SCORE" + icon={Activity} />, ); } @@ -160,6 +166,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(strain.value.band)} href="/insights/scores/strain" metricSlot="STRAIN_SCORE" + icon={Flame} />, ); } @@ -187,9 +194,10 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) aria-label={t("insights.derived.scores.sectionTitle")} className={cn("space-y-3", className)} > -

- {t("insights.derived.scores.sectionTitle")} -

+
Date: Fri, 5 Jun 2026 12:30:32 +0200 Subject: [PATCH 06/17] insights(mood): align mood-chart palette to Dracula tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood line chart hand-picked hex for its bands and lines: a three-band red/orange/green ramp that skipped the yellow rung the mood heatmap and distribution chart use, and a non-token purple primary. Switch the bands to the same four-step red→orange→yellow→green ramp the other two mood surfaces ride, and route the line/overlay colours through the --dracula-* tokens (purple primary matching the health chart, pink moving average, cyan trend) so the three mood surfaces read as one palette and a theme change flows through a single source. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/charts/mood-chart.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index b9c48d648..b7bf08981 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -122,15 +122,24 @@ const TIME_RANGES_KEYS = [ // MOOD_LABELS built dynamically via t() in the component +// v1.12.6 — mood-level bands ride the same Dracula good→bad ramp the mood +// heatmap and distribution chart already use (red → orange → yellow → +// green), so the three mood surfaces read as one palette instead of the +// chart's former three-band red/orange/green that skipped the yellow rung. +// Tokens, not raw hex, so a theme change flows through one source. const VALUE_BANDS = [ - { min: 1, max: 2, color: "#ff5555", opacity: 0.16 }, - { min: 2, max: 3, color: "#ffb86c", opacity: 0.18 }, - { min: 3, max: 5, color: "#50fa7b", opacity: 0.2 }, + { min: 1, max: 2, color: "var(--dracula-red)", opacity: 0.16 }, + { min: 2, max: 3, color: "var(--dracula-orange)", opacity: 0.18 }, + { min: 3, max: 3.5, color: "var(--dracula-yellow)", opacity: 0.18 }, + { min: 3.5, max: 5, color: "var(--dracula-green)", opacity: 0.2 }, ] as const; -const COLOR_MAIN = "#d6acff"; -const COLOR_MA = "#ff79c6"; -const COLOR_TREND = "#8be9fd"; +// The mood line + overlays use the same hues the rest of the Insights +// charts do — purple primary (matching HealthChart), pink moving average, +// cyan trend — as tokens rather than the former hand-picked hex. +const COLOR_MAIN = "var(--dracula-purple)"; +const COLOR_MA = "var(--dracula-pink)"; +const COLOR_TREND = "var(--dracula-cyan)"; // --- Helpers --- From fcd8eccf6ebee0138b92793a0e2c732a64106875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:30:40 +0200 Subject: [PATCH 07/17] insights(mood): order classification first, route every tile through TileHeader Reorder the mood subpage spine so the classification tiles (in-target share + stability band) lead, then the better-days assessment, then the descriptive breakdown sections. The classification answers where the user stands; the better-days board, directly beneath, answers what associates with their good days. Every mood card header now routes through the shared TileHeader (icon + white heading) so the subpage speaks one card language. The in-target and stability tiles gain proper headings (new inTargetTitle / stability.title keys across all six locales) and drop the icon-in-a-circle headline glyph; the narrative feed and the discovered-relations board move into headed cards. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 15 ++-- messages/en.json | 15 ++-- messages/es.json | 15 ++-- messages/fr.json | 15 ++-- messages/it.json | 15 ++-- messages/pl.json | 15 ++-- .../mood/mood-discovered-relations.tsx | 46 ++++++---- .../insights/mood/mood-in-target-tile.tsx | 29 ++++--- .../insights/mood/mood-insights-sections.tsx | 83 ++++++++++++++----- .../insights/mood/mood-stability-tile.tsx | 32 +++---- 10 files changed, 167 insertions(+), 113 deletions(-) diff --git a/messages/de.json b/messages/de.json index 73e41890e..00ea5e3bd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2583,20 +2583,19 @@ "bandVerySteady": "Deine Stimmung war sehr gleichmäßig", "bandSteady": "Deine Stimmung war recht gleichmäßig", "bandVariable": "Deine Stimmung war merklich schwankend", - "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" + "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt", + "title": "Stabilität" }, "influence": { "title": "Einfluss auf die Stimmung", "detail": "Durchschnitt {withAvg} an den {withDays} Tagen mit diesem Tag, {withoutAvg} ohne.", "confidenceLow": "Geringe Sicherheit", "confidenceMedium": "Mittlere Sicherheit", - "confidenceHigh": "Hohe Sicherheit", - "disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für eine Ursache. Muster, die einen Blick wert sind." + "confidenceHigh": "Hohe Sicherheit" }, "betterDays": { "title": "Was zu deinen besseren Tagen passt", - "description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen.", - "disclaimer": "Nur Zusammenhänge, keine Ursachen. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst." + "description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen." }, "crosstab": { "title": "Tags und deine Gesundheitswerte", @@ -2609,8 +2608,7 @@ "unitKcal": "kcal", "unitScore": "Pkt.", "detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.", - "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst.", - "disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für Ursachen. Muster, die einen Blick wert sind." + "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst." }, "discovery": { "title": "Was auffällt", @@ -2625,7 +2623,8 @@ "stat": "{n} gepaarte Tage · r = {r} · q = {q}", "statLabel": "Wie das berechnet wurde", "explainerLabel": "Wie das berechnet wurde" - } + }, + "inTargetTitle": "Im Zielbereich" }, "derived": { "coverage": { diff --git a/messages/en.json b/messages/en.json index 140470549..420795132 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2583,20 +2583,19 @@ "bandVerySteady": "Your mood has been very steady", "bandSteady": "Your mood has been fairly steady", "bandVariable": "Your mood has been noticeably variable", - "bandVeryVariable": "Your mood has swung a lot lately" + "bandVeryVariable": "Your mood has swung a lot lately", + "title": "Stability" }, "influence": { "title": "Influence on mood", "detail": "Averages {withAvg} on the {withDays} days you tagged this vs {withoutAvg} without it.", "confidenceLow": "Low confidence", "confidenceMedium": "Medium confidence", - "confidenceHigh": "High confidence", - "disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching." + "confidenceHigh": "High confidence" }, "betterDays": { "title": "What goes with your better days", - "description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together.", - "disclaimer": "Associations only, not causes. Talk to your doctor before changing anything based on these." + "description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together." }, "crosstab": { "title": "Tags and your health metrics", @@ -2609,8 +2608,7 @@ "unitKcal": "kcal", "unitScore": "pts", "detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.", - "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise.", - "disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching." + "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise." }, "discovery": { "title": "What stands out", @@ -2625,7 +2623,8 @@ "stat": "{n} paired days · r = {r} · q = {q}", "statLabel": "How this was computed", "explainerLabel": "How this was computed" - } + }, + "inTargetTitle": "In your range" }, "derived": { "coverage": { diff --git a/messages/es.json b/messages/es.json index 754212920..1d421bde6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2583,20 +2583,19 @@ "bandVerySteady": "Tu estado de ánimo ha sido muy estable", "bandSteady": "Tu estado de ánimo ha sido bastante estable", "bandVariable": "Tu estado de ánimo ha sido notablemente variable", - "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" + "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente", + "title": "Estabilidad" }, "influence": { "title": "Influencia en el estado de ánimo", "detail": "Promedio de {withAvg} en los {withDays} días que lo etiquetaste, frente a {withoutAvg} sin él.", "confidenceLow": "Confianza baja", "confidenceMedium": "Confianza media", - "confidenceHigh": "Confianza alta", - "disclaimer": "Son asociaciones en tus propios datos, no una prueba de causa. Patrones que vale la pena observar." + "confidenceHigh": "Confianza alta" }, "betterDays": { "title": "Qué acompaña a tus mejores días", - "description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas.", - "disclaimer": "Solo asociaciones, no causas. Consulta a tu médico antes de cambiar nada por esto." + "description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas." }, "crosstab": { "title": "Etiquetas y tus métricas de salud", @@ -2609,8 +2608,7 @@ "unitKcal": "kcal", "unitScore": "pts", "detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.", - "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto.", - "disclaimer": "Son asociaciones en tus propios datos, no prueba de causa. Patrones que vale la pena observar." + "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto." }, "discovery": { "title": "Lo que destaca", @@ -2625,7 +2623,8 @@ "stat": "{n} días emparejados · r = {r} · q = {q}", "statLabel": "Cómo se calculó esto", "explainerLabel": "Cómo se calculó esto" - } + }, + "inTargetTitle": "En tu rango" }, "derived": { "coverage": { diff --git a/messages/fr.json b/messages/fr.json index f4579f8b5..e8b37bdc1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2583,20 +2583,19 @@ "bandVerySteady": "Votre humeur a été très stable", "bandSteady": "Votre humeur a été plutôt stable", "bandVariable": "Votre humeur a été nettement variable", - "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" + "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment", + "title": "Stabilité" }, "influence": { "title": "Influence sur l'humeur", "detail": "Moyenne de {withAvg} les {withDays} jours où vous l'avez indiqué, contre {withoutAvg} sans.", "confidenceLow": "Confiance faible", "confidenceMedium": "Confiance moyenne", - "confidenceHigh": "Confiance élevée", - "disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller." + "confidenceHigh": "Confiance élevée" }, "betterDays": { "title": "Ce qui accompagne vos meilleurs jours", - "description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien.", - "disclaimer": "Des associations seulement, pas des causes. Parlez-en à votre médecin avant de changer quoi que ce soit." + "description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien." }, "crosstab": { "title": "Tags et vos métriques de santé", @@ -2609,8 +2608,7 @@ "unitKcal": "kcal", "unitScore": "pts", "detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.", - "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon.", - "disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller." + "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon." }, "discovery": { "title": "Ce qui ressort", @@ -2625,7 +2623,8 @@ "stat": "{n} jours appariés · r = {r} · q = {q}", "statLabel": "Comment ceci est calculé", "explainerLabel": "Comment ceci est calculé" - } + }, + "inTargetTitle": "Dans votre plage" }, "derived": { "coverage": { diff --git a/messages/it.json b/messages/it.json index 11dfd526e..b824da013 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2583,20 +2583,19 @@ "bandVerySteady": "Il tuo umore è stato molto stabile", "bandSteady": "Il tuo umore è stato abbastanza stabile", "bandVariable": "Il tuo umore è stato notevolmente variabile", - "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" + "bandVeryVariable": "Il tuo umore ha oscillato molto di recente", + "title": "Stabilità" }, "influence": { "title": "Influenza sull'umore", "detail": "Media di {withAvg} nei {withDays} giorni in cui l'hai indicato, contro {withoutAvg} senza.", "confidenceLow": "Affidabilità bassa", "confidenceMedium": "Affidabilità media", - "confidenceHigh": "Affidabilità alta", - "disclaimer": "Sono associazioni nei tuoi dati, non una prova di causa. Schemi da tenere d'occhio." + "confidenceHigh": "Affidabilità alta" }, "betterDays": { "title": "Cosa accompagna le tue giornate migliori", - "description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme.", - "disclaimer": "Solo associazioni, non cause. Parla con il tuo medico prima di cambiare qualcosa." + "description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme." }, "crosstab": { "title": "Tag e le tue metriche di salute", @@ -2609,8 +2608,7 @@ "unitKcal": "kcal", "unitScore": "pt", "detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.", - "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti.", - "disclaimer": "Sono associazioni nei tuoi dati, non prova di causa. Schemi che vale la pena osservare." + "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti." }, "discovery": { "title": "Cosa risalta", @@ -2625,7 +2623,8 @@ "stat": "{n} giorni appaiati · r = {r} · q = {q}", "statLabel": "Come è stato calcolato", "explainerLabel": "Come è stato calcolato" - } + }, + "inTargetTitle": "Nel tuo intervallo" }, "derived": { "coverage": { diff --git a/messages/pl.json b/messages/pl.json index 9cd479969..543939e4f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2583,20 +2583,19 @@ "bandVerySteady": "Twój nastrój był bardzo stabilny", "bandSteady": "Twój nastrój był dość stabilny", "bandVariable": "Twój nastrój był zauważalnie zmienny", - "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" + "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał", + "title": "Stabilność" }, "influence": { "title": "Wpływ na nastrój", "detail": "Średnia {withAvg} w {withDays} dniach z tym tagiem wobec {withoutAvg} bez niego.", "confidenceLow": "Niska pewność", "confidenceMedium": "Średnia pewność", - "confidenceHigh": "Wysoka pewność", - "disclaimer": "To powiązania w Twoich danych, a nie dowód przyczyny. Wzorce warte obserwacji." + "confidenceHigh": "Wysoka pewność" }, "betterDays": { "title": "Co towarzyszy Twoim lepszym dniom", - "description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania.", - "disclaimer": "Tylko powiązania, nie przyczyny. Skonsultuj się z lekarzem, zanim coś zmienisz." + "description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania." }, "crosstab": { "title": "Tagi a Twoje wskaźniki zdrowia", @@ -2609,8 +2608,7 @@ "unitKcal": "kcal", "unitScore": "pkt", "detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.", - "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni.", - "disclaimer": "To powiązania w Twoich danych, nie dowód przyczyny. Wzorce warte obserwacji." + "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni." }, "discovery": { "title": "Co się wyróżnia", @@ -2625,7 +2623,8 @@ "stat": "{n} sparowanych dni · r = {r} · q = {q}", "statLabel": "Jak to obliczono", "explainerLabel": "Jak to obliczono" - } + }, + "inTargetTitle": "W Twoim zakresie" }, "derived": { "coverage": { diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 4acf20058..6e087b84c 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -1,7 +1,10 @@ "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"; @@ -81,26 +84,32 @@ export function MoodDiscoveredRelations() { if (moodPairs.length === 0) return null; return ( -
-
-

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

+ + {/* 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. */} - + } /> -
-

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

-
    + + +

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

    +
      {moodPairs.map((pair) => { const moodIsOutcome = pair.outcome === "MOOD"; const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome; @@ -139,7 +148,8 @@ export function MoodDiscoveredRelations() { ); })} -
    -
+ + + ); } diff --git a/src/components/insights/mood/mood-in-target-tile.tsx b/src/components/insights/mood/mood-in-target-tile.tsx index 6e3241e90..9f19c6bbc 100644 --- a/src/components/insights/mood/mood-in-target-tile.tsx +++ b/src/components/insights/mood/mood-in-target-tile.tsx @@ -2,7 +2,8 @@ import { Target } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -12,6 +13,10 @@ import { useTranslations } from "@/lib/i18n/context"; * the LLM snapshot) to a visible stat: the share of recent days that * landed in the good-mood band. Renders nothing when the number is * unavailable (no recent data) so the page degrades gracefully. + * + * v1.12.6 — the tile now leads with the canonical `TileHeader` + * (icon + white heading) so it speaks the same card language as every + * other Insights tile; the icon-in-a-circle headline glyph is dropped. */ export function MoodInTargetTile({ pct }: { pct: number | null }) { const { t } = useTranslations(); @@ -22,18 +27,16 @@ export function MoodInTargetTile({ pct }: { pct: number | null }) { return ( - -
- -
-
-

- {t("insights.mood.inTargetValue", { pct: String(rounded) })} -

-

- {t("insights.mood.inTargetCaption")} -

-
+ + + + +

+ {t("insights.mood.inTargetValue", { pct: String(rounded) })} +

+

+ {t("insights.mood.inTargetCaption")} +

); diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 7271c786e..061871ca1 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -3,7 +3,22 @@ import dynamic from "next/dynamic"; import { useQuery } from "@tanstack/react-query"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ComponentType } from "react"; +import { + BarChart3, + CalendarDays, + CalendarRange, + Clock, + Grid3x3, + Link2, + Sparkles, + Tag, + Tags, + TrendingUp, +} from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -136,15 +151,19 @@ interface MoodInsightsResponse { function SectionCard({ title, + icon, children, }: { title: string; + // v1.12.6 — every mood section header now routes through `TileHeader` + // (icon + white heading) so the subpage reads as one card language. + icon: ComponentType<{ className?: string }>; children: React.ReactNode; }) { return ( - {title} + {children} @@ -210,7 +229,14 @@ export function MoodInsightsSections() { const hasCrosstab = crosstabRows.length > 0; return ( + // v1.12.6 — the mood spine is ordered as: Einordnung (the classification + // tiles: in-target share + stability band) FIRST, then the "what goes with + // your better days" assessment read, then the descriptive breakdown + // sections. The classification answers "where do I stand", the better-days + // board answers "what associates with my good days", and the rest is the + // supporting detail.
+ {/* Einordnung — the classification tiles. */} {(hasInTargetTile || hasStabilityTile) && ( // Two-up only when BOTH tiles render; a lone tile spans full width so // it never leaves a half-width orphan with dead space beside it. @@ -229,15 +255,25 @@ 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. */} {hasBetterDays && ( - + )} - + {narratives.length > 0 && ( + + + + )} + +
- + - +
{data.timeOfDay.reliable && ( - + )} {hasStructuredTags && ( - + )} {hasTags && ( - + )} {hasInfluence && ( - + )} {hasCrosstab && ( - + )} -
-

- {t("insights.mood.correlationsTitle")} -

-

+ +

{t("insights.mood.correlationsDescription")}

-
+
{/* F3 — the FDR-controlled discovered mood relations. Self-fetches the correlation-discovery surface and renders nothing when the operator diff --git a/src/components/insights/mood/mood-stability-tile.tsx b/src/components/insights/mood/mood-stability-tile.tsx index cc53e9ba5..32da9e524 100644 --- a/src/components/insights/mood/mood-stability-tile.tsx +++ b/src/components/insights/mood/mood-stability-tile.tsx @@ -2,7 +2,8 @@ import { Activity } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -46,20 +47,21 @@ export function MoodStabilityTile({ return ( - -
- -
-
-

- {t("insights.mood.stability.value", { - score: String(stability.score), - })} -

-

- {t(BAND_KEY[stability.band])} -

-
+ + + + +

+ {t("insights.mood.stability.value", { + score: String(stability.score), + })} +

+

+ {t(BAND_KEY[stability.band])} +

); From 424d6ee5e108062ad9ecd9ed3f4ef3368046f837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:30:47 +0200 Subject: [PATCH 08/17] insights(mood): drop the per-card generic disclaimer, defer to the page footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The better-days board, the tag-influence list, and the tag × metric crosstab each repeated a generic associations-not-causes / talk-to-your- doctor caveat. That sentence now lives once in the page-level Insights footer, so the three mood cards no longer restate it; the genuinely specific FDR caveat on the discovered-relations board stays. Drop the now-unused betterDays / influence / crosstab disclaimer keys from every locale and update the two component tests that pinned the removed copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../insights/mood/__tests__/mood-better-days.test.tsx | 6 ++++-- .../insights/mood/__tests__/mood-tag-influence.test.tsx | 6 ++++-- src/components/insights/mood/mood-better-days.tsx | 6 ++---- src/components/insights/mood/mood-tag-influence.tsx | 6 ++---- src/components/insights/mood/mood-tag-metric-crosstab.tsx | 6 ++---- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/insights/mood/__tests__/mood-better-days.test.tsx b/src/components/insights/mood/__tests__/mood-better-days.test.tsx index c2d483750..5f7c048b1 100644 --- a/src/components/insights/mood/__tests__/mood-better-days.test.tsx +++ b/src/components/insights/mood/__tests__/mood-better-days.test.tsx @@ -80,12 +80,14 @@ describe("", () => { expect(html).toContain("r -0.45"); }); - it("renders the description and disclaimer once each", () => { + it("renders the description once and no local generic disclaimer", () => { const html = render( , ); - expect((html.match(/Associations only/g) ?? []).length).toBe(1); expect((html.match(/ranked by how strongly/g) ?? []).length).toBe(1); + // The generic "associations only, not causes" disclaimer now lives once + // in the page-level Insights footer, not on this board. + expect(html).not.toContain("Associations only"); // one row per factor expect((html.match(/data-slot="mood-better-day-factor"/g) ?? []).length).toBe( 2, diff --git a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx index 317409bc5..e08d4f1b9 100644 --- a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx +++ b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx @@ -79,10 +79,12 @@ describe("", () => { expect(html).not.toContain("charts.weekdaysFull.mon"); }); - it("renders the observational disclaimer once", () => { + it("does not render a local generic disclaimer", () => { const html = render( , ); - expect((html.match(/not proof of cause/g) ?? []).length).toBe(1); + // The generic "associations, not causes" caveat now lives once in the + // page-level Insights footer, not on this list. + expect(html).not.toContain("not proof of cause"); }); }); diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx index 4a489a4e7..19a7b9873 100644 --- a/src/components/insights/mood/mood-better-days.tsx +++ b/src/components/insights/mood/mood-better-days.tsx @@ -15,7 +15,8 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence"; * surface — the headline "relations" answer to "what goes with my good * days?". The merge + ranking is pre-computed in `mood-aggregates.ts` * (`computeBetterDays`); this component only paints. Observational only; - * the standing disclaimer is rendered once below the board. + * the standing "describes your own data, not a diagnosis" disclaimer lives + * once in the page-level Insights footer, not on this board. */ export interface MoodBetterDayFactor { @@ -138,9 +139,6 @@ export function MoodBetterDays({ ); })} -

- {t("insights.mood.betterDays.disclaimer")} -

); } diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx index e2ca76acd..5ebef5105 100644 --- a/src/components/insights/mood/mood-tag-influence.tsx +++ b/src/components/insights/mood/mood-tag-influence.tsx @@ -12,7 +12,8 @@ import { cn } from "@/lib/utils"; * the delta, and a confidence chip. The math is pre-computed in * `mood-aggregates.ts` (shared with the LLM snapshot), gated on per-group * sample floors — sparse tags never reach here. Observational only: the - * "association, not cause" caption is rendered once below the list. + * generic "associations, not causes" caveat lives once in the page-level + * Insights footer, so this list no longer repeats it. */ export type MoodInfluenceConfidence = "low" | "medium" | "high"; @@ -110,9 +111,6 @@ export function MoodTagInfluence({ ); })} -

- {t("insights.mood.influence.disclaimer")} -

); } diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index 907561769..3c0062672 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -14,7 +14,8 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence"; * pre-computed in `mood-aggregates.ts` (`computeTagMetricCrosstab`) — the * same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the * relations surface uses; only FDR-surviving rows reach here. Observational - * only: the "association, not cause" caption renders once below the list. + * only: the generic "associations, not causes" caveat lives once in the + * page-level Insights footer, so this card no longer repeats it. */ export type MoodCrosstabDisplay = "hours" | "kcal" | "score"; @@ -150,9 +151,6 @@ export function MoodTagMetricCrosstab({ ); })} -

- {t("insights.mood.crosstab.disclaimer")} -

); } From 18e36e32266a2b21ef79c573631f16634a66541a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:28:38 +0200 Subject: [PATCH 09/17] insights(medications): drop the GLP-1 dose-listing timeline and densify the compliance tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the chronological GLP-1 therapy-event list from the medications insight page — it duplicated data already on the per-medication detail page and added no read on this surface. The page spine collapses to compliance grid -> target -> assessment. Route each per-medication compliance card header through the canonical TileHeader (Pill glyph + med name as the heading) and fold the dose, category, and streak into one compact muted line. Tighten the card gap, header padding, progress-bar height, and content spacing so the same data (7d/30d bars, taken/skipped/missed, heatmap, per-med sentence) reads in noticeably less vertical space, consistent with the rest of the harmonised Insights tiles. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/medications/page.tsx | 60 +++--- src/components/insights/therapy-timeline.tsx | 213 ------------------- 2 files changed, 29 insertions(+), 244 deletions(-) delete mode 100644 src/components/insights/therapy-timeline.tsx diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 5c5e3227e..7af41c192 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Progress } from "@/components/ui/progress"; import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap"; @@ -18,8 +18,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TherapyTimeline } from "@/components/insights/therapy-timeline"; -import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; +import { TileHeader } from "@/components/insights/tile-header"; /** * v1.4.25 W4 — `/insights/medications`. @@ -203,33 +202,37 @@ export default function InsightsMedikamentePage() { > {medications.map((med) => { const medicationSummary = medicationSummaryById.get(med.id); - // UI-H3 — route every medication-list row (medications page - // + insights/medications) through `` - // so the surface stays one shape: `{name} {dose}` on line 1 - // + outline category badge on line 2. The streak chip rides - // the `stateBadges` slot so a strong streak still surfaces. + // v1.12.6 — the per-medication compliance card harmonises with + // the rest of the Insights tiles: its header runs through the + // canonical `` (Pill glyph + med name as the white + // heading), and the dose + category + streak fold into one + // compact muted line below it. Every datum the previous, taller + // card carried (7d/30d bars, taken/skipped/missed, heatmap, + // per-med assistant sentence) stays — only the vertical rhythm + // tightens. const categoryLabel = med.category === "BLOOD_PRESSURE" ? t("medications.categoryBloodPressure") : med.category === "VITAMIN" ? t("medications.categoryVitamin") : t("medications.categoryOther"); - const streakBadge = - med.streak > 0 ? ( - - {t("insights.dayStreak", { count: med.streak })} - - ) : null; return ( - - - -
+ + + +
+ {med.dose} + + {categoryLabel} + {med.streak > 0 ? ( + + {t("insights.dayStreak", { count: med.streak })} + + ) : null} +
+
+ +
{t("insights.compliance7d")} {med.compliance7}% @@ -238,18 +241,18 @@ export default function InsightsMedikamentePage() { accessible name (Lighthouse a11y 91/94). */}
-
+
{t("insights.compliance30d")} {med.compliance30}%
@@ -283,11 +286,6 @@ export default function InsightsMedikamentePage() { - {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users - without an active GLP-1 medication, so the page collapses - back to the compliance grid for everyone else. */} - - {/* v1.12.2 — the assessment is the LAST block on every bespoke metric-detail page, matching the canonical spine the generic scaffold renders. This card reads the medication-compliance diff --git a/src/components/insights/therapy-timeline.tsx b/src/components/insights/therapy-timeline.tsx deleted file mode 100644 index 4e4a765ab..000000000 --- a/src/components/insights/therapy-timeline.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { ArrowDown, ArrowUp, Pill, Syringe } from "lucide-react"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { - describeInjectionSite, - type InjectionSiteKey, -} from "@/lib/medications/injection-sites"; - -/** - * v1.4.25 W4d — therapy-timeline view for /insights/medications. - * - * Renders a chronological list of GLP-1 therapy events: dose changes, - * injections (with site if recorded), inventory events, side-effect tag - * occurrences. Pure list view — no chart — so the medications-page - * "no chart on the medication card" rule stays consistent across all - * GLP-1 surfaces; the dose curve lives on the Dashboard tile only. - * - * Self-hides for users with no GLP-1 medications (the parent route does - * the gating via a non-200 from the API endpoint). - */ - -interface TimelineEntry { - date: string; - kind: "dose-change" | "injection" | "inventory" | "side-effect"; - medicationName?: string; - doseValue?: number; - doseUnit?: string; - doseDelta?: "up" | "down" | null; - note?: string | null; - injectionSite?: InjectionSiteKey | null; - inventoryDelta?: number; - reason?: string; - tags?: string[]; -} - -interface TherapyTimelineResponse { - hasGlp1: boolean; - entries: TimelineEntry[]; -} - -interface TherapyTimelineProps { - /** Optional cap — defaults to the API's default (60 entries). */ - limit?: number; -} - -export function TherapyTimeline({ limit }: TherapyTimelineProps) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const { data, isLoading } = useQuery({ - queryKey: ["insights", "glp1-timeline", limit ?? "default"], - queryFn: async () => { - const url = limit - ? `/api/insights/glp1-timeline?limit=${limit}` - : "/api/insights/glp1-timeline"; - const res = await fetch(url); - if (!res.ok) return null; - const json = await res.json(); - return json.data as TherapyTimelineResponse; - }, - staleTime: 60 * 1000, - }); - - if (isLoading || !data || !data.hasGlp1) return null; - if (data.entries.length === 0) { - return ( - - - - - {t("insights.therapyTimeline.title")} - - - -

- {t("insights.therapyTimeline.empty")} -

-
-
- ); - } - - return ( - - - - - {t("insights.therapyTimeline.title")} - -

- {t("insights.therapyTimeline.description")} -

-
- -
    - {data.entries.map((entry) => ( -
  1. -
  2. - ))} -
-
-
- ); -} - -function colourForKind(kind: TimelineEntry["kind"]): string { - switch (kind) { - case "dose-change": - return "var(--dracula-purple)"; - case "injection": - return "var(--dracula-cyan)"; - case "inventory": - return "var(--dracula-yellow)"; - case "side-effect": - return "var(--dracula-orange)"; - } -} - -function renderEntryLine( - entry: TimelineEntry, - t: (key: string, params?: Record) => string, -): React.ReactNode { - const drug = entry.medicationName ?? ""; - switch (entry.kind) { - case "dose-change": { - const arrow = - entry.doseDelta === "up" ? ( -