feat(medications): pure cadence + compliance helpers for GLP-1 detail

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:10:45 +02:00
1 parent f0f3143cab
commit 4af09170bb
4 files changed
+966

No files matched your search

@@ -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);
});
});
@@ -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);
});
});
+298
View File
@@ -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<number>();
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;
}
@@ -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<string, "all-good" | "bad">();
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,
};
}