From ee98a0c73885c23074309b53b49c861a6bb016b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 20:53:40 +0200 Subject: [PATCH] feat(dashboard): swipeable one-ring carousel on mobile hero, tighter desktop drop Below md the hero wellness rings no longer wrap into a multi-row grid; a dependency-free CSS scroll-snap track shows one ring centred at a time with dot indicators, native touch/keyboard swipe, and reduced-motion respected. At md and up the same nodes revert to the inline right-aligned row unchanged. Desktop tightens the greeting/ring-row drop to the briefing one step (md:gap-3). --- 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 | 60 +++++++ .../dashboard/hero/dashboard-hero.tsx | 144 ++++++++------- .../dashboard/hero/hero-ring-carousel.tsx | 164 ++++++++++++++++++ 9 files changed, 319 insertions(+), 73 deletions(-) create mode 100644 src/components/dashboard/hero/hero-ring-carousel.tsx diff --git a/messages/de.json b/messages/de.json index 4f004de08..2597c44ce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -320,6 +320,10 @@ "scoreComputing": "Score wird berechnet…", "ringDoses": "Dosen heute", "ringDosesAria": "{taken} von {scheduled} Dosen heute genommen", + "ringCarousel": { + "aria": "Wellness-Ringe", + "dot": "Ring {index} von {total} anzeigen" + }, "verdict": { "doseOverdue": "Eine Dosis {name} ist überfällig.", "doseOverdueNoName": "Eine Dosis ist überfällig.", diff --git a/messages/en.json b/messages/en.json index ccd34b44c..e560afbd7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -320,6 +320,10 @@ "scoreComputing": "Calculating score…", "ringDoses": "Doses today", "ringDosesAria": "{taken} of {scheduled} doses taken today", + "ringCarousel": { + "aria": "Wellness rings", + "dot": "Show ring {index} of {total}" + }, "verdict": { "doseOverdue": "A dose of {name} is overdue.", "doseOverdueNoName": "A dose is overdue.", diff --git a/messages/es.json b/messages/es.json index a640f86eb..b8d8207d9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -320,6 +320,10 @@ "scoreComputing": "Calculando la puntuación …", "ringDoses": "Dosis de hoy", "ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy", + "ringCarousel": { + "aria": "Anillos de bienestar", + "dot": "Mostrar anillo {index} de {total}" + }, "verdict": { "doseOverdue": "Una dosis de {name} está atrasada.", "doseOverdueNoName": "Una dosis está atrasada.", diff --git a/messages/fr.json b/messages/fr.json index 87256c3e2..b880048e1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -320,6 +320,10 @@ "scoreComputing": "Calcul du score en cours …", "ringDoses": "Doses du jour", "ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui", + "ringCarousel": { + "aria": "Anneaux de bien-être", + "dot": "Afficher l'anneau {index} sur {total}" + }, "verdict": { "doseOverdue": "Une dose de {name} est en retard.", "doseOverdueNoName": "Une dose est en retard.", diff --git a/messages/it.json b/messages/it.json index 43ae6aefa..7e88fdfa0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -320,6 +320,10 @@ "scoreComputing": "Calcolo del punteggio in corso …", "ringDoses": "Dosi di oggi", "ringDosesAria": "{taken} di {scheduled} dosi assunte oggi", + "ringCarousel": { + "aria": "Anelli del benessere", + "dot": "Mostra anello {index} di {total}" + }, "verdict": { "doseOverdue": "Una dose di {name} è in ritardo.", "doseOverdueNoName": "Una dose è in ritardo.", diff --git a/messages/pl.json b/messages/pl.json index 27cfe24be..81be96ce1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -320,6 +320,10 @@ "scoreComputing": "Trwa obliczanie wyniku …", "ringDoses": "Dzisiejsze dawki", "ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj", + "ringCarousel": { + "aria": "Pierścienie dobrostanu", + "dot": "Pokaż pierścień {index} z {total}" + }, "verdict": { "doseOverdue": "Dawka {name} jest zaległa.", "doseOverdueNoName": "Dawka jest zaległa.", diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 81cc88758..d067d2760 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -412,6 +412,66 @@ describe(" — ring row (v1.27.7)", () => { expect(html).not.toContain("var(--ring-yellow-from)"); }); + it("mobile carousel: one scroll-snap track holds a slide per ring + a dot per slide", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 82, band: "green", delta: 2 }, + scoreRings: [ + { id: "READINESS", score: 71, band: "green" }, + { id: "RECOVERY_SCORE", score: 74, band: "yellow" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, + ], + }), + ); + // Exactly one snap track, one dots row. + expect(html.match(/data-slot="dashboard-hero-ring-track"/g)).toHaveLength( + 1, + ); + expect(html.match(/data-slot="dashboard-hero-ring-dots"/g)).toHaveLength(1); + // A slide per ring: 3 selected + the health-score ring = 4 slides = 4 dots. + expect(html.match(/data-carousel-slide=""/g)).toHaveLength(4); + expect(html.match(/data-slot="dashboard-hero-ring-dot"/g)).toHaveLength(4); + // Track is a labelled, focusable scroll region that snaps. + const track = html.match( + /]*data-slot="dashboard-hero-ring-track"[^>]*>/, + ); + expect(track).not.toBeNull(); + expect(track![0]).toContain("snap-x"); + expect(track![0]).toContain("overflow-x-auto"); + expect(track![0]).toContain('tabindex="0"'); + // The desktop row is preserved: the track reverts to the inline + // right-aligned row (md:justify-end) with the dots hidden (md:hidden). + expect(track![0]).toContain("md:justify-end"); + expect(track![0]).toContain("md:overflow-x-visible"); + const dots = html.match( + /]*data-slot="dashboard-hero-ring-dots"[^>]*>/, + ); + expect(dots![0]).toContain("md:hidden"); + // The first dot is current on the server render (active slide 0). + const firstDot = html.match( + /]*data-slot="dashboard-hero-ring-dot"[^>]*>/, + ); + expect(firstDot![0]).toContain('aria-current="true"'); + }); + + it("a single slide (health ring only) renders no dots — not a carousel", () => { + const html = render( + baseSnapshot({ healthScore: { score: 60, band: "yellow", delta: 0 } }), + ); + expect(html.match(/data-carousel-slide=""/g)).toHaveLength(1); + expect(html).not.toContain('data-slot="dashboard-hero-ring-dots"'); + // A lone slide is not focusable (no scroll affordance to reach). + const track = html.match( + /]*data-slot="dashboard-hero-ring-track"[^>]*>/, + ); + expect(track![0]).not.toContain("tabindex"); + }); + it("no dose text row renders anymore — the ring row carries the slot", () => { const html = render( baseSnapshot({ diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 4d615f84f..eca9e0163 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -53,6 +53,10 @@ import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { RingHue } from "@/components/insights/derived/ring-hues"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight"; +import { + HeroRingCarousel, + type HeroRingSlide, +} from "@/components/dashboard/hero/hero-ring-carousel"; import { METRIC_HREF } from "@/components/insights/daily-briefing"; import { resolveDashboardVerdict, @@ -242,6 +246,66 @@ export function DashboardHero({ // contract), so an older cached snapshot renders the health ring alone. const scoreRings = snapshot.scoreRings ?? []; + // Unified slide set — selected rings in selection order, then the + // health-score ring on the trailing edge. Feeds the carousel on mobile + // and the inline row on desktop from a SINGLE render of each ring node. + const ringSlides: HeroRingSlide[] = [ + ...scoreRings.map((ring) => ({ + key: ring.id, + ringId: ring.id, + node: + // Dose ring — today's tally ("1/3") over the constant med-family + // arc (`hue="meds"` = --primary, the tone every medication surface + // in Insights paints); the arc still sweeps on the 0..100 progress + // `score`. `band="green"` stays as the stable data-band anchor — a + // pending morning dose is not an alert state — while the hue owns + // the paint. Falls through to the score render when a cached + // pre-doses snapshot carries no tally. + ring.id === "MED_COMPLIANCE" && ring.doses ? ( + + ) : ( + + ), + })), + { + key: "health-score", + node: ( + + ), + }, + ]; + return (
+
{/* Greeting leads the typographic hierarchy: larger + heavier @@ -317,82 +381,16 @@ export function DashboardHero({ ) : null}
- {/* Ring row — the selected score rings (max 3, each at the + {/* Ring carousel — the selected score rings (max 3, each at the fixed 120 px sm footprint, flat) lead into the health-score - ring on the trailing edge. A null health score renders the + ring on the trailing edge. On mobile they ride a one-ring + scroll-snap carousel with dot indicators (calmer than the old + wrap-to-grid row); at `md` and up the SAME nodes revert to the + right-aligned inline row. A null health score renders the ring's provisional state (aria announces "not enough data", never 0) at identical geometry, so the column NEVER collapses; missing selected rings render nothing. */} -
- {scoreRings.map((ring) => ( -
- {/* Dose ring — today's tally ("1/3") over the constant - med-family arc (`hue="meds"` = --primary, the tone - every medication surface in Insights paints); the arc - still sweeps on the 0..100 progress `score`. - `band="green"` stays as the stable data-band anchor — - a pending morning dose is not an alert state — while - the hue owns the paint. Falls through to the score - render when a cached pre-doses snapshot carries no - tally. */} - {ring.id === "MED_COMPLIANCE" && ring.doses ? ( - - ) : ( - - )} -
- ))} - -
+
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating (renders nothing unless an episode is active), value-free, and diff --git a/src/components/dashboard/hero/hero-ring-carousel.tsx b/src/components/dashboard/hero/hero-ring-carousel.tsx new file mode 100644 index 000000000..ec2e41799 --- /dev/null +++ b/src/components/dashboard/hero/hero-ring-carousel.tsx @@ -0,0 +1,164 @@ +"use client"; + +/** + * Hero ring carousel — the mobile face of the dashboard-hero ring row. + * + * Below `md` the wellness rings no longer wrap into a multi-row grid + * (which ate vertical space); instead a single ring shows centred and + * the user swipes left/right through the set. The mechanism is a + * dependency-free CSS scroll-snap track — a horizontal `overflow-x-auto` + * flex whose slides are each full-width `snap-center`, so exactly one + * ring is visible at a time and native touch/keyboard scrolling moves + * between them. A row of dot indicators below the track shows the + * position; the active dot rides `--primary`, inactive dots muted. + * + * At `md` and up the SAME track reverts, purely in CSS, to the inline + * right-aligned ring row it always was: `md:overflow-x-visible`, + * `md:snap-none`, `md:justify-end`, and slides collapse to their own + * width (`md:w-auto`). Every ring is visible at once and the dot row is + * hidden — the desktop layout is unchanged. + * + * a11y: the track is a focusable scroll region (native arrow-key + * scrolling), each ring carries its own metric label + aria, the dots + * are real buttons that scroll to their slide with an aria-current on + * the active one, and the programmatic scroll honours + * `prefers-reduced-motion` (jump, don't glide). The track scrolls inside + * itself, so no phone width overflows the page. + */ + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "@/lib/i18n/context"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import type { ScoreRingId } from "@/lib/dashboard-layout"; +import { cn } from "@/lib/utils"; + +export interface HeroRingSlide { + /** Stable key + dot label anchor. */ + key: string; + /** Selected-ring id when the slide is a `scoreRings` entry (carries the + * `data-ring` contract the tests + wellness vocabulary rely on); the + * health-score slide leaves it unset. */ + ringId?: ScoreRingId; + /** The rendered `` node. */ + node: React.ReactNode; +} + +export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) { + const { t } = useTranslations(); + const trackRef = useRef(null); + const [active, setActive] = useState(0); + // A single slide is not a carousel: no dots, no scroll affordance. + const multi = slides.length > 1; + + // Track the active slide from the scroll offset. Each slide is exactly + // the track's own width (full-width snap slide), so `scrollLeft / + // clientWidth` rounds straight to the slide index — no Intersection + // Observer bookkeeping needed. On desktop the track has no overflow, so + // `scrollLeft` stays 0 and the (hidden) dots stay on the first. + useEffect(() => { + const track = trackRef.current; + if (!track || !multi) return; + let raf = 0; + const onScroll = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const w = track.clientWidth || 1; + const idx = Math.round(track.scrollLeft / w); + setActive(Math.min(slides.length - 1, Math.max(0, idx))); + }); + }; + track.addEventListener("scroll", onScroll, { passive: true }); + return () => { + track.removeEventListener("scroll", onScroll); + cancelAnimationFrame(raf); + }; + }, [multi, slides.length]); + + const goTo = (index: number) => { + const track = trackRef.current; + if (!track) return; + track.scrollTo({ + left: index * track.clientWidth, + behavior: prefersReducedMotion() ? "auto" : "smooth", + }); + }; + + return ( +
+ + {multi ? ( +
+ {slides.map((slide, i) => { + const isActive = i === active; + return ( + + ); + })} +
+ ) : null} +
+ ); +}