fix(medications): retrospective rolling-cadence compliance + currentCycle descriptor

This commit is contained in:
Marc-André Bombeck committed 2026-06-05 22:09:06 +02:00
1 parent 617eadee20
commit a55816bf58
7 files changed
+777 -15

No files matched your search

+41
View File
@@ -6761,6 +6761,46 @@ components:
required:
- rate
additionalProperties: false
currentCycle:
type: object
properties:
state:
type: string
enum:
- on_track
- due
- missed
- none
description: "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now
/ in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next
dose (PRN / paused / ended)."
nextDueAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: The open cycle's due instant. Null when `state` is `none`.
graceUntil:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: End of the due slot's grace window. Null when `state` is `none`.
hasClosedCycles:
type: boolean
description: False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the
card should show a neutral 'not enough data yet' state.
required:
- state
- nextDueAt
- graceUntil
- hasClosedCycles
additionalProperties: false
description: v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next
dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward
cycle from their denominator.
required:
- shortDays
- longDays
@@ -6769,6 +6809,7 @@ components:
- minStableDoses
- short
- long
- currentCycle
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
@@ -265,6 +265,11 @@ export const GET = apiHandler(
dayStart,
dayEnd,
medicationContext,
// v1.13.x — pass the intake history so a ROLLING schedule's per-day
// `due` flags come from the retrospective grid (each logged dose is a
// due day) instead of the engine's single forward slot — keeping the
// heatmap, the denominator, and the displayed rate in agreement.
mapped,
);
dailyCompliance[dateKey] = {
+254 -3
View File
@@ -510,7 +510,7 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.totalExpected).toBeLessThanOrEqual(3);
});
it("rolling rollingIntervalDays=7 — only the next-due slot counts", () => {
it("rolling rollingIntervalDays=7 — the open forward cycle is upcoming, not a slot (no events → empty)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
@@ -520,8 +520,14 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
timesOfDay: ["08:00"],
},
];
// Last intake 5 days ago → next due in 2 days (future) → no past
// expected slot inside the window → empty-window contract → 100.
// v1.13.x — this test previously asserted the OLD forward-only behaviour
// ("only the next-due slot counts"). With the retrospective rolling
// expansion the grid is built from the LOGGED intakes — here there are
// none, and `lastIntakeAt` is 5 days ago so the forward next-due slot is
// 2 days out (future, > now) → excluded as upcoming. With no logged
// intakes in the window and no past-due slot the window is empty →
// empty-window contract → 100 / totalExpected 0. (A faithful history now
// reads its true rate — see the dedicated 12-shot test below.)
const result = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx({
lastIntakeAt: new Date(NOW.getTime() - 5 * DAY_MS),
@@ -531,6 +537,127 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.rate).toBe(100);
});
// ──────────────────────────────────────────────────────────────────
// v1.13.x — ROLLING retrospective expansion. The canonical engine's
// `expandRolling` is forward-only: it emits at most the single
// immediately-next slot, so a historical compliance window over a
// rolling weekly injection (the GLP-1 default) saw either zero expected
// slots (vacuous 100%) or one overdue slot (hard 0%) — never the true
// multi-dose adherence. The compliance layer now reconstructs the
// historical grid from the logged intakes (each dose is one satisfied
// expected slot, with synthesized misses for skipped whole cycles + a
// past-due forward slot). These tests pin that the live "0% despite many
// recorded intakes" defect is fixed.
// ──────────────────────────────────────────────────────────────────
it("rolling rollingIntervalDays=7 — 12 weekly shots logged → high rate, not 0%/100%-vacuous", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// 12 consecutive weekly shots, each ~7 days apart, taken at varying
// times of day (the real-world off-HH:mm pattern). Most recent 3 days ago.
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const intakes = Array.from({ length: 12 }, (_, i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(19 + (i % 3), 0, 0, 0); // 19:00/20:00/21:00 — off the 08:00 slot
return { scheduledFor: at, takenAt: at, skipped: false };
});
const lastIntakeAt = intakes[0].takenAt;
for (const days of [30, 90]) {
const result = calculateCompliance(intakes, schedules, days, undefined, {
medicationContext: ctx({ lastIntakeAt, startsOn: intakes[11].takenAt }),
});
// Each logged shot is a satisfied expected slot → taken tracks the
// number of shots in the window; rate is high; not the broken 0%.
expect(result.taken).toBeGreaterThanOrEqual(days === 30 ? 4 : 12);
expect(result.rate).toBeGreaterThanOrEqual(90);
expect(result.missed).toBe(0);
}
});
it("rolling weekly — a shot logged a day late from its cycle still pairs (2 of 2, no phantom miss)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Two shots: cycle anchor + a second shot logged ~8 days later (1 day
// late, inside the 1.5·N tolerance), then nothing — both count taken,
// none missed (the late dose must NOT synthesize a phantom missed cycle).
const first = new Date(NOW.getTime() - 16 * DAY_MS);
const second = new Date(first.getTime() + 8 * DAY_MS); // 1 day late
const intakes = [
{ scheduledFor: second, takenAt: second, skipped: false },
{ scheduledFor: first, takenAt: first, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: second, startsOn: first }),
});
expect(result.taken).toBe(2);
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
});
it("rolling weekly — a 3-week gap between shots synthesizes the skipped cycles as missed", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Shot A, then a 21-day gap (2 missed cycles), then shot B.
const a = new Date(NOW.getTime() - 28 * DAY_MS);
const b = new Date(a.getTime() + 21 * DAY_MS);
const intakes = [
{ scheduledFor: b, takenAt: b, skipped: false },
{ scheduledFor: a, takenAt: a, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: b, startsOn: a }),
});
// 2 taken (A, B) + ~2 synthesized missed cycles in the gap → ~50%.
expect(result.taken).toBe(2);
expect(result.missed).toBeGreaterThanOrEqual(1);
expect(result.rate).toBeLessThan(100);
});
it("rolling weekly — last shot 3 days ago, next due in 4 days → upcoming, excluded from denominator", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const intakes = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: lastShot, startsOn: lastShot }),
});
// The one closed cycle (the logged shot) is taken; the open forward
// cycle is upcoming and must NOT count as missed → 100%, missed 0.
expect(result.missed).toBe(0);
expect(result.taken).toBe(1);
expect(result.rate).toBe(100);
});
// ──────────────────────────────────────────────────────────────────
// v1.12.0 — weekly-injectable matching radius. A weekly med (Mounjaro)
// is dosed once a week, but a real intake is almost never logged within
@@ -1376,6 +1503,130 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
expect(display.long).toBeDefined();
});
it("7-day rolling injection with history → [30, 90] windows (not the widest fallback)", () => {
// v1.13.x Fix 3B — after the retrospective rolling expansion,
// `expectedSlotsBetween` returns the real per-cycle grid for rolling, so
// a 7-day rolling injection with a faithful history clears the floor on
// the [30, 90] rung instead of falling through to the widest [90, 365]
// fallback (the pre-fix behaviour, where rolling emitted ≤ 1 slot).
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// ~16 weekly shots back from a most-recent shot 3 days ago.
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = Array.from({ length: 16 }, (_, i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(20, 0, 0, 0);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const display = buildComplianceDisplay(
events,
schedules,
ctx({
startsOn: events[events.length - 1].takenAt,
lastIntakeAt: events[0].takenAt,
}),
{ now: NOW },
);
expect(display.shortDays).toBe(30);
expect(display.longDays).toBe(90);
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
it("currentCycle — rolling weekly, last shot 3 days ago → on_track (not a red miss)", () => {
// v1.13.x Fix 4 — a between-doses sparse med must NOT render a scary
// state. Last shot 3 days ago → next due in 4 days → the open cycle is
// on_track with a future nextDueAt.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentCycle.state).toBe("on_track");
expect(display.currentCycle.nextDueAt).not.toBeNull();
expect(display.currentCycle.nextDueAt!.getTime()).toBeGreaterThan(
NOW.getTime(),
);
expect(display.currentCycle.hasClosedCycles).toBe(true);
});
it("currentCycle — rolling weekly overdue past grace → missed", () => {
// v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3
// days ago, well past grace → the open cycle is `missed` (the only red
// state). nextDueAt is in the past.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 10 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentCycle.state).toBe("missed");
expect(display.currentCycle.nextDueAt).not.toBeNull();
expect(display.currentCycle.nextDueAt!.getTime()).toBeLessThan(
NOW.getTime(),
);
});
it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => {
// v1.13.x Fix 4 — a med created today with no logged intake has no
// closed dose cycle; the percentage rows are vacuous and the card should
// show a neutral state rather than a misleading 100% / 0%.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({
startsOn: new Date(NOW.getTime() + 4 * DAY_MS), // first dose in the future
createdAt: NOW,
}),
{ now: NOW },
);
expect(display.currentCycle.hasClosedCycles).toBe(false);
expect(display.currentCycle.state).toBe("on_track");
});
it("a 2-day-old daily med's expected counts reflect its real age", () => {
// Created two days ago: the expected-dose counts must reflect ~2 days
// of daily doses, not the 7 / 30 a full-window walk would report. No
+276 -6
View File
@@ -27,6 +27,8 @@ import {
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import {
expandRollingRetrospective,
nextOccurrenceAfter,
occurrencesBetween,
type CanonicalSchedule,
type Occurrence,
@@ -253,6 +255,28 @@ export function lastNonSkippedTakenAt(
}, null);
}
/**
* 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]`.
*/
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
@@ -300,6 +324,59 @@ function toCanonicalSchedule(
};
}
/**
* 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;
}
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.
*/
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);
}
/**
* v1.7.0 item 5 — count the expected dose slots a medication's schedules
* emit inside `[dayStart, dayEnd)`, routed through the canonical engine.
@@ -307,23 +384,36 @@ function toCanonicalSchedule(
* 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 {
let count = 0;
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;
for (let i = 0; i < schedules.length; i++) {
count += occurrencesBetween(
count += expandComplianceOccurrences(
toCanonicalSchedule(schedules[i], `compliance-daily-${i}`),
recurrenceCtx,
dayStart,
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
// slot exactly at the next day's midnight doesn't double-count.
new Date(dayEnd.getTime() - 1),
recurrenceCtx,
retro,
).length;
}
return count;
@@ -356,18 +446,29 @@ export function expectedSlotsBetween(
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;
const all: Occurrence[] = [];
for (let i = 0; i < schedules.length; i++) {
all.push(
...occurrencesBetween(
...expandComplianceOccurrences(
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
recurrenceCtx,
effectiveFrom,
to,
recurrenceCtx,
retro,
),
);
}
@@ -432,7 +533,7 @@ export interface ComplianceWindowSelection {
export function selectComplianceWindows(
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
options?: { now?: Date },
options?: { now?: Date; intakes?: ComplianceIntakeInstant[] },
): ComplianceWindowSelection {
const now = options?.now ?? new Date();
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -443,6 +544,7 @@ export function selectComplianceWindows(
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)
@@ -488,6 +590,43 @@ export function selectComplianceWindows(
* steps both windows up so each row covers enough expected doses to be
* meaningful.
*/
/**
* 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;
}
export interface ComplianceDisplay {
shortDays: number;
longDays: number;
@@ -501,6 +640,102 @@ export interface ComplianceDisplay {
short: { rate: number; streak: number };
/** Compliance percentage over the long window. */
long: { rate: 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.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.
*/
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 lastIntakeAt =
lastNonSkippedTakenAt(intakes ?? []) ?? 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;
}
const occ = nextOccurrenceAfter(
canonical,
new Date(now.getTime() - 1),
recurrenceCtx,
);
if (occ) consider(occ.at, occ.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 };
}
/**
@@ -519,8 +754,12 @@ export function buildComplianceDisplay(
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 });
selectComplianceWindows(schedules, ctx, { now, intakes: events });
const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, {
now,
@@ -531,6 +770,15 @@ export function buildComplianceDisplay(
medicationContext: ctx,
});
// v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates.
const currentCycle = buildCurrentCycle(
schedules,
ctx,
now,
expectedShort,
events,
);
return {
shortDays,
longDays,
@@ -539,6 +787,7 @@ export function buildComplianceDisplay(
minStableDoses: MIN_STABLE_DOSES,
short: { rate: short.rate, streak: short.streak },
long: { rate: long.rate },
currentCycle,
};
}
@@ -665,6 +914,26 @@ export function calculateCompliance(
skipped: e.skipped,
}));
// 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,
@@ -673,6 +942,7 @@ export function calculateCompliance(
medicationCreatedAt ?? effectiveStart,
engineCtx?.timeZone,
engineCtx,
retro,
);
let taken = 0;
+43 -6
View File
@@ -32,10 +32,31 @@ import {
type CanonicalSchedule,
type RecurrenceContext,
type ScheduleType,
expandRollingRetrospective,
occurrencesBetween,
} from "@/lib/medications/scheduling/recurrence";
import { wallClockInTz } from "@/lib/tz/wall-clock";
/**
* v1.13.x compliance — opt-in retrospective-rolling expansion.
*
* The canonical engine's `expandRolling` is forward-only (correct for the
* projector / next-due surfaces). For the compliance + detail-page
* timeline a ROLLING cadence must instead reconstruct its historical
* expected-dose grid from the logged intakes. When a caller threads this
* option, rolling schedules route through `expandRollingRetrospective`
* (each logged intake is one satisfied slot, plus synthesized misses for
* skipped whole cycles + a past-due forward slot). Every non-rolling
* shape and every caller that omits the option keeps the forward-only
* engine path byte-for-byte.
*/
export interface RetrospectiveRollingOptions {
/** Non-skipped intake instants for the medication (the slot anchors). */
intakeInstants: Date[];
/** Wall-clock "now" — decides whether the forward next-due slot is past-due. */
now: Date;
}
export interface ScheduleLike {
/** "HH:mm" 24h, user-tz reference (per existing schema). */
windowStart: string;
@@ -307,6 +328,7 @@ export function expandScheduleSlots(
anchor: Date = from,
timeZone?: string,
engineCtx?: CadenceEngineContext,
retro?: RetrospectiveRollingOptions,
): ExpectedDose[] {
if (to <= from) return [];
@@ -337,12 +359,22 @@ export function expandScheduleSlots(
// landing exactly at the window boundary isn't counted by the engine
// path but dropped by the legacy path — the two branches stay
// denominator-equivalent at the edge.
const occurrences = occurrencesBetween(
canonical,
from,
new Date(to.getTime() - 1),
recurrenceCtx,
);
const inclusiveTo = new Date(to.getTime() - 1);
// v1.13.x — ROLLING + retrospective opt-in: reconstruct the historical
// expected-dose grid from the logged intakes rather than the engine's
// single forward slot. Only rolling schedules take this branch; every
// other shape still routes through `occurrencesBetween`.
const occurrences =
retro && canonical.rollingIntervalDays !== null
? expandRollingRetrospective(
canonical,
recurrenceCtx,
from,
inclusiveTo,
retro.intakeInstants,
retro.now,
)
: occurrencesBetween(canonical, from, inclusiveTo, recurrenceCtx);
return occurrences.map((occ) => ({
day: startOfLocalDay(occ.at, timeZone),
windowStart: occ.at,
@@ -594,6 +626,7 @@ export function buildCadenceTimeline(
anchor?: Date,
timeZone?: string,
engineCtx?: CadenceEngineContext,
retro?: RetrospectiveRollingOptions,
): PairedDose[] {
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const slots: ExpectedDose[] = [];
@@ -607,6 +640,7 @@ export function buildCadenceTimeline(
anchor ?? from,
timeZone,
engineCtx,
retro,
),
);
}
@@ -622,6 +656,7 @@ export function buildCadenceTimeline(
anchor,
timeZone,
engineCtx,
retro,
);
return pairDoses(slots, events, asOf, { radiusFloorMs });
}
@@ -649,6 +684,7 @@ function cadenceRadiusFloor(
anchor: Date | undefined,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
retro?: RetrospectiveRollingOptions,
): number {
const probeDays = Math.max(windowDays, 16 * 7);
const probeFrom = new Date(asOf.getTime() - probeDays * DAY_MS);
@@ -662,6 +698,7 @@ function cadenceRadiusFloor(
anchor ?? probeFrom,
timeZone,
engineCtx,
retro,
);
for (const slot of probeSlots) {
centres.push((slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2);
@@ -423,6 +423,138 @@ function expandRolling(
return [buildOccurrence(at, time, schedule)];
}
/**
* v1.13.x compliance — RETROSPECTIVE rolling expansion.
*
* `expandRolling` is forward-only by contract (the projector + reminder
* worker depend on it emitting ONLY the immediately-next slot). That is
* correct for "what's next" surfaces but wrong for the compliance grid,
* which must reconstruct the *historical* expected-dose slots a rolling
* cadence implies over a trailing window so a faithfully-adherent weekly
* injection reads its true rate instead of the vacuous 100%-or-0% flip
* the forward-only path produced.
*
* Medically-correct reading: a rolling "every N days from last dose"
* cadence re-anchors on each intake, so the retrospective expected grid
* *is* the observed intake history (each logged dose is exactly one
* satisfied expected slot) PLUS:
*
* - back-filled `missed` slots for genuinely skipped whole cycles: when
* two consecutive intakes are more than `1.5 · N` apart the user
* skipped one or more cycles → emit a slot at `prevIntake + k·N` for
* each whole cycle the gap spans (the `1.5·N` tolerance gate means a
* dose logged a day late never synthesizes a phantom miss); and
* - the single forward next-due slot, emitted ONLY when it is past-due
* (`nextDue ≤ now`). A not-yet-due forward slot is `upcoming` and is
* excluded so the open current cycle never counts against the
* denominator.
*
* Because the back-filled / forward slots carry no nearby intake, they
* pair to nothing in `pairDoses` and read `missed`; the per-intake slots
* are anchored AT the intake instant so they pair (distance 0) and read
* `taken`. The same grid feeds the count-only callers
* (`expectedSlotsBetween` / `expectedSlotCountForDay`) so numerator,
* denominator, and the heatmap `due` flags route through ONE expansion
* (the v1.7.3 B15 numerator/denominator-convergence rule).
*
* Kept here (not folded into `expandRolling`) so the engine's forward
* contract is untouched; compliance opts in explicitly via
* `expandRollingRetrospective`.
*
* @param intakeInstants Non-skipped intake instants for THIS medication
* (ascending or unsorted — sorted internally). The compliance layer
* supplies these; the engine has no DB access.
* @param now The wall-clock anchor (the compliance window's `to`-side
* "now"), used to decide whether the forward next-due slot is past-due.
*/
export function expandRollingRetrospective(
schedule: CanonicalSchedule,
ctx: RecurrenceContext,
from: Date,
to: Date,
intakeInstants: Date[],
now: Date,
): Occurrence[] {
const n = schedule.rollingIntervalDays;
if (n === null || n <= 0) return [];
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
const cycleMs = n * DAY_MS;
// Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little
// late). Only a gap strictly beyond this synthesizes skipped cycles.
const gapToleranceMs = 1.5 * cycleMs;
const endsCap = ctx.medication.endsOn
? endOfUtcDay(ctx.medication.endsOn).getTime()
: Infinity;
const inWindow = (at: Date): boolean =>
at.getTime() >= from.getTime() &&
at.getTime() <= to.getTime() &&
at.getTime() <= endsCap;
const slots: Occurrence[] = [];
// Each logged intake is a satisfied expected slot at its own instant.
// Sort ascending so the gap walk between consecutive intakes is correct.
const sorted = [...intakeInstants].sort((a, b) => a.getTime() - b.getTime());
for (const instant of sorted) {
if (inWindow(instant)) {
slots.push(buildOccurrence(instant, time, schedule));
}
}
// Back-fill missed slots for genuinely skipped whole cycles between
// consecutive intakes (gap > 1.5·N). The synthesized slots land at the
// schedule's time-of-day on each skipped cycle's day so they read as a
// real expected dose in the user's timezone.
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const next = sorted[i];
const gap = next.getTime() - prev.getTime();
if (gap <= gapToleranceMs) continue;
// Number of whole cycles the user skipped: floor(gap/N) − 1 missed
// cycles sit strictly between the two intakes.
const cyclesInGap = Math.floor(gap / cycleMs);
for (let k = 1; k < cyclesInGap; k++) {
const anchor = new Date(prev.getTime() + k * cycleMs);
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
if (inWindow(at)) {
slots.push(buildOccurrence(at, time, schedule));
}
}
}
// The single forward next-due slot — emitted as a missed slot ONLY when
// the open cycle is GENUINELY overdue, i.e. `now` is more than half a
// cycle past `nextDue`. A dose merely a little late (within `0.5·N`) is
// the current open cycle still in its window — it must NOT synthesize a
// phantom miss (the N-tolerance gate). A not-yet-due forward slot
// (`nextDue > now`) is excluded entirely (upcoming → out of the
// denominator). This keeps the open current cycle off the percentage rows
// until it is unambiguously missed; the `currentCycle` descriptor carries
// the softer on_track / due / missed state for the card.
const forwardToleranceMs = cycleMs / 2;
const lastInstant =
sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt;
const nextDue =
lastInstant !== null
? new Date(lastInstant.getTime() + cycleMs)
: ctx.medication.startsOn ?? ctx.medication.createdAt;
if (now.getTime() - nextDue.getTime() > forwardToleranceMs) {
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
// Dedupe: a forward slot already represented by a logged intake (the
// intake re-anchored exactly N days out) must not double-count.
const alreadyPresent = slots.some(
(s) => Math.abs(s.at.getTime() - at.getTime()) < cycleMs / 2,
);
if (inWindow(at) && !alreadyPresent) {
slots.push(buildOccurrence(at, time, schedule));
}
}
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
}
// ────────────────────────────────────────────────────────────────────
// Dispatch — RRULE
// ────────────────────────────────────────────────────────────────────
+26
View File
@@ -1243,6 +1243,32 @@ const complianceDisplay = z
streak: z.number().int().nonnegative(),
}),
long: z.object({ rate: z.number().int().min(0).max(100) }),
currentCycle: z
.object({
state: z
.enum(["on_track", "due", "missed", "none"])
.describe(
"Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).",
),
nextDueAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("The open cycle's due instant. Null when `state` is `none`."),
graceUntil: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"End of the due slot's grace window. Null when `state` is `none`.",
),
hasClosedCycles: z
.boolean()
.describe(
"False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.",
),
})
.describe(
"v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.",
),
})
.meta({
id: "ComplianceDisplay",