From abe688caba5b2089bb1c782dc3117eeef6a5f978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 01:18:00 +0200 Subject: [PATCH 1/3] feat(dashboard-hero): health-score-first default + reorderable hero rings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/dashboard/widgets/route.ts | 18 +- .../dashboard/hero/dashboard-hero.tsx | 172 ++++++++++-------- .../settings/dashboard-layout-section.tsx | 144 ++++++++++----- src/lib/dashboard-layout.ts | 122 ++++++++++++- 4 files changed, 338 insertions(+), 118 deletions(-) diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 989dd17f4..928718b86 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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 { @@ -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({ diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 99a34c416..d61d183e7 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -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 - // (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 ? ( - - ) : ( - - ), - })), - { - 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: ( - - ), - }, - ]; + // One slide per ring, keyed by its `HeroRingId`. Each score ring links + // to its natural existing detail surface; a real (in the + // carousel) keeps tap-vs-drag native on mobile. + const scoreRingSlides = new Map( + 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 ? ( + + ) : ( + + ), + }, + ]), + ); + + 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: ( + + ), + }; + + // 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 (
= { 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 = { + [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("/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} > - {selected.map((ringId, index) => ( + {heroOrder.map((ringId, index) => ( ))} @@ -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({ 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} diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 9a52c4f87..721d73399 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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(available); + const seen = new Set(); + 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, + ), }; } From abb2db37ee085810316de6a2fec70dd8f9dfe2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 01:23:58 +0200 Subject: [PATCH 2/3] 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. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/api/dashboard/widgets/route.ts | 8 +- .../hero/__tests__/dashboard-hero.test.tsx | 91 +++++++++++++- .../dashboard/hero/dashboard-hero.tsx | 4 +- .../dashboard-layout-section.test.tsx | 102 ++++++++++++++- .../settings/dashboard-layout-section.tsx | 3 +- src/lib/__tests__/dashboard-layout.test.ts | 118 ++++++++++++++++++ src/lib/dashboard-layout.ts | 5 +- 13 files changed, 317 insertions(+), 26 deletions(-) diff --git a/messages/de.json b/messages/de.json index a74c8bc27..b2dde2823 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3d7bab271..c2be7f586 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 71049856b..1976cb245 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 8885bea78..5197be627 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 927820b10..f5793f68e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 5ba4a0cca..0ab99dbfc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 928718b86..07bdbd727 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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 { @@ -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 || diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 2483828d7..1ac781d05 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -386,10 +386,13 @@ describe(" — 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(" — ring links (v1.27.24)", () => { }); }); +describe(" — 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( + /]*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(" — greeting", () => { it("personalises from the snapshot username with the server greeting hour", () => { const html = render( diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index d61d183e7..972767541 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -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); diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 0b473bd45..d6ed649af 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -455,8 +455,10 @@ describe(" — hero score-ring picker (v1.27.7)", () => queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; const html = render(); 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( /]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/, @@ -465,23 +467,25 @@ describe(" — 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(); 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(); 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(" — 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(" — 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(); + // A sortable ring row exists for the health-score anchor. + expect(html).toContain('data-slot="score-ring-row"'); + const anchorRow = html.match( + /]*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(); + const anchorSwitch = html.match( + /]*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(); + // 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(); + 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/); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 2167f029d..b4f165414 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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 }); } diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 69a99582e..1fd6f6275 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -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"); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 721d73399..8aa782eeb 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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. */ From 99f3824794bfa0379ede3c6bccb7ff1ba5406666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 09:40:58 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.27.27=20=E2=80=94=20re?= =?UTF-8?q?orderable=20hero=20rings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cffed01..8bba30590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.27.27] — 2026-07-08 — Reorderable hero rings + +### Changed + +- The dashboard hero rings now lead with the health score by default, and the order is yours to set: drag (or use the up/down arrows) to reorder the rings in Settings → Appearance, and the order persists across the mobile carousel and the desktop row. The health score is pinned as the anchor ring — it can be repositioned but not removed. + ## [1.27.26] — 2026-07-08 — Hero and wellbeing card polish ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e4d9eec19..dbbe767a7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.26 + version: 1.27.27 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 6a81980f1..d3cc633b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.26", + "version": "1.27.27", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index a575117c9..2a10e0367 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.26"; + /* @sw-version-fallback */ "v1.27.27"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`