mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
65b923ad53
commit
344d1c33b0
2 files changed
+165
-9
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user