test(dashboard-hero): cover hero-ring-order default + reorder; i18n hint

Add resolveHeroRingOrder unit coverage (default health-score-first,
reconcile against selected set, drop/append, dedupe) plus resolver/serializer
round-trip; hero SSR tests for the default lead + a persisted custom order +
dropped-when-unrendered; Settings picker tests for the pinned health-score
anchor row, its locked-on switch, drag/arrow reorder, and a persisted
non-leading order. Extend the score-rings picker description across six
locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 01:23:58 +02:00
1 parent abe688caba
commit abb2db37ee
13 files changed
+317 -26

No files matched your search

+1 -1
View File
@@ -402,7 +402,7 @@
"heroToggleLabel": "Tagesüberblick",
"heroToggleDescription": "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
"scoreRingsTitle": "Score-Ringe",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue.",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
+1 -1
View File
@@ -402,7 +402,7 @@
"heroToggleLabel": "Daily overview",
"heroToggleDescription": "Shows your greeting, daily focus and score rings at the top of the dashboard.",
"scoreRingsTitle": "Score rings",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence.",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
+1 -1
View File
@@ -402,7 +402,7 @@
"heroToggleLabel": "Resumen del día",
"heroToggleDescription": "Muestra el saludo, el foco del día y los anillos de puntuación en la parte superior del panel.",
"scoreRingsTitle": "Anillos de puntuación",
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento.",
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
"layoutReset": "Restablecer valores por defecto",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
+1 -1
View File
@@ -402,7 +402,7 @@
"heroToggleLabel": "Aperçu du jour",
"heroToggleDescription": "Affiche le message d’accueil, le focus du jour et les anneaux de score tout en haut du tableau de bord.",
"scoreRingsTitle": "Anneaux de score",
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance.",
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
"layoutReset": "Rétablir les valeurs par défaut",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
+1 -1
View File
@@ -402,7 +402,7 @@
"heroToggleLabel": "Panoramica del giorno",
"heroToggleDescription": "Mostra saluto, focus del giorno e anelli dei punteggi in cima alla dashboard.",
"scoreRingsTitle": "Anelli dei punteggi",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza.",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
"layoutReset": "Ripristina i valori predefiniti",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
+1 -1
View File
@@ -402,7 +402,7 @@
"heroToggleLabel": "Przegląd dnia",
"heroToggleDescription": "Pokazuje powitanie, fokus dnia i pierścienie wyników na górze pulpitu.",
"scoreRingsTitle": "Pierścienie wyników",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń.",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
"layoutReset": "Przywróć wartości domyślne",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
+5 -3
View File
@@ -139,7 +139,10 @@ const layoutSchema = z.object({
// resolver reconciles it against the selected set on read/serialize, so a
// stale or over-long array is clamped rather than rejected. `.max()`
// bounds the wire length (health-score + up to three score rings).
heroRingOrder: z.array(z.enum(HERO_RING_IDS)).max(MAX_HERO_RING_ORDER).optional(),
heroRingOrder: z
.array(z.enum(HERO_RING_IDS))
.max(MAX_HERO_RING_ORDER)
.optional(),
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
@@ -290,8 +293,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
let mergedScoreRings: ScoreRingId[] | undefined =
parsed.data.selectedScoreRings;
let mergedHeroRingOrder: HeroRingId[] | undefined =
parsed.data.heroRingOrder;
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
if (
mergedChartOverlayPrefs === undefined ||
mergedHeroVisible === undefined ||
@@ -386,10 +386,13 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
);
const row = html.match(/data-slot="dashboard-hero-rings"/g) ?? [];
expect(row).toHaveLength(1);
// Selection order preserved, health ring trailing (3 rings total).
// v1.27.27 — default order leads with the health-score ring, then the
// selected rings in selection order (3 rings total).
const healthIdx = html.indexOf('href="/insights"');
const readinessIdx = html.indexOf('data-ring="READINESS"');
const complianceIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
expect(readinessIdx).toBeGreaterThan(-1);
expect(healthIdx).toBeGreaterThan(-1);
expect(readinessIdx).toBeGreaterThan(healthIdx);
expect(complianceIdx).toBeGreaterThan(readinessIdx);
const rings = html.match(/data-slot="score-ring"/g) ?? [];
expect(rings).toHaveLength(3);
@@ -562,6 +565,90 @@ describe("<DashboardHero> — ring links (v1.27.24)", () => {
});
});
describe("<DashboardHero> — ring order (v1.27.27)", () => {
const threeRings = {
healthScore: { score: 82, band: "green" as const, delta: 2 },
scoreRings: [
{ id: "READINESS" as const, score: 71, band: "green" as const },
{ id: "SLEEP_SCORE" as const, score: 66, band: "yellow" as const },
{
id: "MED_COMPLIANCE" as const,
score: 33,
band: "yellow" as const,
doses: { taken: 1, scheduled: 3 },
},
],
};
it("default (no persisted order) leads with the health-score ring", () => {
const html = render(baseSnapshot(threeRings));
// The health-score slide (its /insights link) precedes every score ring.
const healthIdx = html.indexOf('href="/insights"');
const readinessIdx = html.indexOf('data-ring="READINESS"');
const sleepIdx = html.indexOf('data-ring="SLEEP_SCORE"');
const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
expect(healthIdx).toBeGreaterThan(-1);
expect(readinessIdx).toBeGreaterThan(healthIdx);
expect(sleepIdx).toBeGreaterThan(readinessIdx);
expect(medIdx).toBeGreaterThan(sleepIdx);
// First carousel dot is current (slide 0 = health score).
const firstDot = html.match(
/<button[^>]*data-slot="dashboard-hero-ring-dot"[^>]*>/,
);
expect(firstDot![0]).toContain('aria-current="true"');
});
it("honours a persisted custom order (health score moved off the leading edge)", () => {
const html = render(
baseSnapshot({
...threeRings,
layout: {
version: 1,
widgets: [],
heroRingOrder: ["READINESS", "MED_COMPLIANCE", "HEALTH_SCORE"],
selectedScoreRings: ["READINESS", "SLEEP_SCORE", "MED_COMPLIANCE"],
},
}),
);
const readinessIdx = html.indexOf('data-ring="READINESS"');
const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
const healthIdx = html.indexOf('href="/insights"');
// READINESS first, MED_COMPLIANCE second, health score last.
expect(readinessIdx).toBeGreaterThan(-1);
expect(medIdx).toBeGreaterThan(readinessIdx);
expect(healthIdx).toBeGreaterThan(medIdx);
// A rendered ring the order omits (SLEEP_SCORE) is still drawn (appended).
expect(html).toContain('data-ring="SLEEP_SCORE"');
// Every slide still renders exactly once.
expect(html.match(/data-carousel-slide=""/g)).toHaveLength(4);
});
it("drops an ordered id whose ring isn't rendered (no data / disabled)", () => {
// The persisted order names RECOVERY_SCORE, but the snapshot resolved
// no such ring — it must not leave a gap or a phantom slide.
const html = render(
baseSnapshot({
healthScore: { score: 60, band: "yellow", delta: 0 },
scoreRings: [{ id: "READINESS", score: 71, band: "green" }],
layout: {
version: 1,
widgets: [],
heroRingOrder: ["RECOVERY_SCORE", "HEALTH_SCORE", "READINESS"],
selectedScoreRings: ["READINESS"],
},
}),
);
// Only the two rendered rings appear (health + readiness), in the
// surviving order: health score then readiness.
expect(html.match(/data-carousel-slide=""/g)).toHaveLength(2);
expect(html).not.toContain('data-ring="RECOVERY_SCORE"');
const healthIdx = html.indexOf('href="/insights"');
const readinessIdx = html.indexOf('data-ring="READINESS"');
expect(healthIdx).toBeGreaterThan(-1);
expect(readinessIdx).toBeGreaterThan(healthIdx);
});
});
describe("<DashboardHero> — greeting", () => {
it("personalises from the snapshot username with the server greeting hour", () => {
const html = render(
@@ -362,9 +362,7 @@ export function DashboardHero({
);
const ringSlides: HeroRingSlide[] = heroRingOrder
.map((id) =>
id === HEALTH_SCORE_RING_ID
? healthScoreSlide
: scoreRingSlides.get(id),
id === HEALTH_SCORE_RING_ID ? healthScoreSlide : scoreRingSlides.get(id),
)
.filter((slide): slide is HeroRingSlide => slide !== undefined);
@@ -455,8 +455,10 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="score-rings-picker"');
// v1.27.27 — one switch per score ring (4) plus the always-present
// health-score anchor row = 5.
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
expect(switches).toHaveLength(4);
expect(switches).toHaveLength(5);
// The default selection (MED_COMPLIANCE) reads checked.
const compliance = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
@@ -465,23 +467,25 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
expect(compliance![0]).toContain('data-state="checked"');
});
it("hides rings whose owning module is disabled", () => {
it("hides rings whose owning module is disabled (anchor stays)", () => {
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);
// gone; the adherence ring plus the health-score anchor remain = 2.
expect(switches).toHaveLength(2);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
expect(html).toContain('data-ring="HEALTH_SCORE"');
});
it("hides the derived rings when the insights module is off", () => {
it("hides the derived rings when the insights module is off (anchor stays)", () => {
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);
// Adherence ring + health-score anchor.
expect(switches).toHaveLength(2);
expect(html).toContain('data-ring="MED_COMPLIANCE"');
});
@@ -507,3 +511,89 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
expect(readiness![0]).not.toContain('disabled=""');
});
});
/**
* v1.27.27 — the health-score ring is a first-class, always-present,
* reorderable member of the hero ring row. The picker renders it as a
* pinned row (no on/off toggle — a disabled, checked switch) that leads by
* default and reorders with the same drag-handle + arrow-button pair as the
* score rings and the widget list.
*/
describe("<DashboardLayoutSection> — health-score anchor + reorder (v1.27.27)", () => {
it("renders the health-score anchor as a reorderable row, leading by default", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// A sortable ring row exists for the health-score anchor.
expect(html).toContain('data-slot="score-ring-row"');
const anchorRow = html.match(
/<div[^>]*data-slot="score-ring-row"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
);
expect(anchorRow).not.toBeNull();
// Default order leads with the anchor: its row precedes the adherence
// ring's row.
const anchorIdx = html.indexOf('data-ring="HEALTH_SCORE"');
const medIdx = html.indexOf(
'data-slot="score-ring-row" data-ring="MED_COMPLIANCE"',
);
expect(anchorIdx).toBeGreaterThan(-1);
// The anchor row appears before any selected score-ring row.
expect(anchorIdx).toBeLessThan(html.indexOf('data-ring="MED_COMPLIANCE"'));
expect(medIdx === -1 || anchorIdx < medIdx).toBe(true);
});
it("the anchor switch is checked and disabled (locked-on, no toggle-off)", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const anchorSwitch = html.match(
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
);
expect(anchorSwitch).not.toBeNull();
expect(anchorSwitch![0]).toContain('data-state="checked"');
expect(anchorSwitch![0]).toContain('disabled=""');
});
it("the anchor carries a drag handle + move arrows (reorderable)", () => {
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The health-score row is a full sortable row: its drag handle exists.
const handles = html.match(/data-slot="score-ring-drag-handle"/g) ?? [];
// One handle per hero-order row = anchor + default MED_COMPLIANCE = 2.
expect(handles.length).toBeGreaterThanOrEqual(2);
});
it("honours a persisted order that moves the anchor off the lead", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: true,
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
heroRingOrder: ["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const readinessRow = html.indexOf(
'data-slot="score-ring-row" data-ring="READINESS"',
);
const anchorRow = html.indexOf(
'data-slot="score-ring-row" data-ring="HEALTH_SCORE"',
);
const medRow = html.indexOf(
'data-slot="score-ring-row" data-ring="MED_COMPLIANCE"',
);
expect(readinessRow).toBeGreaterThan(-1);
expect(anchorRow).toBeGreaterThan(readinessRow);
expect(medRow).toBeGreaterThan(anchorRow);
});
it("the ring mutation PUTs both selectedScoreRings and heroRingOrder (source pin)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
expect(src).toContain("selectedScoreRings: next.selectedScoreRings");
expect(src).toContain("heroRingOrder: next.heroRingOrder");
// The order is the single source of truth: selection is derived from it.
expect(src).toMatch(/id !== HEALTH_SCORE_RING_ID/);
});
});
@@ -382,8 +382,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
);
// Keep an open draft's ring slice in sync so a later Save of the
// OTHER layout edits can't revert the instantly-applied choice.
if (draft)
setDraft({ ...draft, selectedScoreRings, heroRingOrder: next });
if (draft) setDraft({ ...draft, selectedScoreRings, heroRingOrder: next });
ringMutation.mutate({ selectedScoreRings, heroRingOrder: next });
}
+118
View File
@@ -7,6 +7,8 @@ import {
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_CATALOGUE_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
DEFAULT_HERO_RING_ORDER,
resolveHeroRingOrder,
type DashboardLayout,
} from "@/lib/dashboard-layout";
@@ -746,3 +748,119 @@ describe("resolveDashboardLayout() — selectedScoreRings", () => {
]);
});
});
/**
* v1.27.27 — hero ring ORDER (`heroRingOrder`). The health-score ring is a
* first-class, always-present, reorderable member of the hero row that
* leads by default. The order rides the layout blob (no migration) and the
* resolver reconciles it against the selected set on every read/serialize.
*/
describe("resolveHeroRingOrder()", () => {
it("defaults a missing / malformed order to health-score-first", () => {
for (const bad of [undefined, null, "READINESS", 3, { a: 1 }]) {
expect(
resolveHeroRingOrder(bad, ["READINESS", "MED_COMPLIANCE"]),
).toEqual(["HEALTH_SCORE", "READINESS", "MED_COMPLIANCE"]);
}
});
it("honours a stored order that moves the health-score ring off the lead", () => {
expect(
resolveHeroRingOrder(
["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
["READINESS", "MED_COMPLIANCE"],
),
).toEqual(["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"]);
});
it("drops an ordered id whose ring is no longer selected", () => {
expect(
resolveHeroRingOrder(
["READINESS", "HEALTH_SCORE", "SLEEP_SCORE"],
["READINESS"], // SLEEP_SCORE deselected
),
).toEqual(["READINESS", "HEALTH_SCORE"]);
});
it("appends a newly-selected ring the stored order didn't place", () => {
expect(
resolveHeroRingOrder(
["HEALTH_SCORE", "READINESS"],
["READINESS", "MED_COMPLIANCE"], // MED_COMPLIANCE freshly added
),
).toEqual(["HEALTH_SCORE", "READINESS", "MED_COMPLIANCE"]);
});
it("drops unknown ids and collapses duplicates, always keeping the health-score ring", () => {
expect(
resolveHeroRingOrder(
["READINESS", "BOGUS", "READINESS", "HEALTH_SCORE"],
["READINESS"],
),
).toEqual(["READINESS", "HEALTH_SCORE"]);
});
it("appends the health-score ring when a corrupt order omits it entirely", () => {
expect(resolveHeroRingOrder(["READINESS"], ["READINESS"])).toEqual([
"READINESS",
"HEALTH_SCORE",
]);
});
});
describe("resolveDashboardLayout() / serialize — heroRingOrder", () => {
const base = {
version: 1,
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
};
it("a legacy blob (field missing) resolves to health-score-first over the selected rings", () => {
const resolved = resolveDashboardLayout({
...base,
selectedScoreRings: ["READINESS", "SLEEP_SCORE"],
});
expect(resolved.heroRingOrder).toEqual([
"HEALTH_SCORE",
"READINESS",
"SLEEP_SCORE",
]);
});
it("reconciles a stored order against the selected set on read", () => {
const resolved = resolveDashboardLayout({
...base,
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
heroRingOrder: ["MED_COMPLIANCE", "HEALTH_SCORE", "READINESS"],
});
expect(resolved.heroRingOrder).toEqual([
"MED_COMPLIANCE",
"HEALTH_SCORE",
"READINESS",
]);
});
it("serialize persists a reconciled order and round-trips", () => {
const serialized = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
heroRingOrder: ["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
} as DashboardLayout);
expect(serialized.heroRingOrder).toEqual([
"READINESS",
"HEALTH_SCORE",
"MED_COMPLIANCE",
]);
expect(resolveDashboardLayout(serialized).heroRingOrder).toEqual([
"READINESS",
"HEALTH_SCORE",
"MED_COMPLIANCE",
]);
});
it("the default layout leads with the health-score ring", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.heroRingOrder).toEqual(
DEFAULT_HERO_RING_ORDER,
);
expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE");
});
});
+1 -4
View File
@@ -372,10 +372,7 @@ export const DEFAULT_SELECTED_SCORE_RINGS: ScoreRingId[] = ["MED_COMPLIANCE"];
* rings; `HeroRingId` is the union the persisted order carries.
*/
export const HEALTH_SCORE_RING_ID = "HEALTH_SCORE" as const;
export const HERO_RING_IDS = [
HEALTH_SCORE_RING_ID,
...SCORE_RING_IDS,
] as const;
export const HERO_RING_IDS = [HEALTH_SCORE_RING_ID, ...SCORE_RING_IDS] as const;
export type HeroRingId = (typeof HERO_RING_IDS)[number];
/** The order carries the health-score ring plus up to three score rings. */