feat(jobs): hourly cron auto-skips past-due unmarked intakes

v1.4.46 — a `MedicationIntakeEvent` that the user neither took nor
manually skipped used to stay in the pending state forever. The
dashboard compliance tile counted it as `pending` indefinitely,
inflating the missing column on the streak chart and crowding the
next day's intake out of the "today" window.

New `intake-auto-skip` cron runs hourly at :05 (off the :00
reminder-check and :30 moodlog-sync ticks) and flips `skipped = true`
for every event matching:

  * `skipped = false` AND
  * `takenAt IS NULL` AND
  * `scheduledFor < NOW() - INTERVAL '24 hours'`

The 24 h grace window is intentional: a late mark (morning dose at
noon) shouldn't auto-skip before the user has had a full day to
record it. Anything older is reliably forgotten — compliance needs a
terminal state to count it as a real miss.

The flip is the same state the manual "Skip" button writes, so the
compliance rollup recompute path picks new rows up via the next
ingest / dashboard read with no extra plumbing.

Wired through `reminder-worker.ts` (`createQueue` + `boss.schedule` +
`boss.work` registration) alongside the existing inventory-expire
cron. Helper lives in `@/lib/jobs/intake-auto-skip` so a unit test
can drive it with an in-memory fake; eight cases pin the cron + grace
contract, the spec'd `updateMany` shape, idempotency on re-run, and
the strict `<` boundary semantics.
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 00:53:55 +02:00
1 parent ce369fac89
commit 7cf760325f
3 files changed
+366

No files matched your search

@@ -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();
}
});
});
+89
View File
@@ -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<IntakeAutoSkipResult> {
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 };
}
+54
View File
@@ -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<IntakeAutoSkipPayload>[]) {
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<OffhostBackupPayload>[]) {
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<IntakeAutoSkipPayload>(
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.