mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(medications): resolve each dose slot's own window duration
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user