diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 36564944b..c37344902 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -4,7 +4,6 @@ * 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"; import { PRIORITY_ITEM_KINDS, isPriorityItemKind, @@ -513,21 +512,6 @@ export function resolveHeroRingOrder( return out; } -/** - * 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" && diff --git a/src/lib/dashboard/__tests__/score-rings.test.ts b/src/lib/dashboard/__tests__/score-rings.test.ts index 8cf0491ba..e9091ef84 100644 --- a/src/lib/dashboard/__tests__/score-rings.test.ts +++ b/src/lib/dashboard/__tests__/score-rings.test.ts @@ -3,7 +3,8 @@ * * The resolver is tested in isolation with the derived dispatcher * mocked, pinning: - * - module gating (owning module + the insights gate on derived rings); + * - module gating (the owning module, from the surface map; the insights + * key, AI analysis, gates no ring); * - the pass-through contract for derived scores (same resolvers the * batch route calls, no recomputation, non-`ok` → no ring); * - the dose ring: today's taken/scheduled progress off the shared @@ -117,7 +118,11 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { ); }); - it("the insights gate drops derived rings but keeps the dose ring", async () => { + it("keeps derived rings with AI analysis off: the scores are data", async () => { + computeDerivedMetric.mockResolvedValue({ + status: "ok", + value: { score: 70, band: "yellow" }, + }); const rings = await buildScoreRingsBlock( fakePrisma, "user-1", @@ -126,16 +131,19 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { NOW, medsToday(2, 2), ); - expect(computeDerivedMetric).not.toHaveBeenCalled(); - expect(loadBaselineProfile).not.toHaveBeenCalled(); - expect(rings).toEqual([ - { - id: "MED_COMPLIANCE", - score: 100, - band: "green", - doses: { taken: 2, scheduled: 2 }, - }, - ]); + expect(rings.map((r) => r.id)).toEqual(["READINESS", "MED_COMPLIANCE"]); + }); + + it("drops the dose ring with the medications module off", async () => { + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + ["MED_COMPLIANCE"], + moduleMap({ medications: false }), + NOW, + medsToday(2, 2), + ); + expect(rings).toEqual([]); }); }); diff --git a/src/lib/dashboard/score-rings.ts b/src/lib/dashboard/score-rings.ts index b688acd9c..400ae431a 100644 --- a/src/lib/dashboard/score-rings.ts +++ b/src/lib/dashboard/score-rings.ts @@ -25,22 +25,18 @@ * taken, `yellow` while doses remain — never `red`: pending doses in * the morning are not an alert state. * - * Module gating rides the client-safe `SCORE_RING_MODULE` map in - * `@/lib/dashboard-layout` (mirroring the derived routes' - * `DERIVED_MODULE`: readiness/recovery → recovery module, sleep score → - * sleep module, MED_COMPLIANCE → medications) plus the `insights` gate - * every derived read sits behind. + * Module gating reads the surface map (`score-ring:` in + * `@/lib/modules/surface`): readiness/recovery → recovery, sleep score → + * sleep, MED_COMPLIANCE → medications. The derived scores are data, so the + * `insights` key (AI analysis) gates no ring. * * Every ring resolves fail-soft: a throwing engine drops its ring, never * the snapshot. */ import type { PrismaClient } from "@/generated/prisma/client"; import type { ModuleKey } from "@/lib/modules/gate"; -import { - SCORE_RING_IDS, - SCORE_RING_MODULE, - type ScoreRingId, -} from "@/lib/dashboard-layout"; +import { SCORE_RING_IDS, type ScoreRingId } from "@/lib/dashboard-layout"; +import { isSurfaceVisible } from "@/lib/modules/surface"; import { computeDerivedMetric, loadBaselineProfile, @@ -122,12 +118,7 @@ export async function buildScoreRingsBlock( ): Promise { const eligible = selected.filter((id) => { if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false; - if (modules[SCORE_RING_MODULE[id]] === false) return false; - // The derived scores are an insights-layer read everywhere else - // (`/api/insights/derived*` sits behind the insights module) — the - // snapshot honours the same gate. - if (DERIVED_RING_METRIC[id] && modules.insights === false) return false; - return true; + return isSurfaceVisible(`score-ring:${id}`, modules); }); if (eligible.length === 0) return []; diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 26761e9fc..61ff4d15f 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -48,7 +48,10 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker"; import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell"; import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups"; -import { DASHBOARD_WIDGET_CATALOGUE_IDS } from "@/lib/dashboard-layout"; +import { + DASHBOARD_WIDGET_CATALOGUE_IDS, + SCORE_RING_IDS, +} from "@/lib/dashboard-layout"; import { SUMMARY_TYPE_MODULE } from "@/lib/dashboard/widget-modules"; import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import { @@ -128,6 +131,17 @@ describe("every surface id names a surface that exists", () => { for (const id of localIds("derived")) expect(ids.has(id), id).toBe(true); }); + it("score-ring: a hero score ring id, the derived ones owned like their score", () => { + const ids = new Set(SCORE_RING_IDS); + for (const id of localIds("score-ring")) { + expect(ids.has(id), id).toBe(true); + const derivedOwner = surfaceModule(`derived:${id}`); + if (derivedOwner !== undefined) { + expect(surfaceModule(`score-ring:${id}`), id).toBe(derivedOwner); + } + } + }); + it("settings: a Settings section slug", () => { const slugs = new Set(SETTINGS_SECTIONS.map((s) => s.slug)); for (const id of localIds("settings")) expect(slugs.has(id), id).toBe(true); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index e1c0c7602..e0d3ba08d 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -47,6 +47,7 @@ import type { ModuleKey } from "@/lib/modules/registry"; * - `settings`: a Settings section, by its slug. * - `settings-layout`: a Settings, Layout group, by its id. * - `correlation`: a correlation-discovery channel, by its channel key. + * - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`. */ export const SURFACE_KINDS = [ "nav", @@ -60,6 +61,7 @@ export const SURFACE_KINDS = [ "settings", "settings-layout", "correlation", + "score-ring", ] as const; export type SurfaceKind = (typeof SURFACE_KINDS)[number]; @@ -163,6 +165,14 @@ const STATIC_SURFACE_MODULE = { "derived:STRESS_SCORE": "recovery", "derived:READINESS": "recovery", + // ── Dashboard hero score rings ── + // The three derived rings follow their derived score; the dose ring is + // medications'. + "score-ring:READINESS": "recovery", + "score-ring:RECOVERY_SCORE": "recovery", + "score-ring:SLEEP_SCORE": "sleep", + "score-ring:MED_COMPLIANCE": "medications", + // ── Settings sections ── "settings:environment": "environment", "settings:coach": "coach",