feat(meds): route the reminder worker through the canonical recurrence engine

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 08:07:41 +02:00
1 parent 65b923ad53
commit 344d1c33b0
2 files changed
+165 -9

No files matched your search

+50 -9
View File
@@ -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<ReminderCheckPayload>[]) {
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<ReminderCheckPayload>[]) {
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;
}
@@ -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;
}