mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
ed6911bb03
commit
5c0cde207f
6 files changed
+707
-25
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user