mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
53f84abfbb
commit
ed6911bb03
3 files changed
+198
-5
No files matched your search
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user