mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(medications): retrospective rolling-cadence compliance + currentCycle descriptor
This commit is contained in:
1 parent
617eadee20
commit
a55816bf58
7 files changed
+777
-15
No files matched your search
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user