From 347e63d653d2f33b579456402393eb2ac2384d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:58:27 +0200 Subject: [PATCH] fix(dashboard): keep the derived score rings with AI analysis off The hero rings read their owner from the surface map instead of their own map, and the readiness, recovery and sleep score rings no longer wait on the AI analysis switch: they are computed scores, not model text. The dose ring still follows the medications module. --- src/lib/dashboard-layout.ts | 16 ---------- .../dashboard/__tests__/score-rings.test.ts | 32 ++++++++++++------- src/lib/dashboard/score-rings.ts | 23 ++++--------- .../__tests__/surface-module-registry.test.ts | 16 +++++++++- src/lib/modules/surface.ts | 10 ++++++ 5 files changed, 52 insertions(+), 45 deletions(-) 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",