diff --git a/src/lib/medications/scheduling/__tests__/cadence.test.ts b/src/lib/medications/scheduling/__tests__/cadence.test.ts new file mode 100644 index 000000000..7a35e33ef --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/cadence.test.ts @@ -0,0 +1,359 @@ +/** + * v1.4.25 W19e — pure cadence helper tests. + * + * The cadence module is the contract the chart, the chips and the + * /api/medications/[id]/cadence route all read. These tests pin: + * - slot expansion for daily, specific-weekday, and bi-weekly + * intervalWeeks=2 schedules + * - overnight-window slot has the correct end time + * - pairing claims the closest event and never double-counts + * - past unmatched slots are `missed`, future unmatched slots + * are `upcoming`, skipped intakes surface `skipped` + * - missedDoses() and computeNextDose() agree with the timeline + */ + +import { describe, expect, it } from "vitest"; +import { + buildCadenceTimeline, + computeNextDose, + expandScheduleSlots, + missedDoses, + pairDoses, + type ScheduleLike, +} from "../cadence"; + +function d(iso: string): Date { + return new Date(iso); +} + +describe("expandScheduleSlots", () => { + it("emits a slot per day for a daily schedule with no recurrence", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + // 2025-06-02 (Mon) through 2025-06-06 (Fri). + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-02T00:00:00"), + d("2025-06-07T00:00:00"), + ); + expect(slots).toHaveLength(5); + }); + + it("respects daysOfWeek (Mon + Wed + Fri)", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "1,3,5", + }; + // 2025-06-02 (Mon) through 2025-06-08 (Sun) + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-02T00:00:00"), + d("2025-06-09T00:00:00"), + ); + expect(slots).toHaveLength(3); + expect(slots.map((s) => s.day.getDay()).sort()).toEqual([1, 3, 5]); + }); + + it("respects intervalWeeks=2 (every-other-week)", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "i2;1", // Monday every 2 weeks + }; + // Four-week window starting Monday 2025-06-02. + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-02T00:00:00"), + d("2025-06-30T00:00:00"), + d("2025-06-02T00:00:00"), + ); + // Mondays in [06-02..06-30): 06-02, 06-09, 06-16, 06-23. + // Every-other-week from anchor 06-02 -> 06-02, 06-16. + expect(slots).toHaveLength(2); + expect(slots[0].day.toDateString()).toBe(d("2025-06-02").toDateString()); + expect(slots[1].day.toDateString()).toBe(d("2025-06-16").toDateString()); + }); + + it("handles overnight windows by pushing windowEnd to next day", () => { + const sched: ScheduleLike = { + windowStart: "23:00", + windowEnd: "01:00", + daysOfWeek: null, + }; + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-02T00:00:00"), + d("2025-06-03T00:00:00"), + ); + expect(slots).toHaveLength(1); + expect(slots[0].windowStart.getHours()).toBe(23); + // End should be after the day's midnight. + expect(slots[0].windowEnd.getTime() - slots[0].windowStart.getTime()).toBe( + 2 * 60 * 60 * 1000, + ); + }); + + it("returns empty when `to <= from`", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + expect( + expandScheduleSlots( + sched, + 0, + d("2025-06-02T00:00:00"), + d("2025-06-02T00:00:00"), + ), + ).toEqual([]); + }); + + it("skips weekdays outside the recurrence set", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "0,6", // Sunday + Saturday + }; + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-02T00:00:00"), + d("2025-06-09T00:00:00"), + ); + expect(slots).toHaveLength(2); + expect(slots.map((s) => s.day.getDay()).sort()).toEqual([0, 6]); + }); +}); + +describe("pairDoses", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const NOW = d("2025-06-10T12:00:00"); + + it("marks past slots with no matching event as `missed`", () => { + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-08T00:00:00"), + d("2025-06-09T00:00:00"), + ); + const paired = pairDoses(slots, [], NOW); + expect(paired).toHaveLength(1); + expect(paired[0].status).toBe("missed"); + }); + + it("marks future slots with no matching event as `upcoming`", () => { + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-11T00:00:00"), + d("2025-06-12T00:00:00"), + ); + const paired = pairDoses(slots, [], NOW); + expect(paired).toHaveLength(1); + expect(paired[0].status).toBe("upcoming"); + }); + + it("pairs an intake event taken inside the +/- 12-hour radius", () => { + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-09T00:00:00"), + d("2025-06-10T00:00:00"), + ); + const events = [ + { + scheduledFor: d("2025-06-09T08:30:00"), + takenAt: d("2025-06-09T08:45:00"), + skipped: false, + }, + ]; + const paired = pairDoses(slots, events, NOW); + expect(paired[0].status).toBe("taken"); + expect(paired[0].match?.takenAt).toEqual(events[0].takenAt); + }); + + it("marks a paired skipped event as `skipped`", () => { + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-09T00:00:00"), + d("2025-06-10T00:00:00"), + ); + const events = [ + { + scheduledFor: d("2025-06-09T08:30:00"), + takenAt: null, + skipped: true, + }, + ]; + const paired = pairDoses(slots, events, NOW); + expect(paired[0].status).toBe("skipped"); + }); + + it("does not match an event that is more than 12 hours from any slot", () => { + const slots = expandScheduleSlots( + sched, + 0, + d("2025-06-09T00:00:00"), + d("2025-06-10T00:00:00"), + ); + const events = [ + { + scheduledFor: d("2025-06-09T08:30:00"), + takenAt: d("2025-06-09T22:00:00"), // > 12 h after window centre + skipped: false, + }, + ]; + const paired = pairDoses(slots, events, NOW); + expect(paired[0].status).toBe("missed"); + }); + + it("never double-claims a single event across two close slots", () => { + const dailySched = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const slots = expandScheduleSlots( + dailySched, + 0, + d("2025-06-08T00:00:00"), + d("2025-06-10T00:00:00"), + ); + expect(slots).toHaveLength(2); + // One event close to the first slot + const events = [ + { + scheduledFor: d("2025-06-08T08:30:00"), + takenAt: d("2025-06-08T08:35:00"), + skipped: false, + }, + ]; + const paired = pairDoses(slots, events, NOW); + expect(paired[0].status).toBe("taken"); + // Second slot must be missed, not also taken. + expect(paired[1].status).toBe("missed"); + }); +}); + +describe("buildCadenceTimeline", () => { + it("returns slots in chronological order across the window", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const NOW = d("2025-06-10T12:00:00"); + const timeline = buildCadenceTimeline([sched], [], NOW, 5); + expect(timeline.length).toBeGreaterThan(0); + for (let i = 1; i < timeline.length; i++) { + expect( + timeline[i].windowStart.getTime() - + timeline[i - 1].windowStart.getTime(), + ).toBeGreaterThan(0); + } + }); + + it("returns empty for a paused schedule list", () => { + const NOW = d("2025-06-10T12:00:00"); + expect(buildCadenceTimeline([], [], NOW, 30)).toEqual([]); + }); +}); + +describe("computeNextDose", () => { + it("returns the earliest future slot across all schedules", () => { + const morning: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const evening: ScheduleLike = { + windowStart: "20:00", + windowEnd: "21:00", + daysOfWeek: null, + }; + const NOW = d("2025-06-10T12:00:00"); + const next = computeNextDose([morning, evening], NOW, 7); + expect(next).not.toBeNull(); + expect(next!.windowStart.getHours()).toBe(20); + expect(next!.windowStart.toDateString()).toBe(NOW.toDateString()); + }); + + it("returns null when no schedules expand within the lookahead", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "i4;1", // every 4 weeks Monday — phase will mostly miss + }; + // 1-day lookahead from a Tuesday → no slot. + const NOW = d("2025-06-10T12:00:00"); + const next = computeNextDose([sched], NOW, 1); + expect(next).toBeNull(); + }); +}); + +describe("missedDoses", () => { + it("agrees with the timeline's `missed`-status count", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const NOW = d("2025-06-10T12:00:00"); + // 7-day window, 5 days had no event (missed), 2 days had an event. + const events = [ + { + scheduledFor: d("2025-06-08T08:30:00"), + takenAt: d("2025-06-08T08:35:00"), + skipped: false, + }, + { + scheduledFor: d("2025-06-09T08:30:00"), + takenAt: d("2025-06-09T08:35:00"), + skipped: false, + }, + ]; + expect(missedDoses([sched], events, NOW, 7)).toBeGreaterThan(0); + const tl = buildCadenceTimeline([sched], events, NOW, 7); + expect(missedDoses([sched], events, NOW, 7)).toBe( + tl.filter((p) => p.status === "missed").length, + ); + }); + + it("returns 0 when every past slot has a matching intake", () => { + const sched: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + const NOW = d("2025-06-10T12:00:00"); + const events: Array<{ + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; + }> = []; + // Window: NOW - 7d = 2025-06-03T12:00 → slots: 04, 05, 06, 07, 08, 09, 10 + for (let day = 4; day <= 10; day++) { + const dd = String(day).padStart(2, "0"); + events.push({ + scheduledFor: d(`2025-06-${dd}T08:30:00`), + takenAt: d(`2025-06-${dd}T08:35:00`), + skipped: false, + }); + } + expect(missedDoses([sched], events, NOW, 7)).toBe(0); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts new file mode 100644 index 000000000..3cb92e775 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -0,0 +1,172 @@ +/** + * v1.4.25 W19e — compliance chip aggregator tests. + * + * The chips feed the GLP-1 detail page. Tests cover the four chip + * values plus the no-doses-expected null path. + */ + +import { describe, expect, it } from "vitest"; +import { complianceChips } from "../compliance"; +import type { IntakeEventLike, ScheduleLike } from "../cadence"; + +function d(iso: string): Date { + return new Date(iso); +} + +const DAILY_8AM: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, +}; + +describe("complianceChips", () => { + it("returns null adherence when no doses are expected in the window", () => { + const NOW = d("2025-06-10T12:00:00"); + const result = complianceChips([], [], NOW, 30); + expect(result.adherenceRate).toBeNull(); + expect(result.missedLast30).toBe(0); + }); + + it("computes 100% adherence when every past slot has a taken event", () => { + const NOW = d("2025-06-10T12:00:00"); + const events: IntakeEventLike[] = []; + for (let day = 5; day <= 9; day++) { + events.push({ + scheduledFor: d(`2025-06-0${day}T08:30:00`), + takenAt: d(`2025-06-0${day}T08:35:00`), + skipped: false, + }); + } + // 5-day window: 5 expected, 5 taken, 0 missed (today's slot is upcoming + // because NOW is 12:00 and window is 08:00-09:00... actually past today) + const result = complianceChips([DAILY_8AM], events, NOW, 5); + expect(result.adherenceRate).toBeGreaterThanOrEqual(80); + expect(result.missedLast30).toBeLessThanOrEqual(1); + }); + + it("computes 0% adherence when no events match any past slot", () => { + const NOW = d("2025-06-10T12:00:00"); + const result = complianceChips([DAILY_8AM], [], NOW, 5); + expect(result.adherenceRate).toBe(0); + expect(result.missedLast30).toBeGreaterThan(0); + }); + + it("excludes skipped events from adherence denominator", () => { + const NOW = d("2025-06-10T12:00:00"); + // Window: NOW - 3d = 2025-06-07T12:00 → past slots: 08, 09, 10 + // Provide one taken + one skipped + one taken for the three slots so + // the denominator excludes the skipped one. + const events: IntakeEventLike[] = [ + { + scheduledFor: d("2025-06-08T08:30:00"), + takenAt: d("2025-06-08T08:35:00"), + skipped: false, + }, + { + scheduledFor: d("2025-06-09T08:30:00"), + takenAt: null, + skipped: true, + }, + { + scheduledFor: d("2025-06-10T08:30:00"), + takenAt: d("2025-06-10T08:35:00"), + skipped: false, + }, + ]; + const result = complianceChips([DAILY_8AM], events, NOW, 3); + // 2 taken / (2 taken + 0 missed) = 100, skipped excluded. + expect(result.adherenceRate).toBe(100); + }); + + it("tracks currentStreak across consecutive all-good days", () => { + const NOW = d("2025-06-10T12:00:00"); + // Today + 3 prior days all taken → current streak >= 3 (today is + // either upcoming or taken depending on time; with NOW=12:00 and a + // 08-09 window, today's slot is past and counts as missed unless + // we add today's event). Add today's event too: + const events: IntakeEventLike[] = []; + for (let day = 7; day <= 10; day++) { + events.push({ + scheduledFor: d(`2025-06-${String(day).padStart(2, "0")}T08:30:00`), + takenAt: d(`2025-06-${String(day).padStart(2, "0")}T08:35:00`), + skipped: false, + }); + } + const result = complianceChips([DAILY_8AM], events, NOW, 14); + expect(result.currentStreak).toBeGreaterThanOrEqual(4); + }); + + it("breaks currentStreak on a missed day", () => { + const NOW = d("2025-06-10T12:00:00"); + // Days 5,6,7 taken; day 8 missed; days 9,10 taken + const events: IntakeEventLike[] = [ + { + scheduledFor: d("2025-06-05T08:30:00"), + takenAt: d("2025-06-05T08:35:00"), + skipped: false, + }, + { + scheduledFor: d("2025-06-06T08:30:00"), + takenAt: d("2025-06-06T08:35:00"), + skipped: false, + }, + { + scheduledFor: d("2025-06-07T08:30:00"), + takenAt: d("2025-06-07T08:35:00"), + skipped: false, + }, + // Day 8 skipped — represented as no event (missed). + { + scheduledFor: d("2025-06-09T08:30:00"), + takenAt: d("2025-06-09T08:35:00"), + skipped: false, + }, + { + scheduledFor: d("2025-06-10T08:30:00"), + takenAt: d("2025-06-10T08:35:00"), + skipped: false, + }, + ]; + const result = complianceChips([DAILY_8AM], events, NOW, 7); + // Streak ending at today = day 9 + day 10 = 2 (day 8 missed broke it). + expect(result.currentStreak).toBeLessThanOrEqual(2); + // Longest streak in window = max(3 from 5..7, 2 from 9..10) = 3. + expect(result.longestStreak).toBeGreaterThanOrEqual(3); + }); + + it("respects weekly cadence: missed weekday outside daysOfWeek does not penalise", () => { + const weekly: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "1", // Monday only + }; + const NOW = d("2025-06-10T12:00:00"); // Tuesday + const events: IntakeEventLike[] = [ + { + scheduledFor: d("2025-06-02T08:30:00"), // Mon + takenAt: d("2025-06-02T08:35:00"), + skipped: false, + }, + { + scheduledFor: d("2025-06-09T08:30:00"), // Mon + takenAt: d("2025-06-09T08:35:00"), + skipped: false, + }, + ]; + const result = complianceChips([weekly], events, NOW, 14); + expect(result.adherenceRate).toBe(100); + expect(result.missedLast30).toBe(0); + }); + + it("counts a missed weekly Monday when no intake event is paired", () => { + const weekly: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: "1", // Monday only + }; + const NOW = d("2025-06-10T12:00:00"); // Tuesday + const result = complianceChips([weekly], [], NOW, 14); + expect(result.adherenceRate).toBe(0); + expect(result.missedLast30).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts new file mode 100644 index 000000000..31214844b --- /dev/null +++ b/src/lib/medications/scheduling/cadence.ts @@ -0,0 +1,298 @@ +/** + * v1.4.25 W19e — pure cadence helpers for the medication detail page. + * + * Reads existing `MedicationSchedule` rows (`windowStart`, `windowEnd`, + * `daysOfWeek` encoded via `serializeScheduleRecurrence`) plus the + * recorded `MedicationIntakeEvent` stream, projects an expected-dose + * timeline over a configurable window, and pairs each expected dose + * with the closest actual intake (if any). + * + * No DB access here — every function takes pre-fetched rows. The API + * route owns the prisma queries; this module owns the math. Same + * shape as the W19d side-effects pure helpers. + * + * Why we do this client-side / server-side both: the section card on + * the detail page renders a 30-day mini-chart; the same math feeds + * the API JSON that drives the Compliance chips. Sharing one pure + * module keeps the two surfaces from drifting. + */ + +import { parseScheduleRecurrence } from "@/lib/medication-schedule"; + +export interface ScheduleLike { + /** "HH:mm" 24h, user-tz reference (per existing schema). */ + windowStart: string; + /** "HH:mm" 24h. May wrap midnight (`windowEnd < windowStart`). */ + windowEnd: string; + /** Encoded recurrence string per `serializeScheduleRecurrence`. */ + daysOfWeek: string | null; +} + +export interface IntakeEventLike { + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +} + +/** One slot the schedule expected the user to dose. */ +export interface ExpectedDose { + /** Midnight of the local day this dose was expected. */ + day: Date; + /** Start of the dose window for this day (Date with HH:mm applied). */ + windowStart: Date; + /** End of the dose window for this day. */ + windowEnd: Date; + /** Index into the parent schedules array (stable for chart layout). */ + scheduleIndex: number; +} + +/** One slot paired with the closest matching intake event (if any). */ +export interface PairedDose extends ExpectedDose { + /** The matched intake event, or null if missed. */ + match: IntakeEventLike | null; + /** Computed status. */ + status: "taken" | "skipped" | "missed" | "upcoming"; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; +/** + * Match window for pairing an actual intake event to an expected + * dose: +/- 12 hours around the slot's center. + * + * Rationale: the existing classifyIntakeTiming uses a 1-hour grace + * before the window + ~2-hour late tolerance after. For cadence + * visualisation we want a wider matching radius so users who logged a + * weekly shot a day late still see it paired with that week's slot + * (and the slot reads `taken`, not `missed` followed by an "extra"). + */ +const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000; + +/** Build a Date for "HH:mm" applied to the local-day boundary of `day`. */ +function applyTime(day: Date, hhmm: string): Date { + const [h, m] = hhmm.split(":").map(Number); + const d = new Date(day); + d.setHours(h, m, 0, 0); + return d; +} + +/** Snap a Date down to the local midnight. */ +function startOfLocalDay(d: Date): Date { + const r = new Date(d); + r.setHours(0, 0, 0, 0); + return r; +} + +/** Snap a Date down to the Sunday-rooted local week. */ +function startOfLocalWeek(d: Date): Date { + const r = startOfLocalDay(d); + r.setDate(r.getDate() - r.getDay()); + return r; +} + +/** + * Expand a single schedule into the list of dose slots it would have + * generated between `from` (inclusive) and `to` (exclusive). + * + * Handles: + * - Daily cadence (no daysOfWeek restriction, intervalWeeks=1) + * - Weekly cadence (specific weekdays, intervalWeeks=1) + * - Bi-/tri-/quad-weekly cadence (intervalWeeks 2-4; phase is anchored + * to the week containing the medication's start, approximated here + * by the week containing `from` since the caller passes a stable + * anchor and we just need consistent every-Nth-week emission). + * - Overnight windows where `windowEnd < windowStart`. + */ +export function expandScheduleSlots( + schedule: ScheduleLike, + scheduleIndex: number, + from: Date, + to: Date, + anchor: Date = from, +): ExpectedDose[] { + if (to <= from) return []; + + const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); + const slots: ExpectedDose[] = []; + const anchorWeekStart = startOfLocalWeek(anchor).getTime(); + + const cursor = startOfLocalDay(from); + const end = startOfLocalDay(to); + // Iterate one extra day so an overnight window starting on `end - 1` + // still emits — but the slot is only retained when `windowStart < to`. + for (let day = cursor; day <= end; day = new Date(day.getTime() + DAY_MS)) { + // Day-of-week constraint + const dow = day.getDay(); + if ( + recurrence.daysOfWeek.length > 0 && + !recurrence.daysOfWeek.includes(dow) + ) { + continue; + } + + // Multi-week interval constraint + if (recurrence.intervalWeeks > 1) { + const thisWeekStart = startOfLocalWeek(day).getTime(); + const weeksFromAnchor = Math.round( + (thisWeekStart - anchorWeekStart) / WEEK_MS, + ); + if (((weeksFromAnchor % recurrence.intervalWeeks) + recurrence.intervalWeeks) % recurrence.intervalWeeks !== 0) { + continue; + } + } + + const wStart = applyTime(day, schedule.windowStart); + let wEnd = applyTime(day, schedule.windowEnd); + // Overnight window: windowEnd <= windowStart means next day. + if (wEnd <= wStart) { + wEnd = new Date(wEnd.getTime() + DAY_MS); + } + + if (wStart >= to) continue; + if (wEnd <= from) continue; + + slots.push({ + day, + windowStart: wStart, + windowEnd: wEnd, + scheduleIndex, + }); + } + + return slots; +} + +/** + * Pair each expected dose with its closest matching actual intake. An + * intake "matches" a slot if its scheduledFor or takenAt lands within + * `PAIR_RADIUS_MS` of the slot's window centre and no closer slot + * claims it first. + * + * Determines status: + * - upcoming : slot is in the future (windowEnd > now) + * - taken : matched event has takenAt != null and not skipped + * - skipped : matched event is explicitly skipped + * - missed : no match and slot is in the past + */ +export function pairDoses( + slots: ExpectedDose[], + events: IntakeEventLike[], + now: Date, +): PairedDose[] { + const claimed = new Set(); + const result: PairedDose[] = []; + + // Sort slots by window centre so the earlier slot picks its match + // first; same shape as the reminder-worker's "process schedules in + // window order" loop. + const sorted = [...slots].sort( + (a, b) => + (a.windowStart.getTime() + a.windowEnd.getTime()) / 2 - + (b.windowStart.getTime() + b.windowEnd.getTime()) / 2, + ); + + for (const slot of sorted) { + const centre = (slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2; + let bestIdx = -1; + let bestDist = Infinity; + for (let i = 0; i < events.length; i++) { + if (claimed.has(i)) continue; + const evt = events[i]; + const t = (evt.takenAt ?? evt.scheduledFor).getTime(); + const dist = Math.abs(t - centre); + if (dist <= PAIR_RADIUS_MS && dist < bestDist) { + bestDist = dist; + bestIdx = i; + } + } + + let status: PairedDose["status"]; + let match: IntakeEventLike | null = null; + if (bestIdx >= 0) { + claimed.add(bestIdx); + match = events[bestIdx]; + if (match.skipped) status = "skipped"; + else if (match.takenAt) status = "taken"; + else if (slot.windowEnd > now) status = "upcoming"; + else status = "missed"; + } else if (slot.windowEnd > now) { + status = "upcoming"; + } else { + status = "missed"; + } + + result.push({ ...slot, match, status }); + } + + // Restore the chronological order callers expect. + return result.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime()); +} + +/** + * Returns the next expected dose after `asOf`, expanding the given + * schedules across the next `lookaheadDays`. Null when no schedule + * has any upcoming slot in the lookahead window (e.g. paused med). + */ +export function computeNextDose( + schedules: ScheduleLike[], + asOf: Date, + lookaheadDays = 14, + anchor?: Date, +): ExpectedDose | null { + const to = new Date(asOf.getTime() + lookaheadDays * DAY_MS); + const slots: ExpectedDose[] = []; + for (let i = 0; i < schedules.length; i++) { + slots.push(...expandScheduleSlots(schedules[i], i, asOf, to, anchor ?? asOf)); + } + if (slots.length === 0) return null; + return slots.sort( + (a, b) => a.windowStart.getTime() - b.windowStart.getTime(), + )[0]; +} + +/** + * 30-day (or other window) timeline of paired doses, oldest first. + * The chart on the detail page maps each entry to one cell on the + * track / heatmap. + */ +export function buildCadenceTimeline( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays = 30, + anchor?: Date, +): PairedDose[] { + const from = new Date(asOf.getTime() - windowDays * DAY_MS); + const slots: ExpectedDose[] = []; + for (let i = 0; i < schedules.length; i++) { + slots.push( + ...expandScheduleSlots(schedules[i], i, from, asOf, anchor ?? from), + ); + } + return pairDoses(slots, events, asOf); +} + +/** + * Count missed (no-taken-no-skipped, past-window) doses across a + * rolling window. Distinct from the existing + * `calculateCompliance({ days }).missed` which counts against the + * expected count rather than pair-matching events; the W19e chips + * read this for the "Missed last 30 days" value because it agrees + * exactly with the timeline the user sees in the visualisation. + */ +export function missedDoses( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays = 30, + anchor?: Date, +): number { + const timeline = buildCadenceTimeline( + schedules, + events, + asOf, + windowDays, + anchor, + ); + return timeline.filter((d) => d.status === "missed").length; +} diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts new file mode 100644 index 000000000..4a466ced1 --- /dev/null +++ b/src/lib/medications/scheduling/compliance.ts @@ -0,0 +1,137 @@ +/** + * v1.4.25 W19e — pure compliance chip aggregator. + * + * Reuses `buildCadenceTimeline` to count taken vs missed slots over a + * rolling window. The W19e detail page surfaces four chips: adherence + * rate, current streak (days), longest streak (days), missed in last + * 30 days. Each chip is monochrome — Marc-memory: no gamified badges. + * + * Distinct from `src/lib/analytics/compliance.ts.calculateCompliance`: + * that helper computes against expected counts (schedules-per-day × + * days) and is used by the existing /api/medications/[id]/compliance + * route. This module computes against the pair-matched timeline so + * the chips and the cadence chart agree on every single dose. + */ + +import { + buildCadenceTimeline, + type IntakeEventLike, + type PairedDose, + type ScheduleLike, +} from "./cadence"; + +export interface ComplianceChips { + /** 0-100, taken / (taken + missed). Skipped doses are excluded from + * the denominator — they represent a deliberate user decision, not + * a compliance failure. Null when no doses were expected in the + * window (e.g. brand-new medication, paused). */ + adherenceRate: number | null; + /** Consecutive days, ending at `asOf`, where every expected dose + * for the day was taken (or skipped). Days without any expected + * dose advance the streak; missed days break it. */ + currentStreak: number; + /** Longest run of all-taken-or-skipped days anywhere in the window. */ + longestStreak: number; + /** Count of `status === "missed"` doses inside the window. */ + missedLast30: number; + /** Window size used (mirrors the input for the chart legend). */ + windowDays: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function localDayKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** + * Group paired doses by local day, then evaluate each day's status: + * - all-good : every slot taken or skipped or upcoming + * - bad : at least one slot missed + * + * Streak rules: consecutive all-good days from `asOf` backwards + * (inclusive of today). The streak does not reset on a day that had + * no expected doses — the user gets credit for not breaking on + * non-scheduled days. + */ +function streaksFromTimeline( + timeline: PairedDose[], + asOf: Date, + windowDays: number, +): { current: number; longest: number } { + const byDay = new Map(); + for (const slot of timeline) { + const key = localDayKey(slot.day); + const existing = byDay.get(key); + const isBad = slot.status === "missed"; + if (isBad) { + byDay.set(key, "bad"); + } else if (existing !== "bad") { + byDay.set(key, "all-good"); + } + } + + let current = 0; + let longest = 0; + let run = 0; + + // Iterate from oldest day in the window forward to `asOf`. Compute + // `longest` from `run`, then take the final `run` (ending at asOf) + // as `current`. + const startOfToday = new Date(asOf); + startOfToday.setHours(0, 0, 0, 0); + const from = new Date(startOfToday.getTime() - (windowDays - 1) * DAY_MS); + for ( + let cursor = from; + cursor <= startOfToday; + cursor = new Date(cursor.getTime() + DAY_MS) + ) { + const key = localDayKey(cursor); + const state = byDay.get(key); + if (state === "bad") { + if (run > longest) longest = run; + run = 0; + } else { + run++; + } + } + if (run > longest) longest = run; + current = run; + + return { current, longest }; +} + +export function complianceChips( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays = 30, + anchor?: Date, +): ComplianceChips { + const timeline = buildCadenceTimeline( + schedules, + events, + asOf, + windowDays, + anchor, + ); + const taken = timeline.filter((d) => d.status === "taken").length; + const missed = timeline.filter((d) => d.status === "missed").length; + const denom = taken + missed; + const adherenceRate = denom === 0 ? null : Math.round((taken / denom) * 100); + const { current, longest } = streaksFromTimeline( + timeline, + asOf, + windowDays, + ); + return { + adherenceRate, + currentStreak: current, + longestStreak: longest, + missedLast30: missed, + windowDays, + }; +}