mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(medications): pure cadence + compliance helpers for GLP-1 detail
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user