feat(medications): resolve each dose slot's own window duration

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:26:53 +02:00
commit 2bab06e323
5 files changed
+1141 -63

No files matched your search

@@ -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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>[];
};
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<string, unknown>[];
};
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<string, unknown>[];
};
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<string, unknown>[];
};
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"),
}),
);
});
});
@@ -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<number>();
// Each anchored or attributed action can suppress only one schedule
// row, including when separate rows emit at the same instant.
const claimedAnchoredActionIndexes = new Set<number>();
const claimedTakenDoseIndexes = new Set<number>();
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,
);
@@ -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);
});
});
@@ -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;
@@ -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