fix(medications): count forgotten doses as missed, not skipped

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 13:26:41 +02:00
1 parent 384fc9caa1
commit 29fa982e11
4 files changed
+97 -42

No files matched your search

@@ -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;
+8
View File
@@ -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")
+45 -18
View File
@@ -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 () => {
+31 -24
View File
@@ -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 };