diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5a8533ea..f471036a0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21417,6 +21417,38 @@ components: description: Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here. + scoreRings: + description: "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved + server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like + `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green + / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or + a disabled module, never zero. Clients render what arrives and never recompute." + type: array + items: + type: object + properties: + id: + type: string + enum: + - READINESS + - RECOVERY_SCORE + - SLEEP_SCORE + - MED_COMPLIANCE + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + band: + type: string + enum: + - green + - yellow + - red + required: + - id + - score + - band + additionalProperties: false briefing: anyOf: - type: object diff --git a/src/lib/dashboard/__tests__/score-rings.test.ts b/src/lib/dashboard/__tests__/score-rings.test.ts new file mode 100644 index 000000000..767011a87 --- /dev/null +++ b/src/lib/dashboard/__tests__/score-rings.test.ts @@ -0,0 +1,269 @@ +/** + * v1.27.7 — unit tests for the hero score-ring resolver. + * + * The resolver is tested in isolation with the derived dispatcher and + * the compliance engine mocked, pinning: + * - module gating (owning module + the insights gate on derived rings); + * - the pass-through contract for derived scores (same resolvers the + * batch route calls, no recomputation, non-`ok` → no ring); + * - the pooled 7-day adherence math (Σtaken / Σ(taken+missed), skips + * out of the denominator, empty denominator → no ring); + * - the adherence band thresholds (≥90 / ≥70 — the targets-tile rule); + * - per-ring fail-softness (one throwing engine drops only its ring). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const computeDerivedMetric = vi.fn(); +const loadBaselineProfile = vi.fn(); +const calculateCompliance = vi.fn(); +const buildComplianceMedicationContext = vi.fn(); +const lastNonSkippedTakenAt = vi.fn(); + +vi.mock("@/lib/insights/derived", () => ({ + computeDerivedMetric: (...a: unknown[]) => computeDerivedMetric(...a), + loadBaselineProfile: (...a: unknown[]) => loadBaselineProfile(...a), + isDerivedOk: (d: { status: string }) => d.status === "ok", +})); +vi.mock("@/lib/analytics/compliance", () => ({ + calculateCompliance: (...a: unknown[]) => calculateCompliance(...a), + buildComplianceMedicationContext: (...a: unknown[]) => + buildComplianceMedicationContext(...a), + lastNonSkippedTakenAt: (...a: unknown[]) => lastNonSkippedTakenAt(...a), + SCHEDULE_COMPLIANCE_SELECT: { id: true }, +})); + +import { buildScoreRingsBlock, complianceBandForRate } from "../score-rings"; +import type { ModuleKey } from "@/lib/modules/gate"; + +const NOW = new Date("2026-07-01T10:00:00.000Z"); +const TZ = "Europe/Berlin"; + +function moduleMap( + overrides: Partial> = {}, +): Record { + return { + recovery: true, + sleep: true, + medications: true, + insights: true, + ...overrides, + } as Record; +} + +const fakePrisma = { + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +/** A minimal medication row matching the resolver's select. */ +function med(id: string) { + return { + id, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [{ id: `${id}-s` }], + scheduleRevisions: [], + pauseEras: [], + }; +} + +function complianceResult(taken: number, missed: number, skipped = 0) { + return { + totalExpected: taken + missed + skipped, + taken, + skipped, + missed, + rate: 0, // the resolver pools counts, never this per-med rate + streak: 0, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + loadBaselineProfile.mockResolvedValue({ userId: "user-1" }); + buildComplianceMedicationContext.mockReturnValue({ ctx: true }); + lastNonSkippedTakenAt.mockReturnValue(null); + fakePrisma.medication.findMany.mockResolvedValue([]); + fakePrisma.medicationIntakeEvent.findMany.mockResolvedValue([]); +}); + +describe("complianceBandForRate()", () => { + it("bands on the targets-tile thresholds (≥90 green / ≥70 yellow / else red)", () => { + expect(complianceBandForRate(100)).toBe("green"); + expect(complianceBandForRate(90)).toBe("green"); + expect(complianceBandForRate(89)).toBe("yellow"); + expect(complianceBandForRate(70)).toBe("yellow"); + expect(complianceBandForRate(69)).toBe("red"); + expect(complianceBandForRate(0)).toBe("red"); + }); +}); + +describe("buildScoreRingsBlock() — selection + module gating", () => { + it("returns [] for an empty selection without touching any engine", async () => { + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + [], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + expect(loadBaselineProfile).not.toHaveBeenCalled(); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + expect(fakePrisma.medication.findMany).not.toHaveBeenCalled(); + }); + + it("drops rings whose owning module is disabled", async () => { + computeDerivedMetric.mockResolvedValue({ + status: "ok", + value: { score: 80, band: "green" }, + }); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["READINESS", "SLEEP_SCORE"], + moduleMap({ recovery: false }), + NOW, + ); + expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 80, band: "green" }]); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ metric: "SLEEP_SCORE", userId: "user-1" }), + ); + }); + + it("the insights gate drops derived rings but keeps the adherence ring", async () => { + fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); + calculateCompliance.mockReturnValue(complianceResult(9, 1)); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["READINESS", "MED_COMPLIANCE"], + moduleMap({ insights: false }), + NOW, + ); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + expect(loadBaselineProfile).not.toHaveBeenCalled(); + expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 90, band: "green" }]); + }); +}); + +describe("buildScoreRingsBlock() — derived rings", () => { + it("passes through the engine's score + band (rounded) in selection order", async () => { + computeDerivedMetric.mockImplementation( + async ({ metric }: { metric: string }) => + metric === "READINESS" + ? { status: "ok", value: { score: 71.6, band: "green" } } + : { status: "ok", value: { score: 38, band: "red" } }, + ); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["RECOVERY_SCORE", "READINESS"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([ + { id: "RECOVERY_SCORE", score: 38, band: "red" }, + { id: "READINESS", score: 72, band: "green" }, + ]); + // Profile loaded exactly once (the batch-route pattern). + expect(loadBaselineProfile).toHaveBeenCalledTimes(1); + }); + + it("a non-ok compute yields no ring (self-gating, never a fabricated value)", async () => { + computeDerivedMetric.mockResolvedValue({ + status: "insufficient", + reason: "not_enough_history", + }); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["SLEEP_SCORE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + }); + + it("a throwing engine drops only its own ring (fail-soft)", async () => { + computeDerivedMetric.mockImplementation( + async ({ metric }: { metric: string }) => { + if (metric === "READINESS") throw new Error("engine down"); + return { status: "ok", value: { score: 55, band: "yellow" } }; + }, + ); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["READINESS", "SLEEP_SCORE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 55, band: "yellow" }]); + }); +}); + +describe("buildScoreRingsBlock() — pooled 7-day adherence", () => { + it("pools Σtaken / Σ(taken+missed) across medications; skips stay out", async () => { + fakePrisma.medication.findMany.mockResolvedValue([med("m1"), med("m2")]); + calculateCompliance + .mockReturnValueOnce(complianceResult(6, 1, 2)) // m1: skips ignored + .mockReturnValueOnce(complianceResult(2, 3)); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["MED_COMPLIANCE"], + moduleMap(), + NOW, + ); + // (6+2) / (6+1+2+3) = 8/12 = 66.67 → 67, red band. + expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 67, band: "red" }]); + // The 7-day window rides the canonical engine call. + expect(calculateCompliance).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 7, + expect.anything(), + expect.objectContaining({ now: NOW }), + ); + }); + + it("no active non-PRN medication → no ring", async () => { + fakePrisma.medication.findMany.mockResolvedValue([]); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["MED_COMPLIANCE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + expect(fakePrisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); + + it("zero expected doses in the window → no ring (never a hollow 100%)", async () => { + fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); + calculateCompliance.mockReturnValue(complianceResult(0, 0, 0)); + const rings = await buildScoreRingsBlock( + fakePrisma, + "user-1", + TZ, + ["MED_COMPLIANCE"], + moduleMap(), + NOW, + ); + expect(rings).toEqual([]); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 0cf95f422..656b147eb 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -64,6 +64,12 @@ const resolveModuleMap = vi.fn(); vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), })); +// v1.27.7 — hero score rings. Mocked so the suite never touches the +// derived engines / compliance reads; the resolver has its own suite. +const buildScoreRingsBlock = vi.fn(); +vi.mock("@/lib/dashboard/score-rings", () => ({ + buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a), +})); /** Every toggleable key on, with optional per-key overrides. */ function moduleMap( @@ -173,6 +179,7 @@ beforeEach(() => { }); computeUserHealthScoreFastPath.mockResolvedValue(null); resolveModuleMap.mockResolvedValue(moduleMap()); + buildScoreRingsBlock.mockResolvedValue([]); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -1171,3 +1178,62 @@ describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => { expect(snap.briefingMemory).toBeNull(); }); }); + +/** + * v1.27.7 — hero score rings on the snapshot wire. The resolver itself + * is pinned in `score-rings.test.ts`; here only the assembly contract: + * the builder receives the RESOLVED `selectedScoreRings` preference + + * the module map, its result rides `snapshot.scoreRings`, and a + * rejection degrades to an empty row (never a sunk snapshot). + */ +describe("buildDashboardSnapshot — scoreRings wire", () => { + it("threads the default selection (MED_COMPLIANCE) for a layout-less user", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + buildScoreRingsBlock.mockResolvedValue([ + { id: "MED_COMPLIANCE", score: 92, band: "green" }, + ]); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(buildScoreRingsBlock).toHaveBeenCalledTimes(1); + const [, userId, tz, selected, modules] = + buildScoreRingsBlock.mock.calls[0]; + expect(userId).toBe("user-1"); + expect(tz).toBe("Europe/Berlin"); + expect(selected).toEqual(["MED_COMPLIANCE"]); + expect(modules).toEqual(moduleMap()); + expect(snap.scoreRings).toEqual([ + { id: "MED_COMPLIANCE", score: 92, band: "green" }, + ]); + }); + + it("threads a saved multi-ring selection in order", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + buildScoreRingsBlock.mockResolvedValue([]); + + await buildDashboardSnapshot( + fakePrisma, + baseUser({ + dashboardWidgetsJson: { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + selectedScoreRings: ["READINESS", "SLEEP_SCORE"], + }, + }), + ); + + const [, , , selected] = buildScoreRingsBlock.mock.calls[0]; + expect(selected).toEqual(["READINESS", "SLEEP_SCORE"]); + }); + + it("degrades a rejecting resolver to an empty ring row (fail-soft)", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + buildScoreRingsBlock.mockRejectedValue(new Error("engine down")); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.scoreRings).toEqual([]); + // The rest of the envelope is untouched by the failure. + expect(snap.tiles.summaries.WEIGHT).toBeDefined(); + }); +}); diff --git a/src/lib/dashboard/score-rings.ts b/src/lib/dashboard/score-rings.ts new file mode 100644 index 000000000..26bee67e3 --- /dev/null +++ b/src/lib/dashboard/score-rings.ts @@ -0,0 +1,245 @@ +/** + * v1.27.7 — hero score-ring resolution for the dashboard snapshot. + * + * Resolves the user-selected `selectedScoreRings` (max 3, closed + * `SCORE_RING_IDS` set) into `{ id, score, band }` entries the hero + * renders next to the health-score ring: + * + * - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` call the SAME + * `computeDerivedMetric` dispatcher the `/api/insights/derived` + * batch route uses (profile loaded once via the shared + * `loadBaselineProfile`) — no score logic is re-derived here, and a + * non-`ok` compute (insufficient data, engine gate) simply yields no + * ring, mirroring the wellness strip's self-gating. + * - `MED_COMPLIANCE` is the pooled 7-day adherence across active + * non-PRN medications through the canonical `calculateCompliance` + * engine (the exact per-medication pattern the targets tile and the + * health-score pillar use): per-med `ComplianceResult`s are pooled + * as Σtaken / Σ(taken + missed) — skips stay out of the denominator, + * matching the per-medication rate semantics — and banded on the + * same ≥90 / ≥70 thresholds the targets-tile classification carries. + * + * 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. + * + * 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 { + computeDerivedMetric, + loadBaselineProfile, + isDerivedOk, + type DerivedMetricId, +} from "@/lib/insights/derived"; +import { + buildComplianceMedicationContext, + calculateCompliance, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; + +export type ScoreRingBand = "green" | "yellow" | "red"; + +/** One resolved hero ring — score + band only, no component breakdown. */ +export interface DashboardScoreRing { + id: ScoreRingId; + /** The 0..100 score (rounded). */ + score: number; + band: ScoreRingBand; +} + +/** The derived-registry id behind each derived ring (identity mapping). */ +const DERIVED_RING_METRIC: Partial> = { + READINESS: "READINESS", + RECOVERY_SCORE: "RECOVERY_SCORE", + SLEEP_SCORE: "SLEEP_SCORE", +}; + +/** + * Adherence % → band, on the thresholds the targets tile's + * MEDICATION_COMPLIANCE classification already uses (≥90 "very good" / + * ≥70 "good" / below "low"), so the ring and the targets surface can + * never disagree on colour for the same rate. + */ +export function complianceBandForRate(rate: number): ScoreRingBand { + if (rate >= 90) return "green"; + if (rate >= 70) return "yellow"; + return "red"; +} + +/** The compute payload slice every derived score ring reads. */ +interface ScoreBandValue { + score: number; + band: ScoreRingBand; +} + +/** + * Pooled 7-day medication adherence, or `null` when nothing was expected + * (no active non-PRN medication, or no due slot in the window) — the + * ring self-gates instead of asserting a hollow 100%. + */ +async function resolveMedComplianceRing( + prisma: PrismaClient, + userId: string, + userTz: string, + now: Date, +): Promise { + const medications = await prisma.medication.findMany({ + // As-needed (PRN) medications carry no expected doses and are + // excluded from every compliance rate (the v1.16.11 rule). + where: { userId, active: true, asNeeded: false }, + select: { + id: true, + createdAt: true, + startsOn: true, + endsOn: true, + oneShot: true, + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // Archived schedule eras + pause eras so a past day scores against + // the schedule that was live then and paused days drop out of the + // denominator — the same context every compliance caller threads. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + }); + if (medications.length === 0) return null; + + // 30 days of intake events even though the rate window is 7: rolling / + // interval cadences anchor `nextDue` off the last non-skipped take, + // which can sit before the window start (the health-score pillar + // fetches the same margin). + const since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + medicationId: { in: medications.map((m) => m.id) }, + scheduledFor: { gte: since, lte: now }, + }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }); + const eventsByMed = new Map(); + for (const ev of intakeEvents) { + const list = eventsByMed.get(ev.medicationId); + if (list) list.push(ev); + else eventsByMed.set(ev.medicationId, [ev]); + } + + let taken = 0; + let missed = 0; + for (const med of medications) { + const events = eventsByMed.get(med.id) ?? []; + const medicationContext = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + userTz, + ); + const result = calculateCompliance( + events, + med.schedules, + 7, + med.createdAt, + { + now, + medicationContext, + }, + ); + taken += result.taken; + missed += result.missed; + } + + const denominator = taken + missed; + if (denominator === 0) return null; + const rate = Math.min(100, Math.round((taken / denominator) * 100)); + return { + id: "MED_COMPLIANCE", + score: rate, + band: complianceBandForRate(rate), + }; +} + +/** + * Resolve the selected hero rings against the module map. Selection + * order is preserved; rings without data drop out. Every ring is + * fail-soft on its own — one throwing engine never takes the row (or + * the snapshot) down. + */ +export async function buildScoreRingsBlock( + prisma: PrismaClient, + userId: string, + userTz: string, + selected: ScoreRingId[], + modules: Record, + now: Date, +): 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; + }); + if (eligible.length === 0) return []; + + // Profile loaded ONCE via the shared loader (the batch-route pattern); + // only when a derived ring is actually selected. + const needsProfile = eligible.some((id) => DERIVED_RING_METRIC[id]); + const profile = needsProfile + ? await loadBaselineProfile(prisma, userId) + : null; + + const resolved = await Promise.all( + eligible.map(async (id): Promise => { + try { + if (id === "MED_COMPLIANCE") { + return await resolveMedComplianceRing(prisma, userId, userTz, now); + } + const metric = DERIVED_RING_METRIC[id]; + if (!metric || !profile) return null; + const derived = await computeDerivedMetric({ + metric, + userId, + profile, + now, + }); + if (!isDerivedOk(derived)) return null; + const value = derived.value as ScoreBandValue; + if (typeof value?.score !== "number" || !Number.isFinite(value.score)) { + return null; + } + return { id, score: Math.round(value.score), band: value.band }; + } catch { + // Fail-soft per ring — a transient engine error drops the ring, + // never the snapshot. + return null; + } + }), + ); + return resolved.filter((r): r is DashboardScoreRing => r !== null); +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 1c2ca209f..1b26e6bdd 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -93,6 +93,10 @@ import { buildCoachMemoryBlock, type CoachMemoryBlock, } from "@/lib/ai/coach/memory-snapshot"; +import { + buildScoreRingsBlock, + type DashboardScoreRing, +} from "@/lib/dashboard/score-rings"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; @@ -401,6 +405,17 @@ export interface DashboardSnapshot { * thick phase alongside `extras`) and when no pillar is computable. */ healthScore: DashboardSnapshotHealthScore | null; + /** + * v1.27.7 — the user-selected hero score rings (max 3), resolved + * server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE through the + * same engines the derived batch route calls, MED_COMPLIANCE as the + * pooled 7-day adherence from the canonical compliance engine. Only + * rings with data appear (selection order preserved); module-disabled + * and data-less selections drop out, so the hero row self-gates. + * Optional on the type (additive contract) so older cached snapshots + * / fixtures stay valid; the live builder always sets it. + */ + scoreRings?: DashboardScoreRing[]; briefing: DailyBriefing | null; /** * v1.21.2 (A4) — server-resolved recall + forward-look for the briefing card. @@ -1027,6 +1042,12 @@ export async function buildDashboardSnapshot( * derived engines. Defaults to the real `buildScoreNarrativeBlock`. */ scoreNarrative?: typeof buildScoreNarrativeBlock; + /** + * v1.27.7 — injectable hero score-ring resolver so the snapshot tests + * can pin the wire without the derived engines / compliance reads. + * Defaults to the real `buildScoreRingsBlock`. + */ + scoreRings?: typeof buildScoreRingsBlock; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -1045,28 +1066,56 @@ export async function buildDashboardSnapshot( const coverage = await time("coverage", () => probeRollupCoverage(user.id)); const warm = isThickPhaseWarm(coverage); - const [slimRaw, moodRaw, extrasResult, flags, medsToday, modules] = - await Promise.all([ - // A5 — reuse the coverage map already probed above so the slice - // doesn't re-run the identical `probeRollupCoverage` query. - time("summaries", () => computeSummariesSlice(user.id, coverage)), - time("mood", () => buildMoodBlock(prisma, user.id)), - warm - ? time("extras", () => - buildExtras(prisma, user, userTz, coverage, now, time), - ) - : Promise.resolve(null), - time("flags", () => getAssistantFlags()), - // Fast phase — projection-backed today tally + earliest next-due. - time("medsToday", () => - buildMedsTodayBlock(prisma, user.id, userTz, now), + // Resolve the stored layout once up front: the hero score rings read + // the `selectedScoreRings` preference off it, and the module-gated + // `layout` block below reuses the same resolution. + const storedLayout = resolveDashboardLayout(user.dashboardWidgetsJson); + + // v1.18.0 — resolved module map (memoised per request); gates the + // toggleable tiles below at the build layer. Held as a shared promise + // so the score-ring task can await it inside the same `Promise.all` + // without a second resolver call. + const modulesPromise = time("modules", () => + (options.modules ?? (() => resolveModuleMap(user.id)))(), + ); + + const [ + slimRaw, + moodRaw, + extrasResult, + flags, + medsToday, + modules, + scoreRings, + ] = await Promise.all([ + // A5 — reuse the coverage map already probed above so the slice + // doesn't re-run the identical `probeRollupCoverage` query. + time("summaries", () => computeSummariesSlice(user.id, coverage)), + time("mood", () => buildMoodBlock(prisma, user.id)), + warm + ? time("extras", () => + buildExtras(prisma, user, userTz, coverage, now, time), + ) + : Promise.resolve(null), + time("flags", () => getAssistantFlags()), + // Fast phase — projection-backed today tally + earliest next-due. + time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), + modulesPromise, + // v1.27.7 — the selected hero score rings, resolved through the + // same engines the derived batch route calls (+ the canonical + // compliance engine for MED_COMPLIANCE). Fail-soft: a throwing + // resolver yields no rings, never a sunk snapshot. + time("scoreRings", async () => + (options.scoreRings ?? buildScoreRingsBlock)( + prisma, + user.id, + userTz, + storedLayout.selectedScoreRings ?? [], + await modulesPromise, + now, ), - // v1.18.0 — resolved module map (memoised per request); gates the - // toggleable tiles below at the build layer. - time("modules", () => - (options.modules ?? (() => resolveModuleMap(user.id)))(), - ), - ]); + ).catch(() => [] as DashboardScoreRing[]), + ]); // v1.18.0 — strip disabled-module data before it leaves the server. // Mood is blanked when the mood module is off; sleep / glucose summary @@ -1090,10 +1139,7 @@ export async function buildDashboardSnapshot( }); } - const layout = gateLayoutByModules( - resolveDashboardLayout(user.dashboardWidgetsJson), - modules, - ); + const layout = gateLayoutByModules(storedLayout, modules); // v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface, // so the `insights` module gates it alongside the operator briefing // flag + per-user coach opt-out. Disabling `insights` yields the same @@ -1191,6 +1237,10 @@ export async function buildDashboardSnapshot( returnToBand: scoreNarrative?.returnToBand ?? null, } : null, + // v1.27.7 — the resolved hero score rings (selection order, data- + // gated). Always set by the live builder; optional on the type so + // older cached snapshots stay valid. + scoreRings, briefing: briefing.briefing, // v1.21.2 (A4) — the briefing recall + forward-look. Null when there is no // prior narrative on file or no briefing is shown. diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 09f805707..dcdcb3d3c 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -852,6 +852,26 @@ export const dashboardSnapshotResponse = z .describe( "Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.", ), + // v1.27.7 — user-selected hero score rings (max 3), resolved + // server-side next to the health score. Additive; optional so + // cached pre-v1.27.7 snapshots stay decodable. + scoreRings: z + .array( + z.object({ + id: z.enum([ + "READINESS", + "RECOVERY_SCORE", + "SLEEP_SCORE", + "MED_COMPLIANCE", + ]), + score: z.number().int(), + band: z.enum(["green", "yellow", "red"]), + }), + ) + .optional() + .describe( + "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute.", + ), briefing: z.record(z.string(), z.unknown()).nullable(), briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), briefingUpdatedAt: z.string().nullable(),