mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(compliance): split the god-module into focused units behind a barrel
The medication-compliance engine lived in one 2,099-line file with 38 exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard pillar, per-medication tile, insight targets, doctor report, …). A single edit intended for one surface could silently shift all of them. Split along the existing internal seams into `analytics/compliance/`: parsing, types, adapters, dose-status, occurrences, windows, cycle, ledger, display. `analytics/compliance.ts` is now a thin barrel that re-exports every public symbol, so no consumer import path changes. Pure move — no logic edits; the tally, window ladder, ledger and cycle math are byte-for-byte the code they were. Tests move alongside under `compliance/__tests__/` and keep exercising the public barrel.
This commit is contained in:
1 parent
e7802701ba
commit
d978c2b662
15 files changed
+2205
-2079
No files matched your search
+23
-2074
File diff suppressed because it is too large.
Load diff
File renamed without changes.
+1
-1
@@ -22,7 +22,7 @@ import {
|
||||
tallyComplianceFromLedger,
|
||||
tallyLedgerRows,
|
||||
type ComplianceSchedule,
|
||||
} from "../compliance";
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import {
|
||||
tallyComplianceFromLedger,
|
||||
type ComplianceSchedule,
|
||||
type MedicationPauseEraLike,
|
||||
} from "../compliance";
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
|
||||
|
||||
const TZ = "UTC";
|
||||
+2
-2
@@ -28,8 +28,8 @@ import {
|
||||
tallyComplianceFromLedger,
|
||||
type ComplianceMedicationContext,
|
||||
type ComplianceSchedule,
|
||||
} from "../compliance";
|
||||
import type { IntakeTimingClass } from "../compliance";
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { IntakeTimingClass } from "@/lib/analytics/compliance";
|
||||
import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SCHEDULE_COMPLIANCE_SELECT } from "../compliance";
|
||||
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
|
||||
|
||||
describe("SCHEDULE_COMPLIANCE_SELECT", () => {
|
||||
it("selects every schedule field the compliance engine consumes", () => {
|
||||
@@ -0,0 +1,131 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import {
|
||||
expandRollingRetrospective,
|
||||
occurrencesBetween,
|
||||
type CanonicalSchedule,
|
||||
type Occurrence,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import type {
|
||||
ComplianceIntakeInstant,
|
||||
ComplianceMedicationContext,
|
||||
ComplianceSchedule,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* v1.13.x — the non-skipped `takenAt` instants at or before `now`, ascending.
|
||||
* These anchor the retrospective rolling expected-dose grid (each logged dose
|
||||
* is one satisfied expected slot). The full history is passed — not just the
|
||||
* compliance window — so the gap-walk between consecutive intakes (which
|
||||
* synthesizes skipped-cycle misses) and the forward next-due anchor stay
|
||||
* correct across the window boundary; `expandRollingRetrospective` clamps the
|
||||
* emitted slots to its own `[from, to]`.
|
||||
*/
|
||||
export function rollingIntakeInstants(
|
||||
events: { takenAt: Date | null; skipped: boolean }[],
|
||||
now: Date,
|
||||
): Date[] {
|
||||
return events
|
||||
.filter(
|
||||
(e): e is { takenAt: Date; skipped: boolean } =>
|
||||
!e.skipped &&
|
||||
e.takenAt !== null &&
|
||||
e.takenAt.getTime() <= now.getTime(),
|
||||
)
|
||||
.map((e) => e.takenAt)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — adapt a compliance medication context to the canonical engine's
|
||||
* {@link RecurrenceContext}. The synthetic medication `id` only labels the
|
||||
* row for the engine's internal logging; the `idTag` keeps the two callers'
|
||||
* historical prefixes (`compliance-daily` vs `compliance-slots`) distinct.
|
||||
*/
|
||||
export function toRecurrenceCtx(
|
||||
ctx: ComplianceMedicationContext,
|
||||
idTag: string,
|
||||
): RecurrenceContext {
|
||||
return {
|
||||
medication: {
|
||||
id: idTag,
|
||||
startsOn: ctx.startsOn,
|
||||
endsOn: ctx.endsOn,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
},
|
||||
timeZone: ctx.timeZone,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — adapt a {@link ComplianceSchedule} to the canonical engine's
|
||||
* {@link CanonicalSchedule}, defaulting the optional fields the compliance
|
||||
* payload may omit. `id` is engine-internal labelling only.
|
||||
*/
|
||||
export function toCanonicalSchedule(
|
||||
s: ComplianceSchedule,
|
||||
id: string,
|
||||
): CanonicalSchedule {
|
||||
return {
|
||||
id,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
timesOfDay: s.timesOfDay ?? [],
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: normaliseDoseWindows(s.doseWindows),
|
||||
};
|
||||
}
|
||||
|
||||
export function intakeInstantsAtOrBefore(
|
||||
intakes: ComplianceIntakeInstant[],
|
||||
now: Date,
|
||||
): Date[] {
|
||||
return intakes
|
||||
.filter(
|
||||
(e): e is { takenAt: Date; skipped: boolean } =>
|
||||
!e.skipped &&
|
||||
e.takenAt !== null &&
|
||||
e.takenAt.getTime() <= now.getTime(),
|
||||
)
|
||||
.map((e) => e.takenAt)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.13.x — expand one schedule's expected occurrences over `[from, to]`,
|
||||
* routing a ROLLING schedule through the retrospective builder when intake
|
||||
* history is supplied (`intakeInstants` + `now`) and the forward-only engine
|
||||
* path for every other shape. This is the single expansion the slot-count
|
||||
* helpers and the displayed-rate timeline both delegate to, so the heatmap
|
||||
* `due` flags, the window-selection denominator, and the percentage agree.
|
||||
*/
|
||||
export function expandComplianceOccurrences(
|
||||
canonical: CanonicalSchedule,
|
||||
recurrenceCtx: RecurrenceContext,
|
||||
from: Date,
|
||||
to: Date,
|
||||
retro: { intakeInstants: Date[]; now: Date } | undefined,
|
||||
): Occurrence[] {
|
||||
if (retro && canonical.rollingIntervalDays !== null) {
|
||||
return expandRollingRetrospective(
|
||||
canonical,
|
||||
recurrenceCtx,
|
||||
from,
|
||||
to,
|
||||
retro.intakeInstants,
|
||||
retro.now,
|
||||
);
|
||||
}
|
||||
return occurrencesBetween(canonical, from, to, recurrenceCtx);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import {
|
||||
nextOccurrenceAfter,
|
||||
type Occurrence,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import {
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceIntakeInstant,
|
||||
type ComplianceMedicationContext,
|
||||
type ComplianceSchedule,
|
||||
} from "./types";
|
||||
import { toCanonicalSchedule, toRecurrenceCtx } from "./adapters";
|
||||
|
||||
/**
|
||||
* v1.13.x Fix 4 — the current-cycle descriptor. SEPARATE from the
|
||||
* percentage rows: a sparse med (weekly+ injection) whose current cycle is
|
||||
* not yet due must NOT render a scary red 0%. The percentage rows reflect
|
||||
* only CLOSED cycles (the open forward cycle is `upcoming`, excluded from
|
||||
* the denominator); this descriptor carries the open-cycle state so the
|
||||
* card can render a neutral "next dose in N days" / "due today" / "overdue"
|
||||
* line decoupled from the rate.
|
||||
*
|
||||
* States:
|
||||
* - `on_track`: `now < nextDueAt`. The next dose simply hasn't come round.
|
||||
* - `due`: `nextDueAt ≤ now ≤ graceUntil`. A neutral / amber call-to-action.
|
||||
* - `missed`: `now > graceUntil` and the cycle has no logged intake. The
|
||||
* only state that should tint red.
|
||||
* - `none`: no schedule projects a next dose (PRN, paused, ended). The
|
||||
* card shows no current-cycle line.
|
||||
*
|
||||
* `hasClosedCycles` is false for a brand-new sparse med with zero closed
|
||||
* dose cycles — the card then surfaces a neutral "no closed dose cycles
|
||||
* yet" state instead of a misleading 100% / 0%.
|
||||
*/
|
||||
export type CurrentCycleState = "on_track" | "due" | "missed" | "none";
|
||||
|
||||
export interface CurrentCycle {
|
||||
state: CurrentCycleState;
|
||||
/** The open cycle's due instant (ISO via JSON). Null when `state === "none"`. */
|
||||
nextDueAt: Date | null;
|
||||
/** End of the due slot's grace window. Null when `state === "none"`. */
|
||||
graceUntil: Date | null;
|
||||
/**
|
||||
* Whether the trailing window holds at least one CLOSED dose cycle. When
|
||||
* false the percentage rows are vacuous (no closed cycle to score) and the
|
||||
* card should show a neutral "not enough data yet" state.
|
||||
*/
|
||||
hasClosedCycles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.13.x Fix 4 — classify the medication's open (current) dose cycle.
|
||||
*
|
||||
* Uses the canonical engine's {@link nextOccurrenceAfter} to find the
|
||||
* earliest projected dose at or after the start of the search (one ms before
|
||||
* `now` so a slot landing exactly now is captured). The rolling branch of
|
||||
* `nextOccurrenceAfter` floors to the start of the user's current day, so an
|
||||
* overdue rolling dose still surfaces as the next slot — that lets us tell a
|
||||
* not-yet-due (`on_track`) cycle apart from an overdue one (`due` / `missed`).
|
||||
*
|
||||
* `hasClosedCycles` reads `expectedShort` from the already-computed window
|
||||
* selection: a window holding zero expected (closed) doses means the
|
||||
* percentage rows are vacuous and the card should show a neutral state.
|
||||
*/
|
||||
/**
|
||||
* v1.15.10 — half-window an intake may sit from a slot's canonical instant
|
||||
* and still count as "resolving" that slot. The intake write paths snap
|
||||
* `scheduledFor` to the exact engine slot instant, so an exact (or
|
||||
* sub-minute) match is the common case; the ±6h tolerance also catches an
|
||||
* off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13)
|
||||
* whose snapped slot is the 07:00 row but whose raw instant we compare
|
||||
* defensively. It is the same ±half-gap radius the cadence pairer uses for a
|
||||
* 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still
|
||||
* matches its one slot.
|
||||
*/
|
||||
const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.15.10 — true when an intake event has already resolved the slot at
|
||||
* `slotAt`. A resolved slot is one the user took, deliberately skipped, or
|
||||
* the auto-miss cron flagged — any of which means the card must NOT keep
|
||||
* surfacing that slot as the next dose. Matches on the snapped `scheduledFor`
|
||||
* first (the canonical, exact path) and falls back to the take/skip instant
|
||||
* within the resolve radius for rows that predate the snap or drifted.
|
||||
*/
|
||||
function slotIsResolved(
|
||||
slotAt: Date,
|
||||
intakes: ComplianceIntakeInstant[],
|
||||
): boolean {
|
||||
const slot = slotAt.getTime();
|
||||
for (const e of intakes) {
|
||||
const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null;
|
||||
if (!isResolved) continue;
|
||||
const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined;
|
||||
if (!ref) continue;
|
||||
if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildCurrentCycle(
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
now: Date,
|
||||
expectedShort: number,
|
||||
intakes?: ComplianceIntakeInstant[],
|
||||
): CurrentCycle {
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle");
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const allIntakes = intakes ?? [];
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt;
|
||||
|
||||
// The open cycle's due instant + grace, picked as the SOONEST across every
|
||||
// schedule. Two paths:
|
||||
// - ROLLING: the engine's `nextOccurrenceAfter` deliberately rolls an
|
||||
// overdue re-anchored dose forward (it floors to the start of the
|
||||
// user's current day), so it cannot surface an open cycle that is more
|
||||
// than a day overdue. For the current-cycle descriptor we instead
|
||||
// compute the due instant directly: `(lastNonSkippedIntake) + N` (or
|
||||
// `startsOn ?? createdAt` with no intake). This is what lets `due` /
|
||||
// `missed` be distinguished from `on_track`.
|
||||
// - NON-ROLLING: `nextOccurrenceAfter` already surfaces the next due
|
||||
// slot (RRULE / legacy / one-shot / cyclic).
|
||||
let dueAt: Date | null = null;
|
||||
let graceUntil: Date | null = null;
|
||||
const consider = (at: Date, grace: Date): void => {
|
||||
if (dueAt === null || at.getTime() < dueAt.getTime()) {
|
||||
dueAt = at;
|
||||
graceUntil = grace;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
const s = schedules[i];
|
||||
const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`);
|
||||
if (canonical.scheduleType === "PRN") continue;
|
||||
const n = canonical.rollingIntervalDays;
|
||||
if (n !== null && n > 0) {
|
||||
const anchor =
|
||||
lastIntakeAt !== null
|
||||
? new Date(lastIntakeAt.getTime() + n * DAY_MS)
|
||||
: (ctx.startsOn ?? ctx.createdAt);
|
||||
if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue;
|
||||
const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000;
|
||||
consider(anchor, new Date(anchor.getTime() + graceMs));
|
||||
continue;
|
||||
}
|
||||
// v1.15.10 — advance past slots the user has already resolved. The
|
||||
// engine's `nextOccurrenceAfter` is purely time-anchored, so for a
|
||||
// twice-daily med it surfaces today's 19:00 slot in the afternoon even
|
||||
// after the user logged that dose early — the card then sticks on a
|
||||
// resolved past/present slot ("träge"). Walk occurrences forward and pick
|
||||
// the first one no intake has resolved (taken / skipped / auto-missed), so
|
||||
// a med whose remaining slots today are all logged advances to tomorrow's
|
||||
// first open slot. Bounded so a fully-logged-ahead history can't spin.
|
||||
let after = new Date(now.getTime() - 1);
|
||||
let picked: Occurrence | null = null;
|
||||
for (let step = 0; step < 64; step++) {
|
||||
const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx);
|
||||
if (!occ) break;
|
||||
if (!slotIsResolved(occ.at, allIntakes)) {
|
||||
picked = occ;
|
||||
break;
|
||||
}
|
||||
// This slot is resolved — step strictly past it and keep looking.
|
||||
after = new Date(occ.at.getTime());
|
||||
}
|
||||
if (picked) consider(picked.at, picked.graceUntil);
|
||||
}
|
||||
|
||||
const hasClosedCycles = expectedShort > 0;
|
||||
|
||||
if (dueAt === null || graceUntil === null) {
|
||||
return {
|
||||
state: "none",
|
||||
nextDueAt: null,
|
||||
graceUntil: null,
|
||||
hasClosedCycles,
|
||||
};
|
||||
}
|
||||
|
||||
// Narrow the union-mutated locals for the comparisons below.
|
||||
const due: Date = dueAt;
|
||||
const grace: Date = graceUntil;
|
||||
let state: CurrentCycleState;
|
||||
if (now.getTime() < due.getTime()) {
|
||||
state = "on_track";
|
||||
} else if (now.getTime() <= grace.getTime()) {
|
||||
state = "due";
|
||||
} else {
|
||||
state = "missed";
|
||||
}
|
||||
|
||||
return { state, nextDueAt: due, graceUntil: grace, hasClosedCycles };
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
type CadenceEngineContext,
|
||||
type IntakeEventLike,
|
||||
type ScheduleLike,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import type {
|
||||
ComplianceMedicationContext,
|
||||
ComplianceResult,
|
||||
ComplianceSchedule,
|
||||
IntakeEvent,
|
||||
} from "./types";
|
||||
import {
|
||||
deriveDoseStatus,
|
||||
doseCadenceFamily,
|
||||
type DoseCadenceFamily,
|
||||
type DoseStatus,
|
||||
} from "./dose-status";
|
||||
import {
|
||||
COMPLIANCE_WINDOW_LADDER,
|
||||
MIN_STABLE_DOSES,
|
||||
selectComplianceWindows,
|
||||
type ComplianceWindowSelection,
|
||||
} from "./windows";
|
||||
import {
|
||||
COMPLIANCE_LEDGER_WINDOW_DAYS,
|
||||
buildComplianceLedgerRows,
|
||||
tallyComplianceFromLedger,
|
||||
tallyLedgerRows,
|
||||
} from "./ledger";
|
||||
import { buildCurrentCycle, type CurrentCycle } from "./cycle";
|
||||
import { rollingIntakeInstants } from "./adapters";
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.15.9 — the {@link DoseCadenceFamily} of the soonest non-PRN schedule,
|
||||
* used to pick the window model for the open dose's status. A multi-schedule
|
||||
* med whose cycle anchors on its earliest window keeps that window's cadence;
|
||||
* when every schedule is PRN (no projected dose) the family is irrelevant
|
||||
* because the caller only reads `currentDose` when a cycle is open.
|
||||
*/
|
||||
function soonestCadenceFamily(
|
||||
schedules: ComplianceSchedule[],
|
||||
): DoseCadenceFamily {
|
||||
for (const s of schedules) {
|
||||
if (s.scheduleType === "PRN") continue;
|
||||
return doseCadenceFamily(s);
|
||||
}
|
||||
return "daily";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.6 — the compliance-display block returned alongside the existing
|
||||
* `compliance7` / `compliance30` fields (which iOS + the Health Score read
|
||||
* verbatim). The card always renders two percentage rows; the server picks
|
||||
* the two windows from the medication's cadence and computes each row's
|
||||
* rate over the chosen span. A dense med keeps `7` / `30`; a sparse med
|
||||
* steps both windows up so each row covers enough expected doses to be
|
||||
* meaningful.
|
||||
*/
|
||||
|
||||
export interface ComplianceDisplay {
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
/** Realised expected dose count over the short window. */
|
||||
expectedShort: number;
|
||||
/** Realised expected dose count over the long window. */
|
||||
expectedLong: number;
|
||||
/** Echo of the density floor so a client can re-derive the rung. */
|
||||
minStableDoses: number;
|
||||
/**
|
||||
* Compliance percentage + counts + day-streak over the short window.
|
||||
* `taken` is the numerator the card renders next to the rate so two
|
||||
* identical percentages stay distinguishable and trustworthy; `expected`
|
||||
* is the rate denominator (taken + missed, EXCLUDING user skips) and
|
||||
* `missed` the count counting against the rate. The card can render
|
||||
* "26 / 30 · 87%" from `taken` / `expected`.
|
||||
*/
|
||||
short: {
|
||||
rate: number;
|
||||
taken: number;
|
||||
expected: number;
|
||||
missed: number;
|
||||
streak: number;
|
||||
};
|
||||
/** Compliance percentage + counts over the long window. */
|
||||
long: { rate: number; taken: number; expected: number; missed: number };
|
||||
/**
|
||||
* v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage
|
||||
* rows so a between-doses sparse med never renders a scary red number.
|
||||
*/
|
||||
currentCycle: CurrentCycle;
|
||||
/**
|
||||
* v1.15.9 — the open cycle's per-dose {@link DoseStatus}, server-derived
|
||||
* so the card renders its state (green take-window, overdue / heavily-
|
||||
* overdue escalation) from one authority instead of re-deriving the window
|
||||
* math client-side. `status` is `upcoming` when no dose is open yet and
|
||||
* there is no projected next dose (PRN / paused / ended). `targetAt` is the
|
||||
* open dose's target instant (echo of `currentCycle.nextDueAt`).
|
||||
*/
|
||||
currentDose: { status: DoseStatus; targetAt: Date | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.6 — compute the two-row {@link ComplianceDisplay} block.
|
||||
*
|
||||
* The card always shows two percentage rows. {@link selectComplianceWindows}
|
||||
* decides which windows they span from the medication's cadence, then each
|
||||
* row's rate is the cadence-aware {@link calculateCompliance} over that
|
||||
* window. The short row also carries the day-streak. The compliance math is
|
||||
* unchanged from the legacy 7-/30-day path — only the window day-counts move.
|
||||
*/
|
||||
export function buildComplianceDisplay(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
options?: { now?: Date },
|
||||
): ComplianceDisplay {
|
||||
const now = options?.now ?? new Date();
|
||||
// v1.13.x — thread the intake history so a ROLLING cadence's window
|
||||
// selection scores the retrospective grid (each logged dose is a closed
|
||||
// cycle) rather than the engine's single forward slot. Without this a
|
||||
// weekly rolling med always falls through to the widest `[90, 365]` rung.
|
||||
const { shortDays, longDays, expectedShort, expectedLong } =
|
||||
selectComplianceWindows(schedules, ctx, { now, intakes: events });
|
||||
|
||||
const short = calculateCompliance(
|
||||
events,
|
||||
schedules,
|
||||
shortDays,
|
||||
ctx.createdAt,
|
||||
{
|
||||
now,
|
||||
medicationContext: ctx,
|
||||
},
|
||||
);
|
||||
const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, {
|
||||
now,
|
||||
medicationContext: ctx,
|
||||
});
|
||||
|
||||
// v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates.
|
||||
const currentCycle = buildCurrentCycle(
|
||||
schedules,
|
||||
ctx,
|
||||
now,
|
||||
expectedShort,
|
||||
events,
|
||||
);
|
||||
|
||||
// v1.15.9 — derive the open dose's per-dose status from one server
|
||||
// authority so the card renders the take-window (green) / overdue /
|
||||
// heavily-overdue escalation without re-spelling the window math. The
|
||||
// cadence family comes from the SOONEST non-PRN schedule (the one the
|
||||
// open cycle anchors on). `none` cycles (PRN / paused / ended) carry an
|
||||
// `upcoming` status with a null target so the card stays calm.
|
||||
const currentDose: { status: DoseStatus; targetAt: Date | null } =
|
||||
currentCycle.nextDueAt
|
||||
? {
|
||||
status: deriveDoseStatus(
|
||||
currentCycle.nextDueAt,
|
||||
soonestCadenceFamily(schedules),
|
||||
now,
|
||||
),
|
||||
targetAt: currentCycle.nextDueAt,
|
||||
}
|
||||
: { status: "upcoming", targetAt: null };
|
||||
|
||||
return {
|
||||
shortDays,
|
||||
longDays,
|
||||
expectedShort,
|
||||
expectedLong,
|
||||
minStableDoses: MIN_STABLE_DOSES,
|
||||
// `expected` is the rate denominator (taken + missed) so the card can
|
||||
// render the trustworthy "taken / expected · rate%" triple; user skips
|
||||
// are excluded from it by construction (they never enter `missed`).
|
||||
short: {
|
||||
rate: short.rate,
|
||||
taken: short.taken,
|
||||
expected: short.taken + short.missed,
|
||||
missed: short.missed,
|
||||
streak: short.streak,
|
||||
},
|
||||
long: {
|
||||
rate: long.rate,
|
||||
taken: long.taken,
|
||||
expected: long.taken + long.missed,
|
||||
missed: long.missed,
|
||||
},
|
||||
currentCycle,
|
||||
currentDose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate compliance for a medication over a given period.
|
||||
*
|
||||
* Honours `daysOfWeek` (e.g. `"1"` for Mondays only) and
|
||||
* `intervalWeeks` (bi-weekly, tri-weekly, …) by delegating to
|
||||
* `buildCadenceTimeline`. The denominator is the number of dose slots
|
||||
* the schedule actually emits inside the window — not
|
||||
* `schedules.length * days`. A user on a weekly Monday-only schedule
|
||||
* who takes every Monday for 30 days reports 100% adherence (4 of 4
|
||||
* Mondays) instead of the pre-v1.5.0 ~13% (4 of 30).
|
||||
*
|
||||
* Skipped doses are excluded from the denominator (deliberate user
|
||||
* decision, not a compliance failure). When the window contains no
|
||||
* expected doses (paused med, brand-new prescription, schedule that
|
||||
* never fires in the window) the helper returns `rate: 100` so the
|
||||
* empty state doesn't trip a "0% compliance" alarm downstream.
|
||||
*
|
||||
* Streak counts consecutive days, ending at `now`, where every
|
||||
* expected dose for the day was taken (or skipped). Days with no
|
||||
* expected dose (out-of-cadence weekdays, off-weeks on a bi-weekly
|
||||
* schedule) advance the streak — the user gets credit for not
|
||||
* breaking on non-scheduled days.
|
||||
*
|
||||
* @param events Recorded intake events from
|
||||
* `MedicationIntakeEvent`. Only `scheduledFor`,
|
||||
* `takenAt`, `skipped` are read.
|
||||
* @param schedules Schedule rows for the medication. `daysOfWeek`
|
||||
* is read when present; missing field is treated
|
||||
* as daily.
|
||||
* @param days Rolling-window size in days (typically 7 / 30
|
||||
* / 90).
|
||||
* @param medicationCreatedAt
|
||||
* When provided, days before the medication
|
||||
* existed are excluded so they don't count as
|
||||
* "missed".
|
||||
* @param options.now Override for the rolling-window anchor. The
|
||||
* fast-path Health-Score helper passes a fixed
|
||||
* `now` so cached medication-compliance rates
|
||||
* agree with the score's other pillars; default
|
||||
* is `new Date()` (current wall-clock instant).
|
||||
* v1.5.0 — added so the cadence-aware adapter
|
||||
* can be driven deterministically from a caller
|
||||
* that already pinned its own `now`.
|
||||
*/
|
||||
export function calculateCompliance(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
days: number,
|
||||
medicationCreatedAt?: Date,
|
||||
options?: { now?: Date; medicationContext?: ComplianceMedicationContext },
|
||||
): ComplianceResult {
|
||||
if (schedules.length === 0) {
|
||||
return {
|
||||
totalExpected: 0,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
missed: 0,
|
||||
rate: 100,
|
||||
streak: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const now = options?.now ?? new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const periodStart = new Date(now.getTime() - days * DAY_MS);
|
||||
const effectiveStart =
|
||||
medicationCreatedAt && medicationCreatedAt > periodStart
|
||||
? medicationCreatedAt
|
||||
: periodStart;
|
||||
const effectiveDays = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS),
|
||||
);
|
||||
|
||||
// v1.15.18 — when a medication context is supplied, the count fields
|
||||
// (taken / skipped / missed / rate) come from the UNIFIED dose-history
|
||||
// ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the
|
||||
// keystone unification: the percentage is a tally over the exact same
|
||||
// ledger the history view renders, so the two can never contradict (a
|
||||
// dose can't read "taken late" in the % while the ledger calls it
|
||||
// "ad-hoc"). The numerator is on-time + late takes (a late dose still
|
||||
// counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded
|
||||
// from the denominator. The streak still walks the cadence timeline below
|
||||
// (its day-grain "every dose taken or skipped" rule is unchanged).
|
||||
//
|
||||
// Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the
|
||||
// legacy timeline tally byte-stable — they have no engine context to mint
|
||||
// bands from.
|
||||
const ledgerCtx = options?.medicationContext;
|
||||
let ledgerCounts: {
|
||||
taken: number;
|
||||
skipped: number;
|
||||
missed: number;
|
||||
rate: number;
|
||||
} | null = null;
|
||||
if (ledgerCtx) {
|
||||
const tally = tallyComplianceFromLedger(
|
||||
events,
|
||||
schedules,
|
||||
ledgerCtx,
|
||||
effectiveStart,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
ledgerCounts = {
|
||||
taken: tally.taken,
|
||||
skipped: tally.skipped,
|
||||
missed: tally.missed,
|
||||
rate: tally.rate,
|
||||
};
|
||||
}
|
||||
|
||||
// Normalise the schedule shape so legacy callers that pass only
|
||||
// `{ windowStart, windowEnd }` still produce a usable `daysOfWeek`
|
||||
// field (treated as daily by the cadence parser).
|
||||
const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({
|
||||
id: `compliance-${i}`,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
// v1.7.0 SB-SCHED-2 — thread the canonical-engine fields so the
|
||||
// cadence expander can delegate to `occurrencesBetween` when a
|
||||
// medication context is supplied. Undefined fields collapse to the
|
||||
// legacy weekday path inside `expandScheduleSlots`.
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
timesOfDay: s.timesOfDay,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
scheduleType: s.scheduleType ?? null,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
}));
|
||||
|
||||
// v1.7.0 SB-SCHED-2 — build the engine context once per medication.
|
||||
// When the caller supplies it, the timeline routes through the
|
||||
// canonical engine (RRULE / rolling / one-shot / PRN / cyclic);
|
||||
// otherwise the legacy weekday walker stays in force.
|
||||
const ctx = options?.medicationContext;
|
||||
const engineCtx: CadenceEngineContext | undefined = ctx
|
||||
? {
|
||||
startsOn: ctx.startsOn,
|
||||
endsOn: ctx.endsOn,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Match events against the same slot grid the cadence chart uses.
|
||||
// The chart's pairing radius is ±12 h so a late-by-six-hours dose
|
||||
// still attaches to the right slot instead of double-counting as
|
||||
// "missed + extra".
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
.filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
// v1.15.9 — carry the forgotten-dose flag into the timeline so an
|
||||
// auto-missed slot pairs to a `missed` status (counts against the
|
||||
// rate) rather than a neutral `skipped`.
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
}));
|
||||
|
||||
// v1.13.x — ROLLING retrospective expansion. A rolling cadence
|
||||
// (`rollingIntervalDays`, the canonical GLP-1 "every N days" shape) is
|
||||
// forward-only in the engine: `expandRolling` emits at most the single
|
||||
// immediately-next slot, so a historical compliance window saw either
|
||||
// zero expected slots (vacuous 100%) or one overdue slot (hard 0%) —
|
||||
// never the true multi-dose adherence over the trailing window. When a
|
||||
// medication context is threaded AND any schedule is rolling, route the
|
||||
// rolling schedules through the retrospective builder so each logged dose
|
||||
// is one satisfied expected slot (plus synthesized misses for skipped
|
||||
// whole cycles + a past-due forward slot). Non-rolling schedules keep the
|
||||
// forward-only engine path; both share `buildCadenceTimeline` so the
|
||||
// numerator and denominator agree (the v1.7.3 B15 convergence rule).
|
||||
const hasRolling = schedules.some(
|
||||
(s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0,
|
||||
);
|
||||
const retro =
|
||||
engineCtx && hasRolling
|
||||
? { intakeInstants: rollingIntakeInstants(events, now), now }
|
||||
: undefined;
|
||||
|
||||
const timeline = buildCadenceTimeline(
|
||||
normalisedSchedules,
|
||||
normalisedEvents,
|
||||
now,
|
||||
effectiveDays,
|
||||
medicationCreatedAt ?? effectiveStart,
|
||||
engineCtx?.timeZone,
|
||||
engineCtx,
|
||||
retro,
|
||||
);
|
||||
|
||||
// v1.15.18 — the count fields come from the unified ledger tally when a
|
||||
// medication context was supplied (the keystone unification), and from the
|
||||
// legacy timeline tally otherwise (context-less pure-math callers). The
|
||||
// streak below always walks the timeline — its day-grain rule is orthogonal
|
||||
// to the per-dose attribution and stays byte-stable for every caller.
|
||||
let taken: number;
|
||||
let skipped: number;
|
||||
let missed: number;
|
||||
let rate: number;
|
||||
if (ledgerCounts) {
|
||||
({ taken, skipped, missed, rate } = ledgerCounts);
|
||||
} else {
|
||||
taken = 0;
|
||||
skipped = 0;
|
||||
missed = 0;
|
||||
for (const slot of timeline) {
|
||||
if (slot.status === "taken") taken++;
|
||||
else if (slot.status === "skipped") skipped++;
|
||||
else if (slot.status === "missed") missed++;
|
||||
// `upcoming` slots (future window) are excluded from every counter
|
||||
// so a partial day at the head of the window doesn't pollute the rate.
|
||||
}
|
||||
// Skipped doses are excluded from the denominator — they represent a
|
||||
// deliberate user decision rather than a missed dose.
|
||||
const denom = taken + missed;
|
||||
rate = denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
|
||||
}
|
||||
|
||||
const totalExpected = taken + skipped + missed;
|
||||
|
||||
// Streak: consecutive days, ending today, where every expected dose
|
||||
// was taken or skipped. Days with no expected dose advance the
|
||||
// streak — out-of-cadence days are not failures. Delegated to the
|
||||
// shared `streaksFromTimeline` so the analytics streak and the
|
||||
// detail-page chip streak agree on every dose AND so the day keys are
|
||||
// computed in the USER's IANA timezone, not the host's. The prior
|
||||
// host-tz `getFullYear/getMonth/getDate` walk drifted off by a day
|
||||
// whenever the server clock's zone differed from the user's — the
|
||||
// timeline `slot.day` is already minted in the user zone, so the
|
||||
// walk has to match it. The window is `effectiveDays` ending at
|
||||
// `now`, which starts no earlier than `effectiveStart` (= the later
|
||||
// of the period start and the medication's creation) — so days
|
||||
// before the medication existed are never iterated, preserving the
|
||||
// old `cursor <= medicationCreatedAt` break by construction.
|
||||
const { current: streak } = streaksFromTimeline(
|
||||
timeline,
|
||||
now,
|
||||
effectiveDays,
|
||||
engineCtx?.timeZone,
|
||||
);
|
||||
|
||||
return { totalExpected, taken, skipped, missed, rate, streak };
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-medication compliance payload computed from ONE shared expansion
|
||||
* pass. `ledgerRows` is the unified dose-history ledger over
|
||||
* `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by
|
||||
* filtering on `row.at`.
|
||||
*/
|
||||
export interface MedicationComplianceBundle {
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
complianceDisplay: ComplianceDisplay;
|
||||
/** Unified ledger rows over `[ledgerFrom, now]`, chronological. */
|
||||
ledgerRows: DoseHistoryRow[];
|
||||
/** Lower bound of the mint window (clamped to the medication's creation). */
|
||||
ledgerFrom: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every block of the per-medication compliance response from a single
|
||||
* band-expansion pass.
|
||||
*
|
||||
* The historical composition called {@link calculateCompliance} four times
|
||||
* (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more
|
||||
* occurrence expansions for the rung probes) and a separate 90-day heatmap
|
||||
* mint — five-plus full band expansions per request. This builder instead:
|
||||
*
|
||||
* 1. mints the bands + reconstructs the ledger ONCE over
|
||||
* `[max(createdAt, now − 365 d), now]` (every served window is a
|
||||
* suffix of that range and ends at `now`, so a sub-window tally is a
|
||||
* filter over `row.at`, not a re-expansion);
|
||||
* 2. builds ONE cadence timeline over the same range for the per-window
|
||||
* streaks ({@link streaksFromTimeline} bounds its day-walk by the
|
||||
* requested window, so the wider timeline serves every window);
|
||||
* 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row
|
||||
* counts (one band per engine occurrence, so the counts match the
|
||||
* historical `expectedSlotsBetween` probes).
|
||||
*
|
||||
* The returned blocks carry the exact public shapes the route has always
|
||||
* served — only the number of expansion passes changed.
|
||||
*/
|
||||
export function buildMedicationComplianceBundle(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
now: Date,
|
||||
): MedicationComplianceBundle {
|
||||
const ledgerPeriodStart = new Date(
|
||||
now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS,
|
||||
);
|
||||
const ledgerFrom =
|
||||
ctx.createdAt.getTime() > ledgerPeriodStart.getTime()
|
||||
? ctx.createdAt
|
||||
: ledgerPeriodStart;
|
||||
|
||||
const hasSchedules = schedules.length > 0;
|
||||
const ledgerRows = hasSchedules
|
||||
? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now)
|
||||
: [];
|
||||
|
||||
// ONE cadence timeline over the full ledger window. Each per-window
|
||||
// streak below walks only its own trailing `effectiveDays`, so sharing
|
||||
// the wide timeline reproduces the per-window builds.
|
||||
const fullDays = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS),
|
||||
);
|
||||
const timeline = hasSchedules
|
||||
? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays)
|
||||
: [];
|
||||
|
||||
const resultForWindow = (days: number): ComplianceResult => {
|
||||
if (!hasSchedules) {
|
||||
// Mirrors `calculateCompliance`'s empty-schedule short-circuit.
|
||||
return {
|
||||
totalExpected: 0,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
missed: 0,
|
||||
rate: 100,
|
||||
streak: 0,
|
||||
};
|
||||
}
|
||||
const periodStart = new Date(now.getTime() - days * ONE_DAY_MS);
|
||||
const effectiveStart =
|
||||
ctx.createdAt.getTime() > periodStart.getTime()
|
||||
? ctx.createdAt
|
||||
: periodStart;
|
||||
const effectiveDays = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS),
|
||||
);
|
||||
const tally = tallyLedgerRows(ledgerRows, {
|
||||
from: effectiveStart,
|
||||
to: now,
|
||||
});
|
||||
const { current: streak } = streaksFromTimeline(
|
||||
timeline,
|
||||
now,
|
||||
effectiveDays,
|
||||
ctx.timeZone,
|
||||
);
|
||||
return {
|
||||
totalExpected: tally.taken + tally.skipped + tally.missed,
|
||||
taken: tally.taken,
|
||||
skipped: tally.skipped,
|
||||
missed: tally.missed,
|
||||
rate: tally.rate,
|
||||
streak,
|
||||
};
|
||||
};
|
||||
|
||||
// Window-ladder selection from the ledger's slot rows. A slot row is one
|
||||
// minted band, and the minter emits one band per engine occurrence, so
|
||||
// counting slot rows over a trailing window equals the historical
|
||||
// `expectedSlotsBetween(...).length` probe for that window.
|
||||
const expectedCache = new Map<number, number>();
|
||||
const expectedOver = (days: number): number => {
|
||||
const hit = expectedCache.get(days);
|
||||
if (hit !== undefined) return hit;
|
||||
const from = Math.max(
|
||||
ctx.createdAt.getTime(),
|
||||
now.getTime() - days * ONE_DAY_MS,
|
||||
);
|
||||
let count = 0;
|
||||
for (const row of ledgerRows) {
|
||||
if (row.kind !== "slot") continue;
|
||||
const t = row.at.getTime();
|
||||
if (t >= from && t <= now.getTime()) count++;
|
||||
}
|
||||
expectedCache.set(days, count);
|
||||
return count;
|
||||
};
|
||||
|
||||
let selection: ComplianceWindowSelection | null = null;
|
||||
for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) {
|
||||
const expectedShort = expectedOver(shortDays);
|
||||
const expectedLong = expectedOver(longDays);
|
||||
if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) {
|
||||
selection = { shortDays, longDays, expectedShort, expectedLong };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!selection) {
|
||||
// No rung cleared the floor — fall back to the widest rung, exactly
|
||||
// like `selectComplianceWindows`.
|
||||
const [shortDays, longDays] =
|
||||
COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1];
|
||||
selection = {
|
||||
shortDays,
|
||||
longDays,
|
||||
expectedShort: expectedOver(shortDays),
|
||||
expectedLong: expectedOver(longDays),
|
||||
};
|
||||
}
|
||||
|
||||
const compliance7 = resultForWindow(7);
|
||||
const compliance30 = resultForWindow(30);
|
||||
const short = resultForWindow(selection.shortDays);
|
||||
const long = resultForWindow(selection.longDays);
|
||||
|
||||
const currentCycle = buildCurrentCycle(
|
||||
schedules,
|
||||
ctx,
|
||||
now,
|
||||
selection.expectedShort,
|
||||
events,
|
||||
);
|
||||
const currentDose: { status: DoseStatus; targetAt: Date | null } =
|
||||
currentCycle.nextDueAt
|
||||
? {
|
||||
status: deriveDoseStatus(
|
||||
currentCycle.nextDueAt,
|
||||
soonestCadenceFamily(schedules),
|
||||
now,
|
||||
),
|
||||
targetAt: currentCycle.nextDueAt,
|
||||
}
|
||||
: { status: "upcoming", targetAt: null };
|
||||
|
||||
const complianceDisplay: ComplianceDisplay = {
|
||||
shortDays: selection.shortDays,
|
||||
longDays: selection.longDays,
|
||||
expectedShort: selection.expectedShort,
|
||||
expectedLong: selection.expectedLong,
|
||||
minStableDoses: MIN_STABLE_DOSES,
|
||||
short: {
|
||||
rate: short.rate,
|
||||
taken: short.taken,
|
||||
expected: short.taken + short.missed,
|
||||
missed: short.missed,
|
||||
streak: short.streak,
|
||||
},
|
||||
long: {
|
||||
rate: long.rate,
|
||||
taken: long.taken,
|
||||
expected: long.taken + long.missed,
|
||||
missed: long.missed,
|
||||
},
|
||||
currentCycle,
|
||||
currentDose,
|
||||
};
|
||||
|
||||
return {
|
||||
compliance7,
|
||||
compliance30,
|
||||
complianceDisplay,
|
||||
ledgerRows,
|
||||
ledgerFrom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The cadence-timeline construction from {@link calculateCompliance},
|
||||
* extracted so {@link buildMedicationComplianceBundle} can build it once
|
||||
* over the full ledger window instead of once per served sub-window. Same
|
||||
* normalisation, same engine context, same rolling-retrospective routing.
|
||||
*/
|
||||
function buildTimelineForWindow(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
now: Date,
|
||||
effectiveStart: Date,
|
||||
effectiveDays: number,
|
||||
): ReturnType<typeof buildCadenceTimeline> {
|
||||
const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({
|
||||
id: `compliance-${i}`,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
timesOfDay: s.timesOfDay,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
scheduleType: s.scheduleType ?? null,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
}));
|
||||
|
||||
const engineCtx: CadenceEngineContext = {
|
||||
startsOn: ctx.startsOn,
|
||||
endsOn: ctx.endsOn,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
};
|
||||
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
.filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
}));
|
||||
|
||||
const hasRolling = schedules.some(
|
||||
(s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0,
|
||||
);
|
||||
const retro = hasRolling
|
||||
? { intakeInstants: rollingIntakeInstants(events, now), now }
|
||||
: undefined;
|
||||
|
||||
return buildCadenceTimeline(
|
||||
normalisedSchedules,
|
||||
normalisedEvents,
|
||||
now,
|
||||
effectiveDays,
|
||||
ctx.createdAt,
|
||||
ctx.timeZone,
|
||||
engineCtx,
|
||||
retro,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import type { ComplianceSchedule } from "./types";
|
||||
|
||||
/**
|
||||
* v1.15.9 — cadence-aware per-dose grace/miss window model.
|
||||
*
|
||||
* A medication's dose either is on-time, is still takeable but late
|
||||
* (counts when taken), or has slipped past the point where a self-hoster
|
||||
* could plausibly still take it (a MISS). The cutoffs differ by cadence:
|
||||
* an intraday/daily dose has tight hour-scale windows; a weekly/rolling
|
||||
* injectable (the GLP-1 case) follows the clinical "take it within 4 days,
|
||||
* otherwise skip to the next scheduled dose" rule.
|
||||
*
|
||||
* The defaults below are centralised named constants so a future revision
|
||||
* can expose them per-medication without re-deriving the boundaries. Make
|
||||
* it correct first; configurability later.
|
||||
*
|
||||
* DAILY / INTRADAY (minute-scale windows around the target instant):
|
||||
* - on-time: target − 60 min … target + 60 min
|
||||
* - overdue: target + 60 min … target + 240 min (still takeable)
|
||||
* - missed: > target + 240 min, OR the next dose's on-time window
|
||||
* starts first (whichever is sooner — adjacent doses never
|
||||
* overlap).
|
||||
*
|
||||
* WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule):
|
||||
* - on-time: target day ± 1 day
|
||||
* - overdue: up to target + 4 days (late-but-counts)
|
||||
* - missed: > target + 4 days
|
||||
*/
|
||||
export { DOSE_WINDOW_DEFAULTS };
|
||||
|
||||
/**
|
||||
* v1.15.9 — the derived per-dose state the medication card renders.
|
||||
*
|
||||
* - `on_time_window` — due now, inside the on-time window (green).
|
||||
* - `overdue` — past on-time, before the miss cutoff (still
|
||||
* takeable; the card escalates the tint as it nears
|
||||
* the cutoff — "stark überfällig").
|
||||
* - `missed` — past the miss cutoff, never acted on.
|
||||
* - `taken_on_time` — taken inside the on-time window.
|
||||
* - `taken_late` — taken in the overdue window (still counts).
|
||||
* - `skipped` — a deliberate user skip (excluded from the rate).
|
||||
* - `upcoming` — the on-time window has not opened yet.
|
||||
*
|
||||
* PRN doses are never scheduled and so never produce a status — callers
|
||||
* exclude them before calling {@link deriveDoseStatus}.
|
||||
*/
|
||||
export type DoseStatus =
|
||||
| "upcoming"
|
||||
| "on_time_window"
|
||||
| "overdue"
|
||||
| "missed"
|
||||
| "taken_on_time"
|
||||
| "taken_late"
|
||||
| "skipped";
|
||||
|
||||
/** Cadence family the window math keys off. */
|
||||
export type DoseCadenceFamily = "daily" | "weekly";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/**
|
||||
* v1.15.9 — derive a single dose's {@link DoseStatus} from its target
|
||||
* instant, the cadence family, `now`, and (optionally) when it was taken /
|
||||
* whether the user skipped it. Pure and deterministic.
|
||||
*
|
||||
* `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next
|
||||
* dose's on-time window start, so two adjacent intraday doses never claim
|
||||
* overlapping overdue windows — the earlier dose flips to `missed` the
|
||||
* moment the later one comes due if that is sooner than its own cutoff.
|
||||
*
|
||||
* The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override
|
||||
* per-medication once that configurability lands.
|
||||
*/
|
||||
export function deriveDoseStatus(
|
||||
targetAt: Date,
|
||||
cadence: DoseCadenceFamily,
|
||||
now: Date,
|
||||
options?: {
|
||||
takenAt?: Date | null;
|
||||
skipped?: boolean;
|
||||
nextDoseAt?: Date | null;
|
||||
windows?: Partial<typeof DOSE_WINDOW_DEFAULTS>;
|
||||
},
|
||||
): DoseStatus {
|
||||
if (options?.skipped) return "skipped";
|
||||
|
||||
const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) };
|
||||
const target = targetAt.getTime();
|
||||
|
||||
let onTimeStart: number;
|
||||
let onTimeEnd: number;
|
||||
let overdueEnd: number;
|
||||
if (cadence === "weekly") {
|
||||
onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS;
|
||||
onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS;
|
||||
overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS;
|
||||
} else {
|
||||
onTimeStart = target - w.dailyOnTimeMinutes * 60_000;
|
||||
onTimeEnd = target + w.dailyOnTimeMinutes * 60_000;
|
||||
overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000;
|
||||
}
|
||||
|
||||
// Never let the miss cutoff bleed into the next dose's on-time window.
|
||||
if (options?.nextDoseAt) {
|
||||
const nextOnTimeStart =
|
||||
cadence === "weekly"
|
||||
? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS
|
||||
: options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000;
|
||||
if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart;
|
||||
}
|
||||
|
||||
const takenAt = options?.takenAt ?? null;
|
||||
if (takenAt) {
|
||||
const t = takenAt.getTime();
|
||||
return t <= onTimeEnd ? "taken_on_time" : "taken_late";
|
||||
}
|
||||
|
||||
const n = now.getTime();
|
||||
if (n < onTimeStart) return "upcoming";
|
||||
if (n <= onTimeEnd) return "on_time_window";
|
||||
if (n <= overdueEnd) return "overdue";
|
||||
return "missed";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily}
|
||||
* the window model uses. A rolling cadence, or any RRULE / legacy weekly
|
||||
* cadence that emits less often than daily, is `weekly` (day-scale windows +
|
||||
* the 4-day rule); everything denser (daily, intraday multi-dose) is
|
||||
* `daily` (minute-scale windows).
|
||||
*/
|
||||
export function doseCadenceFamily(
|
||||
schedule: ComplianceSchedule,
|
||||
): DoseCadenceFamily {
|
||||
if (
|
||||
schedule.rollingIntervalDays != null &&
|
||||
schedule.rollingIntervalDays >= 2
|
||||
) {
|
||||
return "weekly";
|
||||
}
|
||||
const rrule = schedule.rrule ?? "";
|
||||
if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly";
|
||||
// A legacy daysOfWeek restriction to specific weekdays (not every day)
|
||||
// dosed once a day is still effectively daily-cadence on the days it
|
||||
// fires; the per-slot window is minute-scale either way. Only the rolling
|
||||
// / calendar-sparse shapes use the day-scale 4-day rule.
|
||||
return "daily";
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import type {
|
||||
BandMinterMedication,
|
||||
DoseWindowConfig,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import { buildBandsForSchedulesWithEras } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
type HistoryIntake,
|
||||
} from "@/lib/medications/scheduling/dose-history";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import type {
|
||||
ComplianceMedicationContext,
|
||||
ComplianceSchedule,
|
||||
IntakeEvent,
|
||||
} from "./types";
|
||||
import {
|
||||
intakeInstantsAtOrBefore,
|
||||
toCanonicalSchedule,
|
||||
toRecurrenceCtx,
|
||||
} from "./adapters";
|
||||
|
||||
/**
|
||||
* v1.15.18 — the unified compliance tally over the dose-history ledger.
|
||||
*
|
||||
* THE single source of the medication compliance %. It builds the
|
||||
* cadence-aware `SlotBand[]` per schedule (the shared band minter — every
|
||||
* cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic,
|
||||
* PRN), reconstructs the ONE dose-history ledger
|
||||
* (`reconstructDoseHistory`), and tallies it so the percentage and the
|
||||
* history view are mathematically incapable of contradicting each other
|
||||
* (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher
|
||||
* that `calculateCompliance` used for the engine-routed path.
|
||||
*
|
||||
* The tally follows the adherence literature's TAKING-vs-TIMING split:
|
||||
* - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is
|
||||
* still a taken dose; "late" is NOT collapsed into "missed";
|
||||
* - denominator = taken + `missed`;
|
||||
* - EXCLUDED from the denominator: `skipped` (deliberate user decision),
|
||||
* `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the
|
||||
* window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false`
|
||||
* — PRN has no defensible denominator per the literature);
|
||||
* - the rate is capped at 100% (extra doses never inflate it).
|
||||
*
|
||||
* The on-time / late split is surfaced separately so a caller can show both
|
||||
* the TAKING rate (the headline) and the TIMING quality.
|
||||
*
|
||||
* Pure / synchronous: the bands are minted from pre-fetched schedules +
|
||||
* intake instants; no DB access.
|
||||
*/
|
||||
export interface LedgerComplianceTally {
|
||||
/** Doses taken (on-time + late). The TAKING-adherence numerator. */
|
||||
taken: number;
|
||||
/** Of `taken`, the count inside the on-time band. */
|
||||
takenOnTime: number;
|
||||
/** Of `taken`, the count in the late tail (still counts as taken). */
|
||||
takenLate: number;
|
||||
/** Expected doses never acted on past their miss cutoff. */
|
||||
missed: number;
|
||||
/** Deliberate user skips — excluded from the denominator. */
|
||||
skipped: number;
|
||||
/** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */
|
||||
adHoc: number;
|
||||
/** taken + missed (the rate denominator). */
|
||||
denominator: number;
|
||||
/** round(100 · taken / denominator), capped at 100. 100 on empty. */
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export function tallyComplianceFromLedger(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
from: Date,
|
||||
to: Date,
|
||||
now: Date,
|
||||
windowConfig?: DoseWindowConfig,
|
||||
): LedgerComplianceTally {
|
||||
const rows = buildComplianceLedgerRows(
|
||||
events,
|
||||
schedules,
|
||||
ctx,
|
||||
from,
|
||||
to,
|
||||
now,
|
||||
windowConfig,
|
||||
);
|
||||
return tallyLedgerRows(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE
|
||||
* unified dose-history ledger for them. This is the expansion pass behind
|
||||
* {@link tallyComplianceFromLedger}, extracted so a caller that needs
|
||||
* several trailing sub-windows (7-day / 30-day / display / heatmap, all
|
||||
* ending at `now`) can mint the bands ONCE over the widest window and tally
|
||||
* each sub-window from the same rows via {@link tallyLedgerRows} instead of
|
||||
* re-expanding per window.
|
||||
*
|
||||
* Pure / synchronous: bands come from pre-fetched schedules + intake
|
||||
* instants; no DB access.
|
||||
*/
|
||||
export function buildComplianceLedgerRows(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
from: Date,
|
||||
to: Date,
|
||||
now: Date,
|
||||
windowConfig?: DoseWindowConfig,
|
||||
): DoseHistoryRow[] {
|
||||
const medication: BandMinterMedication = {
|
||||
id: "compliance-tally",
|
||||
startsOn: ctx.startsOn,
|
||||
endsOn: ctx.endsOn,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
};
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally");
|
||||
const canonicalSchedules = schedules.map((s, i) => {
|
||||
const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`);
|
||||
// A legacy daily schedule carries only `windowStart` (no `timesOfDay`,
|
||||
// no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy`
|
||||
// reads that as "every day at windowStart", but the band minter's
|
||||
// cadence-detection gate needs an explicit time signal — surface
|
||||
// `windowStart` as the single time-of-day so the daily band is minted.
|
||||
if (
|
||||
canonical.timesOfDay.length === 0 &&
|
||||
canonical.rrule === null &&
|
||||
canonical.rollingIntervalDays === null &&
|
||||
canonical.scheduleType !== "PRN" &&
|
||||
!ctx.oneShot
|
||||
) {
|
||||
return { ...canonical, timesOfDay: [canonical.windowStart] };
|
||||
}
|
||||
return canonical;
|
||||
});
|
||||
// Rolling cadences anchor their retrospective grid AT each logged intake;
|
||||
// the bands need every non-skipped take in (or before) the window. Reuse
|
||||
// the same instant-extraction the legacy rolling path uses so the
|
||||
// numerator and denominator are built from one expansion.
|
||||
const intakeInstants = intakeInstantsAtOrBefore(
|
||||
events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })),
|
||||
to,
|
||||
);
|
||||
|
||||
// v1.16.3 — era-aware mint: archived eras band with THEIR schedules.
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: ctx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz: ctx.timeZone,
|
||||
range: { from, to },
|
||||
now,
|
||||
windowConfig,
|
||||
intakeInstants,
|
||||
});
|
||||
|
||||
// The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The
|
||||
// bands already partition the slot space, so the union of every
|
||||
// non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct
|
||||
// DoseHistory` claims each slot by at most one intake, so pooling the
|
||||
// (already correctly-minted) bands is safe. PRN groups (no expected
|
||||
// slots) contribute no bands; their intakes surface as ad-hoc and are
|
||||
// excluded from the denominator, exactly as the literature requires.
|
||||
const bands: SlotBand[] = [];
|
||||
for (const g of groups) {
|
||||
if (g.hasExpectedSlots) bands.push(...g.bands);
|
||||
}
|
||||
|
||||
const intakes: HistoryIntake[] = events
|
||||
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
// v1.15.20 — a pinned take binds by anchor and tallies as taken-late
|
||||
// (slot served, no on-time gain) instead of ad_hoc + missed.
|
||||
pinned: e.attributionSource === "USER_PIN",
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, intakes, now);
|
||||
|
||||
// v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a
|
||||
// pause interval. While a medication is paused no dose is expected, so a
|
||||
// slot minted across the paused window must never count as "missed" (the
|
||||
// denominator-inflating status). Only `slot` rows are dropped, and only
|
||||
// those whose status feeds the tally (taken / missed) — skip / ad-hoc /
|
||||
// upcoming rows are already excluded from the denominator, so dropping
|
||||
// them here would be a redundant double-exclusion. An open era
|
||||
// (`resumedAt === null`) runs to `now`.
|
||||
const pauseEras = ctx.pauseEras;
|
||||
if (pauseEras && pauseEras.length > 0) {
|
||||
const isInPause = (at: Date): boolean => {
|
||||
const t = at.getTime();
|
||||
for (const era of pauseEras) {
|
||||
const start = era.pausedAt.getTime();
|
||||
const end = (era.resumedAt ?? now).getTime();
|
||||
if (t >= start && t < end) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return rows.filter((row) => {
|
||||
if (row.kind !== "slot") return true;
|
||||
if (
|
||||
row.status !== "taken_on_time" &&
|
||||
row.status !== "taken_late" &&
|
||||
row.status !== "missed"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !isInPause(row.at);
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally pre-built ledger rows into the {@link LedgerComplianceTally}
|
||||
* counters. When `window` is supplied only rows whose instant (`row.at`)
|
||||
* falls inside `[window.from, window.to]` (inclusive) are counted — that is
|
||||
* how a sub-window tally is carved out of a wider single-pass ledger. The
|
||||
* window-less call tallies every row, byte-identical to the historical
|
||||
* {@link tallyComplianceFromLedger} behaviour.
|
||||
*/
|
||||
export function tallyLedgerRows(
|
||||
rows: DoseHistoryRow[],
|
||||
window?: { from: Date; to: Date },
|
||||
): LedgerComplianceTally {
|
||||
let takenOnTime = 0;
|
||||
let takenLate = 0;
|
||||
let missed = 0;
|
||||
let skipped = 0;
|
||||
let adHoc = 0;
|
||||
for (const row of rows) {
|
||||
if (window) {
|
||||
const t = row.at.getTime();
|
||||
if (t < window.from.getTime() || t > window.to.getTime()) continue;
|
||||
}
|
||||
switch (row.status) {
|
||||
case "taken_on_time":
|
||||
takenOnTime++;
|
||||
break;
|
||||
case "taken_late":
|
||||
takenLate++;
|
||||
break;
|
||||
case "missed":
|
||||
missed++;
|
||||
break;
|
||||
case "skipped":
|
||||
skipped++;
|
||||
break;
|
||||
case "ad_hoc":
|
||||
adHoc++;
|
||||
break;
|
||||
// `upcoming` slots are future / still-takeable → excluded from every
|
||||
// counter so a partial head-of-window day never pollutes the rate.
|
||||
}
|
||||
}
|
||||
|
||||
const taken = takenOnTime + takenLate;
|
||||
const denominator = taken + missed;
|
||||
const rate =
|
||||
denominator > 0
|
||||
? Math.min(100, Math.round((taken / denominator) * 100))
|
||||
: 100;
|
||||
|
||||
return {
|
||||
taken,
|
||||
takenOnTime,
|
||||
takenLate,
|
||||
missed,
|
||||
skipped,
|
||||
adHoc,
|
||||
denominator,
|
||||
rate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Widest trailing window the single-pass compliance ledger has to cover:
|
||||
* the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every
|
||||
* sub-window the per-medication compliance endpoint serves (7 / 30 /
|
||||
* cadence-scaled display rows / 90-day heatmap) is a suffix of it.
|
||||
*/
|
||||
export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365;
|
||||
|
||||
/**
|
||||
* One day of the cadence-aware compliance series: the day's compliance %
|
||||
* over the doses the schedule actually expected that day, plus the raw
|
||||
* taken / missed counts behind it.
|
||||
*/
|
||||
export interface DailyComplianceRate {
|
||||
/** The day's anchor instant (the first expected slot of the day). */
|
||||
date: Date;
|
||||
/** round(100 · taken / (taken + missed)), capped at 100. */
|
||||
rate: number;
|
||||
/** Doses taken (on-time + late) on the day. */
|
||||
taken: number;
|
||||
/** Expected doses never acted on past their miss cutoff, on the day. */
|
||||
missed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware
|
||||
* per-day compliance series.
|
||||
*
|
||||
* Only days the schedule's cadence actually expected a dose produce a point:
|
||||
* the series is grouped over the ledger's scheduled `slot` rows, so an
|
||||
* off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week
|
||||
* (the off-week of a bi-weekly schedule) emits no point at all and therefore
|
||||
* can never be read as a 0% "miss". Each day's rate uses the SAME
|
||||
* taken / (taken + missed) tally — and the SAME on-time-plus-late numerator,
|
||||
* skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies
|
||||
* to `compliance7` / `compliance30`, so the per-day series and the window
|
||||
* rates are computed from one ledger and cannot contradict each other.
|
||||
*
|
||||
* `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming`
|
||||
* rows (the window hasn't opened) are dropped before grouping, exactly as the
|
||||
* window tally excludes them. A day whose only ledger rows are skips yields no
|
||||
* point (zero denominator) rather than a misleading 0%.
|
||||
*
|
||||
* Days are bucketed in the USER's timezone (`tz`). Passing the caller's real
|
||||
* zone keeps an evening dose on the local day it was actually due: a fixed
|
||||
* Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day)
|
||||
* into the following Berlin day, splitting one local day's two doses across
|
||||
* two buckets and skewing every per-day rate for non-Berlin users.
|
||||
*/
|
||||
export function dailyComplianceRatesFromLedger(
|
||||
ledgerRows: DoseHistoryRow[],
|
||||
tz: string,
|
||||
): DailyComplianceRate[] {
|
||||
const byDay = new Map<
|
||||
string,
|
||||
{ taken: number; missed: number; date: Date }
|
||||
>();
|
||||
|
||||
for (const row of ledgerRows) {
|
||||
// Only scheduled slots have a defensible denominator; ad-hoc top-ups are
|
||||
// excluded from the rate exactly as `tallyLedgerRows` excludes them.
|
||||
if (row.kind !== "slot") continue;
|
||||
const isTaken =
|
||||
row.status === "taken_on_time" || row.status === "taken_late";
|
||||
const isMissed = row.status === "missed";
|
||||
// `skipped` (deliberate) and `upcoming` (window not open) advance neither
|
||||
// counter — they never enter the day's denominator.
|
||||
if (!isTaken && !isMissed) continue;
|
||||
|
||||
const dayKey = userDayKey(row.at, tz);
|
||||
const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at };
|
||||
if (isTaken) bucket.taken += 1;
|
||||
else bucket.missed += 1;
|
||||
// Keep the earliest slot instant of the day as the point's anchor.
|
||||
if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at;
|
||||
byDay.set(dayKey, bucket);
|
||||
}
|
||||
|
||||
return Array.from(byDay.values())
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
.map((day) => {
|
||||
const denom = day.taken + day.missed;
|
||||
return {
|
||||
date: day.date,
|
||||
rate:
|
||||
denom > 0
|
||||
? Math.min(100, Math.round((day.taken / denom) * 100))
|
||||
: 100,
|
||||
taken: day.taken,
|
||||
missed: day.missed,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import { occurrencesAcrossEras } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import type { Occurrence } from "@/lib/medications/scheduling/recurrence";
|
||||
import type {
|
||||
ComplianceIntakeInstant,
|
||||
ComplianceMedicationContext,
|
||||
ComplianceSchedule,
|
||||
} from "./types";
|
||||
import {
|
||||
expandComplianceOccurrences,
|
||||
intakeInstantsAtOrBefore,
|
||||
toCanonicalSchedule,
|
||||
toRecurrenceCtx,
|
||||
} from "./adapters";
|
||||
|
||||
/**
|
||||
* v1.7.0 item 5 — count the expected dose slots a medication's schedules
|
||||
* emit inside `[dayStart, dayEnd)`, routed through the canonical engine.
|
||||
* Powers the per-day `due` / `expectedCount` fields on the per-med
|
||||
* compliance payload so iOS history renders a "missed" mark only on days
|
||||
* the schedule actually expected a dose (not off-weeks / non-matching
|
||||
* weekdays / PRN days).
|
||||
*
|
||||
* v1.13.x — pass `intakes` so a ROLLING schedule routes through the
|
||||
* retrospective grid (each logged dose is a `due` day, plus skipped-cycle
|
||||
* misses + a past-due forward slot) instead of the engine's single forward
|
||||
* slot. Omitting `intakes` keeps the forward-only behaviour for callers that
|
||||
* don't have the intake history at hand.
|
||||
*/
|
||||
export function expectedSlotCountForDay(
|
||||
schedules: ComplianceSchedule[],
|
||||
dayStart: Date,
|
||||
dayEnd: Date,
|
||||
ctx: ComplianceMedicationContext,
|
||||
intakes?: ComplianceIntakeInstant[],
|
||||
): number {
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily");
|
||||
const now = new Date();
|
||||
const retro =
|
||||
intakes && schedules.some((s) => s.rollingIntervalDays != null)
|
||||
? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now }
|
||||
: undefined;
|
||||
// v1.16.3 — era-aware: a past day counts the slots of the schedule that
|
||||
// was live THEN. With no revisions the single live era expands exactly
|
||||
// the per-schedule loop this used to run.
|
||||
return occurrencesAcrossEras(
|
||||
{
|
||||
from: dayStart,
|
||||
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
|
||||
// slot exactly at the next day's midnight doesn't double-count.
|
||||
to: new Date(dayEnd.getTime() - 1),
|
||||
},
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(
|
||||
schedule,
|
||||
recurrenceCtx,
|
||||
eraFrom,
|
||||
eraTo,
|
||||
retro,
|
||||
),
|
||||
{ oneShot: ctx.oneShot },
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the
|
||||
* expected-dose occurrences *themselves* (ascending by instant) over an
|
||||
* arbitrary window, rather than just the per-day count. Same loop, same
|
||||
* canonical-engine delegation; we keep the slots so the dose-adherence
|
||||
* timeline can pair each expected slot to its intake.
|
||||
*
|
||||
* Used by {@link buildComplianceDisplay} to decide the card's render mode
|
||||
* (percent bars vs an uptime-style per-dose strip) and to build the strip.
|
||||
* `occurrencesBetween` is inclusive of both ends; the caller passes a
|
||||
* `[from, to]` window and we sort the union of every schedule's slots so
|
||||
* a multi-schedule medication interleaves its slots in time order.
|
||||
*
|
||||
* v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The
|
||||
* legacy weekday walker floors on `startsOn` but not on `createdAt`, so a
|
||||
* brand-new daily med queried over a 30-day window would otherwise emit
|
||||
* slots for every day before it existed (7/30 expected on a 2-day-old med).
|
||||
* Clamping here keeps the expected-dose denominator — and the window
|
||||
* selection that reads it — honest about the medication's real age. The
|
||||
* displayed rates clamp independently via `calculateCompliance`'s
|
||||
* `medicationCreatedAt` argument, so this only fixes the slot counts.
|
||||
*/
|
||||
export function expectedSlotsBetween(
|
||||
schedules: ComplianceSchedule[],
|
||||
from: Date,
|
||||
to: Date,
|
||||
ctx: ComplianceMedicationContext,
|
||||
intakes?: ComplianceIntakeInstant[],
|
||||
): Occurrence[] {
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots");
|
||||
const effectiveFrom =
|
||||
ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from;
|
||||
// v1.13.x — for a ROLLING schedule the expected grid is reconstructed from
|
||||
// the intake history (each logged dose is one satisfied expected slot) so
|
||||
// the window-selection denominator matches the displayed rate. The forward
|
||||
// next-due slot counts only when past-due relative to `to` (the window's
|
||||
// upper bound), so a not-yet-due open cycle never inflates the count.
|
||||
const retro =
|
||||
intakes && schedules.some((s) => s.rollingIntervalDays != null)
|
||||
? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to }
|
||||
: undefined;
|
||||
if (effectiveFrom.getTime() > to.getTime()) return [];
|
||||
// v1.16.3 — era-aware: each archived era contributes the slots of ITS
|
||||
// schedules; the live rows cover only the range past the newest revision.
|
||||
return occurrencesAcrossEras(
|
||||
{ from: effectiveFrom, to },
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(
|
||||
schedule,
|
||||
recurrenceCtx,
|
||||
eraFrom,
|
||||
eraTo,
|
||||
retro,
|
||||
),
|
||||
{ oneShot: ctx.oneShot },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
export type IntakeTimingClass =
|
||||
"early" | "on_time" | "late" | "very_late" | "missed";
|
||||
|
||||
/**
|
||||
* Parse "HH:mm" into hours and minutes.
|
||||
*/
|
||||
function parseHHmm(time: string): { hours: number; minutes: number } {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return { hours: h, minutes: m };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the UTC instant of local "HH:mm" on the calendar day `day` falls on.
|
||||
*
|
||||
* When `tz` is supplied the window `HH:mm` is interpreted in the user's zone
|
||||
* (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is
|
||||
* 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring
|
||||
* (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC
|
||||
* must pass its zone or the punctuality band is offset by the zone's offset.
|
||||
*/
|
||||
function toDateOnDay(time: string, day: Date, tz?: string): Date {
|
||||
const { hours, minutes } = parseHHmm(time);
|
||||
if (tz) {
|
||||
return localHmAsUtc(day, tz, hours, minutes);
|
||||
}
|
||||
const d = new Date(day);
|
||||
d.setUTCHours(hours, minutes, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify how punctual an intake was relative to a schedule window.
|
||||
*
|
||||
* v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced
|
||||
* a dedicated `early` bucket so a proactive logger (e.g. 10 min before
|
||||
* the window) no longer gets flushed into `very_late`. The post-window
|
||||
* grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`.
|
||||
* Only doses beyond a 3-hour late tolerance fall into `late`, and only
|
||||
* doses beyond a further `lateMinutes` tail fall into `very_late`.
|
||||
*
|
||||
* Buckets:
|
||||
* - "early": within (windowStart - 3h) .. windowStart (compliant)
|
||||
* - "on_time": windowStart .. (windowEnd + 3h) (compliant)
|
||||
* - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail)
|
||||
* - "very_late": before windowStart - 3h, or after the late tail
|
||||
* - "missed": takenAt is null
|
||||
*
|
||||
* Handles overnight windows (windowEnd < windowStart means next day).
|
||||
*
|
||||
* @param options.lateMinutes Width of the `late` band beyond the 3-hour
|
||||
* on-time tolerance, in minutes. Defaults to 120. Doses past this
|
||||
* tail land in `very_late`.
|
||||
* @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply
|
||||
* the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps
|
||||
* the legacy UTC interpretation (correct only for a UTC user).
|
||||
*/
|
||||
export function classifyIntakeTiming(
|
||||
takenAt: Date | null,
|
||||
windowStart: string, // "HH:mm"
|
||||
windowEnd: string, // "HH:mm"
|
||||
scheduledDate: Date, // the date this was scheduled
|
||||
options?: { lateMinutes?: number; tz?: string },
|
||||
): IntakeTimingClass {
|
||||
if (takenAt === null) return "missed";
|
||||
|
||||
const start = toDateOnDay(windowStart, scheduledDate, options?.tz);
|
||||
let end = toDateOnDay(windowEnd, scheduledDate, options?.tz);
|
||||
|
||||
// Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00")
|
||||
if (end <= start) {
|
||||
end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
// 3h pre-window grace: doses inside this window are `early` (still
|
||||
// compliant). Doses before it are `very_late`.
|
||||
const earlyStart = new Date(start.getTime() - 3 * HOUR_MS);
|
||||
// 3h post-window grace: doses up to here are still `on_time`.
|
||||
const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS);
|
||||
// Configurable `late` tail past the on-time grace (default 120 min).
|
||||
const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000;
|
||||
const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance);
|
||||
|
||||
const t = takenAt.getTime();
|
||||
|
||||
if (t < earlyStart.getTime()) return "very_late";
|
||||
if (t < start.getTime()) return "early";
|
||||
if (t <= onTimeEnd.getTime()) return "on_time";
|
||||
if (t <= lateEnd.getTime()) return "late";
|
||||
return "very_late";
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import type { ScheduleType } from "@/lib/medications/scheduling/recurrence";
|
||||
|
||||
export interface IntakeEvent {
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
scheduledFor: Date;
|
||||
/**
|
||||
* v1.15.9 — true when the auto-miss cron marked this never-acted dose as
|
||||
* a forgotten miss. Threaded into the cadence timeline so it counts as a
|
||||
* `missed` slot (against the rate) instead of being neutralised. Optional
|
||||
* so pre-v1.15.9 callers / fixtures default it to a normal pending/taken
|
||||
* row.
|
||||
*/
|
||||
autoMissed?: boolean;
|
||||
/**
|
||||
* v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately
|
||||
* pinned this off-window take onto its `scheduledFor` slot; the unified
|
||||
* ledger then binds it by anchor (taken-late, never on-time-washed)
|
||||
* instead of degrading it to ad-hoc when the takenAt sits outside the
|
||||
* band tail. Optional so legacy callers / fixtures default to AUTO.
|
||||
*/
|
||||
attributionSource?: "AUTO" | "USER_PIN";
|
||||
}
|
||||
|
||||
export interface ComplianceResult {
|
||||
totalExpected: number;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
missed: number;
|
||||
rate: number; // 0-100
|
||||
streak: number; // consecutive days with all taken
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily compliance data including timing breakdown.
|
||||
*
|
||||
* `early` is the v1.4.34 bucket for doses taken before the window's
|
||||
* 3-hour grace start. Consumers that group by compliance status
|
||||
* should treat `early` as compliant (alongside `onTime`).
|
||||
*/
|
||||
export interface DailyComplianceEntry {
|
||||
expected: number;
|
||||
/**
|
||||
* v1.7.0 item 5 — the true engine-computed due-slot count for the day.
|
||||
* Equals `expected`; carried as an explicit additive field iOS keys
|
||||
* off so it doesn't have to infer "due-ness" from `expected`.
|
||||
*/
|
||||
expectedCount: number;
|
||||
/**
|
||||
* v1.7.0 item 5 — `expectedCount > 0`. iOS renders a "missed" mark
|
||||
* only when `due === true`, so off-weeks / non-matching weekdays / PRN
|
||||
* days no longer paint a false miss.
|
||||
*/
|
||||
due: boolean;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
onTime: number;
|
||||
late: number;
|
||||
veryLate: number;
|
||||
early?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.5.0 — schedule view consumed by the cadence-aware adapter.
|
||||
*
|
||||
* Accepts the historical `ScheduleWindow` shape (`windowStart` +
|
||||
* `windowEnd` only) as well as the richer `ScheduleLike` shape that
|
||||
* carries `daysOfWeek`. When `daysOfWeek` is missing the schedule is
|
||||
* treated as `null` (daily, every day) so legacy fixtures keep
|
||||
* passing without modification.
|
||||
*/
|
||||
export interface ComplianceSchedule {
|
||||
windowStart: string; // HH:mm
|
||||
windowEnd: string; // HH:mm
|
||||
daysOfWeek?: string | null;
|
||||
/**
|
||||
* v1.7.0 SB-SCHED-2 — canonical-engine fields. When present (and a
|
||||
* `medicationContext` is threaded into `calculateCompliance`), the
|
||||
* expected-slot grid is computed through the canonical recurrence
|
||||
* engine, so an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule counts only
|
||||
* Mondays in the denominator instead of every day. Absent fields keep
|
||||
* the legacy `daysOfWeek` path — existing fixtures / callers that pass
|
||||
* only `{ windowStart, windowEnd }` behave exactly as before.
|
||||
*/
|
||||
rrule?: string | null;
|
||||
rollingIntervalDays?: number | null;
|
||||
timesOfDay?: string[];
|
||||
reminderGraceMinutes?: number | null;
|
||||
scheduleType?: ScheduleType | null;
|
||||
cyclicOnWeeks?: number | null;
|
||||
cyclicOffWeeks?: number | null;
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time windows. Threaded onto the
|
||||
* canonical schedule so `tallyComplianceFromLedger` builds the on-time band
|
||||
* from the explicit `[start, end]` range — keeping the % exactly aligned with
|
||||
* the dose-history view and the write/edit attribution (all consume the same
|
||||
* minter). Accepts the raw persisted JSON (a full Prisma row drops straight
|
||||
* in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`.
|
||||
*/
|
||||
doseWindows?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — the ONE Prisma `select` for schedule rows feeding a compliance
|
||||
* computation. Every call site that loads schedules for
|
||||
* `calculateCompliance` / the cadence helpers must use this constant instead
|
||||
* of hand-rolling a field list (or an unbounded `include: { schedules:
|
||||
* true }`), so a future schedule column that the engine consumes — the way
|
||||
* `doseWindows` joined in v1.15.18 — reaches every surface the moment it is
|
||||
* added here. Covers the full {@link ComplianceSchedule} shape plus `id`
|
||||
* (engine-internal labelling / diagnostics).
|
||||
*/
|
||||
export const SCHEDULE_COMPLIANCE_SELECT = {
|
||||
id: true,
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
timesOfDay: true,
|
||||
reminderGraceMinutes: true,
|
||||
rrule: true,
|
||||
rollingIntervalDays: true,
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
// The configurable per-dose on-time windows. Selecting them everywhere is
|
||||
// the point: a user-configured "07:00–09:00" band must shape the rate on
|
||||
// the dashboard pillar, the insights features, the BP-status gate and the
|
||||
// report exactly as it shapes the medication detail page.
|
||||
doseWindows: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 SB-SCHED-2 — medication-level context the canonical engine
|
||||
* needs to expand expected slots. When supplied, `calculateCompliance`
|
||||
* routes the denominator through the engine; when omitted, every
|
||||
* schedule falls back to the legacy weekday walker (the byte-stable
|
||||
* pre-v1.7 behaviour that the parity fixtures pin).
|
||||
*/
|
||||
export interface ComplianceMedicationContext {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
timeZone: string;
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present,
|
||||
* every expected-slot expansion + band mint segments its range so a past
|
||||
* day counts/mints against the schedule that was live THEN. Optional:
|
||||
* callers without revisions keep the live-only expansion.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
/**
|
||||
* v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When
|
||||
* present, expected dose slots whose anchor falls inside any
|
||||
* `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a
|
||||
* resumed medication never counts the paused days as missed. Optional:
|
||||
* callers without eras keep counting every expected slot.
|
||||
*/
|
||||
pauseEras?: MedicationPauseEraLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 H-MED1 — the pause-interval projection the compliance engine reads.
|
||||
* A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal)
|
||||
* drops in. An open era (`resumedAt === null`) runs to `now`.
|
||||
*/
|
||||
export interface MedicationPauseEraLike {
|
||||
pausedAt: Date;
|
||||
resumedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7.0 SB-SCHED-2 — convenience builder so the eight compliance call
|
||||
* sites don't each re-spell the context shape. Pass the medication row
|
||||
* (any object carrying the course-window fields), the latest non-skipped
|
||||
* intake instant, and the user's timezone.
|
||||
*/
|
||||
export function buildComplianceMedicationContext(
|
||||
med: {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
/** v1.16.3 — thread the archived eras when the bundle carries them. */
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
/** v1.25 H-MED1 — thread the pause eras when the caller carries them. */
|
||||
pauseEras?: MedicationPauseEraLike[];
|
||||
},
|
||||
lastIntakeAt: Date | null,
|
||||
timeZone: string,
|
||||
): ComplianceMedicationContext {
|
||||
return {
|
||||
startsOn: med.startsOn,
|
||||
endsOn: med.endsOn,
|
||||
oneShot: med.oneShot,
|
||||
createdAt: med.createdAt,
|
||||
lastIntakeAt,
|
||||
timeZone,
|
||||
...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }),
|
||||
...(med.pauseEras && { pauseEras: med.pauseEras }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7.0 SB-SCHED-2 — the latest non-skipped `takenAt` across an event
|
||||
* list (rolling cadences re-anchor on it). Returns null when the user
|
||||
* has never logged a non-skipped intake. Order-independent, so the
|
||||
* caller can pass an events array in any sort order.
|
||||
*/
|
||||
export function lastNonSkippedTakenAt(
|
||||
events: { takenAt: Date | null; skipped: boolean }[],
|
||||
): Date | null {
|
||||
return events.reduce<Date | null>((latest, e) => {
|
||||
if (e.skipped || e.takenAt === null) return latest;
|
||||
if (latest === null || e.takenAt.getTime() > latest.getTime()) {
|
||||
return e.takenAt;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.13.x — non-skipped intake instants for the retrospective rolling grid,
|
||||
* mirroring {@link rollingIntakeInstants} but reading the public
|
||||
* `{ takenAt, skipped }[]` shape the `expectedSlots*` callers pass. The
|
||||
* `expectedSlots*` helpers accept this so their rolling denominator uses the
|
||||
* SAME expansion as the displayed-rate numerator in {@link calculateCompliance}.
|
||||
*/
|
||||
export interface ComplianceIntakeInstant {
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
/**
|
||||
* v1.15.10 — the dose slot this intake resolves (the canonical snapped
|
||||
* instant, byte-identical to the engine occurrence's `.at`). Optional so
|
||||
* every legacy caller / fixture that omits it keeps working; when present
|
||||
* the open-dose selection in {@link buildCurrentCycle} uses it to skip a
|
||||
* slot the user has already acted on (taken / skipped / auto-missed) so the
|
||||
* card advances to the next genuinely-open slot rather than re-surfacing a
|
||||
* resolved past dose.
|
||||
*/
|
||||
scheduledFor?: Date;
|
||||
/**
|
||||
* v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot
|
||||
* is excluded from the open-dose search the same way a take / skip is.
|
||||
*/
|
||||
autoMissed?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Extracted from the former single-file `compliance.ts`. See `../compliance.ts`
|
||||
// (the barrel) for the module map. Pure move — no logic changes.
|
||||
|
||||
import type {
|
||||
ComplianceIntakeInstant,
|
||||
ComplianceMedicationContext,
|
||||
ComplianceSchedule,
|
||||
} from "./types";
|
||||
import { expectedSlotsBetween } from "./occurrences";
|
||||
|
||||
/**
|
||||
* v1.8.6 — the floor at which a window's percentage is stable. Four
|
||||
* expected doses is the point where a single miss moves the rate by ≤25%
|
||||
* rather than ±50–100%. A daily med clears it in a 7-day window; a weekly
|
||||
* med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or
|
||||
* more. The window ladder below steps up until both rows clear this floor.
|
||||
*/
|
||||
export const MIN_STABLE_DOSES = 4;
|
||||
|
||||
/**
|
||||
* v1.8.6 — the rung ladder for the two compliance windows. Each rung is a
|
||||
* `[short, long]` pair of day-counts. The card always shows two percentage
|
||||
* rows; the only thing that scales with cadence is which rung the windows
|
||||
* sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected
|
||||
* dose frequency drops, both windows step up so each row still spans enough
|
||||
* expected doses to mean something, up to a 12-month long window for very
|
||||
* rare meds.
|
||||
*/
|
||||
export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray<
|
||||
readonly [number, number]
|
||||
> = [
|
||||
[7, 30],
|
||||
[30, 90],
|
||||
[90, 365],
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.8.6 — the day-counts of the two compliance windows a medication's
|
||||
* cadence resolves to, plus the realised expected-dose count each window
|
||||
* holds. `shortDays` / `longDays` drive the row labels; the expected counts
|
||||
* are surfaced so a client can show the denominator or re-derive the rung.
|
||||
*/
|
||||
export interface ComplianceWindowSelection {
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedShort: number;
|
||||
expectedLong: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.6 — pick the two compliance windows for a medication from its
|
||||
* dosing cadence.
|
||||
*
|
||||
* Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and
|
||||
* returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES}
|
||||
* realised expected doses. A daily med clears `[7, 30]` immediately; a
|
||||
* weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a
|
||||
* 35-day-rolling injection needs the top rung `[90, 365]`. When even the
|
||||
* top rung can't clear the floor (a brand-new prescription, a very rare
|
||||
* med) the top rung is returned anyway so the card still shows two honest
|
||||
* percentage rows over the widest windows available.
|
||||
*
|
||||
* The expected count routes through {@link expectedSlotsBetween} (the
|
||||
* canonical recurrence engine), so PRN / off-cadence / pre-creation days
|
||||
* never inflate the denominator.
|
||||
*/
|
||||
export function selectComplianceWindows(
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
options?: { now?: Date; intakes?: ComplianceIntakeInstant[] },
|
||||
): ComplianceWindowSelection {
|
||||
const now = options?.now ?? new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const expectedOver = (days: number): number =>
|
||||
expectedSlotsBetween(
|
||||
schedules,
|
||||
new Date(now.getTime() - days * DAY_MS),
|
||||
now,
|
||||
ctx,
|
||||
options?.intakes,
|
||||
).length;
|
||||
|
||||
// Memoise per distinct window so a shared rung boundary (e.g. 30 / 90)
|
||||
// isn't re-walked across rungs.
|
||||
const cache = new Map<number, number>();
|
||||
const expected = (days: number): number => {
|
||||
const hit = cache.get(days);
|
||||
if (hit !== undefined) return hit;
|
||||
const v = expectedOver(days);
|
||||
cache.set(days, v);
|
||||
return v;
|
||||
};
|
||||
|
||||
for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) {
|
||||
const expectedShort = expected(shortDays);
|
||||
const expectedLong = expected(longDays);
|
||||
if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) {
|
||||
return { shortDays, longDays, expectedShort, expectedLong };
|
||||
}
|
||||
}
|
||||
|
||||
// No rung cleared the floor — fall back to the widest rung so both rows
|
||||
// still render over the most data the cadence affords.
|
||||
const [shortDays, longDays] =
|
||||
COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1];
|
||||
return {
|
||||
shortDays,
|
||||
longDays,
|
||||
expectedShort: expected(shortDays),
|
||||
expectedLong: expected(longDays),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user