From 29fa982e113ea3e20caa021fff93bec0dfaceda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 13:26:41 +0200 Subject: [PATCH] fix(medications): count forgotten doses as missed, not skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A never-acted dose past its 24h cutoff was flipped to skipped=true by the hourly cron. The compliance engine excludes skipped doses from the denominator (a deliberate user pause), so a forgotten dose then counted neither as taken nor missed — silently inflating adherence. Add a dedicated auto_missed terminal state. The cron now sets auto_missed (not skipped) on a never-acted pending row, so the engine counts it as a real miss against the rate, while a user-initiated skip stays excluded. The two are mutually exclusive on a live row by construction. --- .../migration.sql | 13 ++++ prisma/schema.prisma | 8 +++ .../jobs/__tests__/intake-auto-skip.test.ts | 63 +++++++++++++------ src/lib/jobs/intake-auto-skip.ts | 55 +++++++++------- 4 files changed, 97 insertions(+), 42 deletions(-) create mode 100644 prisma/migrations/0135_v1159_intake_auto_missed/migration.sql diff --git a/prisma/migrations/0135_v1159_intake_auto_missed/migration.sql b/prisma/migrations/0135_v1159_intake_auto_missed/migration.sql new file mode 100644 index 000000000..a567106be --- /dev/null +++ b/prisma/migrations/0135_v1159_intake_auto_missed/migration.sql @@ -0,0 +1,13 @@ +-- v1.15.9 — terminal "auto-missed" state for forgotten medication doses. +-- +-- The hourly auto-miss cron previously flipped a never-acted pending dose to +-- `skipped = true`. Because the compliance engine excludes skipped doses from +-- the denominator (a deliberate user pause), a forgotten dose then neither +-- counted as taken nor as missed — silently inflating adherence. +-- +-- This column lets the engine tell the two apart: a user-initiated skip stays +-- `skipped = true` (excluded), while a forgotten dose past its miss cutoff is +-- marked `auto_missed = true` and counts as a MISS against the rate. Every +-- legacy row defaults to false. +ALTER TABLE "medication_intake_events" + ADD COLUMN "auto_missed" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13989e06f..db2fb2cb7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1443,6 +1443,14 @@ model MedicationIntakeEvent { scheduledFor DateTime @map("scheduled_for") takenAt DateTime? @map("taken_at") skipped Boolean @default(false) + /// v1.15.9 — distinguishes a never-acted forgotten dose from a + /// deliberate user skip. The hourly auto-miss cron sets this (NOT + /// `skipped`) on a pending row past its miss cutoff so the compliance + /// engine counts it as a MISS in the denominator, while a user-initiated + /// `skipped = true` stays excluded (a deliberate drug holiday). Every + /// legacy row defaults to false (a user skip or a live pending row, + /// neither of which is an auto-miss). + autoMissed Boolean @default(false) @map("auto_missed") source IntakeSource @default(WEB) idempotencyKey String? @unique @map("idempotency_key") createdAt DateTime @default(now()) @map("created_at") diff --git a/src/lib/jobs/__tests__/intake-auto-skip.test.ts b/src/lib/jobs/__tests__/intake-auto-skip.test.ts index 446ff7c16..648c151d0 100644 --- a/src/lib/jobs/__tests__/intake-auto-skip.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-skip.test.ts @@ -1,15 +1,20 @@ /** - * v1.4.46 — coverage for the hourly intake-auto-skip helper. + * v1.4.46 — coverage for the hourly intake-auto-miss helper. * - * The helper runs a single `updateMany` against `MedicationIntakeEvent` - * with the `skipped = false AND takenAt IS NULL AND scheduledFor < cutoff` - * gate, where `cutoff = now - 24 h`. These tests pin: + * v1.15.9 — the terminal state is now `auto_missed = true`, NOT + * `skipped = true`. A forgotten dose is a real MISS that must count against + * adherence; flipping `skipped` (which the compliance engine excludes from + * the denominator) silently inflated the rate. The helper runs a single + * `updateMany` with the `skipped = false AND auto_missed = false AND + * takenAt IS NULL AND scheduledFor < cutoff` gate, where `cutoff = now - 24 h`. + * These tests pin: * * cron + queue contract (constants don't drift), * * cutoff is `now - 24 h` exactly (off-by-one safety), - * * Prisma `updateMany` is called with the spec'd shape, - * * idempotency-by-construction (a second pass on the same state - * finds zero candidates because the predicate has already flipped - * them). + * * Prisma `updateMany` is called with the spec'd shape + * (`auto_missed = true`, not `skipped = true`), + * * a user-skipped row is left untouched (a deliberate pause is never + * reclassified as a miss), + * * idempotency-by-construction (a second pass finds zero candidates). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { PrismaClient } from "@/generated/prisma/client"; @@ -27,10 +32,12 @@ interface FakeIntakeRow { scheduledFor: Date; takenAt: Date | null; skipped: boolean; + autoMissed: boolean; } interface FakeUpdateManyWhere { skipped?: boolean; + autoMissed?: boolean; takenAt?: null; scheduledFor?: { lt: Date }; } @@ -44,19 +51,24 @@ function makeFakePrisma(state: { rows: FakeIntakeRow[] }) { data, }: { where: FakeUpdateManyWhere; - data: { skipped: boolean }; + data: { autoMissed: boolean }; }) => { let count = 0; for (const row of state.rows) { if (where.skipped !== undefined && row.skipped !== where.skipped) continue; + if ( + where.autoMissed !== undefined && + row.autoMissed !== where.autoMissed + ) + continue; if (where.takenAt === null && row.takenAt !== null) continue; if ( where.scheduledFor?.lt !== undefined && row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime() ) continue; - row.skipped = data.skipped; + row.autoMissed = data.autoMissed; count += 1; } return { count }; @@ -90,7 +102,7 @@ describe("runIntakeAutoSkipPass", () => { vi.clearAllMocks(); }); - it("flips pending rows older than 24 h to skipped=true", async () => { + it("flips pending rows older than 24 h to auto_missed=true (NOT skipped)", async () => { const rows: FakeIntakeRow[] = [ // Stale + unmarked → must flip. { @@ -99,6 +111,7 @@ describe("runIntakeAutoSkipPass", () => { scheduledFor: new Date(NOW_MS - 30 * HOUR_MS), takenAt: null, skipped: false, + autoMissed: false, }, // Stale + unmarked (different user) → must flip. { @@ -107,6 +120,7 @@ describe("runIntakeAutoSkipPass", () => { scheduledFor: new Date(NOW_MS - 48 * HOUR_MS), takenAt: null, skipped: false, + autoMissed: false, }, // Recent (< 24 h) → must stay pending. { @@ -115,6 +129,7 @@ describe("runIntakeAutoSkipPass", () => { scheduledFor: new Date(NOW_MS - 6 * HOUR_MS), takenAt: null, skipped: false, + autoMissed: false, }, // Already taken → must stay. { @@ -123,14 +138,17 @@ describe("runIntakeAutoSkipPass", () => { scheduledFor: new Date(NOW_MS - 36 * HOUR_MS), takenAt: new Date(NOW_MS - 30 * HOUR_MS), skipped: false, + autoMissed: false, }, - // Already manually skipped → must stay. + // Already manually skipped → must stay a deliberate skip, never + // reclassified as an auto-miss. { id: "i-already-skipped", userId: "u1", scheduledFor: new Date(NOW_MS - 48 * HOUR_MS), takenAt: null, skipped: true, + autoMissed: false, }, ]; const state = { rows }; @@ -140,13 +158,19 @@ describe("runIntakeAutoSkipPass", () => { expect(result.skippedCount).toBe(2); expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS); - expect(state.rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(true); - expect(state.rows.find((r) => r.id === "i-stale-2")?.skipped).toBe(true); - expect(state.rows.find((r) => r.id === "i-recent")?.skipped).toBe(false); - expect(state.rows.find((r) => r.id === "i-taken")?.skipped).toBe(false); + expect(state.rows.find((r) => r.id === "i-stale-1")?.autoMissed).toBe(true); + expect(state.rows.find((r) => r.id === "i-stale-2")?.autoMissed).toBe(true); + expect(state.rows.find((r) => r.id === "i-recent")?.autoMissed).toBe(false); + expect(state.rows.find((r) => r.id === "i-taken")?.autoMissed).toBe(false); + // The forgotten doses are NOT marked as a user skip. + expect(state.rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(false); + // A deliberate user skip is left exactly as it was. expect( state.rows.find((r) => r.id === "i-already-skipped")?.skipped, ).toBe(true); + expect( + state.rows.find((r) => r.id === "i-already-skipped")?.autoMissed, + ).toBe(false); }); it("calls prisma.updateMany with the spec'd where + data shape", async () => { @@ -159,10 +183,11 @@ describe("runIntakeAutoSkipPass", () => { expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledWith({ where: { skipped: false, + autoMissed: false, takenAt: null, scheduledFor: { lt: new Date(NOW_MS - DAY_MS) }, }, - data: { skipped: true }, + data: { autoMissed: true }, }); }); @@ -174,6 +199,7 @@ describe("runIntakeAutoSkipPass", () => { scheduledFor: new Date(NOW_MS - 30 * HOUR_MS), takenAt: null, skipped: false, + autoMissed: false, }, ]; const state = { rows }; @@ -197,6 +223,7 @@ describe("runIntakeAutoSkipPass", () => { scheduledFor: new Date(NOW_MS - DAY_MS), takenAt: null, skipped: false, + autoMissed: false, }, ]; const state = { rows }; @@ -204,7 +231,7 @@ describe("runIntakeAutoSkipPass", () => { const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); expect(result.skippedCount).toBe(0); - expect(state.rows[0].skipped).toBe(false); + expect(state.rows[0].autoMissed).toBe(false); }); it("uses Date.now() when no nowMs override is supplied", async () => { diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index acb72824b..0f8ae02c6 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -1,33 +1,39 @@ /** - * v1.4.46 — hourly cron that auto-skips medication intake events the - * user never marked. Without it the dashboard "compliance" tile keeps - * counting a missed dose as `pending` forever (the row's `takenAt` - * stays NULL and `skipped` stays false), which inflates the "missing" - * column on the streak chart and prevents the next day's intake from - * rendering cleanly in the "today" window. + * v1.4.46 — hourly cron that gives a forgotten medication dose a terminal + * state. Without it the dashboard "compliance" tile keeps counting a never- + * acted dose as `pending` forever (the row's `takenAt` stays NULL), which + * prevents the next day's intake from rendering cleanly in the "today" + * window. * - * Rule: flip `skipped = true` for every `MedicationIntakeEvent` where: + * v1.15.9 — the terminal state is now `auto_missed = true`, NOT + * `skipped = true`. A forgotten dose is a real MISS — it must count against + * adherence. The old behaviour flipped `skipped = true`, and because the + * compliance engine EXCLUDES skipped doses from the denominator (a + * deliberate user pause), a forgotten dose then vanished from the rate + * entirely, silently inflating adherence. Setting `auto_missed` keeps the + * never-acted dose distinct from a user-initiated skip: the engine pairs an + * `auto_missed` slot to a `missed` status (counts against the rate) while a + * `skipped` row stays excluded (a deliberate drug holiday). + * + * Rule: set `auto_missed = true` for every `MedicationIntakeEvent` where: * * `skipped = false` AND + * * `auto_missed = false` AND * * `takenAt IS NULL` AND * * `scheduledFor < NOW() - INTERVAL '24 hours'` * - * The 24 h grace window is intentional: a slightly late mark - * (user took the morning dose at noon when the schedule was 09:00) - * shouldn't get auto-skipped before the user has had a full day to - * record it. Anything older than 24 h is reliably forgotten — the - * compliance rollup needs a terminal state to count it as a real miss. + * The 24 h grace window is intentional: a slightly late mark (user took the + * morning dose at noon when the schedule was 09:00) shouldn't be flipped + * before the user has had a full day to record it. Anything older than 24 h + * is reliably forgotten — the compliance engine needs a terminal state to + * count it as a real miss. * - * Idempotent by construction: the second pass within the same hour - * finds zero candidate rows because the first pass already flipped - * them. Safe to re-run on worker restart. + * Idempotent by construction: the second pass within the same hour finds + * zero candidate rows because the first pass already flipped them. Safe to + * re-run on worker restart. * - * Compliance-rollup coupling: the `skipped = true` transition is the - * same terminal state the user's manual "Skip" button writes, so the - * compliance rollup recompute path triggered by the next ingest / - * dashboard read picks the new rows up without any extra plumbing. - * The helper deliberately does NOT touch the rollup directly — the - * next read-path call lazily folds the day, and a fresh - * compliance-rollup backfill (boot-time) sweeps any historical gaps. + * Compliance coupling: the engine-backed compliance paths read `auto_missed` + * directly. The helper deliberately does NOT touch any rollup — the next + * read-path call recomputes from the live rows. */ import type { PrismaClient } from "@/generated/prisma/client"; @@ -52,7 +58,7 @@ export interface IntakeAutoSkipPayload { } export interface IntakeAutoSkipResult { - /** Rows that flipped from pending to `skipped = true` on this pass. */ + /** Rows that flipped from pending to `auto_missed = true` on this pass. */ skippedCount: number; /** Cutoff timestamp the pass applied — useful for the audit trail. */ cutoff: Date; @@ -79,10 +85,11 @@ export async function runIntakeAutoSkipPass( const { count } = await prisma.medicationIntakeEvent.updateMany({ where: { skipped: false, + autoMissed: false, takenAt: null, scheduledFor: { lt: cutoff }, }, - data: { skipped: true }, + data: { autoMissed: true }, }); return { skippedCount: count, cutoff };