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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 15:07:59 +02:00
1 parent 53f84abfbb
commit ed6911bb03
3 files changed
+198 -5

No files matched your search

+22 -5
View File
@@ -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<DashboardLayout> {
@@ -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({
@@ -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",
]);
});
});
+87
View File
@@ -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<ScoreRingId, ModuleKey> = {
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),
};
}