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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 23:34:58 +02:00
1 parent 33c383b6b9
commit 7cd317f1a5
11 files changed
+217 -40

No files matched your search

@@ -521,6 +521,47 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
});
});
describe("<DashboardHero> — 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("<DashboardHero> — greeting", () => {
it("personalises from the snapshot username with the server greeting hour", () => {
const html = render(
@@ -128,6 +128,29 @@ const RING_LABEL_KEY: Record<ScoreRingId, string> = {
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/<slug>`) — 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<ScoreRingId, string> = {
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
// <Link> (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: (
<ScoreRing
score={snapshot.healthScore?.score ?? null}
@@ -318,12 +354,13 @@ export function DashboardHero({
"min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
)}
>
{/* 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. */}
<div className="flex h-full flex-col gap-4 md:gap-3">
<div className="flex h-full flex-col gap-4 md:gap-2">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting leads the typographic hierarchy: larger + heavier
@@ -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 `<ScoreRing>` 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.
<Link
href={slide.href}
aria-label={slide.linkLabel}
data-slot="dashboard-hero-ring-link"
className="focus-visible:ring-ring/50 rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
{slide.node}
</Link>
) : (
slide.node
)}
</div>
))}
</div>