mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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" />
|
||||
|
||||
Reference in new issue
Block a user