mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user