diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 647846e48..a57f60528 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -7,7 +7,7 @@ * so the loop would be a no-op — the predicate keeps it out of the * tick's per-medication intake reads entirely.) */ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const prismaMock = { telegramScheduledDeletion: { @@ -25,6 +25,7 @@ const prismaMock = { findMany: vi.fn().mockResolvedValue([]), findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(), + count: vi.fn().mockResolvedValue(0), }, telegramReminderMessage: { findUnique: vi.fn().mockResolvedValue(null), @@ -68,16 +69,65 @@ vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/notifications/reminder-dedup", () => ({ + hasReminderDedupAnchor: vi.fn().mockResolvedValue(false), + medicationReminderDedupKey: vi.fn(() => "dedup-key"), + writeReminderDedupAnchor: vi.fn().mockResolvedValue(undefined), +})); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +function medicationWithSchedule( + schedule: Record, +): Record { + return { + id: "medication-1", + name: "Morning tablet", + dose: null, + active: true, + asNeeded: false, + notificationsEnabled: true, + snoozedUntil: null, + startsOn: new Date("2026-07-01T00:00:00.000Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + phaseConfig: null, + schedules: [ + { + id: "schedule-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + dose: null, + ...schedule, + }, + ], + user: { id: "user-1", timezone: "UTC", locale: "en" }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); beforeEach(() => { vi.clearAllMocks(); prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]); prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 }); prismaMock.medication.updateMany.mockResolvedValue({ count: 0 }); prismaMock.medication.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findFirst.mockResolvedValue(null); + prismaMock.medicationIntakeEvent.create.mockResolvedValue(undefined); }); describe("handleReminderCheck — telegram prompt-context cleanup (v1.19.0)", () => { @@ -111,3 +161,485 @@ describe("handleReminderCheck — as-needed skip (v1.16.11, #316)", () => { expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled(); }); }); + +describe("handleReminderCheck per-slot reminder windows", () => { + it("escalates a morning slot independently of a distant evening sibling", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + }), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "ORANGE", + timeOfDay: "08:00", + }), + }), + ); + }); + + it("uses an explicit range as bounds instead of a duration anchored at the slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:30" }], + }), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "YELLOW", + timeOfDay: "08:00", + }), + }), + ); + }); + + it("suppresses a slot for a late intake attributed by the shared dose band", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({}), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: new Date("2026-07-28T08:45:00.000Z"), + skipped: false, + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); +}); + +describe("handleReminderCheck medication-wide slot attribution", () => { + it("lets an overlapping schedule claim the nearest medication-wide slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + const medication = medicationWithSchedule({}) as { + schedules: Record[]; + }; + medication.schedules = [ + medication.schedules[0], + { + ...medication.schedules[0], + id: "schedule-2", + windowStart: "09:00", + windowEnd: "10:00", + timesOfDay: ["09:00"], + }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T09:00:00.000Z"), + takenAt: new Date("2026-07-28T08:50:00.000Z"), + skipped: false, + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ timeOfDay: "08:00" }), + }), + ); + }); + + it("keeps same-time band attribution on the owning schedule row", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:45:00.000Z")); + const medication = medicationWithSchedule({ + doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "08:00" }], + }) as { + schedules: Record[]; + }; + medication.schedules = [ + medication.schedules[0], + { + ...medication.schedules[0], + id: "schedule-2", + doseWindows: [{ timeOfDay: "08:00", start: "08:30", end: "09:30" }], + }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: new Date("2026-07-28T08:45:00.000Z"), + skipped: false, + attributionSource: "AUTO", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ scheduleId: "schedule-1" }), + }), + ); + }); + + it("suppresses an open rolling occurrence for a skipped scheduled slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + rrule: null, + rollingIntervalDays: 7, + }), + ] as never); + prismaMock.medicationIntakeEvent.findFirst.mockResolvedValue({ + takenAt: new Date("2026-07-21T08:00:00.000Z"), + }); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: null, + skipped: true, + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("suppresses the effective windowStart slot for an empty legacy row", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:30:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + rrule: null, + daysOfWeek: null, + timesOfDay: [], + }), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: new Date("2026-07-28T08:10:00.000Z"), + skipped: false, + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("binds a skipped event to scheduledFor instead of a neighbouring band", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + const medication = medicationWithSchedule({ + doseWindows: [{ timeOfDay: "08:00", start: "09:00", end: "10:00" }], + }) as { + schedules: Record[]; + }; + medication.schedules = [ + medication.schedules[0], + { + ...medication.schedules[0], + id: "schedule-2", + windowStart: "09:00", + windowEnd: "10:00", + timesOfDay: ["09:00"], + doseWindows: null, + }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: null, + skipped: true, + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ timeOfDay: "09:00" }), + }), + ); + }); + + it("binds an off-band USER_PIN take to its stored slot anchor", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({}), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: new Date("2026-07-28T13:00:00.000Z"), + skipped: false, + attributionSource: "USER_PIN", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("lets one sub-minute-drift skip claim only one colliding schedule row", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + const medication = medicationWithSchedule({}) as { + schedules: Record[]; + }; + medication.schedules = [ + medication.schedules[0], + { + ...medication.schedules[0], + id: "schedule-2", + }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:30.000Z"), + takenAt: null, + skipped: true, + attributionSource: "AUTO", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + }); + + it("does not let a pre-edit action suppress a current-era slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T10:15:00.000Z")); + const medication = medicationWithSchedule({ + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + rrule: "FREQ=WEEKLY;BYDAY=TU", + }); + medication.scheduleRevisions = [ + { validUntil: new Date("2026-07-28T08:30:00.000Z") }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: new Date("2026-07-28T08:00:00.000Z"), + createdAt: new Date("2026-07-28T08:00:00.000Z"), + skipped: false, + attributionSource: "AUTO", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + }); + + it("lets a post-edit action suppress the current-era slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T10:15:00.000Z")); + const medication = medicationWithSchedule({ + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + rrule: "FREQ=WEEKLY;BYDAY=TU", + }); + medication.scheduleRevisions = [ + { validUntil: new Date("2026-07-28T08:30:00.000Z") }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T08:00:00.000Z"), + takenAt: new Date("2026-07-28T09:00:00.000Z"), + createdAt: new Date("2026-07-28T09:00:00.000Z"), + skipped: false, + attributionSource: "AUTO", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("does not remind a current schedule slot from before its live era", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T13:30:00.000Z")); + const medication = medicationWithSchedule({ + windowStart: "08:00", + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + }); + medication.scheduleRevisions = [ + { validUntil: new Date("2026-07-28T08:30:00.000Z") }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + + it("uses a post-edit skip mutation to suppress the current-era slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T10:15:00.000Z")); + const medication = medicationWithSchedule({ + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + }); + medication.scheduleRevisions = [ + { validUntil: new Date("2026-07-28T08:30:00.000Z") }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T10:00:00.000Z"), + takenAt: null, + createdAt: new Date("2026-07-28T08:00:00.000Z"), + updatedAt: new Date("2026-07-28T09:00:00.000Z"), + skipped: true, + attributionSource: "AUTO", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("uses a post-edit USER_PIN mutation to suppress the current-era slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T10:15:00.000Z")); + const medication = medicationWithSchedule({ + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + }); + medication.scheduleRevisions = [ + { validUntil: new Date("2026-07-28T08:30:00.000Z") }, + ]; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + { + scheduledFor: new Date("2026-07-28T10:00:00.000Z"), + takenAt: new Date("2026-07-28T08:00:00.000Z"), + createdAt: new Date("2026-07-28T08:00:00.000Z"), + updatedAt: new Date("2026-07-28T09:00:00.000Z"), + skipped: false, + attributionSource: "USER_PIN", + }, + ]); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); +}); + +describe("handleReminderCheck configured wall-clock phase bounds", () => { + it("honours an explicit range that does not contain the nominal slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + doseWindows: [{ timeOfDay: "08:00", start: "09:00", end: "10:00" }], + }), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "GREEN", + timeOfDay: "08:00", + }), + }), + ); + }); + + it("resolves a DST-spanning phase end in the user's wall clock", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-29T01:15:00.000Z")); // 03:15 CEST + const medication = medicationWithSchedule({ + windowStart: "01:30", + windowEnd: "03:30", + timesOfDay: ["01:30"], + }) as { + startsOn: Date; + createdAt: Date; + user: { id: string; timezone: string; locale: string }; + }; + medication.startsOn = new Date("2026-03-01T00:00:00.000Z"); + medication.createdAt = new Date("2026-03-01T00:00:00.000Z"); + medication.user.timezone = "Europe/Berlin"; + prismaMock.medication.findMany.mockResolvedValue([medication] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "YELLOW", + timeOfDay: "01:30", + }), + }), + ); + }); + + it("rounds fractional countdown minutes up in user-facing text", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:30.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + windowStart: "09:00", + windowEnd: "10:00", + timesOfDay: ["09:00"], + }), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("45 min"), + }), + ); + }); +}); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index a21f07939..3df69c8ae 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -12,9 +12,14 @@ import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { medicationDoseTag } from "@/lib/notifications/dose-tag"; +import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter"; +import { attributeIntakeToSlot } from "@/lib/medications/scheduling/attribution"; +import { nearestAnchorBand } from "@/lib/medications/scheduling/dose-history"; +import { occurrencesBetween } from "@/lib/medications/scheduling/recurrence"; import { buildCanonicalSchedule, buildRecurrenceContext, + resolveSlotPhaseWindow, scheduleEmitsInWindow, shouldMintMissedDoseRow, } from "@/lib/medications/scheduling/worker-helpers"; @@ -167,6 +172,12 @@ export async function handleReminderCheck( include: { schedules: true, phaseConfig: true, + scheduleRevisions: { + where: { supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + take: 1, + select: { validUntil: true }, + }, user: { select: { id: true, @@ -192,13 +203,6 @@ export async function handleReminderCheck( userTz, ); - const currentTime = now.toLocaleTimeString("en-GB", { - timeZone: userTz, - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - // Get today's date string in user's timezone for message tracking const localDateStr = now.toLocaleDateString("sv-SE", { timeZone: userTz, @@ -223,18 +227,57 @@ export async function handleReminderCheck( deletedAt: null, scheduledFor: { gte: todayStart, lte: todayEnd }, }, - select: { scheduledFor: true, takenAt: true, skipped: true }, + select: { + scheduledFor: true, + takenAt: true, + skipped: true, + attributionSource: true, + updatedAt: true, + }, }); - const loggedDoseInstants = todayEvents - .filter((e) => e.takenAt !== null || e.skipped) - .map((e) => (e.takenAt ?? e.scheduledFor).getTime()); + // The worker reminds only the live schedule rows. The newest active + // revision ended when that live era began, so actions performed + // before its boundary belong to the archived cadence and cannot + // suppress a slot minted from today's replacement schedule. Explicit + // skips and USER_PIN decisions are mutations, whose Prisma @updatedAt + // timestamp records the decision. Ordinary takes use their takenAt. + const liveEraStart = + med.scheduleRevisions?.[0]?.validUntil.getTime() ?? null; + const liveEraEvents = + liveEraStart === null + ? todayEvents + : todayEvents.filter((event) => { + const actionAt = + event.skipped || event.attributionSource === "USER_PIN" + ? event.updatedAt + : event.takenAt; + return actionAt !== null && actionAt.getTime() >= liveEraStart; + }); + const anchoredActionSlotInstants = liveEraEvents.flatMap((event) => { + if (event.skipped) return [event.scheduledFor]; + if ( + event.takenAt !== null && + event.attributionSource === "USER_PIN" && + event.scheduledFor.getTime() !== event.takenAt.getTime() + ) { + return [event.scheduledFor]; + } + return []; + }); + const takenDoseInstants = liveEraEvents.flatMap((event) => + event.takenAt !== null && + !event.skipped && + event.attributionSource !== "USER_PIN" + ? [event.takenAt] + : [], + ); - // Resolve phase configuration const phaseConfig = med.phaseConfig ?? DEFAULT_PHASE_CONFIG; - // Slots a logged dose has already claimed (by index into the - // chronologically-sorted slotTimes) so one dose can't suppress two. - const claimedSlotInstants = new Set(); + // Each anchored or attributed action can suppress only one schedule + // row, including when separate rows emit at the same instant. + const claimedAnchoredActionIndexes = new Set(); + const claimedTakenDoseIndexes = new Set(); const sortedSchedules = [...med.schedules].sort((a, b) => a.windowStart.localeCompare(b.windowStart), @@ -268,7 +311,82 @@ export async function handleReminderCheck( lastIntakeAt = lastIntake?.takenAt ?? null; } - for (const schedule of sortedSchedules) { + const canonicalSchedules = sortedSchedules.map(buildCanonicalSchedule); + const recurrenceCtx = buildRecurrenceContext({ + medication: med, + userTz, + lastIntakeAt, + }); + + // Attribute every action against one medication-wide pool. Each + // schedule still mints its own cadence-aware bands, preserving its + // late-tail shape, while the attribution primitive resolves overlaps + // by status and nearest anchor across all schedule rows. + // + // The retrospective rolling minter normally omits the still-open + // forward occurrence. Seed each rolling mint with the recurrence + // engine's actual open slot for this day. This keeps skipped rows, + // whose action instant is scheduledFor, attributable without treating + // a skip as the next cadence anchor. + const pooledOwnedSlotBands = canonicalSchedules.flatMap( + (canonicalSchedule) => { + const rollingOpenSlots = + canonicalSchedule.rollingIntervalDays === null + ? [] + : occurrencesBetween( + canonicalSchedule, + todayStart, + todayEnd, + recurrenceCtx, + ).map((occurrence) => occurrence.at); + const bands = buildBandsForMedication({ + medication: med, + schedule: canonicalSchedule, + ctx: recurrenceCtx, + userTz, + range: { from: todayStart, to: todayEnd }, + now, + intakeInstants: rollingOpenSlots, + }).bands; + return bands + .filter( + (band) => + liveEraStart === null || band.at.getTime() >= liveEraStart, + ) + .map((band) => ({ + scheduleId: canonicalSchedule.id, + band, + })); + }, + ); + const pooledSlotBands = pooledOwnedSlotBands.map((owned) => owned.band); + const scheduleIdByBand = new Map( + pooledOwnedSlotBands.map((owned) => [owned.band, owned.scheduleId]), + ); + const attributedAnchoredActionSlots = anchoredActionSlotInstants.map( + (instant) => { + const band = nearestAnchorBand(instant, pooledSlotBands); + const scheduleId = band ? scheduleIdByBand.get(band) : undefined; + return band && scheduleId + ? { scheduleId, slotInstant: band.at.getTime() } + : null; + }, + ); + const attributedTakenDoseSlots = takenDoseInstants.map((instant) => { + const band = attributeIntakeToSlot(instant, pooledSlotBands)?.band; + const scheduleId = band ? scheduleIdByBand.get(band) : undefined; + return band && scheduleId + ? { scheduleId, slotInstant: band.at.getTime() } + : null; + }); + + for ( + let scheduleIndex = 0; + scheduleIndex < sortedSchedules.length; + scheduleIndex++ + ) { + const schedule = sortedSchedules[scheduleIndex]; + const canonicalSchedule = canonicalSchedules[scheduleIndex]; // 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 @@ -277,12 +395,6 @@ export async function handleReminderCheck( // 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 ( !scheduleEmitsInWindow( canonicalSchedule, @@ -298,19 +410,14 @@ export async function handleReminderCheck( // with `timesOfDay = ["08:00","20:00"]` is two distinct dose // slots per day; the pre-v1.7 worker keyed phase + dedup on the // single `windowStart`, so the evening dose never reminded. - // Iterate every first-class time-of-day, each with its own - // window (anchored at the time, spanning the legacy - // `windowEnd - windowStart` duration), phase, dedup key - // (now including the time-of-day), and RED-mint instant. + // Each slot resolves its own phase bounds. Logged-dose suppression + // uses the same dose bands as intake attribution and history. // // The legacy single-window contract is preserved: a schedule // with no first-class `timesOfDay` emits exactly one slot at // `windowStart` with `timeOfDay = ""`, which dedupes against // pre-v1.7 rows (backfilled to "") byte-for-byte. - const baseStartMins = parseTimeToMinutes(schedule.windowStart); - const baseEndMins = parseTimeToMinutes(schedule.windowEnd); - const windowDuration = baseEndMins - baseStartMins; - const currentMins = parseTimeToMinutes(currentTime); + const nowMs = now.getTime(); const hasFirstClassTimes = schedule.timesOfDay && schedule.timesOfDay.length > 0; @@ -329,39 +436,72 @@ export async function handleReminderCheck( // schedule (byte-stable against pre-v1.7 rows), else the // explicit HH:mm. const dedupTimeOfDay = hasFirstClassTimes ? slotTime : ""; - const slotStartMins = parseTimeToMinutes(slotTime); - const slotEndMins = slotStartMins + windowDuration; - const minutesToEnd = slotEndMins - currentMins; - const minutesFromStart = currentMins - slotStartMins; - // The UTC instant this slot is due (DST-safe). const [slotH, slotM] = slotTime.split(":").map(Number); - const slotInstant = localHmAsUtc( - now, - med.user.timezone, - slotH, - slotM, - ).getTime(); + const slotScheduledFor = localHmAsUtc(now, userTz, slotH, slotM); + const slotInstant = slotScheduledFor.getTime(); + if (liveEraStart !== null && slotInstant < liveEraStart) { + continue; + } + const phaseWindow = resolveSlotPhaseWindow( + schedule, + slotTime, + canonicalSchedule.doseWindows ?? null, + slotScheduledFor, + userTz, + ); + const minutesFromStart = + (nowMs - phaseWindow.start.getTime()) / 60_000; + const minutesToEnd = (phaseWindow.end.getTime() - nowMs) / 60_000; + const windowDuration = + (phaseWindow.end.getTime() - phaseWindow.start.getTime()) / + 60_000; - // Suppress this slot's reminder if a user-logged dose (taken - // or skipped) sits within half the window duration of the - // slot's due time. Matching by proximity — not a positional - // counter — means a partially-dosed day still reminds for the - // correct missing slot. Each logged dose claims at most one - // slot so two slots can't both be suppressed by one dose. - const matchRadiusMs = Math.max(windowDuration, 60) * 60_000 * 0.5; + // Skips and retained USER_PIN takes bind to the nearest canonical + // anchor inside the dose-history epsilon. A released pin has + // scheduledFor === takenAt and is deliberately ad-hoc, so it was + // excluded above. + let anchoredActionIdx = -1; + for (let ai = 0; ai < attributedAnchoredActionSlots.length; ai++) { + if (claimedAnchoredActionIndexes.has(ai)) continue; + const attributed = attributedAnchoredActionSlots[ai]; + if ( + attributed?.scheduleId !== canonicalSchedule.id || + attributed.slotInstant !== slotInstant + ) { + continue; + } + anchoredActionIdx = ai; + break; + } + if (anchoredActionIdx >= 0) { + claimedAnchoredActionIndexes.add(anchoredActionIdx); + continue; + } + + // Taken actions do not necessarily land exactly on their slot, so + // they use medication-wide pooled band attribution. let matchedIdx = -1; let matchedDist = Infinity; - for (let li = 0; li < loggedDoseInstants.length; li++) { - if (claimedSlotInstants.has(li)) continue; - const dist = Math.abs(loggedDoseInstants[li] - slotInstant); - if (dist <= matchRadiusMs && dist < matchedDist) { - matchedDist = dist; + for (let li = 0; li < takenDoseInstants.length; li++) { + if (claimedTakenDoseIndexes.has(li)) continue; + const attributed = attributedTakenDoseSlots[li]; + if ( + attributed?.scheduleId !== canonicalSchedule.id || + attributed.slotInstant !== slotInstant + ) { + continue; + } + const distance = Math.abs( + takenDoseInstants[li].getTime() - slotInstant, + ); + if (distance < matchedDist) { + matchedDist = distance; matchedIdx = li; } } if (matchedIdx >= 0) { - claimedSlotInstants.add(matchedIdx); + claimedTakenDoseIndexes.add(matchedIdx); continue; } @@ -416,9 +556,6 @@ export async function handleReminderCheck( const doseInfo = schedule.dose ?? med.dose; const timeWindow = `${slotTime}`; - // DST-safe slot instant, computed once above. - const slotScheduledFor = new Date(slotInstant); - // RED phase: create missed intake event for this slot. if (currentPhase === "RED") { // v1.8.2 — gate the missed-dose mint through the shared @@ -476,7 +613,7 @@ export async function handleReminderCheck( med.name, doseInfo, timeWindow, - minutesToEnd, + Math.ceil(minutesToEnd), med.user.locale, ); diff --git a/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts index 7a9e5bb10..17d411bb1 100644 --- a/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts +++ b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { buildCanonicalSchedule, normaliseDoseWindows, + resolveSlotPhaseWindow, type WorkerScheduleRow, } from "../worker-helpers"; @@ -77,4 +78,238 @@ describe("normaliseDoseWindows", () => { }; expect(buildCanonicalSchedule(row).doseWindows).toBeNull(); }); + + it("normalises an empty legacy daily row to its windowStart slot", () => { + const row: WorkerScheduleRow = { + id: "legacy", + windowStart: "07:30", + windowEnd: "08:30", + daysOfWeek: null, + timesOfDay: [], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + }; + + expect(buildCanonicalSchedule(row).timesOfDay).toEqual(["07:30"]); + }); +}); + +describe("resolveSlotPhaseWindow", () => { + const base: WorkerScheduleRow = { + id: "schedule-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }; + const utcSlot = new Date("2026-07-28T08:00:00.000Z"); + + it("preserves a legacy single-slot schedule window", () => { + expect(resolveSlotPhaseWindow(base, "08:00", null, utcSlot, "UTC")).toEqual( + { + start: new Date("2026-07-28T08:00:00.000Z"), + end: new Date("2026-07-28T09:00:00.000Z"), + }, + ); + }); + + it("uses the shared post-slot default for each unconfigured sibling", () => { + const schedule = { + ...base, + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + }; + expect( + resolveSlotPhaseWindow(schedule, "08:00", null, utcSlot, "UTC"), + ).toEqual({ + start: utcSlot, + end: new Date("2026-07-28T09:00:00.000Z"), + }); + const eveningSlot = new Date("2026-07-28T18:00:00.000Z"); + expect( + resolveSlotPhaseWindow(schedule, "18:00", null, eveningSlot, "UTC"), + ).toEqual({ + start: eveningSlot, + end: new Date("2026-07-28T19:00:00.000Z"), + }); + }); + + it("uses a configured reminder grace instead of the legacy span", () => { + expect( + resolveSlotPhaseWindow( + { ...base, reminderGraceMinutes: 45 }, + "08:00", + null, + utcSlot, + "UTC", + ), + ).toEqual({ + start: utcSlot, + end: new Date("2026-07-28T08:45:00.000Z"), + }); + }); + + it("honours a valid explicit range even when it excludes the nominal slot", () => { + expect( + resolveSlotPhaseWindow( + base, + "08:00", + [{ timeOfDay: "08:00", start: "09:00", end: "10:00" }], + utcSlot, + "UTC", + ), + ).toEqual({ + start: new Date("2026-07-28T09:00:00.000Z"), + end: new Date("2026-07-28T10:00:00.000Z"), + }); + }); + + it("caps the default phase grace at the next sibling", () => { + expect( + resolveSlotPhaseWindow( + { ...base, timesOfDay: ["08:00", "08:30"] }, + "08:00", + null, + utcSlot, + "UTC", + ), + ).toEqual({ + start: utcSlot, + end: new Date("2026-07-28T08:30:00.000Z"), + }); + }); + + it("keeps a spring-forward-normalized sibling cap monotonic", () => { + const slot = new Date("2026-03-29T01:30:00.000Z"); // requested 02:30 -> 03:30 + const window = resolveSlotPhaseWindow( + { ...base, timesOfDay: ["02:30", "03:00"] }, + "02:30", + null, + slot, + "Europe/Berlin", + ); + + expect(window).toEqual({ start: slot, end: slot }); + expect(window.end.getTime()).toBeGreaterThanOrEqual(window.start.getTime()); + }); + + it("materialises configured bounds as wall-clock instants across DST", () => { + const berlinSlot = new Date("2026-03-29T00:30:00.000Z"); // 01:30 CET + expect( + resolveSlotPhaseWindow( + { + ...base, + windowStart: "01:30", + windowEnd: "03:30", + timesOfDay: ["01:30"], + }, + "01:30", + null, + berlinSlot, + "Europe/Berlin", + ), + ).toEqual({ + start: new Date("2026-03-29T00:30:00.000Z"), + end: new Date("2026-03-29T01:30:00.000Z"), + }); + }); + + it("places a late-night overnight end on the following local day", () => { + const slot = new Date("2026-07-28T23:30:00.000Z"); + expect( + resolveSlotPhaseWindow( + { + ...base, + windowStart: "23:30", + windowEnd: "00:15", + timesOfDay: ["23:30"], + }, + "23:30", + null, + slot, + "UTC", + ), + ).toEqual({ + start: new Date("2026-07-28T23:30:00.000Z"), + end: new Date("2026-07-29T00:15:00.000Z"), + }); + }); + + it("places an after-midnight overnight start on the previous local day", () => { + const slot = new Date("2026-07-29T00:30:00.000Z"); + expect( + resolveSlotPhaseWindow( + { + ...base, + windowStart: "23:00", + windowEnd: "01:00", + timesOfDay: ["00:30"], + }, + "00:30", + null, + slot, + "UTC", + ), + ).toEqual({ + start: new Date("2026-07-28T23:00:00.000Z"), + end: new Date("2026-07-29T01:00:00.000Z"), + }); + }); + + it("anchors a stale one-slot legacy duration at its first-class slot", () => { + const slot = new Date("2026-07-28T20:00:00.000Z"); + expect( + resolveSlotPhaseWindow( + { ...base, timesOfDay: ["20:00"] }, + "20:00", + null, + slot, + "UTC", + ), + ).toEqual({ + start: new Date("2026-07-28T20:00:00.000Z"), + end: new Date("2026-07-28T21:00:00.000Z"), + }); + }); + + it("clamps spring-forward gap bounds instead of reversing the window", () => { + const slot = new Date("2026-03-29T01:30:00.000Z"); // requested 02:30 -> 03:30 + const expected = { + start: new Date("2026-03-29T01:30:00.000Z"), + end: new Date("2026-03-29T01:30:00.000Z"), + }; + + const legacy = resolveSlotPhaseWindow( + { + ...base, + windowStart: "02:30", + windowEnd: "03:00", + timesOfDay: ["02:30"], + }, + "02:30", + null, + slot, + "Europe/Berlin", + ); + const explicit = resolveSlotPhaseWindow( + { ...base, timesOfDay: ["02:30"] }, + "02:30", + [{ timeOfDay: "02:30", start: "02:30", end: "03:00" }], + slot, + "Europe/Berlin", + ); + + expect(legacy).toEqual(expected); + expect(explicit).toEqual(expected); + expect( + legacy.end.getTime() - legacy.start.getTime(), + ).toBeGreaterThanOrEqual(0); + expect( + explicit.end.getTime() - explicit.start.getTime(), + ).toBeGreaterThanOrEqual(0); + }); }); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 266cbd03d..7a44ffe56 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -364,7 +364,10 @@ export function suggestNearestSlot( } /** The band whose anchor is within epsilon of `instant`, nearest wins. */ -function nearestAnchorBand(instant: Date, bands: SlotBand[]): SlotBand | null { +export function nearestAnchorBand( + instant: Date, + bands: SlotBand[], +): SlotBand | null { const t = instant.getTime(); let best: SlotBand | null = null; let bestDist = Infinity; diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index 92e18ffa9..f4f80748a 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -23,7 +23,12 @@ import { type ScheduleType, occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; -import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; +import { + hhmmToMinutes, + hhmmToMinutesOrNull, +} from "@/lib/medications/scheduling/hhmm"; +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import { localHmAsUtc } from "@/lib/tz/local-day"; /** * Minimal Prisma-shape projection used by the worker. Mirrors the @@ -75,16 +80,26 @@ export interface WorkerMedicationRow { export function buildCanonicalSchedule( schedule: WorkerScheduleRow, ): CanonicalSchedule { + const scheduleType = schedule.scheduleType ?? "SCHEDULED"; + const isEmptyLegacyDaily = + scheduleType !== "PRN" && + schedule.timesOfDay.length === 0 && + schedule.rrule === null && + schedule.rollingIntervalDays === null && + (schedule.daysOfWeek === null || schedule.daysOfWeek.trim() === ""); + return { id: schedule.id, rrule: schedule.rrule, rollingIntervalDays: schedule.rollingIntervalDays, - timesOfDay: schedule.timesOfDay, + timesOfDay: isEmptyLegacyDaily + ? [schedule.windowStart] + : schedule.timesOfDay, daysOfWeek: schedule.daysOfWeek, windowStart: schedule.windowStart, windowEnd: schedule.windowEnd, reminderGraceMinutes: schedule.reminderGraceMinutes, - scheduleType: schedule.scheduleType ?? "SCHEDULED", + scheduleType, cyclicOnWeeks: schedule.cyclicOnWeeks ?? null, cyclicOffWeeks: schedule.cyclicOffWeeks ?? null, doseWindows: normaliseDoseWindows(schedule.doseWindows), @@ -170,6 +185,162 @@ export function scheduleEmitsInWindow( return occurrencesBetween(schedule, windowStart, windowEnd, ctx).length > 0; } +/** + * Resolve one slot's phase bounds as real instants. + * + * Explicit dose windows and a containing legacy single-slot window are + * configured as wall-clock HH:mm values, so their endpoints are materialised + * independently in the user's timezone and overnight bounds select the + * adjacent local date around the slot. Deriving them as minute offsets from + * the slot would drift across a DST transition. When a first-class one-slot + * time has moved outside stale legacy clocks, their duration is instead + * anchored at the actual slot. A configured reminder grace remains an elapsed + * duration from the slot. Multi-slot defaults use the shared grace and are + * capped at the next sibling's wall-clock instant. + * + * Intake suppression deliberately does not use these bounds. + */ +export interface SlotPhaseWindow { + start: Date; + end: Date; +} + +export function resolveSlotPhaseWindow( + schedule: Pick< + WorkerScheduleRow, + "windowStart" | "windowEnd" | "timesOfDay" | "reminderGraceMinutes" + >, + slotTime: string, + doseWindows: DoseWindowEntry[] | null, + slotInstant: Date, + userTz: string, +): SlotPhaseWindow { + const slotMinute = hhmmToMinutesOrNull(slotTime); + const explicit = doseWindows?.find((window) => window.timeOfDay === slotTime); + if (explicit) { + const startMinute = hhmmToMinutesOrNull(explicit.start); + const endMinute = hhmmToMinutesOrNull(explicit.end); + if ( + startMinute !== null && + endMinute !== null && + startMinute <= endMinute + ) { + const start = localMinuteAsUtc(slotInstant, userTz, startMinute); + const end = localMinuteAsUtc(slotInstant, userTz, endMinute); + return { + start, + end: end.getTime() < start.getTime() ? start : end, + }; + } + } + + const configuredGrace = schedule.reminderGraceMinutes; + if ( + typeof configuredGrace === "number" && + Number.isFinite(configuredGrace) && + configuredGrace > 0 + ) { + return { + start: slotInstant, + end: new Date(slotInstant.getTime() + configuredGrace * 60_000), + }; + } + + const slots = schedule.timesOfDay + .map(hhmmToMinutesOrNull) + .filter((minute): minute is number => minute !== null) + .sort((a, b) => a - b); + if (slots.length <= 1 || slotMinute === null) { + const startMinute = hhmmToMinutesOrNull(schedule.windowStart); + const endMinute = hhmmToMinutesOrNull(schedule.windowEnd); + if (slotMinute === null || startMinute === null || endMinute === null) { + return { start: slotInstant, end: slotInstant }; + } + + const wrapsMidnight = endMinute < startMinute; + const slotIsInsideConfiguredWindow = wrapsMidnight + ? slotMinute >= startMinute || slotMinute <= endMinute + : slotMinute >= startMinute && slotMinute <= endMinute; + + // A first-class single slot may outlive stale legacy windowStart/windowEnd + // clocks. Preserve that legacy span as a duration, but anchor it at the + // actual slot instead of opening a phase at unrelated wall-clock times. + if (schedule.timesOfDay.length > 0 && !slotIsInsideConfiguredWindow) { + const durationMinutes = + (((endMinute - startMinute) % (24 * 60)) + 24 * 60) % (24 * 60); + return { + start: slotInstant, + end: new Date(slotInstant.getTime() + durationMinutes * 60_000), + }; + } + + if (!wrapsMidnight) { + const start = localMinuteAsUtc(slotInstant, userTz, startMinute); + const end = localMinuteAsUtc(slotInstant, userTz, endMinute); + return { + start, + end: end.getTime() < start.getTime() ? start : end, + }; + } + + const localNoon = localHmAsUtc(slotInstant, userTz, 12, 0); + + if (slotMinute >= startMinute) { + return { + start: localMinuteAsUtc(slotInstant, userTz, startMinute), + end: localMinuteAsUtc( + new Date(localNoon.getTime() + 24 * 60 * 60_000), + userTz, + endMinute, + ), + }; + } + + return { + start: localMinuteAsUtc( + new Date(localNoon.getTime() - 24 * 60 * 60_000), + userTz, + startMinute, + ), + end: localMinuteAsUtc(slotInstant, userTz, endMinute), + }; + } + + const currentIndex = slots.indexOf(slotMinute); + const nextMinute = + currentIndex >= 0 && currentIndex < slots.length - 1 + ? slots[currentIndex + 1] + : slots[0]; + const nextReference = + nextMinute > slotMinute + ? slotInstant + : new Date( + localHmAsUtc(slotInstant, userTz, 12, 0).getTime() + 24 * 60 * 60_000, + ); + const nextSlot = localMinuteAsUtc(nextReference, userTz, nextMinute); + const defaultEnd = new Date( + slotInstant.getTime() + DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes * 60_000, + ); + const cappedEnd = Math.min(defaultEnd.getTime(), nextSlot.getTime()); + return { + start: slotInstant, + end: new Date(Math.max(slotInstant.getTime(), cappedEnd)), + }; +} + +function localMinuteAsUtc( + dayReference: Date, + userTz: string, + minuteOfDay: number, +): Date { + return localHmAsUtc( + dayReference, + userTz, + Math.floor(minuteOfDay / 60), + minuteOfDay % 60, + ); +} + /** * Minimal Prisma surface the missed-dose guard needs — just a `count` * over `MedicationIntakeEvent`. Keeps the helper unit-testable with a