From eb55c235c65a18ad7fe5c3042d23b6c1ac1a857f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 23:15:22 +0200 Subject: [PATCH] Rebuild the Insights health score as one pinned card The score had two mounts: a compact face in the hero band and a nine-block stack further down the overview. The stack also rode inside the daily-briefing branch, so an instance with the briefing switched off lost the whole breakdown. Fold both into one progressive-disclosure card pinned between the greeting and the customizable region. Collapsed it reads as the score face: the ring, the band sentence, the weekly delta or the honest reason there is none, Rest Mode, the tension and return-to-baseline lines, and the coverage meter. Opening it adds a three-across pillar grid, the named list of pillars that are not scored yet, the personal weight goal, and the method footer with its cited standard. The card is deliberately not a section in the insights layout registry: an id a saved layout has never seen merges in as default-invisible, which would have deleted the score for every account that has ever customised the page. A pillar the server gated stays visible by name with its reason instead of being drawn as a zero, safety signposting leads that list, and a pillar whose read failed gets an error row with a retry rather than silence. The hero band goes back to the full-width greeting, the shape it already had for an account without a score. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- src/__tests__/v1341-locale-parity.test.ts | 7 +- src/app/__tests__/insights-polish.test.ts | 36 +- src/app/insights/page-client.tsx | 110 +-- .../__tests__/health-score-card.test.tsx | 697 ++++++++++----- .../insights/__tests__/hero-strip.test.tsx | 178 +--- src/components/insights/health-score-card.tsx | 837 ++++++++++-------- .../insights/health-score-pillar-detail.ts | 192 ++++ src/components/insights/hero-strip.tsx | 327 ++----- 14 files changed, 1347 insertions(+), 1061 deletions(-) create mode 100644 src/components/insights/health-score-pillar-detail.ts diff --git a/messages/de.json b/messages/de.json index ad16401f4..460565c78 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2262,6 +2262,9 @@ "red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs" }, "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", + "anatomyToggle": "Wie dieser Score zustande kommt", + "notScored": "Noch nicht bewertet ({count})", + "method": "Der Score ist der gleich gewichtete Mittelwert aller Säulen mit ausreichend aktuellen Daten, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichende Daten bleibt außen vor und zählt nicht als null.", "bandSetter": "Gesamtstufe bestimmt durch {pillar}", "versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.", "methodVersion": "Methodenversion {version}.", @@ -2320,7 +2323,6 @@ "insufficientHistory": "Nicht genügend aktuelle geeignete Daten", "unavailable": "Dieser Bereich kann derzeit nicht bewertet werden" }, - "pillarInsufficient": "Kein Bereichsscore: {reason}", "tension": "Die Bereitschaftssignale unterscheiden sich. Unterstützend: {positive}. Belastet: {negative}.", "returnToBand": "{metric} liegt seit {days} Tagen wieder im üblichen Bereich.", "weightGoal": { diff --git a/messages/en.json b/messages/en.json index fa2d42cc3..574dbe5e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2262,6 +2262,9 @@ "red": "One or more pillars are outside the reference range" }, "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", + "anatomyToggle": "How this score comes about", + "notScored": "Not scored yet ({count})", + "method": "The score is the equal-weighted average of every pillar that has enough recent data, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough data is left out rather than counted as a zero.", "bandSetter": "Overall band set by {pillar}", "versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.", "methodVersion": "Method version {version}.", @@ -2320,7 +2323,6 @@ "insufficientHistory": "Not enough recent eligible data", "unavailable": "This pillar is not currently eligible" }, - "pillarInsufficient": "No pillar score: {reason}", "tension": "Readiness signals differ. Supportive: {positive}. Under strain: {negative}.", "returnToBand": "{metric} has been back inside your usual range for {days} days.", "weightGoal": { diff --git a/messages/es.json b/messages/es.json index 251f651ca..a236e7749 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2262,6 +2262,9 @@ "red": "Uno o más pilares están fuera del intervalo de referencia" }, "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", + "anatomyToggle": "Cómo se forma esta puntuación", + "notScored": "Aún sin puntuar ({count})", + "method": "La puntuación es la media con igual peso de todos los pilares que tienen suficientes datos recientes, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos suficientes queda fuera en lugar de contar como cero.", "bandSetter": "Categoría general determinada por {pillar}", "versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.", "methodVersion": "Versión del método {version}.", @@ -2320,7 +2323,6 @@ "insufficientHistory": "No hay suficientes datos recientes aptos", "unavailable": "Este pilar no reúne los requisitos actualmente" }, - "pillarInsufficient": "Sin puntuación del pilar: {reason}", "tension": "Las señales de preparación difieren. Favorables: {positive}. Con tensión: {negative}.", "returnToBand": "{metric} lleva {days} días de nuevo dentro de tu intervalo habitual.", "weightGoal": { diff --git a/messages/fr.json b/messages/fr.json index a67466eed..ccfb0d7a7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2262,6 +2262,9 @@ "red": "Un ou plusieurs piliers sont hors de la plage de référence" }, "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", + "anatomyToggle": "Comment ce score est établi", + "notScored": "Pas encore évalué ({count})", + "method": "Le score est la moyenne à poids égal de tous les piliers disposant d'assez de données récentes, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données suffisantes est écarté plutôt que compté comme un zéro.", "bandSetter": "Catégorie globale fixée par {pillar}", "versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.", "methodVersion": "Version de la méthode {version}.", @@ -2320,7 +2323,6 @@ "insufficientHistory": "Pas assez de données récentes admissibles", "unavailable": "Ce pilier n'est pas admissible actuellement" }, - "pillarInsufficient": "Aucun score pour ce pilier : {reason}", "tension": "Les signaux de préparation diffèrent. Favorables : {positive}. Sous tension : {negative}.", "returnToBand": "{metric} est revenu dans ta plage habituelle depuis {days} jours.", "weightGoal": { diff --git a/messages/it.json b/messages/it.json index eb3baad20..7f2933e87 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2262,6 +2262,9 @@ "red": "Uno o più pilastri sono fuori dall'intervallo di riferimento" }, "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", + "anatomyToggle": "Come nasce questo punteggio", + "notScored": "Non ancora valutato ({count})", + "method": "Il punteggio è la media a peso uguale di tutti i pilastri con dati recenti sufficienti, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati sufficienti resta escluso invece di contare come zero.", "bandSetter": "Fascia complessiva determinata da {pillar}", "versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.", "methodVersion": "Versione del metodo {version}.", @@ -2320,7 +2323,6 @@ "insufficientHistory": "Dati recenti idonei insufficienti", "unavailable": "Questo pilastro non è attualmente idoneo" }, - "pillarInsufficient": "Nessun punteggio per il pilastro: {reason}", "tension": "I segnali di preparazione differiscono. Favorevoli: {positive}. Sotto sforzo: {negative}.", "returnToBand": "{metric} è di nuovo nel tuo intervallo abituale da {days} giorni.", "weightGoal": { diff --git a/messages/pl.json b/messages/pl.json index 2cdb2cd35..c171f69db 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2262,6 +2262,9 @@ "red": "Co najmniej jeden filar jest poza zakresem referencyjnym" }, "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", + "anatomyToggle": "Jak powstaje ten wynik", + "notScored": "Jeszcze bez oceny ({count})", + "method": "Wynik to średnia o równych wagach ze wszystkich filarów, które mają wystarczająco dużo aktualnych danych, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających danych jest pomijany, a nie liczony jako zero.", "bandSetter": "Ogólny zakres wyznacza {pillar}", "versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.", "methodVersion": "Wersja metody {version}.", @@ -2320,7 +2323,6 @@ "insufficientHistory": "Za mało aktualnych danych spełniających kryteria", "unavailable": "Ten filar obecnie nie spełnia kryteriów" }, - "pillarInsufficient": "Brak wyniku filaru: {reason}", "tension": "Sygnały gotowości są rozbieżne. Sprzyjające: {positive}. Obciążone: {negative}.", "returnToBand": "{metric} od {days} dni ponownie mieści się w typowym zakresie.", "weightGoal": { diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index 69c39370b..19ac817c4 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -37,6 +37,12 @@ const REQUIRED_KEYS = [ "settings.syncOutcome.partial", "settings.syncOutcome.failed", // Retained score, records, and navigation contracts. + // `insights.healthScore.method` was listed obsolete in v1.34.1 because its + // only reference was `METRIC_PROVENANCE.HEALTH_SCORE.methodKey`, which the + // card then passed as `method={null}` — a dangling reference, not a dead + // one. The score card now renders that method paragraph in its disclosure + // footer, so the key is a live contract again. + "insights.healthScore.method", "insights.healthScore.bandSetter", "insights.healthScore.versionAndNoise", "insights.healthScore.methodVersion", @@ -53,7 +59,6 @@ const REQUIRED_KEYS = [ const OBSOLETE_KEYS = [ "insights.healthScore.algorithmChanged", "insights.healthScore.dismissAlgorithmChanged", - "insights.healthScore.method", "insights.healthScore.composition", "documents.detail.summary.regenerate", "records.profileFacts.correct", diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 93ff804fc..b225b623d 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -268,22 +268,42 @@ describe("v1.16.8 — insights overview cold paint", () => { expect(src).not.toMatch(/useDashboardDerived\([^)]*data/); }); - it("reserves the health-score column while the analytics payload is pending", () => { + it("reserves the health-score card's footprint while the payload is pending", () => { const src = load(INSIGHTS_PATH); - // The hero receives the pending flag so the score card's slot holds - // its footprint from first paint (skeleton in ``). + // The pinned slot holds the collapsed card's footprint from first paint, + // so the resolved card is a swap rather than a push. expect(src).toMatch( - /healthScorePending=\{analyticsQuery\.isPending && !analytics\}/, + /analyticsQuery\.isPending && !analytics \? \(\s* { + it("pins the health score under the hero, outside the section registry", () => { + const src = load(INSIGHTS_PATH); + // An id a saved layout has never seen merges in as default-INVISIBLE, so + // registering the score would delete it for every customised page. It is + // also no longer inside the briefing branch, which used to take the whole + // breakdown down with the flag. + const hero = src.indexOf(" { const heroSrc = readFileSync( join(ROOT, "src/components/insights/hero-strip.tsx"), "utf8", ); - expect(heroSrc).toContain('data-slot="health-score-card-skeleton"'); - // The two-column split keys on score-or-pending, not score alone. - expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/); + expect(heroSrc).not.toContain("HealthScoreCard"); + expect(heroSrc).not.toContain("health-score-card-skeleton"); + expect(heroSrc).not.toContain("healthScorePending"); }); }); diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 772d30b99..769efcc54 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -26,7 +26,10 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; -import { HealthScoreCard } from "@/components/insights/health-score-card"; +import { + HealthScoreCard, + HealthScoreCardSkeleton, +} from "@/components/insights/health-score-card"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -475,41 +478,36 @@ export default function InsightsPageClient() { /> ), "daily-briefing": flags.briefing ? ( - <> - - {analytics?.healthScore ? ( - - ) : null} - + ) : null, vitals: , trends: ( @@ -558,28 +556,38 @@ export default function InsightsPageClient() { briefing={briefingPayload} updatedAt={heroStripUpdatedAt} userName={heroGreetingName} - healthScore={analytics?.healthScore ?? undefined} - // v1.16.8 — reserve the score card's column while the thick - // analytics payload is in flight. Every overview query - // (comprehensive, advisor, analytics, derived batch, layout) - // already fires in parallel at mount; the "greeting first, - // score card later" stagger came from the right column not - // existing until the slowest payload resolved. `isPending` - // settles false on success AND error, so a no-score account - // collapses the column exactly once. - healthScorePending={analyticsQuery.isPending && !analytics} // v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph; // when it can never refresh (no AI provider) pair the "Generated // " line with a discreet connect-provider hint, matching // the briefing card footer. Only when a briefing is actually shown. noProviderStale={briefingPayload !== null && !advisor.hasProvider} - // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- - // baseline off the dashboard snapshot. The hero localises the keys and - // forwards them to the score card; null leaves the card quiet. - tension={heroTension} - returnToBand={heroReturnToBand} /> + {/* The Health Score is PINNED here, between the greeting and the + customizable region, and is deliberately NOT a registry section: an + id a saved layout has never seen merges in as default-INVISIBLE + (`orderedVisibleSectionIds`), so registering it would silently delete + the score for every account that has ever customised this page. It + also used to render inside the daily-briefing branch, which meant an + instance with the briefing switched off lost the whole breakdown. + `isPending` settles false on success AND error, so a no-score account + drops the reserve exactly once. */} + {analyticsQuery.isPending && !analytics ? ( + + ) : analytics?.healthScore ? ( + void analyticsQuery.refetch()} + /> + ) : null} + {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the overview (section show/hide + order) and sorting the nav pills now live ONLY in Settings → Insights, reached via the top-right cog on the diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 3faa061f7..0316245bd 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -1,13 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { I18nProvider } from "@/lib/i18n/context"; +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; -import type { HealthScoreReport } from "@/lib/analytics/score/types"; +import type { + HealthScoreReport, + ScorePillarResult, +} from "@/lib/analytics/score/types"; import { HealthScoreCard, + HealthScoreCardSkeleton, markAlgorithmNoticeDismissed, } from "../health-score-card"; +import { + pillarDetailLines, + pillarObservedText, + type Translate, +} from "../health-score-pillar-detail"; + let mockAuthUser: { unitPreference: string; glucoseUnit: string } = { unitPreference: "metric", glucoseUnit: "mg/dL", @@ -32,12 +42,148 @@ const coverage = { missing: ["ACTIVITY", "SLEEP"], }; -function render(report: HealthScoreReport, locale: Locale = "en") { +function render(node: React.ReactNode, locale: Locale = "en"): string { return renderToStaticMarkup( + {node}, + ); +} + +/** + * The pillar detail lines live behind a Radix popover, which only mounts its + * body once opened, so they are asserted through the pure formatter the card + * feeds the popover. Lifting the live translator out of a throwaway render + * keeps those assertions on the SAME strings the card shows rather than a + * second copy of the copy. + */ +function translator(locale: Locale = "en"): Translate { + let captured: Translate | null = null; + function Probe() { + captured = useTranslations().t; + return null; + } + renderToStaticMarkup( - + , ); + if (!captured) throw new Error("translator probe did not run"); + return captured; +} + +const BLOOD_PRESSURE: ScorePillarResult = { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 82, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + personalReference: { + kind: "guideline-band", + low: 115, + high: 125, + label: "115 to 125/65 to 75 mmHg", + source: "personal target", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "graded_bp_pair_series", + }, + coverage: { + requiredInputs: 12, + presentInputs: 12, + historyDays: 28, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, +}; + +const ACTIVITY_BELOW_FLOOR: ScorePillarResult = { + id: "ACTIVITY", + domain: "activity", + result: { + status: "insufficient", + coverage: { + requiredInputs: 21, + presentInputs: 7, + historyDays: 7, + missing: ["14 days"], + }, + provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 }, + reason: "below_day_floor_or_stale", + }, +}; + +const FASTING_GLUCOSE: ScorePillarResult = { + id: "GLYCAEMIA", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 80, + observed: { + value: 100, + unit: "mg/dL", + label: "Fasting glucose", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 70, + high: 99, + label: "70 to 99 mg/dL fasting", + source: "ADA 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "fasting_glucose", + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, +}; + +function gatedPillar( + id: ScorePillarResult["id"], + reason: string, +): ScorePillarResult { + return { + id, + domain: "wellbeing", + result: { + status: "insufficient", + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { ...provenance, inputs: [id] }, + reason, + }, + }; } function report(overrides: Partial = {}): HealthScoreReport { @@ -48,65 +194,7 @@ function report(overrides: Partial = {}): HealthScoreReport { provenance, reason: "three_domains_required", }, - pillars: [ - { - id: "BLOOD_PRESSURE", - domain: "cardiometabolic", - result: { - status: "ok", - value: { - score: 82, - observed: { - value: 126, - unit: "mmHg", - label: "126/78 mmHg", - asOf: "2026-07-27T08:00:00.000Z", - sources: ["MANUAL"], - }, - reference: { - kind: "clinical-threshold", - low: 120, - high: 129, - label: "120 to 129/70 to 79 mmHg", - source: "ESH 2023", - }, - personalReference: { - kind: "guideline-band", - low: 115, - high: 125, - label: "115 to 125/65 to 75 mmHg", - source: "personal target", - }, - noiseFloor: 1, - deltaEligible: true, - deltaIdentity: "graded_bp_pair_series", - }, - coverage: { - requiredInputs: 12, - presentInputs: 12, - historyDays: 28, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance, - }, - }, - { - id: "ACTIVITY", - domain: "activity", - result: { - status: "insufficient", - coverage: { - requiredInputs: 21, - presentInputs: 7, - historyDays: 7, - missing: ["14 days"], - }, - provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 }, - reason: "below_day_floor_or_stale", - }, - }, - ], + pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR], delta: null, deltaReason: "no_current_score", scoreVersion: 2, @@ -158,208 +246,345 @@ function scoredReport( }); } +/** The markup between the disclosure region's open tag and the card's end. */ +function anatomyRegion(html: string): string { + const start = html.indexOf('data-slot="health-score-anatomy-region"'); + expect(start).toBeGreaterThan(-1); + return html.slice(start); +} + beforeEach(() => { mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" }; }); -describe("", () => { - it("keeps below-floor pillars visible without a composite headline", () => { - const html = render(report()); - expect(html).toContain('data-status="insufficient"'); - expect(html).toContain('data-contributor="BLOOD_PRESSURE"'); - expect(html).toContain('data-contributor="ACTIVITY"'); - expect(html).toContain("Not enough recent eligible data"); - expect(html).not.toContain("below_day_floor_or_stale"); - expect(html).toContain("Your target: 115 to 125/65 to 75 mmHg"); - expect(html).not.toContain('data-slot="score-ring"'); +describe(" disclosure", () => { + it("opens collapsed: the breakdown is present but hidden and out of tab order", () => { + const html = render(); + expect(html).toMatch( + /aria-expanded="false"[^>]*data-slot="health-score-anatomy-toggle"/, + ); + // The trigger points at the region it controls, and the region is `hidden` + // rather than unmounted, so opening it is a paint, not a fetch. + const controls = html.match(/aria-controls="([^"]+)"/); + expect(controls).not.toBeNull(); + expect(html).toContain(`id="${controls![1]}" hidden=""`); + expect(anatomyRegion(html)).toContain('data-slot="health-score-pillars"'); }); - it("shows personal goal progress as explicitly unscored context", () => { + it("keeps the score face outside the disclosure", () => { + const html = render(); + const face = html.slice( + 0, + html.indexOf('data-slot="health-score-anatomy-toggle"'), + ); + expect(face).toContain('data-slot="health-score-band"'); + expect(face).toContain('data-slot="health-score-delta"'); + expect(face).toContain(">86<"); + // The pillar grid and the method footer belong to the opened region only. + expect(face).not.toContain('data-slot="health-score-pillars"'); + expect(face).not.toContain('data-slot="health-score-method"'); + }); +}); + +describe(" pillars", () => { + it("grids the scored pillars only and never draws a gated one as a cell", () => { + const html = render(); + const region = anatomyRegion(html); + expect(region).toContain("sm:grid-cols-2"); + expect(region).toContain("lg:grid-cols-3"); + expect(region).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"'); + expect(region).not.toContain('data-pillar="ACTIVITY" data-status="ok"'); + // Blood pressure scores 82, so its impact bar is band-green at 82%. + expect(region).toContain("width:82%"); + expect(region).toContain("bg-success"); + }); + + it("names every gated pillar with its reason instead of scoring it zero", () => { + const html = render(); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-not-scored"'); + expect(region).toContain("Not scored yet (1)"); + expect(region).toContain('data-reason="below_day_floor_or_stale"'); + expect(region).toContain("Not enough recent eligible data"); + expect(region).not.toContain("below_day_floor_or_stale<"); + }); + + it("treats a failed read as an error with a retry, never as absence", () => { + const retry = vi.fn(); const html = render( - report({ - weightGoal: { - status: "ok", - value: { - currentKg: 81, - target: { min: 74, max: 78 }, - distanceKg: 3, - deltaKg: 1, - asOf: "2026-07-27T08:00:00.000Z", - source: "MANUAL", - }, - coverage: { - requiredInputs: 2, - presentInputs: 2, - historyDays: 1, - missing: [], + , + ); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-pillar-error"'); + expect(region).toContain('role="alert"'); + expect(region).toContain('data-slot="health-score-pillar-retry"'); + expect(region).toContain("Data could not be loaded"); + // A failed read is NOT absence, so it stays out of the "not scored" list. + expect(region).toContain("Not scored yet (1)"); + expect(region).not.toContain('data-reason="read_failed"'); + }); + + it("leads the gated list with safety signposting", () => { + const html = render( + , + ); + const region = anatomyRegion(html); + expect(region).toContain("Not scored yet (2)"); + const crisis = region.indexOf('data-reason="crisis_signposting"'); + const activity = region.indexOf('data-reason="below_day_floor_or_stale"'); + expect(crisis).toBeGreaterThan(-1); + expect(crisis).toBeLessThan(activity); + expect(region).toContain("Safety guidance is shown instead of a score"); + }); +}); + +describe(" composite states", () => { + it("shows a provisional ring and the learning gate when the composite is gated", () => { + const html = render(); + expect(html).toMatch( + /data-slot="health-score-card"[^>]*data-status="insufficient"/, + ); + expect(html).toContain('data-slot="health-score-insufficient"'); + expect(html).toContain( + "The score needs at least three, including a measured physiological pillar.", + ); + // No band sentence and no fabricated number in the face. + expect(html).not.toContain('data-slot="health-score-band"'); + expect(html).toContain("Method version 2"); + }); + + it("explains a missing comparison where the trend would be", () => { + const html = render(); + expect(html).toContain('data-slot="health-score-delta-reason"'); + expect(html).toContain( + "No comparison because the current window is not eligible.", + ); + expect(html).not.toContain('data-slot="health-score-delta"'); + }); + + it("annotates the number with Rest Mode and the ambient context lines", () => { + const html = render( + , + ); + const face = html.slice( + 0, + html.indexOf('data-slot="health-score-anatomy-toggle"'), + ); + expect(face).toContain("Rest Mode active since 2026-07-20"); + expect(face).toContain('data-slot="health-score-tension"'); + expect(face).toContain('data-slot="health-score-return-to-band"'); + // The contributor key and the metric type are localised, never raw. + expect(face).toContain("Resting heart rate"); + expect(face).not.toContain("RESTING_HEART_RATE"); + expect(face).not.toContain(">rhr<"); + }); + + it("keeps a one-sided tension quiet", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="health-score-tension"'); + }); +}); + +describe(" footer", () => { + it("carries the band setter, the version and the cited method", () => { + const html = render(); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-method"'); + expect(region).toContain("Overall band set by none"); + expect(region).toContain("Method version 2. Weekly comparison floor"); + expect(region).toContain('data-slot="provenance-explainer-method"'); + expect(region).toContain("equal-weighted average"); + }); + + it("shows the personal weight goal as explicitly unscored context", () => { + const html = render( + , + ); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-weight-goal"'); + // The caption sits on the type scale, never the bespoke 11 px it carried + // while the goal was a standalone card. + expect(region).toContain( + '

Weight-goal progress is personal context and does not affect the Health Score.

', ); - expect(html).toContain('data-slot="health-score-weight-goal"'); - expect(html).toContain('data-status="ok"'); - expect(html).toContain("does not affect the Health Score"); }); it("renders the personal weight goal in the account unit system", () => { mockAuthUser = { unitPreference: "imperial", glucoseUnit: "mg/dL" }; const html = render( - report({ - weightGoal: { - status: "ok", - value: { - currentKg: 81, - target: { min: 74, max: 78 }, - distanceKg: 3, - deltaKg: 1, - asOf: "2026-07-27T08:00:00.000Z", - source: "MANUAL", + , ); expect(html).toContain("178.6 lb now"); expect(html).toContain("Goal 163.1 to 172 lb"); - expect(html).toContain("6.6 lb"); expect(html).not.toContain("81 kg"); }); +}); - it("renders fasting glucose and its reference in the account unit", () => { - mockAuthUser = { unitPreference: "metric", glucoseUnit: "mmol/L" }; - const base = report(); +describe("pillar detail formatting", () => { + it("renders the observed line in the account glucose unit", () => { const html = render( - report({ - pillars: [ - { - id: "GLYCAEMIA", - domain: "cardiometabolic", - result: { - status: "ok", - value: { - score: 80, - observed: { - value: 100, - unit: "mg/dL", - label: "Fasting glucose", - asOf: "2026-07-27T08:00:00.000Z", - sources: ["MANUAL"], - }, - reference: { - kind: "clinical-threshold", - low: 70, - high: 99, - label: "70 to 99 mg/dL fasting", - source: "ADA 2026", - }, - noiseFloor: 1, - deltaEligible: true, - deltaIdentity: "fasting_glucose", - }, - coverage: { - requiredInputs: 1, - presentInputs: 1, - historyDays: 1, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance, - }, - }, - ...base.pillars.slice(1), - ], - }), + , ); - expect(html).toContain("Fasting glucose: 5.5 mmol/L"); - expect(html).toContain("3.9 to 5.5 mmol/L fasting"); - expect(html).not.toContain("100 mg/dL"); - expect(html).not.toContain("70 to 99 mg/dL"); + // The card mounts with mmol/L accounts too; the visible observed line is + // the one always on screen. + expect(html).toContain("Observed: Fasting glucose: 100 mg/dL"); + + const mmol = pillarObservedText(FASTING_GLUCOSE, { + t: translator(), + glucoseUnit: "mmol/L", + }); + expect(mmol).toBe("Fasting glucose: 5.5 mmol/L"); }); - it("shows one headline score without algorithm, method, or composition copy", () => { - const html = render( - scoredReport({ - algorithmNotice: { - itemKey: "health_score_algorithm:2", - dismissed: false, + it("converts the reference band behind the popover into the account unit", () => { + const lines = pillarDetailLines(FASTING_GLUCOSE, { + t: translator(), + glucoseUnit: "mmol/L", + }); + expect(lines.join("\n")).toContain("3.9 to 5.5 mmol/L fasting"); + expect(lines.join("\n")).not.toContain("70 to 99 mg/dL"); + }); + + it("keeps the reference, the personal target and the source together", () => { + const lines = pillarDetailLines(BLOOD_PRESSURE, { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines).toHaveLength(3); + expect(lines[0]).toContain("ESH 2023"); + expect(lines[1]).toContain("Your target: 115 to 125/65 to 75 mmHg"); + expect(lines[2]).toContain("Source: MANUAL"); + }); + + it("localises the structured activity labels and references", () => { + const activity: ScorePillarResult = { + id: "ACTIVITY", + domain: "activity", + result: { + status: "ok", + value: { + score: 80, + observed: { + value: 8_000, + unit: "steps/day", + label: "8,000 steps/day", + asOf: "2026-07-27T00:00:00.000Z", + sources: ["APPLE_HEALTH"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 10_000, + label: "benefit plateaus by 10,000 steps/day", + source: "Paluch 2022", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "ACTIVITY_STEPS", }, - }), - ); - - expect(html.match(/>86 { - const base = report(); - const html = render( - report({ - pillars: [ - { - id: "ACTIVITY", - domain: "activity", - result: { - status: "ok", - value: { - score: 80, - observed: { - value: 8_000, - unit: "steps/day", - label: "8,000 steps/day", - asOf: "2026-07-27T00:00:00.000Z", - sources: ["APPLE_HEALTH"], - }, - reference: { - kind: "guideline-band", - low: 0, - high: 10_000, - label: "benefit plateaus by 10,000 steps/day", - source: "Paluch 2022", - }, - noiseFloor: 1, - deltaEligible: true, - deltaIdentity: "ACTIVITY_STEPS", - }, - coverage: { - requiredInputs: 21, - presentInputs: 21, - historyDays: 21, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance, - }, - }, - ...base.pillars.slice(1), - ], - }), - "de", - ); - expect(html).toContain("Schritte pro Tag"); - expect(html).toContain("Der Nutzen flacht"); - expect(html).toContain("Leitlinienbereich"); - expect(html).not.toContain("benefit plateaus by"); - expect(html).not.toContain("guideline-band"); +describe("", () => { + it("reserves the collapsed footprint and announces nothing", () => { + const html = render(); + expect(html).toContain('data-slot="health-score-card-skeleton"'); + expect(html).toContain('aria-hidden="true"'); + // The `md` ring is a fixed 168 px box; the reserve matches it exactly. + expect(html).toContain("size-[168px]"); }); +}); + +describe("markAlgorithmNoticeDismissed", () => { it("patches the cached analytics report after the notice is dismissed", () => { const payload = { healthScore: report({ diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index c06d080d0..65e3dc1e8 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { HeroStrip } from "../hero-strip"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; -import type { HealthScoreReport } from "@/lib/analytics/score/types"; // The "generated" freshness line reads the profile timezone via `useAuth`; stub // it so the SSR render does not reach for a QueryClient the test omits. @@ -41,54 +40,6 @@ const sampleBriefing: DailyBriefingPayload = { const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin -const SCORE_REPORT: HealthScoreReport = { - composite: { - status: "ok", - value: { - score: 86, - band: "green", - bandSetter: null, - composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], - noiseFloor: 3, - scoreVersion: 2, - }, - coverage: { - requiredInputs: 3, - presentInputs: 3, - historyDays: 28, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance: { - inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], - source: "live", - windowDays: 90, - computedAt: "2026-05-10T07:00:00.000Z", - }, - }, - pillars: [], - delta: 5, - deltaReason: null, - scoreVersion: 2, - weightGoal: { - status: "insufficient", - coverage: { - requiredInputs: 2, - presentInputs: 0, - historyDays: 0, - missing: ["weight reading", "personal weight target"], - }, - provenance: { - inputs: ["WEIGHT"], - source: "none", - windowDays: 90, - computedAt: "2026-05-10T07:00:00.000Z", - }, - reason: "no_personal_goal", - }, - algorithmNotice: null, -}; - describe("", () => { it("renders the slot wrapper + Dracula gradient utility", () => { const html = render(); @@ -287,126 +238,35 @@ describe("", () => { expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"'); }); - // ── B5 — Health Score panel ──────────────────────────────────────── - it("does NOT render the Health Score panel when healthScore is omitted", () => { + // ── B5 — the Health Score left the hero band ────────────────────── + // The score used to mount twice: a compact face in this band and the full + // breakdown below it. The breakdown is now one pinned card under the band + // (`page-client.tsx`), so the hero carries no score surface at all — no + // panel, no reserved column, no skeleton. + it("never renders a Health Score surface in the band", () => { const html = render(); expect(html).not.toContain('data-slot="health-score-card"'); + expect(html).not.toContain('data-slot="health-score-card-compact"'); + expect(html).not.toContain('data-slot="health-score-card-skeleton"'); }); - it("renders the Health Score panel when the score is supplied", () => { - const html = render( - , - ); - expect(html).toMatch(/data-slot="health-score-card-compact"/); - expect(html).toMatch(/data-band="green"/); - expect(html).toContain(">86<"); + it("keeps the greeting on the full width with no reserved second column", () => { + // With the panel gone the band is single-column on every breakpoint; the + // `md:`/`lg:` split + stretch contract it carried for the score column + // must not come back, or the greeting reflows around an empty slot. + const html = render(); + expect(html).not.toContain("md:flex-row"); + expect(html).not.toContain("lg:flex-row"); + expect(html).not.toContain("md:items-stretch"); + expect(html).not.toContain("md:basis-[22rem]"); }); it("does not render any Ask-the-Coach affordance in the hero band", () => { // v1.4.27 F8 — the inline HSC Ask-the-Coach button was retired. // v1.18.7 — the hero action-row coach button was removed too; the - // Coach is the bottom-right drawer, not a hero affordance. Neither - // the score card nor the band surfaces a coach button. - const html = render( - , - ); + // Coach is the bottom-right drawer, not a hero affordance. + const html = render(); expect(html).not.toContain('data-slot="health-score-card-ask-coach"'); expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"'); }); - - it("uses the lg row layout when healthScore is supplied (smoke test on container class)", () => { - const html = render( - , - ); - // The wrapper picks up the `lg:flex-row` modifier when the score - // is present so the panel sits beside the title block on desktop. - expect(html).toContain("lg:flex-row"); - }); - - // ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ─────── - it("stretches the row's cross-axis when the HealthScore card mounts (md+/lg+)", () => { - // Per Inv-4 the right column painted 75-110 px shorter than the - // left column on desktop. Switching the parent flex row from - // `items-start` to `items-stretch` gives the card a stretched - // shell to grow into. The class is load-bearing for FB-H1/H2 — - // if a future refactor reverts to `items-start` the height gap - // returns. - const html = render( - , - ); - expect(html).toContain("md:items-stretch"); - expect(html).toContain("lg:items-stretch"); - expect(html).not.toContain("md:items-start"); - }); - - it("does NOT stretch the row when healthScore is omitted", () => { - // Pin the negative case so a future refactor can't blanket-apply - // the stretch contract on the no-score layout (no right column - // means nothing to stretch toward). - const html = render(); - expect(html).not.toContain("md:items-stretch"); - }); - - // ── v1.16.8 — score-column reservation while analytics is pending ── - it("reserves the score column with a skeleton while the payload is pending", () => { - const html = render( - , - ); - // The placeholder mirrors the card's column footprint so the band - // paints at its final geometry from the first frame. - expect(html).toContain('data-slot="health-score-card-skeleton"'); - expect(html).toContain("md:basis-[22rem]"); - expect(html).toContain('aria-hidden="true"'); - // The real card stays absent until the payload lands. - expect(html).not.toContain('data-slot="health-score-card" '); - }); - - it("keeps the two-column split active while pending", () => { - const html = render( - , - ); - expect(html).toContain("md:flex-row"); - expect(html).toContain("md:items-stretch"); - }); - - it("drops the skeleton once the payload resolves without a score", () => { - const html = render( - , - ); - expect(html).not.toContain('data-slot="health-score-card-skeleton"'); - expect(html).not.toContain("md:items-stretch"); - }); - - it("renders the real card, never the skeleton, when a score is present", () => { - const html = render( - , - ); - expect(html).toContain('data-slot="health-score-card-compact"'); - expect(html).not.toContain('data-slot="health-score-card-skeleton"'); - }); }); diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index ec642dc1e..587056d8a 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,20 +1,59 @@ "use client"; -import { ScoreAnatomyView } from "@/components/insights/derived/score-anatomy-view"; +import { useId, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { InfoPopover } from "@/components/ui/info-popover"; +import { LearningGate } from "@/components/ui/learning-gate"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TileHeader } from "@/components/insights/tile-header"; +import { + BAND_PROGRESS_CLASS, + bandForScore, + clampScore, +} from "@/components/insights/derived/band-tokens"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; +import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; +import { ScoreRing } from "@/components/insights/derived/score-ring"; +import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; +import { + pillarDetailLines, + pillarObservedText, + type PillarDetailContext, +} from "@/components/insights/health-score-pillar-detail"; import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import type { HealthScoreReport, - PillarReference, ScorePillarId, ScoreBand, ScorePillarResult, } from "@/lib/analytics/score/types"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import { ScoreRing } from "@/components/insights/derived/score-ring"; + +/** + * The Health Score surface: one card, one score, one place. + * + * Pre-rework the score had two mounts — a compact face in the hero band and a + * nine-block stack further down the overview that only rendered while the + * daily-briefing flag was on. The stack is now a single progressive-disclosure + * card pinned directly under the hero, and the hero band is the full-width + * greeting again. + * + * Collapsed reads as the score face: the ring, the band sentence, the weekly + * delta (or the honest reason there is none), Rest Mode, the two ambient + * context lines, and the coverage meter. Expanded adds the pillar grid, the + * named list of pillars that are not scored yet, the personal weight goal, and + * the method footer with its cited standard. + * + * Absence stays absence: a pillar the server gated as `insufficient` is listed + * by name with its reason instead of being drawn as a zero, and a pillar whose + * read FAILED is a visible error row with a retry, never silence. + */ const PILLAR_LABEL_KEY: Record = { BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure", @@ -43,6 +82,41 @@ const REASON_KEY: Record = { stale: "insufficientHistory", }; +/** + * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. + * Kept as a local string union so the client never imports the server + * derived-engine types. Localised through the existing + * `insights.derived.composite.READINESS.component.{key}` labels. + */ +export type ReadinessContributorKey = + "rhr" | "hrv" | "sleep" | "respiratory" | "mood"; + +/** + * v1.21.2 (A5) — readiness contributor key → existing localised label key. + * Reuses the score-anatomy contributor labels so the Tension Verdict reads the + * same names the wellness ring's anatomy view renders — no new keys. + */ +const TENSION_CONTRIBUTOR_LABEL_KEY: Record = { + rhr: "insights.derived.composite.READINESS.component.rhr", + hrv: "insights.derived.composite.READINESS.component.hrv", + sleep: "insights.derived.composite.READINESS.component.sleep", + respiratory: "insights.derived.composite.READINESS.component.respiratory", + mood: "insights.derived.composite.READINESS.component.mood", +}; + +/** + * v1.21.2 (A6) — `MeasurementType` → existing localised metric-name key + * (`measurements.type*`). Only the salient deviation vitals the + * return-to-baseline detector scans need an entry; an unmapped type falls back + * to a prettified raw form so a new type never blanks. + */ +const RETURN_METRIC_LABEL_KEY: Record = { + RESTING_HEART_RATE: "measurements.typeRestingHeartRate", + HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", + RESPIRATORY_RATE: "measurements.typeRespiratoryRate", + WEIGHT: "measurements.typeWeight", +}; + export function markAlgorithmNoticeDismissed( payload: unknown, itemKey: string, @@ -76,381 +150,407 @@ export function markAlgorithmNoticeDismissed( }; } +/** The reason a pillar was gated, or null when it carries a score. */ +function gateReason(pillar: ScorePillarResult): string | null { + return pillar.result.status === "insufficient" ? pillar.result.reason : null; +} + export interface HealthScoreCardProps { report: HealthScoreReport; + /** + * v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band` + * is the readiness composite's band; `positive` / `negative` carry the + * readiness contributor KEYS. Null on a coherent day (or under a clinical + * red-flag suppress). + */ tension?: { band: ScoreBand; - positive: string[]; - negative: string[]; + positive: ReadinessContributorKey[]; + negative: ReadinessContributorKey[]; } | null; - returnToBand?: { metricLabel: string; daysInside: number } | null; - /** Keep only the score face when the card is used above the fold. */ - compact?: boolean; + /** + * v1.21.2 (A6) — return-to-baseline, server-resolved + locale-agnostic. + * `metricType` is a `MeasurementType` this card maps to its localised metric + * name. Null when no salient metric returned from a genuine prior + * out-of-band run. + */ + returnToBand?: { metricType: string; daysInside: number } | null; + /** Retry for a pillar whose read failed — wired to the analytics refetch. */ + onRetry?: () => void; className?: string; } export function HealthScoreCard({ report, - tension, - returnToBand, - compact = false, + tension = null, + returnToBand = null, + onRetry, className, }: HealthScoreCardProps) { const { t } = useTranslations(); const { user } = useAuth(); - const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); + const [open, setOpen] = useState(false); + const regionId = useId(); + + const detail: PillarDetailContext = { + t, + glucoseUnit: resolveGlucoseUnit(user?.glucoseUnit), + }; function reasonText(reason: string): string { const key = REASON_KEY[reason] ?? "unavailable"; return t(`insights.healthScore.reason.${key}`); } - function referenceKindText(reference: PillarReference): string { - return t( - `insights.healthScore.referenceKind.${reference.kind === "clinical-threshold" ? "clinical" : reference.kind === "population-percentile" ? "population" : "guideline"}`, - ); - } - - function formatReference( - reference: PillarReference, - label: string, - source = reference.source, - ): string { - return t("insights.healthScore.referenceDetail", { - label, - source, - kind: referenceKindText(reference), - low: reference.low ?? t("insights.healthScore.openBound"), - high: reference.high ?? t("insights.healthScore.openBound"), - }); - } - - function observedText(pillar: ScorePillarResult): string { - if (pillar.result.status !== "ok") return ""; - const observed = pillar.result.value.observed; - if (pillar.id === "GLYCAEMIA" && observed.unit === "mg/dL") { - return t("insights.healthScore.fastingObserved", { - value: convertGlucose(observed.value, glucoseUnit), - unit: glucoseUnit, - }); - } - if (pillar.id === "ACTIVITY") { - return t("insights.healthScore.observedActivity", { - value: observed.value, - }); - } - if (pillar.id === "SLEEP") { - return t("insights.healthScore.observedSleep", { - value: observed.value, - }); - } - if (pillar.id === "ADIPOSITY") { - return t("insights.healthScore.observedAdiposity", { - value: observed.value, - }); - } - if (pillar.id === "FITNESS") { - return t("insights.healthScore.observedFitness", { - value: observed.value, - unit: observed.unit, - }); - } - return observed.label; - } - - function scoredReferenceText(pillar: ScorePillarResult): string { - if (pillar.result.status !== "ok") return ""; - const value = pillar.result.value; - const reference = value.reference; - if (pillar.id === "GLYCAEMIA" && value.observed.unit === "mg/dL") { - const low = - reference.low == null - ? null - : convertGlucose(reference.low, glucoseUnit); - const high = - reference.high == null - ? null - : convertGlucose(reference.high, glucoseUnit); - const converted = { ...reference, low, high }; - return formatReference( - converted, - t("insights.healthScore.fastingReference", { - low: low ?? "", - high: high ?? "", - unit: glucoseUnit, - }), - ); - } - const label = - pillar.id === "ACTIVITY" - ? t("insights.healthScore.referenceActivity", { - high: reference.high ?? "", - }) - : pillar.id === "SLEEP" - ? t("insights.healthScore.referenceSleep", { - low: reference.low ?? "", - }) - : pillar.id === "ADIPOSITY" - ? t("insights.healthScore.referenceAdiposity", { - high: reference.high ?? "", - }) - : pillar.id === "FITNESS" - ? t("insights.healthScore.referenceFitness", { - low: reference.low ?? "", - high: reference.high ?? "", - unit: value.observed.unit, - }) - : pillar.id === "GLYCAEMIA" - ? t("insights.healthScore.referenceHba1c", { - low: reference.low ?? "", - high: reference.high ?? "", - }) - : reference.label; - return formatReference( - reference, - label, - pillar.id === "LIPIDS" - ? t("insights.healthScore.laboratoryReferenceSource") - : reference.source, - ); - } const composite = report.composite; - const eligibleCount = report.pillars.filter( - (pillar) => pillar.result.status === "ok", - ).length; - const contributors = report.pillars.map((pillar) => ({ - key: pillar.id, - label: t(PILLAR_LABEL_KEY[pillar.id]), - value: pillar.result.status === "ok" ? pillar.result.value.score : null, - weight: - pillar.result.status === "ok" && eligibleCount > 0 - ? 1 / eligibleCount - : 0, - })); - if (compact) { - return ( -
- - {composite.status === "ok" ? ( -

- {t(`insights.healthScore.band.${composite.value.band}`)} -

- ) : ( -

- {t("insights.healthScore.insufficient", { + const scored = report.pillars.filter((p) => p.result.status === "ok"); + const readFailed = report.pillars.filter( + (p) => gateReason(p) === "read_failed", + ); + // A failed read is not absence, so it never joins the "not scored yet" list; + // it gets its own error row above. Safety copy leads the list — signposting + // is content, never buried meta. + const notScored = report.pillars + .filter((p) => { + const reason = gateReason(p); + return reason !== null && reason !== "read_failed"; + }) + .sort( + (a, b) => + Number(gateReason(b) === "crisis_signposting") - + Number(gateReason(a) === "crisis_signposting"), + ); + + // Only forwarded when a real disagreement is present on BOTH sides; the + // server resolver already enforces that, but the guard keeps the card from + // rendering a one-sided line. + const tensionLine = + tension && tension.positive.length > 0 && tension.negative.length > 0 + ? t("insights.healthScore.tension", { + positive: tension.positive + .map((k) => t(TENSION_CONTRIBUTOR_LABEL_KEY[k])) + .join(", "), + negative: tension.negative + .map((k) => t(TENSION_CONTRIBUTOR_LABEL_KEY[k])) + .join(", "), + }) + : null; + + const returnToBandLine = returnToBand + ? t("insights.healthScore.returnToBand", { + metric: RETURN_METRIC_LABEL_KEY[returnToBand.metricType] + ? t(RETURN_METRIC_LABEL_KEY[returnToBand.metricType]) + : returnToBand.metricType.replace(/_/g, " ").toLowerCase(), + days: returnToBand.daysInside, + }) + : null; + + return ( + + + + + + {/* The score face. The ring keeps its own identity block; everything + that qualifies the number sits beside it, so a person reads the + score and the reason for it in one pass. */} +

+
+ +
+
+ {composite.status === "ok" ? ( +

+ {t(`insights.healthScore.band.${composite.value.band}`)} +

+ ) : null} + {report.delta != null ? ( +

+ {t("insights.healthScore.delta", { + delta: report.delta > 0 ? `+${report.delta}` : report.delta, + })} +

+ ) : report.deltaReason ? ( +

+ {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} +

+ ) : null} + {report.restMode?.active ? ( +

+ {t("insights.healthScore.restMode", { + since: + report.restMode.since ?? t("insights.healthScore.none"), + })} +

+ ) : null} + {tensionLine ? ( +

+ {tensionLine} +

+ ) : null} + {returnToBandLine ? ( +

+ {returnToBandLine} +

+ ) : null} +
+
+ + {composite.status !== "ok" ? ( + - )} -
- ); - } - return ( -
- + /> + ) : null} - {composite.status === "ok" || - report.deltaReason || - report.restMode?.active ? ( -
- {composite.status === "ok" ? ( - <> -

- {t("insights.healthScore.bandSetter", { - pillar: composite.value.bandSetter - ? t(PILLAR_LABEL_KEY[composite.value.bandSetter]) - : t("insights.healthScore.none"), - })} -

-

- {t("insights.healthScore.versionAndNoise", { - version: report.scoreVersion, - noise: composite.value.noiseFloor, - })} -

- - ) : ( -

- {t("insights.healthScore.methodVersion", { - version: report.scoreVersion, - })} -

- )} - {report.delta != null ? ( -

- {t("insights.healthScore.delta", { - delta: report.delta > 0 ? `+${report.delta}` : report.delta, - })} -

- ) : report.deltaReason ? ( -

- {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} -

- ) : null} - {report.restMode?.active ? ( -

- {t("insights.healthScore.restMode", { - since: report.restMode.since ?? t("insights.healthScore.none"), - })} -

- ) : null} -
- ) : null} + -
    - {report.pillars.map((pillar) => ( -
  • +
  • - ))} -
+ - - {tension || returnToBand ? ( -
- {tension ? ( -

- {t("insights.healthScore.tension", { - positive: tension.positive.join(", "), - negative: tension.negative.join(", "), - })} -

- ) : null} - {returnToBand ? ( -

- {t("insights.healthScore.returnToBand", { - metric: returnToBand.metricLabel, - days: returnToBand.daysInside, - })} -

- ) : null} +
- ) : null} -
+ + ); } +/** + * One scored pillar: name + score, a band-tinted impact bar, and the observed + * line with its detail popover and coverage meter. The reference band, the + * personal target and the source live in the popover — the same strings as + * before, one tap away instead of four permanent lines per pillar. + */ +function PillarCell({ + pillar, + detail, +}: { + pillar: ScorePillarResult; + detail: PillarDetailContext; +}) { + const { t } = useTranslations(); + if (pillar.result.status !== "ok") return null; + const score = clampScore(pillar.result.value.score); + const band = bandForScore(score); + const lines = pillarDetailLines(pillar, detail); + + return ( +
  • +
    + + {t(PILLAR_LABEL_KEY[pillar.id])} + + + {Math.round(score)} + +
    +
    +
    +
    +
    + + {t("insights.healthScore.observed", { + value: pillarObservedText(pillar, detail), + })} + + + ( + + {line} + + ))} + bodyDataSlot="health-score-pillar-detail" + /> + + +
    +
  • + ); +} + +/** + * The personal weight goal: an inner tile inside the disclosure, deliberately + * NOT a pillar cell, because it is context the user set for themselves and it + * never moves the score. + */ function WeightGoalRow({ report }: { report: HealthScoreReport }) { const { t } = useTranslations(); const goal = report.weightGoal; @@ -460,9 +560,9 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) {
    -

    +

    {t("insights.healthScore.weightGoal.title")}

    {goal.status === "ok" ? ( @@ -502,9 +602,46 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) { size="sm" className="mt-3" /> -

    +

    {t("insights.healthScore.weightGoal.notScored")}

    ); } + +/** + * The pinned slot's reserve while the analytics payload is in flight. Mirrors + * the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger + * row — so first paint lands at the card's real height and the resolved card + * is a swap rather than a push. Decorative: the card announces nothing while + * it loads. + */ +export function HealthScoreCardSkeleton({ className }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/insights/health-score-pillar-detail.ts b/src/components/insights/health-score-pillar-detail.ts new file mode 100644 index 000000000..76d25a992 --- /dev/null +++ b/src/components/insights/health-score-pillar-detail.ts @@ -0,0 +1,192 @@ +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import type { + PillarReference, + ScorePillarResult, +} from "@/lib/analytics/score/types"; + +/** + * Pillar display formatting for the Health Score card. + * + * The card renders one observed line per scored pillar and hides the + * reference band, the personal target and the source behind the cell's info + * popover. A Radix popover only mounts its body once it is opened, so keeping + * that formatting inside the component would put the account-unit conversion + * (fasting glucose in mg/dL vs mmol/L) beyond the reach of a render test. + * These are pure functions over the report DTO plus the caller's translator, + * so both the card and the tests read the exact same strings. + * + * Client-safe: the score types and the glucose conversion are both pure. + */ + +export type Translate = ( + key: string, + params?: Record, +) => string; + +export interface PillarDetailContext { + t: Translate; + /** Account glucose unit; the fasting pillar is converted into it. */ + glucoseUnit: GlucoseUnit; +} + +function referenceKindText(reference: PillarReference, t: Translate): string { + return t( + `insights.healthScore.referenceKind.${ + reference.kind === "clinical-threshold" + ? "clinical" + : reference.kind === "population-percentile" + ? "population" + : "guideline" + }`, + ); +} + +function formatReference( + reference: PillarReference, + label: string, + t: Translate, + source = reference.source, +): string { + return t("insights.healthScore.referenceDetail", { + label, + source, + kind: referenceKindText(reference, t), + low: reference.low ?? t("insights.healthScore.openBound"), + high: reference.high ?? t("insights.healthScore.openBound"), + }); +} + +/** The one always-visible line: what was actually measured. */ +export function pillarObservedText( + pillar: ScorePillarResult, + { t, glucoseUnit }: PillarDetailContext, +): string { + if (pillar.result.status !== "ok") return ""; + const observed = pillar.result.value.observed; + if (pillar.id === "GLYCAEMIA" && observed.unit === "mg/dL") { + return t("insights.healthScore.fastingObserved", { + value: convertGlucose(observed.value, glucoseUnit), + unit: glucoseUnit, + }); + } + if (pillar.id === "ACTIVITY") { + return t("insights.healthScore.observedActivity", { + value: observed.value, + }); + } + if (pillar.id === "SLEEP") { + return t("insights.healthScore.observedSleep", { value: observed.value }); + } + if (pillar.id === "ADIPOSITY") { + return t("insights.healthScore.observedAdiposity", { + value: observed.value, + }); + } + if (pillar.id === "FITNESS") { + return t("insights.healthScore.observedFitness", { + value: observed.value, + unit: observed.unit, + }); + } + return observed.label; +} + +/** The scored reference band, already converted into the account unit. */ +export function pillarReferenceText( + pillar: ScorePillarResult, + { t, glucoseUnit }: PillarDetailContext, +): string { + if (pillar.result.status !== "ok") return ""; + const value = pillar.result.value; + const reference = value.reference; + if (pillar.id === "GLYCAEMIA" && value.observed.unit === "mg/dL") { + const low = + reference.low == null ? null : convertGlucose(reference.low, glucoseUnit); + const high = + reference.high == null + ? null + : convertGlucose(reference.high, glucoseUnit); + return formatReference( + { ...reference, low, high }, + t("insights.healthScore.fastingReference", { + low: low ?? "", + high: high ?? "", + unit: glucoseUnit, + }), + t, + ); + } + const label = + pillar.id === "ACTIVITY" + ? t("insights.healthScore.referenceActivity", { + high: reference.high ?? "", + }) + : pillar.id === "SLEEP" + ? t("insights.healthScore.referenceSleep", { + low: reference.low ?? "", + }) + : pillar.id === "ADIPOSITY" + ? t("insights.healthScore.referenceAdiposity", { + high: reference.high ?? "", + }) + : pillar.id === "FITNESS" + ? t("insights.healthScore.referenceFitness", { + low: reference.low ?? "", + high: reference.high ?? "", + unit: value.observed.unit, + }) + : pillar.id === "GLYCAEMIA" + ? t("insights.healthScore.referenceHba1c", { + low: reference.low ?? "", + high: reference.high ?? "", + }) + : reference.label; + return formatReference( + reference, + label, + t, + pillar.id === "LIPIDS" + ? t("insights.healthScore.laboratoryReferenceSource") + : reference.source, + ); +} + +/** + * The lines behind the cell's info popover: the scored reference, the + * user-authored target when one exists, and the source with its as-of date. + * Ordered as they render; an absent personal target contributes no line + * rather than an empty one. + */ +export function pillarDetailLines( + pillar: ScorePillarResult, + ctx: PillarDetailContext, +): string[] { + if (pillar.result.status !== "ok") return []; + const { t } = ctx; + const value = pillar.result.value; + const lines = [ + t("insights.healthScore.reference", { + value: pillarReferenceText(pillar, ctx), + }), + ]; + if (value.personalReference) { + lines.push( + t("insights.healthScore.personalReference", { + value: formatReference( + value.personalReference, + value.personalReference.label, + t, + t("insights.healthScore.personalTargetSource"), + ), + }), + ); + } + lines.push( + t("insights.healthScore.source", { + source: + value.observed.sources.join(", ") || pillar.result.provenance.source, + date: value.observed.asOf, + }), + ); + return lines; +} diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index b90df64ab..62de9a9ab 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { Sparkles } from "lucide-react"; -import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters, @@ -13,19 +12,8 @@ import { useAuth } from "@/hooks/use-auth"; import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { cn } from "@/lib/utils"; -import { HealthScoreCard } from "./health-score-card"; -import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; -/** - * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. Kept - * as a local string union so the client never imports the server derived-engine - * types. Localised through the existing - * `insights.derived.composite.READINESS.component.{key}` labels. - */ -export type ReadinessContributorKey = - "rhr" | "hrv" | "sleep" | "respiratory" | "mood"; - /** * Insights redesign hero strip. * @@ -43,8 +31,14 @@ export type ReadinessContributorKey = * chip strip were removed from the band. The Coach lives in the * bottom-right drawer (mounted by the insights layout shell); the * overview now leads with the present-focused daily briefing, not a - * coach prompt. The hero is greeting + subtitle + baseline meta + the - * optional right-side Health Score panel. + * coach prompt. + * + * The right-side Health Score panel is gone too. The score had two mounts, + * a compact face here and the full breakdown below; the breakdown is now one + * progressive-disclosure card pinned directly under this band, which needs + * the full page width when it opens. The hero is back to the full-width + * greeting + subtitle + baseline meta — the shape it already had for an + * account without a score. * * Pure presentational — the page owns the briefing data. */ @@ -74,45 +68,6 @@ interface HeroStripProps { * footer hint below. */ noProviderStale?: boolean; - /** Complete transparent Health Score report from the analytics route. */ - healthScore?: HealthScoreReport | null; - /** - * v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band` - * is the readiness composite's band; `positive` / `negative` carry the - * readiness contributor KEYS (`rhr` / `hrv` / `sleep` / `respiratory` / - * `mood`). The hero localises each key through the existing - * `insights.derived.composite.READINESS.component.*` labels before handing the - * score card its already-localised strings. Null on a coherent day (or under a - * clinical red-flag suppress). The score card carries it only when the score - * is present. - */ - tension?: { - band: ScoreBand; - positive: ReadinessContributorKey[]; - negative: ReadinessContributorKey[]; - } | null; - /** - * v1.21.2 (A6) — return-to-baseline, server-resolved + locale-agnostic. - * `metricType` is a `MeasurementType` the hero maps to its localised metric - * name (the existing `measurements.type*` keys). Null when no salient metric - * returned from a genuine prior out-of-band run. - */ - returnToBand?: { - metricType: string; - daysInside: number; - } | null; - /** - * v1.16.8 — true while the analytics payload that carries - * `healthScore` is still in flight. The right column then reserves the - * score card's footprint with a skeleton, so the hero band paints at - * roughly its final size from the first frame and the card landing is - * a swap, not a push. Pre-fix the column collapsed while loading and - * the whole overview reflowed when the (slow, thick-slice) analytics - * response arrived — the "greeting first, score card later" stagger. - * When the payload resolves WITHOUT a score (no data yet) the column - * collapses once, which is the legacy no-score shape. - */ - healthScorePending?: boolean; } /** @@ -133,42 +88,12 @@ function resolveGreetingKey(hour: number): string { return "insights.heroGreetingEvening"; } -/** - * v1.21.2 (A5) — readiness contributor key → existing localised label key. - * Reuses the score-anatomy contributor labels so the Tension Verdict reads the - * same names the wellness ring's anatomy view renders — no new keys. - */ -const TENSION_CONTRIBUTOR_LABEL_KEY: Record = { - rhr: "insights.derived.composite.READINESS.component.rhr", - hrv: "insights.derived.composite.READINESS.component.hrv", - sleep: "insights.derived.composite.READINESS.component.sleep", - respiratory: "insights.derived.composite.READINESS.component.respiratory", - mood: "insights.derived.composite.READINESS.component.mood", -}; - -/** - * v1.21.2 (A6) — `MeasurementType` → existing localised metric-name key - * (`measurements.type*`). Only the salient deviation vitals the - * return-to-baseline detector scans need an entry; an unmapped type falls back - * to a prettified raw form so a new type never blanks. - */ -const RETURN_METRIC_LABEL_KEY: Record = { - RESTING_HEART_RATE: "measurements.typeRestingHeartRate", - HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", - RESPIRATORY_RATE: "measurements.typeRespiratoryRate", - WEIGHT: "measurements.typeWeight", -}; - export function HeroStrip({ briefing, updatedAt, userName, now, - healthScore, - healthScorePending = false, noProviderStale = false, - tension = null, - returnToBand = null, }: HeroStripProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -177,33 +102,6 @@ export function HeroStrip({ // Berlin), so the today/yesterday boundary and the clock can never split. const displayTz = useDisplayTimezone(); - // v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the - // card's already-localised `{ positive, negative }` shape. Only forwarded when - // a real disagreement is present on BOTH sides; the resolver already enforces - // that, but the guard keeps the card from rendering a one-sided line. - const tensionProp = - tension && tension.positive.length > 0 && tension.negative.length > 0 - ? { - band: tension.band, - positive: tension.positive.map((k) => - t(TENSION_CONTRIBUTOR_LABEL_KEY[k]), - ), - negative: tension.negative.map((k) => - t(TENSION_CONTRIBUTOR_LABEL_KEY[k]), - ), - } - : null; - - // v1.21.2 (A6) — localise the return-to-baseline metric into the card's - // already-localised `metricLabel`. - const returnToBandProp = returnToBand - ? { - metricLabel: RETURN_METRIC_LABEL_KEY[returnToBand.metricType] - ? t(RETURN_METRIC_LABEL_KEY[returnToBand.metricType]) - : returnToBand.metricType.replace(/_/g, " ").toLowerCase(), - daysInside: returnToBand.daysInside, - } - : null; // Greeting hour in the user's configured zone, not the browser's — a // traveller / VPN user / wrong device clock otherwise sees the wrong // salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`). @@ -236,157 +134,86 @@ export function HeroStrip({ "relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6", )} > - {/* - * v1.4.20 phase B5 — split layout. On `md+` (v1.4.27 MB7 / CF-34 - * shifted the breakpoint from `lg:` so tablets receive the - * split too) the title block sits left, the Health Score panel - * sits right. On ` -
    -
    -
    - {/* Header glyph stays in the foreground tier — UI-STANDARDS §1 + {/* One column. The band carried a right-hand score panel from v1.4.20 + until the score moved to its own pinned card below; with the panel + gone the greeting owns the full width on every breakpoint, which is + the shape this band already had for an account without a score. */} +
    +
    +
    + {/* Header glyph stays in the foreground tier — UI-STANDARDS §1 reserves primary/accent for actions, not decorative header icons. */} -
    - {/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks +
    + {/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks so a multi-paragraph verdict-first briefing shows real paragraph spacing (still plain text children, no markdown). */} -
    - -
    -
    - - {t("insights.heroPersonalBaseline")} - - {generatedLine && ( - <> - - - {generatedLine} - - - )} - {/* v1.18.9 (#4) — the subtitle is a cached briefing that can +
    + +
    +
    + + {t("insights.heroPersonalBaseline")} + + {generatedLine && ( + <> + + + {generatedLine} + + + )} + {/* v1.18.9 (#4) — the subtitle is a cached briefing that can never refresh (no AI provider). Append a discreet hint + Settings → AI link so the relative age above reads as intentionally held, consistent with the briefing card. */} - {noProviderStale && ( - <> - - + + + {t("insights.dailyBriefing.staleNoProviderHint")} + - - {t("insights.dailyBriefing.staleNoProviderHint")} - - - {t("insights.dailyBriefing.noProviderAction")} - - - - )} -
    + {t("insights.dailyBriefing.noProviderAction")} + + + + )}
    - - {/* - * v1.18.7 — the overview "Ask the coach" action row + the - * guided-questions chip strip were removed from the hero band. - * The Coach is the bottom-right drawer (mounted in the insights - * layout shell); the overview leads with the present-focused - * daily briefing, not a coach prompt. The drawer itself is - * untouched — only the hero entry points are gone, so the band - * is now greeting + subtitle + baseline meta. - */}
    - {!healthScore && healthScorePending && ( - /* v1.16.8 — layout-stable placeholder mirroring the score - card's column footprint (same basis / stretch classes as - ``): label row, headline number, progress - bar, four component rows. Decorative — the card announces - nothing while loading. */ - - )} - {healthScore && ( - - )} + {/* + * v1.18.7 — the overview "Ask the coach" action row + the + * guided-questions chip strip were removed from the hero band. + * The Coach is the bottom-right drawer (mounted in the insights + * layout shell); the overview leads with the present-focused + * daily briefing, not a coach prompt. The drawer itself is + * untouched — only the hero entry points are gone, so the band + * is now greeting + subtitle + baseline meta. + */}
    );