feat(dashboard): resolve the selected score rings on the snapshot

Resolve the selected rings server-side and publish them as scoreRings
on the dashboard snapshot (additive, optional for cached payloads).
READINESS, RECOVERY_SCORE and SLEEP_SCORE go through the same
computeDerivedMetric dispatcher the derived batch route calls, with the
profile loaded once via the shared loader; MED_COMPLIANCE pools the
7-day adherence across active non-PRN medications through the canonical
compliance engine (taken over taken-plus-missed, skips out of the
denominator) and bands on the targets-tile thresholds. Rings gate on
their owning module plus the insights gate for the derived trio, drop
out when no data backs them, and resolve fail-soft per ring so a
throwing engine never sinks the snapshot. OpenAPI regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 15:08:09 +02:00
1 parent ed6911bb03
commit 5c0cde207f
6 files changed
+707 -25

No files matched your search

+32
View File
@@ -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
@@ -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<ModuleKey, boolean>> = {},
): Record<ModuleKey, boolean> {
return {
recovery: true,
sleep: true,
medications: true,
insights: true,
...overrides,
} as Record<ModuleKey, boolean>;
}
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([]);
});
});
@@ -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();
});
});
+245
View File
@@ -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<Record<ScoreRingId, DerivedMetricId>> = {
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<DashboardScoreRing | null> {
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<string, typeof intakeEvents>();
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<ModuleKey, boolean>,
now: Date,
): Promise<DashboardScoreRing[]> {
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<DashboardScoreRing | null> => {
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);
}
+75 -25
View File
@@ -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<DashboardSnapshot> {
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.
@@ -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(),