From ed6911bb0347a02763a3b63433857593c1f3c221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 15:07:59 +0200 Subject: [PATCH] feat(dashboard): add the selectable hero score-ring preference Add selectedScoreRings to the dashboard layout blob: a closed four-id set (READINESS, RECOVERY_SCORE, SLEEP_SCORE, MED_COMPLIANCE) capped at three rings, with a client-safe ring-to-module map alongside. The resolver drops unknown ids, dedupes, and clamps; a legacy blob without the field defaults to the single MED_COMPLIANCE ring while an explicit empty array is respected. The widgets PUT validates the field against the closed enum and preserves the stored value when a client omits it, the same contract heroVisible already rides. --- src/app/api/dashboard/widgets/route.ts | 27 +++++-- src/lib/__tests__/dashboard-layout.test.ts | 89 ++++++++++++++++++++++ src/lib/dashboard-layout.ts | 87 +++++++++++++++++++++ 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 15121fc65..989dd17f4 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -22,8 +22,11 @@ import { DASHBOARD_WIDGET_CATALOGUE_IDS, COMPARISON_BASELINES, CHART_OVERLAY_KEYS, + SCORE_RING_IDS, + MAX_SELECTED_SCORE_RINGS, type ChartOverlayPrefsMap, type DashboardLayout, + type ScoreRingId, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; @@ -121,6 +124,13 @@ const layoutSchema = z.object({ // `chartOverlayPrefs` — a Settings save that doesn't know the field // must not silently reset the user's choice). heroVisible: z.boolean().optional(), + // v1.27.7 — hero score rings (max 3, closed id set). Optional with the + // same preserve-when-absent contract as `heroVisible`; the resolver + // dedupes on read/serialize, `.max()` bounds the wire length. + selectedScoreRings: z + .array(z.enum(SCORE_RING_IDS)) + .max(MAX_SELECTED_SCORE_RINGS) + .optional(), }); async function buildDashboardLayout(userId: string): Promise { @@ -262,16 +272,19 @@ export const PUT = apiHandler(async (request: NextRequest) => { // didn't send. The dashboard-layout PUT typically saves widget // visibility / order; chart prefs are PUT through their own route // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. `heroVisible` rides the - // same preserve-when-absent contract — an older client's layout - // save must not reset the hero toggle. One stored-layout read - // covers both fallbacks. + // wiped here on a subsequent layout save. `heroVisible` and + // `selectedScoreRings` ride the same preserve-when-absent contract — + // an older client's layout save must not reset either choice. One + // stored-layout read covers all fallbacks. let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible; + let mergedScoreRings: ScoreRingId[] | undefined = + parsed.data.selectedScoreRings; if ( mergedChartOverlayPrefs === undefined || - mergedHeroVisible === undefined + mergedHeroVisible === undefined || + mergedScoreRings === undefined ) { const existing = await prisma.user.findUnique({ where: { id: user.id }, @@ -286,11 +299,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (mergedHeroVisible === undefined) { mergedHeroVisible = existingLayout.heroVisible === true; } + if (mergedScoreRings === undefined) { + mergedScoreRings = existingLayout.selectedScoreRings; + } } const normalized = serializeDashboardLayout({ ...parsed.data, chartOverlayPrefs: mergedChartOverlayPrefs, heroVisible: mergedHeroVisible, + selectedScoreRings: mergedScoreRings, } as DashboardLayout); await prisma.user.update({ diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 8772cca2f..69a99582e 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -657,3 +657,92 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { } }); }); + +/** + * v1.27.7 — hero score rings (`selectedScoreRings`). Closed id set, max + * three, dedupe + unknown-drop on read, default single MED_COMPLIANCE + * ring for legacy blobs, explicit empty array respected. + */ +describe("resolveDashboardLayout() — selectedScoreRings", () => { + const base = { + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + }; + + it("defaults a legacy blob (field missing) to the single MED_COMPLIANCE ring", () => { + const resolved = resolveDashboardLayout(base); + expect(resolved.selectedScoreRings).toEqual(["MED_COMPLIANCE"]); + }); + + it("defaults a null / non-array field to MED_COMPLIANCE", () => { + for (const bad of [null, "READINESS", 3, { READINESS: true }]) { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: bad, + }); + expect(resolved.selectedScoreRings).toEqual(["MED_COMPLIANCE"]); + } + }); + + it("respects an explicitly-saved empty array (user chose no rings)", () => { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: [], + }); + expect(resolved.selectedScoreRings).toEqual([]); + }); + + it("drops unknown ids and dedupes, preserving selection order", () => { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: [ + "SLEEP_SCORE", + "STRESS_SCORE", // not a ring id — drops + "READINESS", + "SLEEP_SCORE", // duplicate — collapses + ], + }); + expect(resolved.selectedScoreRings).toEqual(["SLEEP_SCORE", "READINESS"]); + }); + + it("clamps the selection to three rings (first three win)", () => { + const resolved = resolveDashboardLayout({ + ...base, + selectedScoreRings: [ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + "MED_COMPLIANCE", + ], + }); + expect(resolved.selectedScoreRings).toEqual([ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + ]); + }); + + it("serializeDashboardLayout persists the coerced selection explicitly", () => { + const serialized = serializeDashboardLayout({ + ...DEFAULT_DASHBOARD_LAYOUT, + selectedScoreRings: [ + "MED_COMPLIANCE", + "MED_COMPLIANCE", + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + ], + } as DashboardLayout); + expect(serialized.selectedScoreRings).toEqual([ + "MED_COMPLIANCE", + "READINESS", + "RECOVERY_SCORE", + ]); + }); + + it("the default layout carries the MED_COMPLIANCE ring", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.selectedScoreRings).toEqual([ + "MED_COMPLIANCE", + ]); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 7c37f9dae..9a52c4f87 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -4,6 +4,7 @@ * Single source of truth for which widgets show on /, what order, and * what the default layout is for new users. Null / missing = default. */ +import type { ModuleKey } from "@/lib/modules/registry"; /** * Every widget the dashboard layout knows about. The order of @@ -334,6 +335,76 @@ function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap { return out; } +/** + * v1.27.7 — selectable hero score rings. The closed id set the hero's + * ring row can render next to the health score: + * + * - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` resolve through the + * derived registry's engines (module-gated like the derived routes); + * - `MED_COMPLIANCE` is the pooled 7-day medication adherence from the + * canonical compliance engine — the ring that absorbs the retired + * hero dose row's information role. + * + * The preference piggy-backs on the layout blob like `heroVisible` (a UI + * affordance, no Prisma migration). The resolver drops unknown ids, + * dedupes, and clamps to `MAX_SELECTED_SCORE_RINGS`; a missing / + * malformed field falls back to the default single MED_COMPLIANCE ring, + * while an explicitly-saved empty array stays empty (the user chose no + * extra rings). + */ +export const SCORE_RING_IDS = [ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + "MED_COMPLIANCE", +] as const; +export type ScoreRingId = (typeof SCORE_RING_IDS)[number]; + +export const MAX_SELECTED_SCORE_RINGS = 3; + +export const DEFAULT_SELECTED_SCORE_RINGS: ScoreRingId[] = ["MED_COMPLIANCE"]; + +/** + * Ring id → owning toggleable module. Mirrors the derived routes' + * `DERIVED_MODULE` map for the three derived rings (READINESS rides the + * recovery module like the recovery/strain/stress trio); MED_COMPLIANCE + * belongs to the medications module. Client-safe (type-only ModuleKey + * import) so the Settings picker and the server snapshot resolver gate + * on the same map — the `WIDGET_MODULE_BY_ID` pattern. + */ +export const SCORE_RING_MODULE: Record = { + READINESS: "recovery", + RECOVERY_SCORE: "recovery", + SLEEP_SCORE: "sleep", + MED_COMPLIANCE: "medications", +}; + +function isScoreRingId(value: unknown): value is ScoreRingId { + return ( + typeof value === "string" && + (SCORE_RING_IDS as readonly string[]).includes(value) + ); +} + +/** + * Coerce the persisted `selectedScoreRings` field: unknown ids drop, + * duplicates collapse (first occurrence wins), the list clamps to + * `MAX_SELECTED_SCORE_RINGS`. A non-array (missing field, legacy blob, + * malformed client) falls back to the default; an array — even an empty + * one — is respected as an explicit choice. + */ +function coerceSelectedScoreRings(value: unknown): ScoreRingId[] { + if (!Array.isArray(value)) return [...DEFAULT_SELECTED_SCORE_RINGS]; + const out: ScoreRingId[] = []; + for (const entry of value) { + if (!isScoreRingId(entry)) continue; + if (out.includes(entry)) continue; + out.push(entry); + if (out.length >= MAX_SELECTED_SCORE_RINGS) break; + } + return out; +} + export interface DashboardLayout { version: number; widgets: DashboardWidgetConfig[]; @@ -351,6 +422,12 @@ export interface DashboardLayout { * serializer persists the resolved boolean explicitly. */ heroVisible?: boolean; + /** + * v1.27.7 — hero score rings (max 3, closed `SCORE_RING_IDS` set) + * rendered next to the health-score ring. See the doc on + * `SCORE_RING_IDS`; the resolver clamps/dedupes/drops-unknown. + */ + selectedScoreRings?: ScoreRingId[]; } const DASHBOARD_LAYOUT_VERSION = 1; @@ -365,6 +442,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // Hero (daily verdict) is off by default; users opt in via // Settings → Dashboard. heroVisible: false, + // 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], widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -506,6 +586,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { // clamps to `false` (default-off for legacy blobs and malformed // values alike; the hero is opt-in). heroVisible: candidate.heroVisible === true, + // 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), }; } @@ -536,5 +620,8 @@ export function serializeDashboardLayout( // Persist hero visibility explicitly (same clamp as the resolver) // so a re-read never has to guess the default. 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), }; }