diff --git a/src/lib/jobs/__tests__/intake-auto-skip.test.ts b/src/lib/jobs/__tests__/intake-auto-skip.test.ts new file mode 100644 index 000000000..446ff7c16 --- /dev/null +++ b/src/lib/jobs/__tests__/intake-auto-skip.test.ts @@ -0,0 +1,223 @@ +/** + * v1.4.46 — coverage for the hourly intake-auto-skip 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: + * * 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). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; + +import { + INTAKE_AUTO_SKIP_CRON, + INTAKE_AUTO_SKIP_GRACE_HOURS, + INTAKE_AUTO_SKIP_QUEUE, + runIntakeAutoSkipPass, +} from "../intake-auto-skip"; + +interface FakeIntakeRow { + id: string; + userId: string; + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +} + +interface FakeUpdateManyWhere { + skipped?: boolean; + takenAt?: null; + scheduledFor?: { lt: Date }; +} + +function makeFakePrisma(state: { rows: FakeIntakeRow[] }) { + return { + medicationIntakeEvent: { + updateMany: vi.fn( + async ({ + where, + data, + }: { + where: FakeUpdateManyWhere; + data: { skipped: boolean }; + }) => { + let count = 0; + for (const row of state.rows) { + if (where.skipped !== undefined && row.skipped !== where.skipped) + 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; + count += 1; + } + return { count }; + }, + ), + }, + } as unknown as PrismaClient; +} + +const NOW = new Date("2026-05-22T12:00:00Z"); +const NOW_MS = NOW.getTime(); +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +describe("intake-auto-skip — schedule contract", () => { + it("cron fires hourly at :05 to dodge the :00 reminder-check tick", () => { + expect(INTAKE_AUTO_SKIP_CRON).toBe("5 * * * *"); + }); + + it("queue name stays stable so the scheduler entry doesn't desync", () => { + expect(INTAKE_AUTO_SKIP_QUEUE).toBe("intake-auto-skip"); + }); + + it("grace window is exactly 24 hours", () => { + expect(INTAKE_AUTO_SKIP_GRACE_HOURS).toBe(24); + }); +}); + +describe("runIntakeAutoSkipPass", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("flips pending rows older than 24 h to skipped=true", async () => { + const rows: FakeIntakeRow[] = [ + // Stale + unmarked → must flip. + { + id: "i-stale-1", + userId: "u1", + scheduledFor: new Date(NOW_MS - 30 * HOUR_MS), + takenAt: null, + skipped: false, + }, + // Stale + unmarked (different user) → must flip. + { + id: "i-stale-2", + userId: "u2", + scheduledFor: new Date(NOW_MS - 48 * HOUR_MS), + takenAt: null, + skipped: false, + }, + // Recent (< 24 h) → must stay pending. + { + id: "i-recent", + userId: "u1", + scheduledFor: new Date(NOW_MS - 6 * HOUR_MS), + takenAt: null, + skipped: false, + }, + // Already taken → must stay. + { + id: "i-taken", + userId: "u1", + scheduledFor: new Date(NOW_MS - 36 * HOUR_MS), + takenAt: new Date(NOW_MS - 30 * HOUR_MS), + skipped: false, + }, + // Already manually skipped → must stay. + { + id: "i-already-skipped", + userId: "u1", + scheduledFor: new Date(NOW_MS - 48 * HOUR_MS), + takenAt: null, + skipped: true, + }, + ]; + const state = { rows }; + const prisma = makeFakePrisma(state); + + const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + + 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-already-skipped")?.skipped, + ).toBe(true); + }); + + it("calls prisma.updateMany with the spec'd where + data shape", async () => { + const state = { rows: [] as FakeIntakeRow[] }; + const prisma = makeFakePrisma(state); + + await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledWith({ + where: { + skipped: false, + takenAt: null, + scheduledFor: { lt: new Date(NOW_MS - DAY_MS) }, + }, + data: { skipped: true }, + }); + }); + + it("is idempotent — a second pass on the same state finds zero candidates", async () => { + const rows: FakeIntakeRow[] = [ + { + id: "i-stale", + userId: "u1", + scheduledFor: new Date(NOW_MS - 30 * HOUR_MS), + takenAt: null, + skipped: false, + }, + ]; + const state = { rows }; + const prisma = makeFakePrisma(state); + + const first = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(first.skippedCount).toBe(1); + + const second = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(second.skippedCount).toBe(0); + }); + + it("does NOT flip rows exactly at the 24 h boundary (uses strict <)", async () => { + // The SQL gate is `scheduledFor < NOW - 24h` — a row whose + // `scheduledFor` lands at the cutoff instant must stay pending so + // the user still has the closing minute of the window to mark it. + const rows: FakeIntakeRow[] = [ + { + id: "i-boundary", + userId: "u1", + scheduledFor: new Date(NOW_MS - DAY_MS), + takenAt: null, + skipped: false, + }, + ]; + const state = { rows }; + const prisma = makeFakePrisma(state); + + const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(result.skippedCount).toBe(0); + expect(state.rows[0].skipped).toBe(false); + }); + + it("uses Date.now() when no nowMs override is supplied", async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + try { + const state = { rows: [] as FakeIntakeRow[] }; + const prisma = makeFakePrisma(state); + + const result = await runIntakeAutoSkipPass(prisma); + expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts new file mode 100644 index 000000000..acb72824b --- /dev/null +++ b/src/lib/jobs/intake-auto-skip.ts @@ -0,0 +1,89 @@ +/** + * 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. + * + * Rule: flip `skipped = true` for every `MedicationIntakeEvent` where: + * * `skipped = 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. + * + * 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. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; + +/** + * Cron schedule. Hourly at :05 so the tick doesn't collide with the + * top-of-the-hour reminder-check (:00) or the moodlog-sync (:30) + * cadences. Europe/Berlin matches the rest of the worker's schedules. + */ +export const INTAKE_AUTO_SKIP_CRON = "5 * * * *"; + +/** + * Grace window before auto-skip kicks in. 24 h matches the spec and + * the user's real-world cadence (late entries up to a full day after + * the schedule are still legitimate). + */ +export const INTAKE_AUTO_SKIP_GRACE_HOURS = 24; + +export interface IntakeAutoSkipPayload { + triggeredAt: string; +} + +export interface IntakeAutoSkipResult { + /** Rows that flipped from pending to `skipped = true` on this pass. */ + skippedCount: number; + /** Cutoff timestamp the pass applied — useful for the audit trail. */ + cutoff: Date; +} + +/** + * Run one auto-skip pass. Returns the flipped row count + the cutoff + * the pass used. Pure function over the injected Prisma client so + * unit tests can drive it with an in-memory fake. + * + * `nowMs` is injectable for the same testing reason — the production + * caller passes `Date.now()`; tests pass a frozen instant so the + * cutoff arithmetic is deterministic. + */ +export async function runIntakeAutoSkipPass( + prisma: PrismaClient, + options: { nowMs?: number } = {}, +): Promise { + const nowMs = options.nowMs ?? Date.now(); + const cutoff = new Date( + nowMs - INTAKE_AUTO_SKIP_GRACE_HOURS * 60 * 60 * 1000, + ); + + const { count } = await prisma.medicationIntakeEvent.updateMany({ + where: { + skipped: false, + takenAt: null, + scheduledFor: { lt: cutoff }, + }, + data: { skipped: true }, + }); + + return { skippedCount: count, cutoff }; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 9298c94f4..9ed964804 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -50,6 +50,12 @@ import { MEDICATION_INVENTORY_EXPIRE_CRON, type MedicationInventoryExpirePayload, } from "@/lib/jobs/medication-inventory-expire"; +import { + INTAKE_AUTO_SKIP_QUEUE, + INTAKE_AUTO_SKIP_CRON, + runIntakeAutoSkipPass, + type IntakeAutoSkipPayload, +} from "@/lib/jobs/intake-auto-skip"; import { APPLE_HEALTH_IMPORT_QUEUE, APPLE_HEALTH_IMPORT_CONCURRENCY, @@ -1391,6 +1397,35 @@ async function handleMedicationInventoryExpire( }); } +/** + * v1.4.46 — hourly auto-skip for stale unmarked medication intakes. + * + * Flips `MedicationIntakeEvent.skipped` to `true` for every event the + * user neither took nor explicitly skipped within the 24 h grace + * window. The pure helper lives in `@/lib/jobs/intake-auto-skip` so a + * unit test can drive it with an in-memory fake Prisma; this wrapper + * threads the worker's pg-boss + background-event plumbing. + */ +async function handleIntakeAutoSkip(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.intake_auto_skip", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const result = await runIntakeAutoSkipPass(prisma, { + nowMs: Date.now(), + }); + evt.addMeta("intake_auto_skip_count", result.skippedCount); + evt.addMeta("intake_auto_skip_cutoff", result.cutoff.toISOString()); + } catch (err) { + evt.addWarning( + `intake-auto-skip failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }); +} + async function handleOffhostBackup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.offhost_backup", async (evt) => { @@ -1642,6 +1677,11 @@ export async function startReminderWorker() { GEO_BACKFILL_QUEUE, PR_DETECTION_QUEUE, MEDICATION_INVENTORY_EXPIRE_QUEUE, + // v1.4.46 — hourly auto-skip pass for stale unmarked intakes. + // Same pg-boss v12 createQueue contract as the other crons; without + // this entry the schedule silently no-ops and pending rows older + // than 24 h pile up unflipped. + INTAKE_AUTO_SKIP_QUEUE, APPLE_HEALTH_IMPORT_QUEUE, ROLLUP_RECOMPUTE_QUEUE, ROLLUP_FULL_BACKFILL_QUEUE, @@ -1723,6 +1763,11 @@ export async function startReminderWorker() { // EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx // maintenance window, right after idempotency-cleanup). [MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON], + // v1.4.46 — hourly auto-skip for medication intakes the user never + // marked. Cron `5 * * * *` slots off the top-of-the-hour + // reminder-check (:00) and the moodlog-sync (:30) so the three + // hourly ticks don't pile up on the same boss poll. + [INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON], // v1.4.37 W7c — nightly fold of per-sample APPLE_HEALTH cumulative // rows into one row per day per type. Slots between the // audit-log cleanup (03:15) and the feedback aggregator (04:00). @@ -1859,6 +1904,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleMedicationInventoryExpire, ); + // v1.4.46 — hourly auto-skip for stale unmarked intakes. + // Single-flight: two ticks racing against the same row pile is wasted + // work, and the underlying `updateMany` is the canonical idempotent + // shape anyway. + await boss.work( + INTAKE_AUTO_SKIP_QUEUE, + { localConcurrency: 1 }, + handleIntakeAutoSkip, + ); // v1.4.34 — Apple Health export.zip ingest worker. localConcurrency // caps at 1 because the parse loop is CPU-bound and a concurrent // second 1 GB import would race the first for RSS.