Fix medication reminder slot attribution

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:06:07 +02:00
1 parent 08e535a0d9
commit 87544af2cd
5 files changed
+1090 -310

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"),
}),
);
});
});
@@ -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<ReminderCheckPayload>[]) {
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<ReminderCheckPayload>[]) {
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<ReminderCheckPayload>[]) {
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),
@@ -260,7 +302,82 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
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<ReminderCheckPayload>[]) {
// 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<ReminderCheckPayload>[]) {
// 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<ReminderCheckPayload>[]) {
// 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<ReminderCheckPayload>[]) {
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<ReminderCheckPayload>[]) {
med.name,
doseInfo,
timeWindow,
minutesToEnd,
Math.ceil(minutesToEnd),
med.user.locale,
);
@@ -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);
});
});
@@ -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;
+143 -112
View File
@@ -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,
);
}
/**