mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(medications): cadence-aware dose-state windows + miss-counting engine
Thread the auto_missed flag through the cadence timeline so a forgotten dose pairs to a missed slot (against the rate) rather than a neutral skip; a deliberate user skip stays excluded. This unifies the skip semantics across every compliance surface: user skip = excluded, forgotten = missed. Add a per-dose grace/miss window model the card will render: deriveDoseStatus derives on_time_window / overdue / missed / taken_on_time / taken_late / skipped / upcoming from the target instant, cadence family, and now. Daily/intraday uses minute-scale windows (on-time +/-60min, overdue to +240min, capped at the next dose); weekly/rolling uses the clinical 4-day rule (on-time +/-1 day, overdue to +4 days). Defaults are centralised named constants for later per-medication configurability. Surface taken / expected / missed counts on each compliance-display row so the card can show the trustworthy 'taken / expected . rate%' triple, and pin a single now through the per-medication compliance route so the two windows can't straddle a day boundary on a slow request.
This commit is contained in:
1 parent
29fa982e11
commit
cb239c2803
4 files changed
+489
-12
No files matched your search
@@ -113,6 +113,9 @@ export const GET = apiHandler(
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
|
||||
// miss, not a neutral skip.
|
||||
autoMissed: e.autoMissed,
|
||||
}));
|
||||
|
||||
const createdAt = medication.createdAt;
|
||||
@@ -131,7 +134,14 @@ export const GET = apiHandler(
|
||||
userTz,
|
||||
);
|
||||
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation. The two `calculateCompliance` calls + the display windows
|
||||
// can otherwise straddle a day boundary on a slow request, so a dose
|
||||
// would count in one window and not the next within the same response.
|
||||
const now = new Date();
|
||||
|
||||
const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
|
||||
now,
|
||||
medicationContext,
|
||||
});
|
||||
const compliance30 = calculateCompliance(
|
||||
@@ -139,7 +149,7 @@ export const GET = apiHandler(
|
||||
medication.schedules,
|
||||
30,
|
||||
createdAt,
|
||||
{ medicationContext },
|
||||
{ now, medicationContext },
|
||||
);
|
||||
|
||||
// v1.8.6 — the two-row compliance display. The card always shows two
|
||||
@@ -152,10 +162,10 @@ export const GET = apiHandler(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
{ now },
|
||||
);
|
||||
|
||||
// Build daily compliance map for heatmap/line chart (90 days)
|
||||
const now = new Date();
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
|
||||
for (let d = 0; d < 90; d++) {
|
||||
|
||||
@@ -14,11 +14,14 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
DOSE_WINDOW_DEFAULTS,
|
||||
MIN_STABLE_DOSES,
|
||||
buildComplianceDisplay,
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
classifyIntakeTiming,
|
||||
deriveDoseStatus,
|
||||
doseCadenceFamily,
|
||||
expectedSlotCountForDay,
|
||||
expectedSlotsBetween,
|
||||
lastNonSkippedTakenAt,
|
||||
@@ -1881,3 +1884,262 @@ describe("expectedSlotsBetween", () => {
|
||||
expect(slots.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.15.9 — forgotten doses count as MISSED (BUG #2) + user-skip vs
|
||||
// auto-miss semantics (BUG #3). A never-acted dose the auto-miss cron
|
||||
// flipped carries `autoMissed: true`; the engine must pair it to a
|
||||
// `missed` slot (against the rate). A deliberate user skip
|
||||
// (`skipped: true`) stays excluded from the denominator.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", () => {
|
||||
const NOW = new Date("2025-01-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const daily: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
];
|
||||
|
||||
it("a forgotten daily dose (autoMissed) lowers the rate — not neutralised", () => {
|
||||
// Three days: two taken + one forgotten (autoMissed). Before v1.15.9 the
|
||||
// auto-skip cron flipped the forgotten dose to skipped, which the engine
|
||||
// excluded → 2/2 = 100% (inflated). Now it is a real miss → 2/3 = 67%.
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||||
{
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: true,
|
||||
},
|
||||
eventAt(new Date("2025-01-12T08:30:00Z"), true),
|
||||
];
|
||||
const result = calculateCompliance(events, daily, 7);
|
||||
expect(result.taken).toBe(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.missed).toBeGreaterThanOrEqual(1);
|
||||
// The autoMissed day is inside the denominator and drags the rate below
|
||||
// the inflated 100% the old skip-neutralisation produced.
|
||||
expect(result.rate).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("a deliberate user-skip is excluded; an auto-miss on the same day-grid counts (BUG #3)", () => {
|
||||
// One taken, one user-skip (excluded), one auto-miss (counted). The
|
||||
// user-skip must NOT appear in the denominator; the auto-miss must.
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||||
{
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
takenAt: null,
|
||||
skipped: true, // deliberate pause
|
||||
autoMissed: false,
|
||||
},
|
||||
{
|
||||
scheduledFor: new Date("2025-01-12T08:30:00Z"),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: true, // forgotten
|
||||
},
|
||||
];
|
||||
const result = calculateCompliance(events, daily, 7);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.taken).toBe(1);
|
||||
// Denominator = taken + missed (the user-skip is out). At least the
|
||||
// auto-miss counts, so the rate is taken/(taken+missed) < 100.
|
||||
expect(result.missed).toBeGreaterThanOrEqual(1);
|
||||
expect(result.rate).toBeLessThan(100);
|
||||
// The user-skip alone (no auto-miss) would have kept 100% — confirm the
|
||||
// auto-miss is what pulls it down by re-running with the skip only.
|
||||
// Cover every slot the 7-day window emits (today's 08:00 is past NOW =
|
||||
// 12:00, so the window holds Jan 9..15). Take all but one user-skip.
|
||||
const skipOnlyEvents = [];
|
||||
for (let day = 9; day <= 15; day++) {
|
||||
const at = new Date(`2025-01-${String(day).padStart(2, "0")}T08:30:00Z`);
|
||||
if (day === 13) {
|
||||
skipOnlyEvents.push({
|
||||
scheduledFor: at,
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
});
|
||||
} else {
|
||||
skipOnlyEvents.push(eventAt(at, true));
|
||||
}
|
||||
}
|
||||
const skipOnly = calculateCompliance(skipOnlyEvents, daily, 7);
|
||||
// Every non-skipped day taken → the deliberate skip never lowers the rate.
|
||||
expect(skipOnly.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("buildComplianceDisplay surfaces taken / expected / missed counts per row", () => {
|
||||
const events = [
|
||||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||||
{
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: true,
|
||||
},
|
||||
];
|
||||
const ctxVal = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt: new Date("2025-01-08T00:00:00Z") },
|
||||
lastNonSkippedTakenAt(events),
|
||||
"UTC",
|
||||
);
|
||||
const display = buildComplianceDisplay(events, daily, ctxVal, { now: NOW });
|
||||
// `expected` is the rate denominator (taken + missed), `missed` counts the
|
||||
// forgotten doses, `taken` the numerator — the "taken / expected" triple.
|
||||
expect(display.short.expected).toBe(display.short.taken + display.short.missed);
|
||||
expect(display.short.missed).toBeGreaterThanOrEqual(1);
|
||||
expect(display.long.expected).toBe(display.long.taken + display.long.missed);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.15.9 — cadence-aware per-dose grace/miss window model. The card
|
||||
// reads `deriveDoseStatus` to render on-time / overdue / missed states.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("deriveDoseStatus — daily / intraday windows", () => {
|
||||
const target = new Date("2025-01-15T08:00:00Z");
|
||||
|
||||
it("on-time within ±60 min of target", () => {
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T08:30:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T07:15:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
});
|
||||
|
||||
it("upcoming before the on-time window opens", () => {
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T06:30:00Z"))).toBe(
|
||||
"upcoming",
|
||||
);
|
||||
});
|
||||
|
||||
it("overdue between +60 min and +240 min", () => {
|
||||
// +90 min → past on-time (60), before miss cutoff (240).
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"))).toBe(
|
||||
"overdue",
|
||||
);
|
||||
// +239 min → still overdue (takeable).
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T11:59:00Z"))).toBe(
|
||||
"overdue",
|
||||
);
|
||||
});
|
||||
|
||||
it("missed past +240 min", () => {
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T12:30:00Z"))).toBe(
|
||||
"missed",
|
||||
);
|
||||
});
|
||||
|
||||
it("never overlaps the next dose's on-time window", () => {
|
||||
// Next dose at 10:00; its on-time window opens at 09:00 (−60 min). The
|
||||
// 08:00 dose's miss cutoff is therefore clamped to 09:00, well before its
|
||||
// own +240 min (12:00). At 09:30 the 08:00 dose is already missed.
|
||||
const nextDoseAt = new Date("2025-01-15T10:00:00Z");
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"), {
|
||||
nextDoseAt,
|
||||
}),
|
||||
).toBe("missed");
|
||||
});
|
||||
|
||||
it("taken inside the on-time window → taken_on_time, late → taken_late", () => {
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
|
||||
takenAt: new Date("2025-01-15T08:30:00Z"),
|
||||
}),
|
||||
).toBe("taken_on_time");
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
|
||||
takenAt: new Date("2025-01-15T10:00:00Z"),
|
||||
}),
|
||||
).toBe("taken_late");
|
||||
});
|
||||
|
||||
it("a deliberate skip short-circuits to skipped", () => {
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
|
||||
skipped: true,
|
||||
}),
|
||||
).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
|
||||
const target = new Date("2025-01-13T08:00:00Z"); // a Monday shot
|
||||
|
||||
it("on-time within ±1 day of the target day", () => {
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-13T20:00:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-14T07:00:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
});
|
||||
|
||||
it("overdue up to +4 days (still counts when taken — the clinical rule)", () => {
|
||||
// +3 days from target → past the ±1-day on-time window, before +4 days.
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-16T12:00:00Z"))).toBe(
|
||||
"overdue",
|
||||
);
|
||||
// Taken on day +3 still counts as late, not missed.
|
||||
expect(
|
||||
deriveDoseStatus(target, "weekly", new Date("2025-01-20T00:00:00Z"), {
|
||||
takenAt: new Date("2025-01-16T12:00:00Z"),
|
||||
}),
|
||||
).toBe("taken_late");
|
||||
});
|
||||
|
||||
it("missed past +4 days", () => {
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-18T12:00:00Z"))).toBe(
|
||||
"missed",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults match the documented 60-min / 240-min / 4-day boundaries", () => {
|
||||
expect(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes).toBe(60);
|
||||
expect(
|
||||
DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
|
||||
).toBe(240);
|
||||
expect(DOSE_WINDOW_DEFAULTS.weeklyOverdueDays).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doseCadenceFamily", () => {
|
||||
it("rolling ≥ 2 days → weekly", () => {
|
||||
expect(
|
||||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rollingIntervalDays: 7 }),
|
||||
).toBe("weekly");
|
||||
});
|
||||
it("WEEKLY rrule → weekly", () => {
|
||||
expect(
|
||||
doseCadenceFamily({
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
}),
|
||||
).toBe("weekly");
|
||||
});
|
||||
it("daily rrule → daily", () => {
|
||||
expect(
|
||||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rrule: "FREQ=DAILY" }),
|
||||
).toBe("daily");
|
||||
});
|
||||
it("plain legacy daily → daily", () => {
|
||||
expect(
|
||||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }),
|
||||
).toBe("daily");
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,14 @@ 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;
|
||||
}
|
||||
|
||||
export type IntakeTimingClass =
|
||||
@@ -164,6 +172,166 @@ export function classifyIntakeTiming(
|
||||
return "very_late";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 const DOSE_WINDOW_DEFAULTS = {
|
||||
/** Daily / intraday on-time half-width around the target instant (min). */
|
||||
dailyOnTimeMinutes: 60,
|
||||
/**
|
||||
* Daily / intraday overdue tail past the on-time window (min). A dose
|
||||
* taken inside `[onTime, onTime + overdue]` still counts; beyond it the
|
||||
* dose is missed.
|
||||
*/
|
||||
dailyOverdueMinutes: 240 - 60,
|
||||
/** Weekly / rolling on-time half-width around the target day (days). */
|
||||
weeklyOnTimeDays: 1,
|
||||
/**
|
||||
* Weekly / rolling overdue tail past the target day (days) — the clinical
|
||||
* 4-day GLP-1 rule. A shot inside `[target, target + 4d]` still counts;
|
||||
* beyond it the dose is missed and the user skips to the next slot.
|
||||
*/
|
||||
weeklyOverdueDays: 4,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.5.0 — schedule view consumed by the cadence-aware adapter.
|
||||
*
|
||||
@@ -637,14 +805,16 @@ export interface ComplianceDisplay {
|
||||
/** Echo of the density floor so a client can re-derive the rung. */
|
||||
minStableDoses: number;
|
||||
/**
|
||||
* Compliance percentage + taken-dose count + day-streak over the short
|
||||
* window. `taken` is the count of doses taken in the window (the numerator
|
||||
* the card renders next to the rate so two identical percentages stay
|
||||
* distinguishable and trustworthy).
|
||||
* 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; streak: number };
|
||||
/** Compliance percentage + taken-dose count over the long window. */
|
||||
long: { rate: number; taken: number };
|
||||
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.
|
||||
@@ -790,8 +960,22 @@ export function buildComplianceDisplay(
|
||||
expectedShort,
|
||||
expectedLong,
|
||||
minStableDoses: MIN_STABLE_DOSES,
|
||||
short: { rate: short.rate, taken: short.taken, streak: short.streak },
|
||||
long: { rate: long.rate, taken: long.taken },
|
||||
// `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,
|
||||
};
|
||||
}
|
||||
@@ -917,6 +1101,10 @@ export function calculateCompliance(
|
||||
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
|
||||
|
||||
@@ -150,6 +150,14 @@ export interface IntakeEventLike {
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
/**
|
||||
* v1.15.9 — true when the hourly auto-miss cron marked this never-acted
|
||||
* dose as a forgotten miss (NOT a deliberate user skip). The pairing pass
|
||||
* reads it so an auto-missed row counts as `missed` (against the rate)
|
||||
* rather than `skipped` (excluded). Optional so every legacy caller /
|
||||
* fixture that omits it keeps the user-skip-vs-taken-vs-missed contract.
|
||||
*/
|
||||
autoMissed?: boolean;
|
||||
}
|
||||
|
||||
/** One slot the schedule expected the user to dose. */
|
||||
@@ -563,7 +571,16 @@ export function pairDoses(
|
||||
if (bestIdx >= 0) {
|
||||
claimed.add(bestIdx);
|
||||
match = events[bestIdx];
|
||||
if (match.skipped) status = "skipped";
|
||||
// v1.15.9 — an auto-missed dose (the cron marked a never-acted row
|
||||
// past its miss cutoff) counts as `missed` even though it carries no
|
||||
// `takenAt`. Check it BEFORE the user-skip branch: a forgotten dose
|
||||
// must count against the rate, while a deliberate user skip stays
|
||||
// `skipped` (excluded from the denominator). `autoMissed` and a
|
||||
// user-`skipped` are mutually exclusive on a live row by construction
|
||||
// (the cron only touches `skipped = false` pending rows), but the
|
||||
// ordering makes the precedence explicit and regression-proof.
|
||||
if (match.autoMissed) status = "missed";
|
||||
else if (match.skipped) status = "skipped";
|
||||
else if (match.takenAt) status = "taken";
|
||||
else if (slot.windowEnd > now) status = "upcoming";
|
||||
else status = "missed";
|
||||
|
||||
Reference in new issue
Block a user