mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
A medication taken now and then (#1024) keeps one row and gains course rows: calendar spans, both ends inclusive, an optional encrypted note, no schedule or dose of their own. Courses never overlap and every course but the latest has ended, so the latest course is always the one the medication's startsOn/endsOn project. src/lib/medications/courses.ts is the only writer of those two columns for a medication with courses; it writes under a per-medication advisory lock and re-projects in the same transaction, so reminders, the next-due line and intakeActionable keep reading the columns and stay right. - Migration 0368 adds medication_courses and backfills one course per medication window (an end-only window starts on the creation day, or on its end day when that lies earlier); a medication without a window stays course-less. Idempotent, data-preserving. - GET/POST /api/medications/{id}/courses and PATCH/DELETE .../courses/{courseId} (owner-only); create seeds the first course from a window; the medication PUT edits the latest course and refuses a window that would break the rules (medications.course.*). - List, detail, create and update carry courses, courseCount, previousCourseEndedOn and canStartCourse, resolved on the server. - Compliance expands over the span of all courses and drops the slots between them; every compliance read selects the courses. A rolling cadence is not anchored by an intake from a previous course. - The card names the last course's end and the running course's number; the detail Lifecycle group lists the courses with start, edit and delete. The Coach compliance block, the MCP resource, the doctor report window and table, the clinician view and FHIR (one MedicationStatement per course with effectivePeriod and status) read the courses. - Backup carries courses per medication and the restore writes them and re-projects; an older file derives the one course its window describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
/**
|
||
* v1.26.0 SEAM-N3 — the ONE schedule-anchored, cadence-aware daily
|
||
* compliance computation.
|
||
*
|
||
* Extracted verbatim from the `GET /api/medications/intake?scope=compliance`
|
||
* dashboard-tile handler so every surface that surfaces a "taken of expected"
|
||
* adherence number reads through the SAME engine instead of the raw coverage
|
||
* rollup (`readMedicationCompliance`), whose `scheduled` counts LOGGED intake
|
||
* slots — a denominator that collapses to ~100% for a user who logs only the
|
||
* doses they took and never mints `takenAt:null` reminder rows for the ones
|
||
* they missed. See the adherence storyline (`adherence-storyline.ts`) and the
|
||
* dashboard tile: both now consume this.
|
||
*
|
||
* `scheduled` is the canonical recurrence engine's expected-dose count for the
|
||
* day, summed across the user's active medications and MINUS the doses the
|
||
* user deliberately skipped, so days the schedule expected a dose that the
|
||
* user missed pull the rate down — and the 7/30/90 windows genuinely diverge
|
||
* with partial adherence. `taken` stays the count of taken (non-skipped,
|
||
* non-auto-missed) doses that day, capped at the day's expected count so a
|
||
* duplicate log can't push a day above 100%.
|
||
*
|
||
* The skip exclusion is what keeps this engine and the ledger tally
|
||
* (`tallyComplianceFromLedger`, the source of the rate on the medication card,
|
||
* the dose history and the doctor report) reporting ONE number: both treat a
|
||
* skip as a deliberate pause outside the denominator, never as a miss.
|
||
*
|
||
* Pure-ish over a single pinned `now` so a cached row is internally
|
||
* consistent. Bounded: one active-medications query + one window-events query,
|
||
* then per-medication-per-day engine expansion over the trailing window.
|
||
*/
|
||
import { prisma } from "@/lib/db";
|
||
import {
|
||
buildComplianceMedicationContext,
|
||
COURSES_COMPLIANCE_SELECT,
|
||
expectedSlotCountForDay,
|
||
lastNonSkippedTakenAt,
|
||
SCHEDULE_COMPLIANCE_SELECT,
|
||
} from "@/lib/analytics/compliance";
|
||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||
import { userDayKey } from "@/lib/tz/resolver";
|
||
import {
|
||
TRACKED_INTAKE_EVENT_WHERE,
|
||
TRACKED_INTAKE_WHERE,
|
||
} from "@/lib/medications/intake-tracking";
|
||
|
||
export interface ScheduleAnchoredComplianceBucket {
|
||
/** YYYY-MM-DD in the user's tz. */
|
||
date: string;
|
||
scheduled: number;
|
||
taken: number;
|
||
}
|
||
|
||
export async function buildScheduleAnchoredComplianceBuckets(
|
||
userId: string,
|
||
days: number,
|
||
userTz: string,
|
||
now: Date = new Date(),
|
||
): Promise<ScheduleAnchoredComplianceBucket[]> {
|
||
const nowMs = now.getTime();
|
||
const start = new Date(nowMs - days * 86_400_000);
|
||
|
||
// v1.39.1 (#1033) — a medication with intake tracking off expects no
|
||
// dose, so it contributes to neither side of the daily buckets.
|
||
const medications = await prisma.medication.findMany({
|
||
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
|
||
include: {
|
||
// v1.15.20 — the shared compliance select so a future engine column
|
||
// reaches this surface the moment it joins SCHEDULE_COMPLIANCE_SELECT.
|
||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||
courses: COURSES_COMPLIANCE_SELECT,
|
||
},
|
||
});
|
||
|
||
const events = await prisma.medicationIntakeEvent.findMany({
|
||
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
|
||
where: {
|
||
userId,
|
||
deletedAt: null,
|
||
scheduledFor: { gte: start },
|
||
...TRACKED_INTAKE_EVENT_WHERE,
|
||
},
|
||
select: {
|
||
medicationId: true,
|
||
scheduledFor: true,
|
||
takenAt: true,
|
||
skipped: true,
|
||
autoMissed: true,
|
||
},
|
||
});
|
||
|
||
// Pre-compute each local day's [start, end) bounds + key, oldest → newest.
|
||
// Each day's representative instant anchors on the user's LOCAL NOON of
|
||
// the current day and steps back in 24-hour increments: noon stays inside
|
||
// the intended local day across DST shifts (23/25-hour days), and —
|
||
// unlike the previous `now − 12 h` anchor — it cannot slip into
|
||
// yesterday when the user's local time is before noon, which silently
|
||
// dropped TODAY from the buckets for every pre-noon read.
|
||
const { start: todayStart } = getUserTodayBounds(now, userTz);
|
||
const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000;
|
||
const dayKeys: string[] = [];
|
||
const dayBounds = new Map<string, { start: Date; end: Date }>();
|
||
for (let i = days - 1; i >= 0; i--) {
|
||
const representative = new Date(todayNoonMs - i * 86_400_000);
|
||
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
|
||
representative,
|
||
userTz,
|
||
);
|
||
const key = userDayKey(dayStart, userTz);
|
||
if (dayBounds.has(key)) continue;
|
||
dayKeys.push(key);
|
||
dayBounds.set(key, {
|
||
start: dayStart,
|
||
end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
|
||
});
|
||
}
|
||
|
||
const totals = new Map<string, { scheduled: number; taken: number }>();
|
||
for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
|
||
|
||
// Group events by medication so each med's engine context is built once.
|
||
const eventsByMed = new Map<
|
||
string,
|
||
{
|
||
scheduledFor: Date;
|
||
takenAt: Date | null;
|
||
skipped: boolean;
|
||
autoMissed: boolean;
|
||
}[]
|
||
>();
|
||
for (const e of events) {
|
||
const list = eventsByMed.get(e.medicationId) ?? [];
|
||
list.push({
|
||
scheduledFor: e.scheduledFor,
|
||
takenAt: e.takenAt,
|
||
skipped: e.skipped,
|
||
autoMissed: e.autoMissed,
|
||
});
|
||
eventsByMed.set(e.medicationId, list);
|
||
}
|
||
|
||
for (const med of medications) {
|
||
if (med.schedules.length === 0) continue;
|
||
const medEvents = eventsByMed.get(med.id) ?? [];
|
||
const ctx = buildComplianceMedicationContext(
|
||
med,
|
||
lastNonSkippedTakenAt(medEvents),
|
||
userTz,
|
||
);
|
||
|
||
for (const key of dayKeys) {
|
||
const bounds = dayBounds.get(key)!;
|
||
// Skip days before the medication existed so a young med doesn't paint
|
||
// missed-denominator days it could not have been dosed on.
|
||
if (bounds.end <= med.createdAt) continue;
|
||
|
||
const scheduled = expectedSlotCountForDay(
|
||
med.schedules,
|
||
bounds.start,
|
||
bounds.end,
|
||
ctx,
|
||
medEvents,
|
||
);
|
||
if (scheduled === 0) continue;
|
||
|
||
const inDay = (at: Date) => at >= bounds.start && at < bounds.end;
|
||
|
||
// A deliberate skip is a pause, not a miss (the semantics the ledger
|
||
// tally has carried since v1.15.9 and the only reading the "consistent
|
||
// skip handling" contract admits). The skipped slot leaves the
|
||
// denominator entirely instead of being counted as expected-and-missed
|
||
// — otherwise the dashboard tile reported a lower rate than the card,
|
||
// the history view and the doctor report for the very same day. The
|
||
// count is capped at the expected slots so a stray skip row outside
|
||
// the minted grid cannot drive the denominator negative; a day whose
|
||
// every expected dose was skipped drops out of the buckets, exactly as
|
||
// it drops out of the ledger denominator.
|
||
//
|
||
// `skipped` outranks `autoMissed` here for the same reason it does in
|
||
// `reconstructDoseHistory`: the cron marks a forgotten dose
|
||
// `autoMissed`, so a row still carrying `skipped` is a real user
|
||
// decision.
|
||
const skippedThisDay = Math.min(
|
||
medEvents.filter((e) => e.skipped && inDay(e.scheduledFor)).length,
|
||
scheduled,
|
||
);
|
||
const expected = scheduled - skippedThisDay;
|
||
if (expected === 0) continue;
|
||
|
||
// Taken doses that landed in this day's window (non-skipped, non-auto-
|
||
// missed), capped at the expected count so a duplicate log can't push a
|
||
// single day above 100%.
|
||
const takenThisDay = medEvents.filter(
|
||
(e) =>
|
||
e.takenAt !== null &&
|
||
!e.skipped &&
|
||
!e.autoMissed &&
|
||
inDay(e.scheduledFor),
|
||
).length;
|
||
|
||
const bucket = totals.get(key)!;
|
||
bucket.scheduled += expected;
|
||
bucket.taken += Math.min(takenThisDay, expected);
|
||
}
|
||
}
|
||
|
||
return dayKeys
|
||
.map((date) => {
|
||
const v = totals.get(date)!;
|
||
return { date, scheduled: v.scheduled, taken: v.taken };
|
||
})
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
}
|