mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'feat/hero-ring-carousel' into release/v1.27.23
This commit is contained in:
commit
9298e7ca9f
9 files changed
+319
-73
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user