From 87544af2cd4691012909d9bf02c62e598ffd5a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 21:06:07 +0200 Subject: [PATCH] Fix medication reminder slot attribution --- .../medication-reminder-check.test.ts | 534 +++++++++++++++++- .../reminder/medication-reminder-check.ts | 258 ++++++--- .../__tests__/worker-helpers.test.ts | 348 +++++++----- .../medications/scheduling/dose-history.ts | 5 +- .../medications/scheduling/worker-helpers.ts | 255 +++++---- 5 files changed, 1090 insertions(+), 310 deletions(-) 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 f230c5cd7..81bfe40d8 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -11,10 +11,14 @@ import { decrypt } from "@/lib/crypto"; 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, - resolveSlotWindowDurationMinutes, + resolveSlotPhaseWindow, scheduleEmitsInWindow, shouldMintMissedDoseRow, } from "@/lib/medications/scheduling/worker-helpers"; @@ -165,6 +169,12 @@ export async function handleReminderCheck(jobs: Job[]) { include: { schedules: true, phaseConfig: true, + scheduleRevisions: { + where: { supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + take: 1, + select: { validUntil: true }, + }, user: { select: { id: true, @@ -184,13 +194,6 @@ export async function handleReminderCheck(jobs: Job[]) { 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, @@ -215,18 +218,57 @@ export async function handleReminderCheck(jobs: Job[]) { 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), @@ -260,7 +302,82 @@ export async function handleReminderCheck(jobs: Job[]) { 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 @@ -269,12 +386,6 @@ export async function handleReminderCheck(jobs: Job[]) { // 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, @@ -290,17 +401,14 @@ export async function handleReminderCheck(jobs: Job[]) { // 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 its own resolved - // per-slot duration — see `resolveSlotWindowDurationMinutes`), - // 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 currentMins = parseTimeToMinutes(currentTime); + const nowMs = now.getTime(); const hasFirstClassTimes = schedule.timesOfDay && schedule.timesOfDay.length > 0; @@ -319,51 +427,72 @@ export async function handleReminderCheck(jobs: Job[]) { // schedule (byte-stable against pre-v1.7 rows), else the // explicit HH:mm. const dedupTimeOfDay = hasFirstClassTimes ? slotTime : ""; - const slotStartMins = parseTimeToMinutes(slotTime); - // Per-slot, not per-schedule — see resolveSlotWindowDurationMinutes. - // A multi-time-of-day schedule's whole-span windowEnd-windowStart - // is not a safe per-slot duration; this resolves each slot's own - // explicit dose-window / reminderGraceMinutes override (or a - // shared unconfigured-dose default, bounded by the gap to the - // adjacent slot) so a dose's resolved window never depends on - // how many sibling doses its schedule has. - const windowDuration = resolveSlotWindowDurationMinutes( + // The UTC instant this slot is due (DST-safe). + const [slotH, slotM] = slotTime.split(":").map(Number); + 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 slotEndMins = slotStartMins + windowDuration; - const minutesToEnd = slotEndMins - currentMins; - const minutesFromStart = currentMins - slotStartMins; + 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; - // 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(); + // 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; + } - // 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; + // 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; } @@ -418,9 +547,6 @@ export async function handleReminderCheck(jobs: Job[]) { 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 @@ -477,7 +603,7 @@ export async function handleReminderCheck(jobs: Job[]) { 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 1179e9610..17d411bb1 100644 --- a/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts +++ b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts @@ -8,10 +8,9 @@ import { describe, expect, it } from "vitest"; import { buildCanonicalSchedule, normaliseDoseWindows, - resolveSlotWindowDurationMinutes, + resolveSlotPhaseWindow, type WorkerScheduleRow, } from "../worker-helpers"; -import { DOSE_WINDOW_DEFAULTS } from "../dose-window-defaults"; describe("normaliseDoseWindows", () => { it("returns null for null / non-array input", () => { @@ -79,149 +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"]); + }); }); -/** - * `resolveSlotWindowDurationMinutes` — closes the bug where a - * multi-time-of-day schedule's reminder tick reused the WHOLE - * schedule's `windowEnd - windowStart` span for every individual - * slot, silently giving each dose a grace period equal to the gap - * between the schedule's earliest and latest dose instead of its own - * configured window. Reproduces the real-world case that surfaced it: - * two identically-*intended* once/twice-daily medications - * (Lansoprazole: one 08:00 dose; Metoprolol: 08:00 + 18:00 doses) - * resolving to wildly different per-slot durations (60min vs 600min) - * purely because one schedule had a sibling dose. - */ -describe("resolveSlotWindowDurationMinutes", () => { - it("single-time-of-day schedule: unchanged legacy windowEnd - windowStart", () => { - const schedule: WorkerScheduleRow = { - id: "s1", - windowStart: "08:00", - windowEnd: "09:00", - daysOfWeek: null, - timesOfDay: ["08:00"], - reminderGraceMinutes: null, - rrule: null, - rollingIntervalDays: null, - }; - expect(resolveSlotWindowDurationMinutes(schedule, "08:00", null)).toBe(60); - }); +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("REGRESSION (Lansoprazole real data): single 08:00-09:00 slot resolves to 60min, not inflated", () => { - const schedule: WorkerScheduleRow = { - id: "lansoprazole-schedule", - windowStart: "08:00", - windowEnd: "09:00", - daysOfWeek: null, - timesOfDay: ["08:00"], - reminderGraceMinutes: null, - rrule: "FREQ=DAILY", - rollingIntervalDays: null, - }; - expect(resolveSlotWindowDurationMinutes(schedule, "08:00", null)).toBe(60); - }); - - it("REGRESSION (Metoprolol real data): a 08:00/18:00 schedule no longer gives the 08:00 slot a 600min window", () => { - const schedule: WorkerScheduleRow = { - id: "metoprolol-schedule", - windowStart: "08:00", - windowEnd: "18:00", - daysOfWeek: null, - timesOfDay: ["08:00", "18:00"], - reminderGraceMinutes: null, - rrule: "FREQ=DAILY", - rollingIntervalDays: null, - }; - const morning = resolveSlotWindowDurationMinutes(schedule, "08:00", null); - const evening = resolveSlotWindowDurationMinutes(schedule, "18:00", null); - expect(morning).toBeLessThan(600); - expect(evening).toBeLessThan(600); - expect(morning).toBe(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes * 2); - expect(evening).toBe(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes * 2); - }); - - it("core requirement: an unconfigured dose resolves the SAME whether or not it has a sibling slot, once both use an explicit override", () => { - const single: WorkerScheduleRow = { - id: "single", - windowStart: "08:00", - windowEnd: "09:00", - daysOfWeek: null, - timesOfDay: ["08:00"], - reminderGraceMinutes: 180, - rrule: null, - rollingIntervalDays: null, - }; - const multi: WorkerScheduleRow = { - id: "multi", - windowStart: "08:00", - windowEnd: "18:00", - daysOfWeek: null, - timesOfDay: ["08:00", "18:00"], - reminderGraceMinutes: 180, - rrule: null, - rollingIntervalDays: null, - }; - // Same explicit reminderGraceMinutes, same slotTime — the presence - // of Metoprolol-style sibling slot must not change the 08:00 - // dose's resolved duration at all. - expect(resolveSlotWindowDurationMinutes(single, "08:00", null)).toBe( - resolveSlotWindowDurationMinutes(multi, "08:00", null), + 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"), + }, ); - expect(resolveSlotWindowDurationMinutes(multi, "08:00", null)).toBe(180); }); - it("an explicit per-dose doseWindows entry wins over everything else, keyed by timeOfDay", () => { - const schedule: WorkerScheduleRow = { - id: "s1", - windowStart: "08:00", + it("uses the shared post-slot default for each unconfigured sibling", () => { + const schedule = { + ...base, windowEnd: "18:00", - daysOfWeek: null, timesOfDay: ["08:00", "18:00"], - reminderGraceMinutes: 180, // present but should be overridden for 08:00 - rrule: null, - rollingIntervalDays: null, }; - const doseWindows = [ - { timeOfDay: "08:00", start: "07:30", end: "09:30" }, // 120min - ]; expect( - resolveSlotWindowDurationMinutes(schedule, "08:00", doseWindows), - ).toBe(120); - // The 18:00 slot has no matching doseWindows entry, so it still - // falls through to reminderGraceMinutes. + 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( - resolveSlotWindowDurationMinutes(schedule, "18:00", doseWindows), - ).toBe(180); + resolveSlotPhaseWindow(schedule, "18:00", null, eveningSlot, "UTC"), + ).toEqual({ + start: eveningSlot, + end: new Date("2026-07-28T19:00:00.000Z"), + }); }); - it("caps the unconfigured-dose default at the minimum inter-slot gap so two close doses never overlap", () => { - const schedule: WorkerScheduleRow = { - id: "s1", - windowStart: "08:00", - windowEnd: "09:30", // legacy span irrelevant here — multi-slot ignores it - daysOfWeek: null, - timesOfDay: ["08:00", "09:30"], // only 90min apart — narrower than the 120min default - reminderGraceMinutes: null, - rrule: null, - rollingIntervalDays: null, - }; - const duration = resolveSlotWindowDurationMinutes(schedule, "08:00", null); - expect(duration).toBe(90); - expect(duration).toBeLessThan(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes * 2); + 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("caps by the midnight-wrap gap too, not just forward gaps", () => { - const schedule: WorkerScheduleRow = { - id: "s1", - windowStart: "23:00", - windowEnd: "23:30", - daysOfWeek: null, - timesOfDay: ["23:00", "23:30"], // 30min apart, wrapping close to midnight - reminderGraceMinutes: null, - rrule: null, - rollingIntervalDays: null, + 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"), }; - expect(resolveSlotWindowDurationMinutes(schedule, "23:00", null)).toBe(30); + + 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 8d3f843f4..f4f80748a 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -28,6 +28,7 @@ import { 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 @@ -79,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), @@ -175,139 +186,159 @@ export function scheduleEmitsInWindow( } /** - * Per-slot phase-window duration, in minutes: how long after a dose - * slot's own start time it stays "on-time" before the reminder tick - * starts escalating through ORANGE/RED, and (halved) the radius a - * logged dose must land within to suppress that slot's reminder. + * Resolve one slot's phase bounds as real instants. * - * Bug this closes: the reminder tick used to compute this ONCE per - * schedule as the whole schedule's `windowEnd - windowStart` span and - * apply that SAME span to every `timeOfDay` slot in the schedule. That - * is correct for a single-time-of-day schedule (the span already IS - * that one slot's window), but for a multi-time-of-day schedule it - * silently inflates every slot's grace period to the full gap between - * the schedule's earliest and latest dose. A twice-daily schedule with - * `windowStart=08:00`/`windowEnd=18:00` (representing "somewhere - * between the morning and evening dose", not a per-dose window) gave - * its 08:00 slot a 600-minute (10h) grace before it even entered - * ORANGE — silently far too lenient — while a schedule with a single - * `08:00` dose and the same-looking `windowEnd=09:00` correctly used a - * 60-minute span, and thus a 30-minute suppression radius that a dose - * logged more than 30 minutes late could never satisfy — far too - * strict. Same misapplied field, opposite-looking symptoms. + * 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. * - * A multi-time-of-day schedule must NOT get a different answer than a - * single-time-of-day one for a dose configured the same way — the - * point of this function is that the count of sibling slots on the - * schedule never changes one slot's own resolved duration except - * through the explicit, bounded inter-slot-overlap guard in tier 4 - * below (which only ever narrows, and only when slots truly are close - * enough together to otherwise overlap). - * - * Four-tier resolution, checked in priority order — the first - * applicable tier wins, each strictly a "the user configured this - * exact thing" tier before falling back to a shared default: - * - * 1. An explicit per-dose `doseWindows` entry for THIS `slotTime` - * (`{ timeOfDay, start, end }`, persisted by the dose-window - * editor — v1.15.18) is the actual rule the user set for this - * specific dose in the GUI. Its `end - start` (in minutes) is - * used directly. Independent of every other dose on the - * schedule, by construction — this is a per-`timeOfDay` lookup. - * 2. `reminderGraceMinutes`, when set, is used directly. The field - * already exists and is documented ("Replaces the implicit - * windowEnd - windowStart span for late-classification") for - * exactly this purpose, and the write-path already reads it via - * `graceToleranceMs` — but the reminder tick itself never read - * it, so setting it had no effect on when/whether a reminder - * fired. This is a schedule-wide override, applied identically - * to every slot on the schedule (the field itself is per-schedule, - * not per-dose, so uniform application here is what the value - * represents — not a slot-count-dependent choice). - * 3. With NEITHER of the above set, this dose has no explicit - * configuration at all, and gets the same default an unconfigured - * "point" dose gets everywhere else in the app (`defaultBandForTime` - * in `dose-window.ts`, `±DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes`). - * Deliberately NOT the legacy per-schedule `windowEnd - windowStart` - * span here: for a multi-time-of-day schedule that span is not a - * per-dose window at all — it was derived as the earliest and - * latest `timeOfDay` (see the bug writeup above), so an - * unconfigured dose on a two-dose schedule must not silently - * inherit a multi-hour gap purely because it has a sibling. - * 4. Bounded by the minimum gap between adjacent sorted `timesOfDay` - * (the midnight wrap counted as one of those gaps) whenever the - * schedule has more than one slot, so two distinct same-day doses - * can never share a window — this can only ever narrow tiers 1-3, - * never widen them, and is a no-op for a single-time-of-day - * schedule (nothing to overlap with). - * - * The one legacy exception, preserved for backward compatibility: a - * single-time-of-day schedule with none of tiers 1-2 set keeps using - * the literal `windowEnd - windowStart` span rather than tier 3's - * default — for a schedule that predates the per-dose `doseWindows` - * system (v1.15.18), that legacy span IS the one and only window the - * user configured for that dose, and reinterpreting it as "unconfigured" - * would silently narrow an intentionally wide window on every such - * pre-existing schedule. + * Intake suppression deliberately does not use these bounds. */ -export function resolveSlotWindowDurationMinutes( +export interface SlotPhaseWindow { + start: Date; + end: Date; +} + +export function resolveSlotPhaseWindow( schedule: Pick< WorkerScheduleRow, "windowStart" | "windowEnd" | "timesOfDay" | "reminderGraceMinutes" >, slotTime: string, doseWindows: DoseWindowEntry[] | null, -): number { - const explicit = doseWindows?.find((w) => w.timeOfDay === slotTime); + slotInstant: Date, + userTz: string, +): SlotPhaseWindow { + const slotMinute = hhmmToMinutesOrNull(slotTime); + const explicit = doseWindows?.find((window) => window.timeOfDay === slotTime); if (explicit) { - const startMin = hhmmToMinutesOrNull(explicit.start); - const endMin = hhmmToMinutesOrNull(explicit.end); - if (startMin !== null && endMin !== null) { - let span = endMin - startMin; - if (span < 0) span += 24 * 60; // overnight window - return span; + 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 schedule.reminderGraceMinutes === "number" && - Number.isFinite(schedule.reminderGraceMinutes) && - schedule.reminderGraceMinutes > 0 + typeof configuredGrace === "number" && + Number.isFinite(configuredGrace) && + configuredGrace > 0 ) { - return schedule.reminderGraceMinutes; + return { + start: slotInstant, + end: new Date(slotInstant.getTime() + configuredGrace * 60_000), + }; } - const slots = ( - schedule.timesOfDay && schedule.timesOfDay.length > 0 - ? schedule.timesOfDay - : [schedule.windowStart] - ) + const slots = schedule.timesOfDay .map(hhmmToMinutesOrNull) - .filter((m): m is number => m !== null) + .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 }; + } - let span: number; - if (slots.length > 1) { - span = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes * 2; - } else { - const startMin = hhmmToMinutesOrNull(schedule.windowStart); - const endMin = hhmmToMinutesOrNull(schedule.windowEnd); - span = startMin === null || endMin === null ? 0 : endMin - startMin; - if (span < 0) span += 24 * 60; // overnight window - return span; // single-slot legacy path — no sibling to cap against + 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), + }; } - let minGap = Infinity; - for (let i = 1; i < slots.length; i++) { - minGap = Math.min(minGap, slots[i] - slots[i - 1]); - } - const wrapGap = slots[0] + 24 * 60 - slots[slots.length - 1]; - minGap = Math.min(minGap, wrapGap); - if (Number.isFinite(minGap) && minGap > 0) { - span = Math.min(span, minGap); - } + 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)), + }; +} - return span; +function localMinuteAsUtc( + dayReference: Date, + userTz: string, + minuteOfDay: number, +): Date { + return localHmAsUtc( + dayReference, + userTz, + Math.floor(minuteOfDay / 60), + minuteOfDay % 60, + ); } /**