mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user