From 344d1c33b0fa3151e022b3b383945cda12f2b0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 08:07:41 +0200 Subject: [PATCH] feat(meds): route the reminder worker through the canonical recurrence engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that ship as one because they share a single rewrite of the worker's schedule-resolution site: ### The bi-weekly fix `grep intervalWeeks src/lib/jobs/reminder-worker.ts` used to return zero hits. The worker iterated `recurrence.daysOfWeek.includes( todayDow)` and called it good — a bi-weekly Wednesday schedule fired EVERY Wednesday, regardless of "on" vs "off" week. The cadence chart respected the interval; the worker did not. This is the single line at `reminder-worker.ts:514` and it's been wrong since `intervalWeeks` was introduced. The fix routes the same predicate through the new canonical engine (`src/lib/medications/scheduling/recurrence.ts`). The engine honours INTERVAL natively for RRULE schedules and honours the legacy `intervalWeeks > 1` arithmetic on the fallback path. Both paths now agree with the chart. ### New cadence types The same rewrite picks up monthly / quarterly / yearly / rolling / one-shot for free because the engine dispatches on the schedule's shape. The worker doesn't special-case anything — it just asks the engine. ### Rolling needs a `lastIntakeAt` Rolling schedules anchor next-due on the latest `MedicationIntakeEvent.takenAt`. The worker now does a conditional findFirst on the medication's intake stream when the schedule list contains a rolling row. The lookup is gated on `med.schedules.some(s => s.rollingIntervalDays !== null)` so the calendar-cadence common case keeps its single-query hot path. ### The seam — `worker-helpers.ts` A small module exports `buildCanonicalSchedule`, `buildRecurrenceContext`, `scheduleEmitsInWindow`. Keeps the worker file focused on phase math and dispatch (it's 2347 LOC; a deeper rewrite is for another release) and gives the integration tests a clean place to exercise the schedule-resolution path with real Postgres rows. ### Dead code retired - `todayDow` (line 492) — replaced by the engine's per-instant check. - `getDayOfWeekInTz` alias (line 355) — last caller was `todayDow`. - `parseScheduleRecurrence` import — the worker no longer touches the legacy decoder directly; the engine handles the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/jobs/reminder-worker.ts | 59 +++++++-- .../medications/scheduling/worker-helpers.ts | 115 ++++++++++++++++++ 2 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 src/lib/medications/scheduling/worker-helpers.ts diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 9d8f9a16e..cf04e631d 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -11,7 +11,11 @@ import type { Job } from "pg-boss"; import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; -import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + scheduleEmitsInWindow, +} from "@/lib/medications/scheduling/worker-helpers"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { syncUserActivity } from "@/lib/withings/sync-activity"; import { syncUserSleep } from "@/lib/withings/sync-sleep"; @@ -116,7 +120,6 @@ import { assertSubsystemEnabled } from "@/lib/process-type"; import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; import { getUserTodayBounds as getUserTodayBoundsUtil, - getDayOfWeekInTz as getDayOfWeekInTzUtil, localHmAsUtc, } from "@/lib/timezone"; @@ -349,7 +352,6 @@ interface MoodReminderPayload { // Re-export timezone utilities under local names for backward compatibility const getUserTodayBounds = getUserTodayBoundsUtil; -const getDayOfWeekInTz = getDayOfWeekInTzUtil; /** * Process expired TelegramScheduledDeletion records. @@ -485,8 +487,6 @@ async function handleReminderCheck(jobs: Job[]) { hour12: false, }); - const todayDow = getDayOfWeekInTz(now, userTz); - // Get today's date string in user's timezone for message tracking const localDateStr = now.toLocaleDateString("sv-SE", { timeZone: userTz, @@ -509,12 +509,53 @@ async function handleReminderCheck(jobs: Job[]) { a.windowStart.localeCompare(b.windowStart), ); + // v1.5.0 — fetch the latest `takenAt` for this medication + // once per tick when any schedule on it is rolling. The + // canonical recurrence engine needs `lastIntakeAt` to compute + // the next-due instant for rolling cadences; calendar + // cadences ignore the field, so the fetch is conditional to + // avoid an extra round-trip on the common path. Failures bias + // toward "treat as never logged" so a flaky DB doesn't + // suppress reminders. + const hasRollingSchedule = med.schedules.some( + (s) => s.rollingIntervalDays !== null, + ); + let lastIntakeAt: Date | null = null; + if (hasRollingSchedule) { + const lastIntake = await prisma.medicationIntakeEvent.findFirst({ + where: { + userId: med.user.id, + medicationId: med.id, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + lastIntakeAt = lastIntake?.takenAt ?? null; + } + for (const schedule of sortedSchedules) { - // Check day-of-week / recurrence constraints - const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); + // v1.5.0 — route every "does today emit a slot?" decision + // through the canonical recurrence engine. Replaces the + // legacy weekday-only filter that silently ignored + // `intervalWeeks` (the pre-v1.5 bi-weekly bug — a Wed + // bi-weekly schedule fired every Wed instead of every other + // Wed). The engine honours RRULE / rolling / one-shot / + // legacy-with-interval-weeks / `endsOn` cap; no special- + // casing here. + const canonicalSchedule = buildCanonicalSchedule(schedule); + const recurrenceCtx = buildRecurrenceContext({ + medication: med, + userTz, + lastIntakeAt, + }); if ( - recurrence.daysOfWeek.length > 0 && - !recurrence.daysOfWeek.includes(todayDow) + !scheduleEmitsInWindow( + canonicalSchedule, + recurrenceCtx, + todayStart, + todayEnd, + ) ) { continue; } diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts new file mode 100644 index 000000000..0689c9492 --- /dev/null +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -0,0 +1,115 @@ +/** + * v1.5.0 — worker-side adapters for the canonical recurrence engine. + * + * Bridges the Prisma `Medication` + `MedicationSchedule` row shape the + * reminder worker reads against `src/lib/medications/scheduling/recurrence.ts`'s + * `CanonicalSchedule` + `RecurrenceContext` shapes. Keeps the worker file + * focused on phase math + dispatch and concentrates the cadence-decoding + * surface in one small, easily unit-testable module. + * + * Closes the pre-existing `intervalWeeks` bi-weekly bug + * (`grep intervalWeeks src/lib/jobs/reminder-worker.ts` returned zero + * hits before v1.5) by routing every "does today emit a slot?" + * decision through the canonical engine. The engine prefers the new + * `rrule` field, falls back to the legacy `daysOfWeek` string only + * when neither `rrule` nor `rollingIntervalDays` are populated — and + * the legacy fallback path now honours `intervalWeeks > 1`, which the + * pre-v1.5 worker did not. + */ +import { + type CanonicalSchedule, + type RecurrenceContext, + occurrencesBetween, +} from "@/lib/medications/scheduling/recurrence"; + +/** + * Minimal Prisma-shape projection used by the worker. Mirrors the + * fields the canonical engine consumes from a `MedicationSchedule` + * row — kept narrow so a caller can `select` exactly these columns + * without pulling the full Prisma type. + */ +export interface WorkerScheduleRow { + id: string; + windowStart: string; + windowEnd: string; + daysOfWeek: string | null; + timesOfDay: string[]; + reminderGraceMinutes: number | null; + rrule: string | null; + rollingIntervalDays: number | null; +} + +/** Minimal `Medication` projection used by the worker. */ +export interface WorkerMedicationRow { + id: string; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; +} + +/** + * Adapt a Prisma `MedicationSchedule` row to the canonical engine's + * `CanonicalSchedule` shape. Pure / synchronous; no DB access. + */ +export function buildCanonicalSchedule( + schedule: WorkerScheduleRow, +): CanonicalSchedule { + return { + id: schedule.id, + rrule: schedule.rrule, + rollingIntervalDays: schedule.rollingIntervalDays, + timesOfDay: schedule.timesOfDay, + daysOfWeek: schedule.daysOfWeek, + windowStart: schedule.windowStart, + windowEnd: schedule.windowEnd, + reminderGraceMinutes: schedule.reminderGraceMinutes, + }; +} + +/** Build the canonical engine context from worker-loop state. */ +export function buildRecurrenceContext(input: { + medication: WorkerMedicationRow; + userTz: string; + lastIntakeAt: Date | null; +}): RecurrenceContext { + return { + medication: { + id: input.medication.id, + startsOn: input.medication.startsOn, + endsOn: input.medication.endsOn, + oneShot: input.medication.oneShot, + createdAt: input.medication.createdAt, + }, + timeZone: input.userTz, + lastIntakeAt: input.lastIntakeAt, + }; +} + +/** + * Does the schedule emit at least one occurrence somewhere in + * `[todayStart, todayEnd]`? The reminder worker calls this once per + * `(medication, schedule)` pair on each 15-minute tick — replaces the + * legacy weekday-only filter (`recurrence.daysOfWeek.length > 0 && + * !recurrence.daysOfWeek.includes(todayDow)`) at + * `src/lib/jobs/reminder-worker.ts:514`. + * + * Honours every cadence the canonical engine supports: + * - one-shot (only true on the medication's `startsOn` day) + * - rolling (true when `lastIntakeAt + N days` lands inside today) + * - RRULE (true when today is a matching weekday/monthday/etc.) + * - legacy `daysOfWeek` string (with `intervalWeeks > 1` honoured — + * the pre-v1.5 worker silently dropped this; v1.5 closes the bug) + * - `endsOn` cap (false after the course ends) + * + * Pure; no DB access. The caller threads `lastIntakeAt` and `userTz` + * in via `buildRecurrenceContext`. + */ +export function scheduleEmitsInWindow( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + windowStart: Date, + windowEnd: Date, +): boolean { + return occurrencesBetween(schedule, windowStart, windowEnd, ctx).length > 0; +}