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, + ), }; }