Files
HealthLog/src/lib/dashboard/score-rings.ts
T
Marc-André Bombeck 47d638cf02 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.
2026-07-05 18:05:13 +02:00

170 lines
6.3 KiB
TypeScript

/**
* v1.27.7 — hero score-ring resolution for the dashboard snapshot.
*
* Resolves the user-selected `selectedScoreRings` (max 3, closed
* `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`
* 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 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'
* `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";
export type ScoreRingBand = "green" | "yellow" | "red";
/** One resolved hero ring. */
export interface DashboardScoreRing {
id: ScoreRingId;
/** 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). */
const DERIVED_RING_METRIC: Partial<Record<ScoreRingId, DerivedMetricId>> = {
READINESS: "READINESS",
RECOVERY_SCORE: "RECOVERY_SCORE",
SLEEP_SCORE: "SLEEP_SCORE",
};
/** The compute payload slice every derived score ring reads. */
interface ScoreBandValue {
score: number;
band: ScoreRingBand;
}
/**
* 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%.
*/
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,
// 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 },
};
}
/**
* 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,
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;
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 resolveDoseRing(medsToday);
}
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);
}