feat(dashboard): replace the hero dose row with the score-ring row

The hero's dose text row gives way to a ring row: the selected score
rings render at the health-score ring's sm flat geometry, readiness /
recovery / sleep in their wellness-strip hues and the 7-day adherence
ring in band semantics — the ring that carries the dose row's
information role. Rings self-gate on data, the verdict line above is
untouched, and the skeleton mirrors the new geometry (plain card shell,
no dose pill, the always-present health circle reserved). Settings ->
Dashboard gains a picker under the overview toggle: up to three rings,
module-gated rows, switches disable at the cap, persisted through the
existing widgets PUT. Locale bundles drop the retired dose-row strings
and pick up the picker copy in all six languages.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 15:08:20 +02:00
1 parent 5c0cde207f
commit 8d73542fde
12 files changed
+325 -181

No files matched your search

@@ -305,7 +305,7 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
const de = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(de).toContain("Tagesüberblick");
expect(de).toContain(
"Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
"Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
);
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(en).toContain("Daily overview");
@@ -438,3 +438,72 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
expect(html).toContain(ACHIEVEMENTS_ARIA);
});
});
/**
* v1.27.7 — hero score-ring picker. Rendered only while the hero is on
* (the rings live inside the hero band); offers only rings whose owning
* module is enabled; caps the selection at three via disabled switches.
* SSR smoke assertions, matching the rest of this suite.
*/
describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () => {
it("stays hidden while the hero is off (default layout)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).not.toContain('data-slot="score-rings-picker"');
});
it("renders one switch per ring when the hero is on (fail-open module map)", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="score-rings-picker"');
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
expect(switches).toHaveLength(4);
// The default selection (MED_COMPLIANCE) reads checked.
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
);
expect(compliance).not.toBeNull();
expect(compliance![0]).toContain('data-state="checked"');
});
it("hides rings whose owning module is disabled", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
authState.modules = { recovery: false, sleep: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
// READINESS + RECOVERY_SCORE (recovery) and SLEEP_SCORE (sleep) are
// gone; only the adherence ring remains offered.
expect(switches).toHaveLength(1);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
});
it("hides the derived rings when the insights module is off", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
authState.modules = { insights: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
expect(switches).toHaveLength(1);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
});
it("disables the remaining switches at the three-ring cap", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: true,
selectedScoreRings: ["READINESS", "RECOVERY_SCORE", "SLEEP_SCORE"],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
);
expect(compliance).not.toBeNull();
// The rendered `disabled` HTML attribute — not the Tailwind
// `disabled:` variant prefixes that every switch class list carries.
expect(compliance![0]).toContain('disabled=""');
// The three selected rings stay enabled (unchecking must stay possible).
const readiness = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="READINESS"[^>]*>/,
);
expect(readiness).not.toBeNull();
expect(readiness![0]).not.toContain('disabled=""');
});
});
@@ -36,10 +36,14 @@ import {
type DashboardLayout,
type DashboardWidgetId,
type ComparisonBaseline,
type ScoreRingId,
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
} from "@/lib/dashboard-layout";
import {
Select,
@@ -152,6 +156,19 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
vorsorge: "measurementReminders.sectionTitle",
};
/**
* 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.
*/
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",
};
export function DashboardLayoutSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -283,6 +300,24 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, heroVisible: value });
}
/**
* v1.27.7 — toggle one hero score ring. Selection order is preserved
* (a newly-enabled ring appends), the count is capped at
* `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the
* cap), and the choice persists as `selectedScoreRings` on the layout
* blob through the same PUT the widget toggles use.
*/
function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) {
if (!layout) return;
const current = layout.selectedScoreRings ?? [];
const next = enabled
? current.includes(ringId)
? current
: [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS)
: current.filter((id) => id !== ringId);
setDraft({ ...layout, selectedScoreRings: next });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
@@ -439,6 +474,60 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
)}
{/* v1.27.7 — hero score-ring picker. Only rendered while the hero
is on (the rings live inside the hero band, so the picker would
be a dead control otherwise). Offers only rings whose owning
module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the
three derived rings additionally sit behind the insights
module, mirroring the derived routes. Selection caps at
MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. */}
{layout && layout.heroVisible === true && (
<div
data-slot="score-rings-picker"
className="border-border bg-background/30 space-y-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{t("dashboard.scoreRingsTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.scoreRingsDescription")}
</p>
</div>
<div className="space-y-2">
{SCORE_RING_IDS.filter((ringId) => {
if (modules?.[SCORE_RING_MODULE[ringId]] === false) return false;
if (ringId !== "MED_COMPLIANCE" && modules?.insights === false)
return false;
return true;
}).map((ringId) => {
const selected = layout.selectedScoreRings ?? [];
const checked = selected.includes(ringId);
const atCap =
!checked && selected.length >= MAX_SELECTED_SCORE_RINGS;
return (
<div
key={ringId}
className="flex min-h-9 items-center justify-between gap-3"
>
<span className="text-foreground truncate text-sm">
{t(SCORE_RING_LABEL_KEYS[ringId])}
</span>
<Switch
checked={checked}
onCheckedChange={(v) => toggleScoreRing(ringId, v)}
disabled={saveMutation.isPending || atCap}
aria-label={t(SCORE_RING_LABEL_KEYS[ringId])}
data-slot="score-ring-switch"
data-ring={ringId}
/>
</div>
);
})}
</div>
</div>
)}
{isLoading || !layout ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />