From 7cd317f1a57563fd3761201e2d0ca72c1d0eb027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 23:34:58 +0200 Subject: [PATCH] feat(dashboard,mental-health): tighten hero gap, link hero rings, move wellbeing attribution to a header info popover - Dashboard hero: pull the briefing closer to the ring row (md:gap-3 -> md:gap-2); mobile spacing unchanged. - Each hero ring is now a focusable link to its natural detail: readiness/recovery/sleep -> their score-anatomy page, doses -> the medications surface, health score -> the Insights overview. Real anchors keep tap-vs-drag native on the mobile carousel. - Mental-wellbeing: shorten the page title to the short noun form (matches the nav/module label) across all six locales. - Instrument cards: move the required attribution/licence line off the space under the Start button to a discreet info control at the top-right of the header; a popover carries the verbatim citation. Start now stands alone. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../hero/__tests__/dashboard-hero.test.tsx | 41 +++++++++ .../dashboard/hero/dashboard-hero.tsx | 47 +++++++++- .../dashboard/hero/hero-ring-carousel.tsx | 24 ++++- .../__tests__/mental-wellbeing.test.tsx | 30 +++++- .../mental-health/instrument-card.tsx | 91 ++++++++++++++----- 11 files changed, 217 insertions(+), 40 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4fa9fe2f1..48e0a0b53 100644 --- a/messages/de.json +++ b/messages/de.json @@ -316,6 +316,7 @@ "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", "hero": { "scoreLabel": "Health Score", + "ringLink": "{metric}-Details öffnen", "scoreProvisional": "Noch nicht genug Daten", "scoreComputing": "Score wird berechnet…", "ringDoses": "Dosen heute", @@ -7681,9 +7682,10 @@ } }, "mentalHealth": { - "pageTitle": "Check-in zum seelischen Wohlbefinden", + "pageTitle": "Seelisches Wohlbefinden", "pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.", "attributionLabel": "Quelle", + "attributionShow": "Quelle und Lizenz für {instrument}", "choosePrompt": "Check-in auswählen", "start": "Starten", "back": "Zurück", diff --git a/messages/en.json b/messages/en.json index 11139b420..06fcae882 100644 --- a/messages/en.json +++ b/messages/en.json @@ -316,6 +316,7 @@ "welcomeBackWithName": "{greeting} {name}, welcome back.", "hero": { "scoreLabel": "Health Score", + "ringLink": "Open {metric} details", "scoreProvisional": "Not enough data yet", "scoreComputing": "Calculating score…", "ringDoses": "Doses today", @@ -7681,9 +7682,10 @@ } }, "mentalHealth": { - "pageTitle": "Mental wellbeing check-in", + "pageTitle": "Mental wellbeing", "pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.", "attributionLabel": "Attribution", + "attributionShow": "Source and licence for {instrument}", "choosePrompt": "Choose a check-in", "start": "Start", "back": "Back", diff --git a/messages/es.json b/messages/es.json index 771e37580..2541e524b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -316,6 +316,7 @@ "welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.", "hero": { "scoreLabel": "Health Score", + "ringLink": "Abrir detalles de {metric}", "scoreProvisional": "Aún no hay datos suficientes", "scoreComputing": "Calculando la puntuación …", "ringDoses": "Dosis de hoy", @@ -7681,9 +7682,10 @@ } }, "mentalHealth": { - "pageTitle": "Revisión de bienestar mental", + "pageTitle": "Bienestar mental", "pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.", "attributionLabel": "Fuente", + "attributionShow": "Fuente y licencia de {instrument}", "choosePrompt": "Elige una revisión", "start": "Empezar", "back": "Atrás", diff --git a/messages/fr.json b/messages/fr.json index 251d74cb5..2a4214317 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -316,6 +316,7 @@ "welcomeBackWithName": "{greeting} {name}, content de te revoir.", "hero": { "scoreLabel": "Health Score", + "ringLink": "Ouvrir les détails de {metric}", "scoreProvisional": "Pas encore assez de données", "scoreComputing": "Calcul du score en cours …", "ringDoses": "Doses du jour", @@ -7681,9 +7682,10 @@ } }, "mentalHealth": { - "pageTitle": "Point sur le bien-être mental", + "pageTitle": "Bien-être mental", "pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.", "attributionLabel": "Source", + "attributionShow": "Source et licence pour {instrument}", "choosePrompt": "Choisis un bilan", "start": "Commencer", "back": "Retour", diff --git a/messages/it.json b/messages/it.json index 3fc8f452a..d9155f675 100644 --- a/messages/it.json +++ b/messages/it.json @@ -316,6 +316,7 @@ "welcomeBackWithName": "{greeting} {name}, bentornato.", "hero": { "scoreLabel": "Health Score", + "ringLink": "Apri i dettagli di {metric}", "scoreProvisional": "Dati ancora insufficienti", "scoreComputing": "Calcolo del punteggio in corso …", "ringDoses": "Dosi di oggi", @@ -7681,9 +7682,10 @@ } }, "mentalHealth": { - "pageTitle": "Check-in sul benessere mentale", + "pageTitle": "Benessere mentale", "pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.", "attributionLabel": "Fonte", + "attributionShow": "Fonte e licenza di {instrument}", "choosePrompt": "Scegli un check-in", "start": "Inizia", "back": "Indietro", diff --git a/messages/pl.json b/messages/pl.json index fd8299a7a..0b85914ba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -316,6 +316,7 @@ "welcomeBackWithName": "{greeting} {name}, witaj ponownie.", "hero": { "scoreLabel": "Health Score", + "ringLink": "Otwórz szczegóły: {metric}", "scoreProvisional": "Za mało danych", "scoreComputing": "Trwa obliczanie wyniku …", "ringDoses": "Dzisiejsze dawki", @@ -7681,9 +7682,10 @@ } }, "mentalHealth": { - "pageTitle": "Sprawdzenie dobrostanu psychicznego", + "pageTitle": "Dobrostan psychiczny", "pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.", "attributionLabel": "Źródło", + "attributionShow": "Źródło i licencja: {instrument}", "choosePrompt": "Wybierz sprawdzenie", "start": "Rozpocznij", "back": "Wstecz", diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index d067d2760..2483828d7 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -521,6 +521,47 @@ describe(" — ring row (v1.27.7)", () => { }); }); +describe(" — ring links (v1.27.24)", () => { + it("each ring links to its natural detail surface with an aria-label", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 82, band: "green", delta: 2 }, + scoreRings: [ + { id: "READINESS", score: 71, band: "green" }, + { id: "SLEEP_SCORE", score: 66, band: "yellow" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, + ], + }), + ); + // Derived rings → their score-anatomy detail page. + const readiness = html.slice( + html.indexOf('data-ring="READINESS"'), + html.indexOf('data-ring="SLEEP_SCORE"'), + ); + expect(readiness).toContain('data-slot="dashboard-hero-ring-link"'); + expect(readiness).toContain('href="/insights/scores/readiness"'); + expect(readiness).toContain("aria-label="); + const sleep = html.slice( + html.indexOf('data-ring="SLEEP_SCORE"'), + html.indexOf('data-ring="MED_COMPLIANCE"'), + ); + expect(sleep).toContain('href="/insights/scores/sleep"'); + // Dose ring → the medications surface. + const med = html.slice(html.indexOf('data-ring="MED_COMPLIANCE"')); + expect(med).toContain('href="/medications"'); + // Health-score ring → the Insights overview. + expect(html).toContain('href="/insights"'); + // Every ring link is keyboard-focusable (native anchor) — one per slide. + const links = html.match(/data-slot="dashboard-hero-ring-link"/g) ?? []; + expect(links).toHaveLength(4); + }); +}); + describe(" — greeting", () => { it("personalises from the snapshot username with the server greeting hour", () => { const html = render( diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index eca9e0163..99a34c416 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -128,6 +128,29 @@ const RING_LABEL_KEY: Record = { MED_COMPLIANCE: "dashboard.hero.ringDoses", }; +/** + * v1.27.24 — per-ring detail destination. Each hero ring links to the + * SAME surface its sibling already owns elsewhere in the app, so tapping + * a ring opens the natural deeper view rather than inventing one: + * + * - the three derived rings route to their score-anatomy detail page + * (`/insights/scores/`) — exactly where the /insights wellness + * strip links each ring; + * - the dose ring routes to the medications surface (`/medications`) — + * the today/intake destination the dashboard dose CTA already uses. + * + * The health-score ring is wired separately to the Insights overview + * (`/insights`, the destination the health-score card carries). A ring + * with no sensible destination would be left non-interactive; every id + * in the closed set has one, so the map is total. + */ +const RING_HREF: Record = { + READINESS: "/insights/scores/readiness", + RECOVERY_SCORE: "/insights/scores/recovery", + SLEEP_SCORE: "/insights/scores/sleep", + MED_COMPLIANCE: "/medications", +}; + export function DashboardHero({ snapshot, onQuickEntry, @@ -253,6 +276,12 @@ export function DashboardHero({ ...scoreRings.map((ring) => ({ key: ring.id, ringId: ring.id, + // Each ring links to its natural existing detail surface; a real + // (in the carousel) keeps tap-vs-drag native on mobile. + href: RING_HREF[ring.id], + linkLabel: t("dashboard.hero.ringLink", { + metric: t(RING_LABEL_KEY[ring.id]), + }), node: // Dose ring — today's tally ("1/3") over the constant med-family // arc (`hue="meds"` = --primary, the tone every medication surface @@ -288,6 +317,13 @@ export function DashboardHero({ })), { key: "health-score", + // The health-score ring opens the Insights overview — the same + // destination the health-score card links (why the hero verdict + // dropped its redundant "open Insights" button). + href: "/insights", + linkLabel: t("dashboard.hero.ringLink", { + metric: t("dashboard.hero.scoreLabel"), + }), node: ( - {/* Vertical rhythm: greeting/ring row and the briefing sit one - spacing step apart — tight enough that the briefing doesn't feel - adrift below a floating greeting. The top row aligns to its start - on desktop so the greeting anchors to the top edge instead of + {/* Vertical rhythm: greeting/ring row and the briefing sit one tight + spacing step apart on desktop (md:gap-2) so the briefing hugs the + ring row instead of drifting below a floating greeting; mobile + keeps the roomier gap-4. The top row aligns to its start on + desktop so the greeting anchors to the top edge instead of drifting to the vertical centre of the taller ring column. */} -
+
{/* Greeting leads the typographic hierarchy: larger + heavier diff --git a/src/components/dashboard/hero/hero-ring-carousel.tsx b/src/components/dashboard/hero/hero-ring-carousel.tsx index ec2e41799..415d78556 100644 --- a/src/components/dashboard/hero/hero-ring-carousel.tsx +++ b/src/components/dashboard/hero/hero-ring-carousel.tsx @@ -27,6 +27,7 @@ */ import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { useTranslations } from "@/lib/i18n/context"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import type { ScoreRingId } from "@/lib/dashboard-layout"; @@ -41,6 +42,13 @@ export interface HeroRingSlide { ringId?: ScoreRingId; /** The rendered `` node. */ node: React.ReactNode; + /** Optional detail destination — wraps the ring in a focusable link so a + * tap opens the metric's detail. A real anchor keeps tap-vs-drag native + * on the mobile carousel: a swipe scrolls the track, only a tap fires + * navigation. Omit to leave the ring non-interactive. */ + href?: string; + /** aria-label for the ring link ("Open {metric} details"). */ + linkLabel?: string; } export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) { @@ -122,7 +130,21 @@ export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) { "md:w-auto", )} > - {slide.node} + {slide.href ? ( + // A real anchor sized to the ring: a tap navigates, a + // horizontal drag scrolls the track (native tap-vs-drag), and + // it is keyboard-focusable with a rounded focus ring. + + {slide.node} + + ) : ( + slide.node + )}
))}
diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index 2187c0453..e5dc8a3df 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -405,13 +405,33 @@ describe("instrument card (med-/Vorsorge card anatomy + detail opener)", () => { expect(html).toContain(mh.start); // No history yet → the calm no-check-in line, no fabricated dashes. expect(html).toContain(mh.noResultYet); - // v1.27.9 — the required attribution footer rides every card. - expect(html).toContain('data-slot="instrument-card-attribution"'); + // v1.27.24 — the attribution moved off the space under Start to a + // top-right info control on the header. The trigger renders; nothing + // sits under the Start button anymore. + expect(html).toContain('data-slot="instrument-card-attribution-trigger"'); + // The citation now lives in the trigger's popover (portaled, opened on + // tap), never as a paragraph beneath the action. + const startIdx = html.indexOf(mh.start); + expect(startIdx).toBeGreaterThan(-1); + expect(html.slice(startIdx)).not.toContain( + 'data-slot="instrument-card-attribution"', + ); }); - it("carries the instrument's licence line on the WHO-5 / SCI cards", () => { - expect(card("WHO5")).toContain("CC BY-NC-SA 3.0 IGO"); - expect(card("SCI")).toContain("Sleepio Limited"); + it("the header info control is a sibling of the detail-open button, never nested", () => { + const html = card("WHO5"); + // Both the info trigger and the detail-open button render, but neither is + // inside the other (nested interactive controls are invalid + would let a + // tap open both). A simple structural check: the trigger appears before + // the open button's closing markup boundary is irrelevant — assert both + // slots exist and the trigger carries its own accessible name. + expect(html).toContain('data-slot="instrument-card-attribution-trigger"'); + expect(html).toContain('data-slot="instrument-card-open"'); + const trigger = html.match( + /]*data-slot="instrument-card-attribution-trigger"[^>]*>/, + ); + expect(trigger).not.toBeNull(); + expect(trigger![0]).toContain("aria-label="); }); it("shows last test (relative) and last result (score + band word)", () => { diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx index e908ae919..ab8618442 100644 --- a/src/components/mental-health/instrument-card.tsx +++ b/src/components/mental-health/instrument-card.tsx @@ -12,17 +12,33 @@ * through discreet text only, never a card wash). * * v1.27.9 — the card generalises over the four-instrument registry and - * carries the instrument's required attribution/licence line as a small - * factual footer (WHO CC BY-NC-SA citation for the WHO-5, the Sleepio - * CC BY-NC citation for the SCI, the Pfizer grant for PHQ/GAD). A locale - * without validated item wording additionally gets the honest - * "validated in English" note. The card BODY (header + last-result block) - * opens the per-instrument detail — the Vorsorge-/med-card interaction — - * while the bottom-pinned Start button stays a separate, non-overlapping - * action. + * carries the instrument's required attribution/licence line (WHO + * CC BY-NC-SA citation for the WHO-5, the Sleepio CC BY-NC citation for + * the SCI, the Pfizer grant for PHQ/GAD). A locale without validated item + * wording additionally gets the honest "validated in English" note. The + * card BODY (header + last-result block) opens the per-instrument detail — + * the Vorsorge-/med-card interaction — while the bottom-pinned Start + * button stays a separate, non-overlapping action. + * + * v1.27.24 — the attribution left the space UNDER the Start button (which + * crowded the action) for a discreet info affordance at the top-right of + * the card header, at the heading's height. A single icon button opens a + * popover carrying the verbatim citation (+ the "validated in English" + * note where it applies), so the Start button stands alone with clean + * space below it. The citation still renders verbatim in full view on the + * result and detail surfaces; here it is one tap away. The trigger is a + * sibling of the detail-open button (never nested inside it) so the two + * actions never collide. */ +import { Info } from "lucide-react"; + import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { MedicationCardHeader } from "@/components/medications/medication-card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { relativeCalendarDate } from "@/lib/i18n/relative-time"; @@ -56,10 +72,48 @@ export function InstrumentCard({ const title = t(`mentalHealth.instrument.${key}`); return ( - + + {/* Source / licence — a discreet info control at the top-right of the + header, at the heading's height. A sibling of the detail-open + button (never nested), so a tap opens the citation popover without + also opening the detail. */} + + + + + +

+ {t("mentalHealth.attributionLabel")} +

+

+ {!hasValidatedItems(instrument, locale) && ( + <>{t("mentalHealth.validatedInEnglishNote")} + )} + {def.attribution} +

+
+
+ {/* Body is the detail target: clicking the header / last-result block opens this instrument's detail (chart + dated history), like a - medication card. The Start button below stays a separate action. */} + medication card. The Start button below stays a separate action. + The short instrument titles clear the top-right info control that + floats over the header's right padding. */} - {/* Required attribution — factual and unobtrusive; identical slot on - every card so the grid stays symmetric. */} -

- {!hasValidatedItems(instrument, locale) && ( - <>{t("mentalHealth.validatedInEnglishNote")} - )} - {def.attribution} -