Merge branch 'feat/hero-ring-carousel' into release/v1.27.23

This commit is contained in:
Marc-André Bombeck committed 2026-07-07 21:56:00 +02:00
commit 9298e7ca9f
9 files changed
+319 -73

No files matched your search

+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
@@ -412,6 +412,66 @@ describe("<DashboardHero> — 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(
/<div[^>]*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(
/<div[^>]*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(
/<button[^>]*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(
/<div[^>]*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({
@@ -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 ? (
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("dashboard.hero.ringDosesAria", {
taken: ring.doses.taken,
scheduled: ring.doses.scheduled,
})}
size="sm"
flat
label={t(RING_LABEL_KEY[ring.id])}
/>
) : (
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
),
})),
{
key: "health-score",
node: (
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
flat
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: hasScoreInputs
? t("dashboard.hero.scoreComputing")
: t("dashboard.hero.scoreProvisional")
}
/>
),
},
];
return (
<section
data-slot="dashboard-hero"
@@ -259,7 +323,7 @@ export function DashboardHero({
adrift below a floating greeting. 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">
<div className="flex h-full flex-col gap-4 md:gap-3">
<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
@@ -317,82 +381,16 @@ export function DashboardHero({
) : null}
</div>
</div>
{/* 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. */}
<div
data-slot="dashboard-hero-rings"
className={cn(
// Mobile: the rings stack under the greeting as a full-width
// row that wraps to a 2-up grid at four rings. `justify-evenly`
// spreads them with equal space before / between / after so
// the block reads symmetric edge-to-edge instead of a centred
// cluster with dead outer margin; `gap-y-6` gives the wrapped
// rows the same breathing room as the horizontal spread.
// Desktop: back to a right-aligned inline row beside the copy.
"flex w-full flex-wrap items-center justify-evenly gap-x-3 gap-y-6",
"md:w-auto md:shrink-0 md:justify-end md:gap-6",
)}
>
{scoreRings.map((ring) => (
<div
key={ring.id}
data-slot="dashboard-hero-ring"
data-ring={ring.id}
className="flex shrink-0 items-center"
>
{/* 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 ? (
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("dashboard.hero.ringDosesAria", {
taken: ring.doses.taken,
scheduled: ring.doses.scheduled,
})}
size="sm"
flat
label={t(RING_LABEL_KEY[ring.id])}
/>
) : (
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
)}
</div>
))}
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
flat
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: hasScoreInputs
? t("dashboard.hero.scoreComputing")
: t("dashboard.hero.scoreProvisional")
}
/>
</div>
<HeroRingCarousel slides={ringSlides} />
</div>
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating
(renders nothing unless an episode is active), value-free, and
@@ -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 `<ScoreRing>` node. */
node: React.ReactNode;
}
export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) {
const { t } = useTranslations();
const trackRef = useRef<HTMLDivElement>(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 (
<div
data-slot="dashboard-hero-rings"
className="w-full md:w-auto md:shrink-0"
>
<div
ref={trackRef}
data-slot="dashboard-hero-ring-track"
role="group"
aria-label={t("dashboard.hero.ringCarousel.aria")}
tabIndex={multi ? 0 : undefined}
className={cn(
// Mobile: one full-width ring per viewport, native swipe between
// them; the scrollbar is hidden (the dots are the affordance).
"no-scrollbar flex snap-x snap-mandatory overflow-x-auto rounded-lg",
"motion-safe:scroll-smooth",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
// Desktop: back to the inline right-aligned ring row, every ring
// visible, no snapping — the pre-carousel layout unchanged.
"md:snap-none md:justify-end md:gap-6 md:overflow-x-visible md:rounded-none",
)}
>
{slides.map((slide) => (
<div
key={slide.key}
data-carousel-slide=""
{...(slide.ringId
? {
"data-slot": "dashboard-hero-ring",
"data-ring": slide.ringId,
}
: {})}
className={cn(
// Mobile: a full-width centred slide → exactly one ring shows.
"flex w-full shrink-0 snap-center items-center justify-center",
// Desktop: collapse to the ring's own width, inline in the row.
"md:w-auto",
)}
>
{slide.node}
</div>
))}
</div>
{multi ? (
<div
data-slot="dashboard-hero-ring-dots"
className="mt-2 flex items-center justify-center gap-1 md:hidden"
>
{slides.map((slide, i) => {
const isActive = i === active;
return (
<button
key={slide.key}
type="button"
onClick={() => goTo(i)}
data-slot="dashboard-hero-ring-dot"
data-active={isActive ? "true" : undefined}
aria-current={isActive ? "true" : undefined}
aria-label={t("dashboard.hero.ringCarousel.dot", {
index: i + 1,
total: slides.length,
})}
className="focus-visible:ring-ring/50 flex items-center justify-center rounded-full p-1.5 focus-visible:ring-2 focus-visible:outline-none"
>
<span
aria-hidden="true"
className={cn(
"block h-2 rounded-full transition-all",
isActive ? "bg-primary w-4" : "bg-muted-foreground/40 w-2",
)}
/>
</button>
);
})}
</div>
) : null}
</div>
);
}