feat(dashboard-hero): health-score-first default + reorderable hero rings

Add a persisted hero ring ORDER over the health-score anchor ring plus the
selected score rings. Defaults to the health-score ring on the leading edge;
the user can reorder the full set (health score included) from Settings →
Darstellung with the existing drag-handle + arrow-button pair.

The order rides the dashboard-layout blob (dashboardWidgetsJson) — no
migration. resolveHeroRingOrder reconciles a stored order against the current
ring set on every read/serialize, so a deselected ring drops and a
newly-selected ring appends; the hero orders only the rings it actually
renders.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 01:18:00 +02:00
1 parent b00a2206c5
commit abe688caba
4 files changed
+338 -118

No files matched your search

+17 -1
View File
@@ -24,9 +24,12 @@ import {
CHART_OVERLAY_KEYS,
SCORE_RING_IDS,
MAX_SELECTED_SCORE_RINGS,
HERO_RING_IDS,
MAX_HERO_RING_ORDER,
type ChartOverlayPrefsMap,
type DashboardLayout,
type ScoreRingId,
type HeroRingId,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -131,6 +134,12 @@ const layoutSchema = z.object({
.array(z.enum(SCORE_RING_IDS))
.max(MAX_SELECTED_SCORE_RINGS)
.optional(),
// v1.27.27 — hero ring display order (health-score ring + selected score
// rings). Optional with the same preserve-when-absent contract; the
// 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(),
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
@@ -281,10 +290,13 @@ 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;
if (
mergedChartOverlayPrefs === undefined ||
mergedHeroVisible === undefined ||
mergedScoreRings === undefined
mergedScoreRings === undefined ||
mergedHeroRingOrder === undefined
) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
@@ -302,12 +314,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (mergedScoreRings === undefined) {
mergedScoreRings = existingLayout.selectedScoreRings;
}
if (mergedHeroRingOrder === undefined) {
mergedHeroRingOrder = existingLayout.heroRingOrder;
}
}
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
selectedScoreRings: mergedScoreRings,
heroRingOrder: mergedHeroRingOrder,
} as DashboardLayout);
await prisma.user.update({
@@ -63,7 +63,12 @@ import {
type DashboardVerdictVariant,
} from "@/lib/dashboard/verdict";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import type { ScoreRingId } from "@/lib/dashboard-layout";
import {
HEALTH_SCORE_RING_ID,
resolveHeroRingOrder,
type ScoreRingId,
type HeroRingId,
} from "@/lib/dashboard-layout";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
@@ -269,78 +274,99 @@ 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,
// Each ring links to its natural existing detail surface; a real
// <Link> (in the carousel) keeps tap-vs-drag native on mobile.
href: RING_HREF[ring.id],
linkLabel: t("dashboard.hero.ringLink", {
metric: t(RING_LABEL_KEY[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",
// The health-score ring opens the Insights overview — the same
// destination the health-score card links (why the hero verdict
// dropped its redundant "open Insights" button).
href: "/insights",
linkLabel: t("dashboard.hero.ringLink", {
metric: t("dashboard.hero.scoreLabel"),
}),
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")
}
/>
),
},
];
// One slide per ring, keyed by its `HeroRingId`. Each score ring links
// to its natural existing detail surface; a real <Link> (in the
// carousel) keeps tap-vs-drag native on mobile.
const scoreRingSlides = new Map<HeroRingId, HeroRingSlide>(
scoreRings.map((ring) => [
ring.id,
{
key: ring.id,
ringId: ring.id,
href: RING_HREF[ring.id],
linkLabel: t("dashboard.hero.ringLink", {
metric: t(RING_LABEL_KEY[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])}
/>
),
},
]),
);
const healthScoreSlide: HeroRingSlide = {
key: "health-score",
// The health-score ring opens the Insights overview — the same
// destination the health-score card links (why the hero verdict
// dropped its redundant "open Insights" button).
href: "/insights",
linkLabel: t("dashboard.hero.ringLink", {
metric: t("dashboard.hero.scoreLabel"),
}),
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")
}
/>
),
};
// v1.27.27 — the hero ring sequence honours the persisted order (health
// score + selected rings). The reconciler defaults to health-score-first
// when the user hasn't customised, drops any ordered id whose ring isn't
// rendered (no data / disabled module), and appends a rendered ring the
// order omits — so the order can never hide or duplicate a slide. Feeds
// the carousel on mobile and the inline row on desktop from a SINGLE
// render of each ring node.
const heroRingOrder = resolveHeroRingOrder(
snapshot.layout?.heroRingOrder,
scoreRings.map((ring) => ring.id),
);
const ringSlides: HeroRingSlide[] = heroRingOrder
.map((id) =>
id === HEALTH_SCORE_RING_ID
? healthScoreSlide
: scoreRingSlides.get(id),
)
.filter((slide): slide is HeroRingSlide => slide !== undefined);
return (
<section
@@ -37,6 +37,7 @@ import {
type DashboardWidgetId,
type ComparisonBaseline,
type ScoreRingId,
type HeroRingId,
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
@@ -44,6 +45,8 @@ import {
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
HEALTH_SCORE_RING_ID,
resolveHeroRingOrder,
} from "@/lib/dashboard-layout";
import {
Select,
@@ -169,6 +172,16 @@ const SCORE_RING_LABEL_KEYS: Record<ScoreRingId, string> = {
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
/**
* v1.27.27 — label key for any hero ring id, including the always-present
* health-score anchor ring. Reuses the hero's own "Health Score" label so
* the picker row and the hero ring name match verbatim.
*/
const HERO_RING_LABEL_KEYS: Record<HeroRingId, string> = {
[HEALTH_SCORE_RING_ID]: "dashboard.hero.scoreLabel",
...SCORE_RING_LABEL_KEYS,
};
export function DashboardLayoutSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -312,11 +325,15 @@ export function DashboardLayoutSection({ id }: { id: string }) {
* settle so the hero reflects the choice on the next visit.
*/
const ringMutation = useMutation({
mutationFn: async (next: ScoreRingId[]) => {
mutationFn: async (next: {
selectedScoreRings: ScoreRingId[];
heroRingOrder: HeroRingId[];
}) => {
if (!remote) throw new Error("layout not loaded");
return apiPut<DashboardLayout>("/api/dashboard/widgets", {
...remote,
selectedScoreRings: next,
selectedScoreRings: next.selectedScoreRings,
heroRingOrder: next.heroRingOrder,
});
},
onMutate: async (next) => {
@@ -329,7 +346,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
if (previous) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), {
...previous,
selectedScoreRings: next,
selectedScoreRings: next.selectedScoreRings,
heroRingOrder: next.heroRingOrder,
});
}
return { previous };
@@ -351,60 +369,80 @@ export function DashboardLayoutSection({ id }: { id: string }) {
},
});
/** Apply a new ring selection/order instantly (see `ringMutation`). */
function applyScoreRings(next: ScoreRingId[]) {
/**
* v1.27.27 — apply a new hero ring ORDER instantly (see `ringMutation`).
* The order is the single source of truth: `selectedScoreRings` is
* derived from it (order minus the always-present health-score ring) so
* the server resolver and the hero read one consistent sequence.
*/
function applyHeroRingOrder(next: HeroRingId[]) {
if (!remote) return;
const selectedScoreRings = next.filter(
(id): id is ScoreRingId => id !== HEALTH_SCORE_RING_ID,
);
// 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: next });
ringMutation.mutate(next);
if (draft)
setDraft({ ...draft, selectedScoreRings, heroRingOrder: next });
ringMutation.mutate({ selectedScoreRings, heroRingOrder: next });
}
/** The reconciled hero ring order (health-score anchor + selected rings). */
function currentHeroOrder(l: DashboardLayout): HeroRingId[] {
return resolveHeroRingOrder(l.heroRingOrder, l.selectedScoreRings ?? []);
}
/**
* 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). v1.27.8 — applies instantly via `applyScoreRings`.
* v1.27.7 — toggle one hero score ring on/off. Enabling appends to the
* order (capped at `MAX_SELECTED_SCORE_RINGS`; the remaining switches
* disable at the cap); disabling drops it from the order. The
* health-score ring has no toggle — it is the always-present anchor.
* v1.27.8 / v1.27.27 — applies instantly via `applyHeroRingOrder`.
*/
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);
applyScoreRings(next);
const order = currentHeroOrder(layout);
if (enabled) {
if (order.includes(ringId)) return;
const selectedCount = order.filter(
(id) => id !== HEALTH_SCORE_RING_ID,
).length;
if (selectedCount >= MAX_SELECTED_SCORE_RINGS) return;
applyHeroRingOrder([...order, ringId]);
} else {
applyHeroRingOrder(order.filter((id) => id !== ringId));
}
}
/**
* v1.27.8 — reorder the selected rings (the array order IS the render
* order on the hero; the server preserves it). Same interaction pair
* as the widget rows below: drag handle + arrow buttons.
* v1.27.8 / v1.27.27 — reorder the hero rings (the array order IS the
* render order on the hero; the health-score anchor moves like any other
* ring). Same interaction pair as the widget rows below: drag handle +
* arrow buttons.
*/
function moveScoreRing(ringId: ScoreRingId, delta: -1 | 1) {
function moveHeroRing(ringId: HeroRingId, delta: -1 | 1) {
if (!layout) return;
const current = layout.selectedScoreRings ?? [];
const idx = current.indexOf(ringId);
const order = currentHeroOrder(layout);
const idx = order.indexOf(ringId);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= current.length) return;
const next = [...current];
if (idx < 0 || target < 0 || target >= order.length) return;
const next = [...order];
[next[idx], next[target]] = [next[target], next[idx]];
applyScoreRings(next);
applyHeroRingOrder(next);
}
function handleRingDragEnd(event: DragEndEvent) {
if (!layout) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const current = layout.selectedScoreRings ?? [];
const from = current.indexOf(active.id as ScoreRingId);
const to = current.indexOf(over.id as ScoreRingId);
const order = currentHeroOrder(layout);
const from = order.indexOf(active.id as HeroRingId);
const to = order.indexOf(over.id as HeroRingId);
if (from < 0 || to < 0) return;
const next = [...current];
const next = [...order];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
applyScoreRings(next);
applyHeroRingOrder(next);
}
/**
@@ -596,8 +634,17 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const selected = (layout.selectedScoreRings ?? []).filter(
(ringId) => available.includes(ringId),
);
// v1.27.27 — the reorderable list is the health-score anchor
// ring plus the selected score rings, in the persisted order
// (health-score first by default). The health-score row has no
// on/off switch — it is always present — but drags/moves like
// any other ring.
const heroOrder = resolveHeroRingOrder(
layout.heroRingOrder,
selected,
);
const unselected = available.filter(
(ringId) => !selected.includes(ringId),
(ringId) => !heroOrder.includes(ringId),
);
const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS;
const busy = ringMutation.isPending;
@@ -609,23 +656,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
onDragEnd={handleRingDragEnd}
>
<SortableContext
items={selected}
items={heroOrder}
strategy={verticalListSortingStrategy}
>
{selected.map((ringId, index) => (
{heroOrder.map((ringId, index) => (
<SortableRingRow
key={ringId}
ringId={ringId}
label={t(SCORE_RING_LABEL_KEYS[ringId])}
label={t(HERO_RING_LABEL_KEYS[ringId])}
index={index}
total={selected.length}
total={heroOrder.length}
dragHintId={dragHintId}
disabled={busy}
// The health-score ring is the anchor — no toggle.
pinned={ringId === HEALTH_SCORE_RING_ID}
moveUpLabel={t("dashboard.moveUp")}
moveDownLabel={t("dashboard.moveDown")}
dragHandleLabel={t("dashboard.dragHandle")}
onToggle={toggleScoreRing}
onMove={moveScoreRing}
onMove={moveHeroRing}
/>
))}
</SortableContext>
@@ -979,23 +1028,30 @@ function SortableRingRow({
total,
dragHintId,
disabled,
pinned = false,
moveUpLabel,
moveDownLabel,
dragHandleLabel,
onToggle,
onMove,
}: {
ringId: ScoreRingId;
ringId: HeroRingId;
label: string;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
/**
* v1.27.27 — the health-score anchor ring: always present, no on/off
* switch (a disabled, checked switch communicates the locked-on state),
* but reorderable like any other ring.
*/
pinned?: boolean;
moveUpLabel: string;
moveDownLabel: string;
dragHandleLabel: string;
onToggle: (id: ScoreRingId, value: boolean) => void;
onMove: (id: ScoreRingId, delta: -1 | 1) => void;
onMove: (id: HeroRingId, delta: -1 | 1) => void;
}) {
const {
attributes,
@@ -1040,8 +1096,12 @@ function SortableRingRow({
</span>
<Switch
checked
onCheckedChange={(v) => onToggle(ringId, v)}
disabled={disabled}
onCheckedChange={
pinned ? undefined : (v) => onToggle(ringId as ScoreRingId, v)
}
// The health-score anchor cannot be switched off — a disabled,
// checked switch reads as "always on, locked".
disabled={disabled || pinned}
aria-label={label}
data-slot="score-ring-switch"
data-ring={ringId}
+120 -2
View File
@@ -364,6 +364,90 @@ export const MAX_SELECTED_SCORE_RINGS = 3;
export const DEFAULT_SELECTED_SCORE_RINGS: ScoreRingId[] = ["MED_COMPLIANCE"];
/**
* v1.27.27 — hero ring ORDER. The health-score ring is a first-class,
* always-present member of the hero ring row that the user can position
* anywhere in the sequence (it has no on/off toggle — it is the anchor
* ring). `HERO_RING_IDS` is the health-score id plus the selectable score
* 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 type HeroRingId = (typeof HERO_RING_IDS)[number];
/** The order carries the health-score ring plus up to three score rings. */
export const MAX_HERO_RING_ORDER = MAX_SELECTED_SCORE_RINGS + 1;
/**
* Default hero ring order: the health-score ring leads, then the default
* selected score rings in their default order. The maintainer's contract —
* "always start with the health score by default, but let the user
* reorder" — lives here: a user who never opens the picker gets the
* health-score ring on the leading edge.
*/
export const DEFAULT_HERO_RING_ORDER: HeroRingId[] = [
HEALTH_SCORE_RING_ID,
...DEFAULT_SELECTED_SCORE_RINGS,
];
function isHeroRingId(value: unknown): value is HeroRingId {
return (
typeof value === "string" &&
(HERO_RING_IDS as readonly string[]).includes(value)
);
}
/**
* Reconcile the persisted hero ring order against the CURRENT available
* ring set (the always-present health-score ring plus the passed selected
* score rings). The order is the single source of truth for how the hero
* sequences its rings:
*
* - a missing / malformed order → the default: health score first, then
* the selected rings in selection order (the maintainer's contract);
* - a stored order is filtered to the available set — deselected /
* unknown ids drop, duplicates collapse;
* - any available ring the stored order didn't place (a newly-selected
* ring, or the health-score ring a corrupt order somehow omits) is
* appended so a ring never silently vanishes.
*
* Passing the RESOLVED score rings (what actually renders) lets the hero
* order only the rings it draws; passing the SELECTED score rings lets the
* Settings picker and the layout resolver share the identical reconciler.
*/
export function resolveHeroRingOrder(
rawOrder: unknown,
selected: ScoreRingId[],
): HeroRingId[] {
const available: HeroRingId[] = [HEALTH_SCORE_RING_ID, ...selected];
if (!Array.isArray(rawOrder)) {
// Default — health score leads, selected rings follow in order.
return available;
}
const availableSet = new Set<HeroRingId>(available);
const seen = new Set<HeroRingId>();
const out: HeroRingId[] = [];
for (const entry of rawOrder) {
if (!isHeroRingId(entry)) continue;
if (!availableSet.has(entry)) continue;
if (seen.has(entry)) continue;
out.push(entry);
seen.add(entry);
}
// Append available rings the stored order didn't place (new ring, or a
// health-score ring a corrupt order lacks), preserving `available` order.
for (const id of available) {
if (!seen.has(id)) {
out.push(id);
seen.add(id);
}
}
return out;
}
/**
* Ring id → owning toggleable module. Mirrors the derived routes'
* `DERIVED_MODULE` map for the three derived rings (READINESS rides the
@@ -428,6 +512,14 @@ export interface DashboardLayout {
* `SCORE_RING_IDS`; the resolver clamps/dedupes/drops-unknown.
*/
selectedScoreRings?: ScoreRingId[];
/**
* v1.27.27 — hero ring display ORDER over the health-score ring plus the
* selected score rings. The single source of truth for the hero ring
* sequence; defaults to health-score first (see `resolveHeroRingOrder` +
* `DEFAULT_HERO_RING_ORDER`). The resolver reconciles it against the
* selected set on every read.
*/
heroRingOrder?: HeroRingId[];
}
const DASHBOARD_LAYOUT_VERSION = 1;
@@ -445,6 +537,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
// One medication-adherence ring next to the health score by default —
// the successor of the hero dose row's information role.
selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS],
// v1.27.27 — the health-score ring leads the row by default; the user
// can reorder (see `resolveHeroRingOrder`).
heroRingOrder: [...DEFAULT_HERO_RING_ORDER],
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: true, tileVisible: true, order: 1 },
@@ -568,6 +663,12 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
tileVisible: typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
}));
// Resolve the selected score rings once — the hero-ring-order reconciler
// below keys off the same resolved set the layout persists.
const resolvedSelectedScoreRings = coerceSelectedScoreRings(
candidate.selectedScoreRings,
);
return {
version: DASHBOARD_LAYOUT_VERSION,
widgets: [...normalized, ...appended].sort((a, b) => a.order - b.order),
@@ -589,13 +690,23 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
// v1.27.7 — hero score rings: unknown ids drop, duplicates collapse,
// the list clamps to three. A legacy blob without the field gets the
// default single MED_COMPLIANCE ring.
selectedScoreRings: coerceSelectedScoreRings(candidate.selectedScoreRings),
selectedScoreRings: resolvedSelectedScoreRings,
// v1.27.27 — hero ring order, reconciled against the resolved selected
// set. A legacy blob without the field gets the default (health-score
// first, then the selected rings in order).
heroRingOrder: resolveHeroRingOrder(
candidate.heroRingOrder,
resolvedSelectedScoreRings,
),
};
}
export function serializeDashboardLayout(
layout: DashboardLayout,
): DashboardLayout {
const resolvedSelectedScoreRings = coerceSelectedScoreRings(
layout.selectedScoreRings,
);
return {
version: DASHBOARD_LAYOUT_VERSION,
widgets: layout.widgets
@@ -622,6 +733,13 @@ export function serializeDashboardLayout(
heroVisible: layout.heroVisible === true,
// v1.27.7 — persist the score-ring selection through the same
// coercion the resolver runs so the wire shape is stable.
selectedScoreRings: coerceSelectedScoreRings(layout.selectedScoreRings),
selectedScoreRings: resolvedSelectedScoreRings,
// v1.27.27 — persist the hero ring order, reconciled against the
// resolved selected set (same reconciler as the resolver) so the wire
// shape is stable and a deselected ring can't linger in the order.
heroRingOrder: resolveHeroRingOrder(
layout.heroRingOrder,
resolvedSelectedScoreRings,
),
};
}