diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1d3349801..d121a4285 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21432,9 +21432,11 @@ components: 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." + `/api/insights/derived`); MED_COMPLIANCE is TODAY's dose progress off the snapshot's medsToday tally — + `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress + semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is + absent when no dose is scheduled today. 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 @@ -21456,6 +21458,23 @@ components: - green - yellow - red + doses: + description: MED_COMPLIANCE only — today's dose tally behind the progress score, for a 'taken/scheduled' ring display + (e.g. 1/3). Absent on the derived score rings. + type: object + properties: + taken: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scheduled: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - taken + - scheduled + additionalProperties: false required: - id - score diff --git a/messages/de.json b/messages/de.json index 709d25f44..5c5a46b7b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -318,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", "scoreComputing": "Score wird berechnet…", + "ringDoses": "Dosen heute", + "ringDosesAria": "{taken} von {scheduled} Dosen heute genommen", "verdict": { "doseOverdue": "Eine Dosis {name} ist überfällig.", "doseOverdueNoName": "Eine Dosis ist überfällig.", diff --git a/messages/en.json b/messages/en.json index aff6b59f7..f5e419f74 100644 --- a/messages/en.json +++ b/messages/en.json @@ -318,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", "scoreComputing": "Calculating score…", + "ringDoses": "Doses today", + "ringDosesAria": "{taken} of {scheduled} doses taken today", "verdict": { "doseOverdue": "A dose of {name} is overdue.", "doseOverdueNoName": "A dose is overdue.", diff --git a/messages/es.json b/messages/es.json index 0e0bbe983..3f42877e9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -318,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Aún no hay datos suficientes", "scoreComputing": "Calculando la puntuación …", + "ringDoses": "Dosis de hoy", + "ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy", "verdict": { "doseOverdue": "Una dosis de {name} está atrasada.", "doseOverdueNoName": "Una dosis está atrasada.", diff --git a/messages/fr.json b/messages/fr.json index 1760c656a..82c8922ef 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -318,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Pas encore assez de données", "scoreComputing": "Calcul du score en cours …", + "ringDoses": "Doses du jour", + "ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui", "verdict": { "doseOverdue": "Une dose de {name} est en retard.", "doseOverdueNoName": "Une dose est en retard.", diff --git a/messages/it.json b/messages/it.json index b510b0fbb..0c572fa4c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -318,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Dati ancora insufficienti", "scoreComputing": "Calcolo del punteggio in corso …", + "ringDoses": "Dosi di oggi", + "ringDosesAria": "{taken} di {scheduled} dosi assunte oggi", "verdict": { "doseOverdue": "Una dose di {name} è in ritardo.", "doseOverdueNoName": "Una dose è in ritardo.", diff --git a/messages/pl.json b/messages/pl.json index aa1917476..eec36bce2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -318,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Za mało danych", "scoreComputing": "Trwa obliczanie wyniku …", + "ringDoses": "Dzisiejsze dawki", + "ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj", "verdict": { "doseOverdue": "Dawka {name} jest zaległa.", "doseOverdueNoName": "Dawka jest zaległa.", diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index b7e02fe1a..e093e3a8f 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -375,7 +375,12 @@ describe(" — ring row (v1.27.7)", () => { healthScore: { score: 82, band: "green", delta: 2 }, scoreRings: [ { id: "READINESS", score: 71, band: "green" }, - { id: "MED_COMPLIANCE", score: 95, band: "green" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, ], }), ); @@ -389,9 +394,14 @@ describe(" — ring row (v1.27.7)", () => { const rings = html.match(/data-slot="score-ring"/g) ?? []; expect(rings).toHaveLength(3); // Labels (de locale): the derived ring reuses its wellness-strip - // title, the adherence ring the medications adherence label. + // title; the dose ring names today's tally and shows it as + // taken/scheduled over the constant green arc — never the band + // gradient (a pending morning dose is not an alert state). expect(html).toContain("Bereitschaft"); - expect(html).toContain("Therapietreue"); + expect(html).toContain("Dosen heute"); + expect(html).toContain(">1/3<"); + expect(html).toContain("var(--ring-green-from)"); + expect(html).not.toContain("var(--ring-yellow-from)"); }); it("no dose text row renders anymore — the ring row carries the slot", () => { diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 17093d7f5..ed226a59d 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -99,10 +99,12 @@ const CTA_LABEL_KEY: Partial> = { /** * Per-ring hue for the selected score rings — the wellness-strip - * vocabulary. MED_COMPLIANCE deliberately carries NO hue: the adherence - * ring falls back to the ring's band gradient (green / yellow / red - * semantic tokens), because for adherence the band IS the message — - * exactly the classification colouring the targets tile uses. + * vocabulary, so every hero ring paints EXACTLY the hue its insights + * sibling paints. MED_COMPLIANCE carries no per-metric hue entry: the + * dose ring pins the ring system's green arc (`band="green"`, constant) + * — the same green family the medication compliance surfaces in + * Insights paint for a taken dose — instead of a band gradient that + * would flash yellow/red over pending morning doses. */ const RING_HUE_BY_ID: Partial> = { READINESS: "readiness", @@ -110,12 +112,13 @@ const RING_HUE_BY_ID: Partial> = { SLEEP_SCORE: "sleep", }; -/** Per-ring label key — reuses the existing score / adherence labels. */ +/** Per-ring label key — reuses the existing score labels; the dose ring + * names today's tally. */ const RING_LABEL_KEY: Record = { READINESS: "insights.derived.composite.READINESS.title", RECOVERY_SCORE: "insights.derived.scores.recovery", SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", - MED_COMPLIANCE: "medications.compliance", + MED_COMPLIANCE: "dashboard.hero.ringDoses", }; export function DashboardHero({ @@ -295,14 +298,33 @@ export function DashboardHero({ data-ring={ring.id} className="flex shrink-0 items-center" > - + {/* Dose ring — today's tally ("1/3") over the constant + green arc; the arc still sweeps on the 0..100 + progress `score`. Falls through to the score render + when a cached pre-doses snapshot carries no tally. */} + {ring.id === "MED_COMPLIANCE" && ring.doses ? ( + + ) : ( + + )} ))} - {hasScore ? displayedRounded : "—"} + {hasScore ? (valueText ?? displayedRounded) : "—"} {label ? ( diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 84a8c1e2b..0a5c09176 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -158,15 +158,15 @@ const WIDGET_LABEL_KEYS: Record = { /** * v1.27.7 — option labels for the hero score-ring picker. The three - * derived rings reuse their wellness-strip titles; the adherence ring - * reuses the existing "adherence (7 days)" label so the picker names - * the exact window the ring shows. + * derived rings reuse their wellness-strip titles; the dose ring names + * today's tally (the exact thing the ring shows since it moved off the + * 7-day adherence percentage). */ const SCORE_RING_LABEL_KEYS: Record = { READINESS: "insights.derived.composite.READINESS.title", RECOVERY_SCORE: "insights.derived.scores.recovery", SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", - MED_COMPLIANCE: "dashboard.compliance7d", + MED_COMPLIANCE: "dashboard.hero.ringDoses", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/lib/dashboard/__tests__/score-rings.test.ts b/src/lib/dashboard/__tests__/score-rings.test.ts index 767011a87..8cf0491ba 100644 --- a/src/lib/dashboard/__tests__/score-rings.test.ts +++ b/src/lib/dashboard/__tests__/score-rings.test.ts @@ -1,42 +1,31 @@ /** * 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: + * The resolver is tested in isolation with the derived dispatcher + * 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); + * - the dose ring: today's taken/scheduled progress off the shared + * medsToday block (no ring when nothing is scheduled today, never a + * red band — pending doses are not an alert state); * - 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 { buildScoreRingsBlock, resolveDoseRing } 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> = {}, @@ -50,54 +39,46 @@ function moduleMap( } as Record; } -const fakePrisma = { - medication: { findMany: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -} as any; +// The dose ring reads the pre-computed medsToday block — no queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const fakePrisma = {} 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, - }; +function medsToday(taken: number, scheduled: number) { + return { takenToday: taken, scheduledToday: scheduled }; } 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("resolveDoseRing()", () => { + it("renders today's progress with the taken/scheduled pair", () => { + expect(resolveDoseRing(medsToday(1, 3))).toEqual({ + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }); + }); + + it("goes green only when every scheduled dose is taken — never red", () => { + expect(resolveDoseRing(medsToday(3, 3))?.band).toBe("green"); + expect(resolveDoseRing(medsToday(0, 3))?.band).toBe("yellow"); + }); + + it("no doses scheduled today → no ring (never a hollow 100%)", () => { + expect(resolveDoseRing(medsToday(0, 0))).toBeNull(); + expect(resolveDoseRing(null)).toBeNull(); + }); + + it("clamps a taken overshoot (extra manual logs) to the scheduled count", () => { + expect(resolveDoseRing(medsToday(5, 3))).toEqual({ + id: "MED_COMPLIANCE", + score: 100, + band: "green", + doses: { taken: 3, scheduled: 3 }, + }); }); }); @@ -106,15 +87,14 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, [], moduleMap(), NOW, + medsToday(1, 3), ); 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 () => { @@ -125,10 +105,10 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["READINESS", "SLEEP_SCORE"], moduleMap({ recovery: false }), NOW, + null, ); expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 80, band: "green" }]); expect(computeDerivedMetric).toHaveBeenCalledTimes(1); @@ -137,20 +117,25 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { ); }); - it("the insights gate drops derived rings but keeps the adherence ring", async () => { - fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); - calculateCompliance.mockReturnValue(complianceResult(9, 1)); + it("the insights gate drops derived rings but keeps the dose ring", async () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["READINESS", "MED_COMPLIANCE"], moduleMap({ insights: false }), NOW, + medsToday(2, 2), ); expect(computeDerivedMetric).not.toHaveBeenCalled(); expect(loadBaselineProfile).not.toHaveBeenCalled(); - expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 90, band: "green" }]); + expect(rings).toEqual([ + { + id: "MED_COMPLIANCE", + score: 100, + band: "green", + doses: { taken: 2, scheduled: 2 }, + }, + ]); }); }); @@ -165,10 +150,10 @@ describe("buildScoreRingsBlock() — derived rings", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["RECOVERY_SCORE", "READINESS"], moduleMap(), NOW, + null, ); expect(rings).toEqual([ { id: "RECOVERY_SCORE", score: 38, band: "red" }, @@ -186,10 +171,10 @@ describe("buildScoreRingsBlock() — derived rings", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["SLEEP_SCORE"], moduleMap(), NOW, + null, ); expect(rings).toEqual([]); }); @@ -204,66 +189,11 @@ describe("buildScoreRingsBlock() — derived rings", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["READINESS", "SLEEP_SCORE"], moduleMap(), NOW, + null, ); 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 656b147eb..4be9e7e8c 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -1196,12 +1196,14 @@ describe("buildDashboardSnapshot — scoreRings wire", () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); expect(buildScoreRingsBlock).toHaveBeenCalledTimes(1); - const [, userId, tz, selected, modules] = + const [, userId, selected, modules, , medsToday] = buildScoreRingsBlock.mock.calls[0]; expect(userId).toBe("user-1"); - expect(tz).toBe("Europe/Berlin"); expect(selected).toEqual(["MED_COMPLIANCE"]); expect(modules).toEqual(moduleMap()); + // The dose ring reads the SAME medsToday block the snapshot ships — + // one builder call, no second tally. + expect(medsToday).toEqual(snap.medsToday); expect(snap.scoreRings).toEqual([ { id: "MED_COMPLIANCE", score: 92, band: "green" }, ]); @@ -1222,7 +1224,7 @@ describe("buildDashboardSnapshot — scoreRings wire", () => { }), ); - const [, , , selected] = buildScoreRingsBlock.mock.calls[0]; + const [, , selected] = buildScoreRingsBlock.mock.calls[0]; expect(selected).toEqual(["READINESS", "SLEEP_SCORE"]); }); diff --git a/src/lib/dashboard/score-rings.ts b/src/lib/dashboard/score-rings.ts index 26bee67e3..b688acd9c 100644 --- a/src/lib/dashboard/score-rings.ts +++ b/src/lib/dashboard/score-rings.ts @@ -2,8 +2,8 @@ * 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: + * `SCORE_RING_IDS` set) into ring entries the hero renders next to the + * health-score ring: * * - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` call the SAME * `computeDerivedMetric` dispatcher the `/api/insights/derived` @@ -11,13 +11,19 @@ * `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. + * - `MED_COMPLIANCE` is TODAY's dose progress from the snapshot's own + * `medsToday` block (takenToday / scheduledToday) — the same numbers + * the old hero dose row carried and the native client shows. The + * v1.27.7 first cut showed the pooled 7-day adherence percentage + * here; a percentage on a ring next to "today" scores read as a + * mystery number, so the ring now answers the question the hero + * actually poses: how many of today's doses are done. `score` stays + * the 0..100 progress (rounded) for wire compatibility; the additive + * `doses` field carries the taken/scheduled pair for the "1/3" + * display. No doses scheduled today → no ring (self-gating). The + * band is progress semantics — `green` once every scheduled dose is + * 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' @@ -41,21 +47,27 @@ import { 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. */ +/** One resolved hero ring. */ export interface DashboardScoreRing { id: ScoreRingId; - /** The 0..100 score (rounded). */ + /** The 0..100 score (rounded). For `MED_COMPLIANCE`: today's dose progress. */ score: number; band: ScoreRingBand; + /** + * `MED_COMPLIANCE` only — today's dose tally behind the progress + * score, for the "taken/scheduled" ring display. Additive; absent on + * the derived score rings. + */ + doses?: { taken: number; scheduled: number }; +} + +/** The medsToday slice the dose ring reads (subset of `MedsTodayBlock`). */ +export interface MedsTodayForRings { + takenToday: number; + scheduledToday: number; } /** The derived-registry id behind each derived ring (identity mapping). */ @@ -65,18 +77,6 @@ const DERIVED_RING_METRIC: Partial> = { 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; @@ -84,101 +84,25 @@ interface ScoreBandValue { } /** - * 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%. + * Today's dose-progress ring from the snapshot's `medsToday` block, or + * `null` when nothing is scheduled today — 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)); +export function resolveDoseRing( + medsToday: MedsTodayForRings | null, +): DashboardScoreRing | null { + if (!medsToday) return null; + const scheduled = medsToday.scheduledToday; + if (!Number.isFinite(scheduled) || scheduled <= 0) return null; + const taken = Math.max(0, Math.min(medsToday.takenToday, scheduled)); + const score = Math.round((taken / scheduled) * 100); return { id: "MED_COMPLIANCE", - score: rate, - band: complianceBandForRate(rate), + score, + // Progress semantics, not adherence judgment: green when done, + // yellow while doses remain. Pending morning doses are not "red". + band: taken >= scheduled ? "green" : "yellow", + doses: { taken, scheduled }, }; } @@ -191,10 +115,10 @@ async function resolveMedComplianceRing( export async function buildScoreRingsBlock( prisma: PrismaClient, userId: string, - userTz: string, selected: ScoreRingId[], modules: Record, now: Date, + medsToday: MedsTodayForRings | null, ): Promise { const eligible = selected.filter((id) => { if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false; @@ -218,7 +142,7 @@ export async function buildScoreRingsBlock( eligible.map(async (id): Promise => { try { if (id === "MED_COMPLIANCE") { - return await resolveMedComplianceRing(prisma, userId, userTz, now); + return resolveDoseRing(medsToday); } const metric = DERIVED_RING_METRIC[id]; if (!metric || !profile) return null; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 1b26e6bdd..424177dc9 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -1079,6 +1079,14 @@ export async function buildDashboardSnapshot( (options.modules ?? (() => resolveModuleMap(user.id)))(), ); + // Fast phase — projection-backed today tally + earliest next-due. Held + // as a shared promise (the `modulesPromise` pattern) so the score-ring + // task can read today's dose tally inside the same `Promise.all` + // without a second builder call. + const medsTodayPromise = time("medsToday", () => + buildMedsTodayBlock(prisma, user.id, userTz, now), + ); + const [ slimRaw, moodRaw, @@ -1098,21 +1106,20 @@ export async function buildDashboardSnapshot( ) : Promise.resolve(null), time("flags", () => getAssistantFlags()), - // Fast phase — projection-backed today tally + earliest next-due. - time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), + medsTodayPromise, 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. + // same engines the derived batch route calls; the dose ring reads + // today's tally off the shared medsToday block. 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, + await medsTodayPromise, ), ).catch(() => [] as DashboardScoreRing[]), ]); diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index dcdcb3d3c..075d250ea 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -866,11 +866,20 @@ export const dashboardSnapshotResponse = z ]), score: z.number().int(), band: z.enum(["green", "yellow", "red"]), + doses: z + .object({ + taken: z.number().int(), + scheduled: z.number().int(), + }) + .optional() + .describe( + "MED_COMPLIANCE only — today's dose tally behind the progress score, for a 'taken/scheduled' ring display (e.g. 1/3). Absent on the derived score rings.", + ), }), ) .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.", + "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 is TODAY's dose progress off the snapshot's medsToday tally — `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is absent when no dose is scheduled today. 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"]),