mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(dashboard): show today's dose tally on the medication hero ring
The MED_COMPLIANCE hero ring carried the pooled 7-day adherence percentage — a mystery number next to today-focused scores. It now answers the question the hero poses: how many of today's doses are done. The resolver reads takenToday/scheduledToday off the snapshot's own medsToday block (one shared builder call, no second tally); score stays the rounded 0..100 progress for wire compatibility and a new additive doses field carries the taken/scheduled pair. No dose scheduled today means no ring, and the band is progress semantics — green once everything is taken, yellow while doses remain, never red (a pending morning dose is not an alert state). The web ring renders the tally (1/3) with a doses label over the ring system's constant green arc — the same green family the compliance surfaces in Insights paint — instead of a band gradient; the derived rings keep their exact insights hues. ScoreRing gains documented valueText/ariaLabel overrides for tally-shaped centres. OpenAPI regenerated; labels land in all six locales.
This commit is contained in:
1 parent
9b4f3ce56d
commit
47d638cf02
16 files changed
+236
-287
No files matched your search
+22
-3
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -375,7 +375,12 @@ describe("<DashboardHero> — 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("<DashboardHero> — 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", () => {
|
||||
|
||||
@@ -99,10 +99,12 @@ const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
|
||||
|
||||
/**
|
||||
* 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<Record<ScoreRingId, RingHue>> = {
|
||||
READINESS: "readiness",
|
||||
@@ -110,12 +112,13 @@ const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
|
||||
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<ScoreRingId, string> = {
|
||||
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"
|
||||
>
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band={ring.band}
|
||||
size="sm"
|
||||
flat
|
||||
hue={RING_HUE_BY_ID[ring.id]}
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
{/* 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 ? (
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band="green"
|
||||
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
|
||||
ariaLabel={t("dashboard.hero.ringDosesAria", {
|
||||
taken: ring.doses.taken,
|
||||
scheduled: ring.doses.scheduled,
|
||||
})}
|
||||
size="sm"
|
||||
flat
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
) : (
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band={ring.band}
|
||||
size="sm"
|
||||
flat
|
||||
hue={RING_HUE_BY_ID[ring.id]}
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<ScoreRing
|
||||
|
||||
@@ -103,6 +103,16 @@ export interface ScoreRingProps {
|
||||
* + tests don't break.
|
||||
*/
|
||||
variant?: "band" | "onGradient";
|
||||
/**
|
||||
* v1.27.x — optional centre-text override for rings whose number is a
|
||||
* tally rather than a score (the hero dose ring's "1/3"). The arc still
|
||||
* sweeps on `score` (the 0..100 progress); only the displayed text
|
||||
* changes, and the count-up is skipped. Pair with `ariaLabel` — the
|
||||
* default announcement reads score + band, which is wrong for a tally.
|
||||
*/
|
||||
valueText?: string;
|
||||
/** Optional aria-label override (see `valueText`). */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -116,6 +126,8 @@ export function ScoreRing({
|
||||
delayMs = 0,
|
||||
baseline,
|
||||
flat = false,
|
||||
valueText,
|
||||
ariaLabel: ariaLabelOverride,
|
||||
className,
|
||||
}: ScoreRingProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -162,12 +174,14 @@ export function ScoreRing({
|
||||
});
|
||||
const displayedRounded = Math.round(displayed);
|
||||
|
||||
const ariaLabel = hasScore
|
||||
? t("insights.derived.scoreRing.aria", {
|
||||
score: Math.round(clamped),
|
||||
band: t(`insights.derived.scoreRing.band.${resolvedBand}`),
|
||||
})
|
||||
: t("insights.derived.scoreRing.ariaProvisional");
|
||||
const ariaLabel =
|
||||
ariaLabelOverride ??
|
||||
(hasScore
|
||||
? t("insights.derived.scoreRing.aria", {
|
||||
score: Math.round(clamped),
|
||||
band: t(`insights.derived.scoreRing.band.${resolvedBand}`),
|
||||
})
|
||||
: t("insights.derived.scoreRing.ariaProvisional"));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -259,7 +273,7 @@ export function ScoreRing({
|
||||
hasScore ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasScore ? displayedRounded : "—"}
|
||||
{hasScore ? (valueText ?? displayedRounded) : "—"}
|
||||
</span>
|
||||
{label ? (
|
||||
<span className={cn("text-muted-foreground", dims.labelClass)}>
|
||||
|
||||
@@ -158,15 +158,15 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
|
||||
/**
|
||||
* 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<ScoreRingId, string> = {
|
||||
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 }) {
|
||||
|
||||
@@ -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<Record<ModuleKey, boolean>> = {},
|
||||
@@ -50,54 +39,46 @@ function moduleMap(
|
||||
} 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;
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Record<ScoreRingId, DerivedMetricId>> = {
|
||||
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<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));
|
||||
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<ModuleKey, boolean>,
|
||||
now: Date,
|
||||
medsToday: MedsTodayForRings | null,
|
||||
): Promise<DashboardScoreRing[]> {
|
||||
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<DashboardScoreRing | null> => {
|
||||
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;
|
||||
|
||||
@@ -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[]),
|
||||
]);
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
Reference in new issue
Block a user