Files
HealthLog/src/lib/medications/compliance-payload.ts
T
Marc-André Bombeck a827fe2ca7 fix(insights): correct medication compliance and per-user insight bucketing
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:

- Paused medications. A doctor-ordered pause flipped `active` off and stamped
  `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
  and every paused day became a missed dose after resume — collapsing
  adherence across the Health Score, the BP gate, the Coach snapshot and the
  report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
  migration 0211), close the open era on resume, thread the eras through the
  compliance context and drop slots inside a paused interval from the
  denominator. Every compliance call site now fetches and passes the eras.

- Health Score eras. The Health-Score fast path was the only compliance
  caller that skipped the shared schedule select and schedule revisions, so
  after a schedule edit it scored past days against the current schedule
  while every other surface used the historical era. Switch it to the shared
  select and thread revisions (and pause eras) like the canonical path.

- Insight bucketing timezone. The per-metric status series bucketed every
  user in Europe/Berlin, so a near-midnight reading landed in the wrong day
  and the relative-day phrasing was off for non-Berlin self-hosters. Thread
  the resolved user timezone and key off `userDayKey`.

- Correlation significance. User-facing correlations surfaced from five
  paired points with strength read straight off the coefficient. Route them
  through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
  so small-N coincidences no longer render as confident findings.

- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
  raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
  label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
  daily low-percentile proxy) so the label matches the input.
2026-06-28 03:28:37 +02:00

245 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The full compliance computation for one medication, shared by
* `GET /api/medications/[id]/compliance` (detail page, full payload incl.
* the 90-day heatmap) and `GET /api/medications/compliance` (the batched
* card read). Lifted out of the per-id route so the server cache can wrap
* one canonical builder from both entry points: ONE bounded intake-event
* read (only the columns the math consumes, floored at the 366-day fetch
* window) feeds ONE shared band-expansion pass
* (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks,
* the cadence-scaled display rows AND the 90-day heatmap.
*/
import { prisma } from "@/lib/db";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
lastNonSkippedTakenAt,
type ComplianceDisplay,
type ComplianceResult,
type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import { userDayKey } from "@/lib/tz/format";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Lower bound for the intake-event read. The widest window any served
* block needs is the 365-day display rung; one extra day absorbs the
* inclusive-boundary edge so the ledger's own 365-day clamp never sits
* on a half-fetched day.
*/
const EVENT_FETCH_WINDOW_DAYS = 366;
/** The cached response body (the exact public wire shape). */
export interface CompliancePayload {
compliance7: ComplianceResult;
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay;
}
/** The medication slice the payload builder consumes. */
export interface ComplianceMedicationInput {
id: string;
createdAt: Date;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
/** v1.16.3 — archived schedule eras for era-aware compliance. */
scheduleRevisions?: Parameters<
typeof buildComplianceMedicationContext
>[0]["scheduleRevisions"];
/** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */
pauseEras?: Parameters<
typeof buildComplianceMedicationContext
>[0]["pauseEras"];
}
/**
* One canonical cache key per `(user, medication, timezone)` cell. The
* key carries the timezone the payload was bucketed in: day keys, the
* heatmap, and the overdue cut all derive from `userTz`, so a timezone
* change must miss the old entry instead of serving day buckets computed
* for the previous zone until the TTL lapses. The `${userId}|` prefix is
* what `invalidateUserMedications` sweeps.
*/
export function complianceCacheKey(
userId: string,
medicationId: string,
userTz: string,
): string {
return `${userId}|${medicationId}|compliance|${userTz}`;
}
/**
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
*
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
*/
function bucketLedgerRow(
entry: DailyComplianceEntry,
row: DoseHistoryRow,
): void {
switch (row.status) {
case "taken_on_time":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
case "taken_late":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.late++;
break;
case "missed":
entry.expected++;
entry.expectedCount++;
break;
case "skipped":
entry.expected++;
entry.expectedCount++;
entry.skipped++;
break;
case "upcoming":
// A future / still-takeable slot is due but not yet acted on. It counts
// toward the day's expected/due grid but not toward taken or missed.
entry.expected++;
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
}
}
export async function buildCompliancePayload(
medication: ComplianceMedicationInput,
userId: string,
userTz: string,
): Promise<CompliancePayload> {
// v1.15.9 — pin a single `now` and thread it into every cadence
// computation so no block can straddle a day boundary on a slow request.
const now = new Date();
const createdAt = medication.createdAt;
const fetchFrom = new Date(
Math.max(
createdAt.getTime(),
now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
),
);
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
// Bounded to the 366-day fetch window: every served block is a suffix
// of the 365-day ledger window, so older rows can never change the
// response.
where: {
medicationId: medication.id,
userId,
deletedAt: null,
scheduledFor: { gte: fetchFrom },
},
orderBy: { scheduledFor: "desc" },
select: {
takenAt: true,
skipped: true,
scheduledFor: true,
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
// miss, not a neutral skip.
autoMissed: true,
// v1.15.20 — pinned takes bind by anchor in the unified ledger so the
// % agrees with the history view on a "zugeordnet" dose (taken-late).
attributionSource: true,
},
});
// v1.7.0 SB-SCHED-2 — thread the medication context so the denominator
// routes through the canonical engine (RRULE / rolling / one-shot / PRN /
// cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the
// fetch window (rolling cadences re-anchor on it).
const lastIntakeAt = lastNonSkippedTakenAt(events);
const medicationContext = buildComplianceMedicationContext(
medication,
lastIntakeAt,
userTz,
);
// ONE shared expansion pass for every served block (the v1.15.20
// performance fix): bands + ledger + timeline are minted once over the
// widest window and each sub-window is a tally over the same rows.
const bundle = buildMedicationComplianceBundle(
events,
medication.schedules,
medicationContext,
now,
);
// v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE
// unified dose-history ledger (the same bands the % + the history view
// read), so the per-day timing split (on-time / late) and the per-day
// missed marks can never disagree with the headline rate. The 90-day
// window is carved out of the shared ledger by `row.at`; the window
// floor clamps to the medication's creation so pre-existence days never
// mint phantom slots.
const heatmapFrom = new Date(
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
);
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
const byDay = new Map<string, DailyComplianceEntry>();
const ensureDay = (key: string): DailyComplianceEntry => {
let entry = byDay.get(key);
if (!entry) {
entry = {
expected: 0,
expectedCount: 0,
due: false,
taken: 0,
skipped: 0,
onTime: 0,
late: 0,
veryLate: 0,
early: 0,
};
byDay.set(key, entry);
}
return entry;
};
// Bucket each ledger row into its user-tz day. A slot row buckets on its
// anchor; an ad-hoc row on its real take time.
for (const row of bundle.ledgerRows) {
if (row.at.getTime() < heatmapFrom.getTime()) continue;
const key = userDayKey(row.at, userTz);
const entry = ensureDay(key);
bucketLedgerRow(entry, row);
}
for (const [key, entry] of byDay) {
entry.due = entry.expectedCount > 0;
dailyCompliance[key] = entry;
}
return {
compliance7: bundle.compliance7,
compliance30: bundle.compliance30,
dailyCompliance,
complianceDisplay: bundle.complianceDisplay,
};
}