feat(dashboard): show today's dose tally on the medication hero ring

The MED_COMPLIANCE hero ring carried the pooled 7-day adherence
percentage — a mystery number next to today-focused scores. It now
answers the question the hero poses: how many of today's doses are
done. The resolver reads takenToday/scheduledToday off the snapshot's
own medsToday block (one shared builder call, no second tally); score
stays the rounded 0..100 progress for wire compatibility and a new
additive doses field carries the taken/scheduled pair. No dose
scheduled today means no ring, and the band is progress semantics —
green once everything is taken, yellow while doses remain, never red
(a pending morning dose is not an alert state).

The web ring renders the tally (1/3) with a doses label over the ring
system's constant green arc — the same green family the compliance
surfaces in Insights paint — instead of a band gradient; the derived
rings keep their exact insights hues. ScoreRing gains documented
valueText/ariaLabel overrides for tally-shaped centres. OpenAPI
regenerated; labels land in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 18:05:13 +02:00
1 parent 9b4f3ce56d
commit 47d638cf02
16 files changed
+236 -287

No files matched your search

@@ -375,7 +375,12 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
healthScore: { score: 82, band: "green", delta: 2 },
scoreRings: [
{ id: "READINESS", score: 71, band: "green" },
{ id: "MED_COMPLIANCE", score: 95, band: "green" },
{
id: "MED_COMPLIANCE",
score: 33,
band: "yellow",
doses: { taken: 1, scheduled: 3 },
},
],
}),
);
@@ -389,9 +394,14 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
const rings = html.match(/data-slot="score-ring"/g) ?? [];
expect(rings).toHaveLength(3);
// Labels (de locale): the derived ring reuses its wellness-strip
// title, the adherence ring the medications adherence label.
// title; the dose ring names today's tally and shows it as
// taken/scheduled over the constant green arc — never the band
// gradient (a pending morning dose is not an alert state).
expect(html).toContain("Bereitschaft");
expect(html).toContain("Therapietreue");
expect(html).toContain("Dosen heute");
expect(html).toContain(">1/3<");
expect(html).toContain("var(--ring-green-from)");
expect(html).not.toContain("var(--ring-yellow-from)");
});
it("no dose text row renders anymore — the ring row carries the slot", () => {
@@ -99,10 +99,12 @@ const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
/**
* Per-ring hue for the selected score rings — the wellness-strip
* vocabulary. MED_COMPLIANCE deliberately carries NO hue: the adherence
* ring falls back to the ring's band gradient (green / yellow / red
* semantic tokens), because for adherence the band IS the message —
* exactly the classification colouring the targets tile uses.
* vocabulary, so every hero ring paints EXACTLY the hue its insights
* sibling paints. MED_COMPLIANCE carries no per-metric hue entry: the
* dose ring pins the ring system's green arc (`band="green"`, constant)
* — the same green family the medication compliance surfaces in
* Insights paint for a taken dose — instead of a band gradient that
* would flash yellow/red over pending morning doses.
*/
const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
READINESS: "readiness",
@@ -110,12 +112,13 @@ const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
SLEEP_SCORE: "sleep",
};
/** Per-ring label key — reuses the existing score / adherence labels. */
/** Per-ring label key — reuses the existing score labels; the dose ring
* names today's tally. */
const RING_LABEL_KEY: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "medications.compliance",
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
export function DashboardHero({
@@ -295,14 +298,33 @@ export function DashboardHero({
data-ring={ring.id}
className="flex shrink-0 items-center"
>
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
{/* Dose ring — today's tally ("1/3") over the constant
green arc; the arc still sweeps on the 0..100
progress `score`. 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"
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
+21 -7
View File
@@ -103,6 +103,16 @@ export interface ScoreRingProps {
* + tests don't break.
*/
variant?: "band" | "onGradient";
/**
* v1.27.x — optional centre-text override for rings whose number is a
* tally rather than a score (the hero dose ring's "1/3"). The arc still
* sweeps on `score` (the 0..100 progress); only the displayed text
* changes, and the count-up is skipped. Pair with `ariaLabel` — the
* default announcement reads score + band, which is wrong for a tally.
*/
valueText?: string;
/** Optional aria-label override (see `valueText`). */
ariaLabel?: string;
className?: string;
}
@@ -116,6 +126,8 @@ export function ScoreRing({
delayMs = 0,
baseline,
flat = false,
valueText,
ariaLabel: ariaLabelOverride,
className,
}: ScoreRingProps) {
const { t } = useTranslations();
@@ -162,12 +174,14 @@ export function ScoreRing({
});
const displayedRounded = Math.round(displayed);
const ariaLabel = hasScore
? t("insights.derived.scoreRing.aria", {
score: Math.round(clamped),
band: t(`insights.derived.scoreRing.band.${resolvedBand}`),
})
: t("insights.derived.scoreRing.ariaProvisional");
const ariaLabel =
ariaLabelOverride ??
(hasScore
? t("insights.derived.scoreRing.aria", {
score: Math.round(clamped),
band: t(`insights.derived.scoreRing.band.${resolvedBand}`),
})
: t("insights.derived.scoreRing.ariaProvisional"));
return (
<div
@@ -259,7 +273,7 @@ export function ScoreRing({
hasScore ? "text-foreground" : "text-muted-foreground",
)}
>
{hasScore ? displayedRounded : "—"}
{hasScore ? (valueText ?? displayedRounded) : "—"}
</span>
{label ? (
<span className={cn("text-muted-foreground", dims.labelClass)}>
@@ -158,15 +158,15 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
/**
* v1.27.7 — option labels for the hero score-ring picker. The three
* derived rings reuse their wellness-strip titles; the adherence ring
* reuses the existing "adherence (7 days)" label so the picker names
* the exact window the ring shows.
* derived rings reuse their wellness-strip titles; the dose ring names
* today's tally (the exact thing the ring shows since it moved off the
* 7-day adherence percentage).
*/
const SCORE_RING_LABEL_KEYS: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "dashboard.compliance7d",
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
export function DashboardLayoutSection({ id }: { id: string }) {