mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(reminders): keep a course window's end through every reschedule
The first due slot of a Coach course honoured endsOn, but the tick's roll-on, satisfy and skip all built their schedule input without it, so a seven-day twice-daily course kept rolling on indefinitely and never reached the null nextDueAt the expired-course cleanup waits for. Carry endsOn on RollableReminder and SatisfiableReminder and select it wherever a caller narrows the row.
This commit is contained in:
1 parent
f521cdbbaf
commit
6c8458d911
8 files changed
+152
No files matched your search
@@ -110,6 +110,14 @@
|
||||
come back under the new link, and an option changed at that moment
|
||||
could be lost to the new link. Turning it off now wins whichever comes
|
||||
first, and the change lands on the link that stays live.
|
||||
- **A seven-day blood pressure course now ends after seven days.** A
|
||||
course suggested by the Coach knew its last day only until its first
|
||||
reminder went out. From then on every sent reminder, every reading and
|
||||
every skip planned the next one without that end, so the morning and
|
||||
evening reminders went on for good. The end now holds through every
|
||||
reschedule, and once the last slot is past the course is cleared from
|
||||
the list as intended. A course that already ran past its end sends the
|
||||
one reminder it has planned and then stops.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ export async function resolveClosableReminder(
|
||||
lastSatisfiedAt: true,
|
||||
lastSkippedAt: true,
|
||||
createdAt: true,
|
||||
endsOn: true,
|
||||
},
|
||||
});
|
||||
if (!row || row.userId !== userId || row.deletedAt !== null) return undefined;
|
||||
@@ -340,6 +341,7 @@ export async function resolveClosableReminder(
|
||||
lastSatisfiedAt: row.lastSatisfiedAt,
|
||||
lastSkippedAt: row.lastSkippedAt,
|
||||
createdAt: row.createdAt,
|
||||
endsOn: row.endsOn,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -116,4 +116,83 @@ describe.each(ZONES)("BYHOUR=7,19 in %s", (tz) => {
|
||||
|
||||
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
|
||||
});
|
||||
|
||||
it("stops after the last slot of a course window and leaves the row for the cleanup", async () => {
|
||||
const endsOn = at(tz, 15, 12);
|
||||
const base = {
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=DAILY;BYHOUR=7,19",
|
||||
anchorDate: at(tz, 14, 0),
|
||||
notifyHour: 7,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: at(tz, 13, 12),
|
||||
endsOn,
|
||||
};
|
||||
const row: Record<string, unknown> = {
|
||||
id: "r-bp-course",
|
||||
userId: "u1",
|
||||
origin: "COACH",
|
||||
measurementType: null,
|
||||
label: "BP",
|
||||
location: null,
|
||||
lastSkippedAt: null,
|
||||
lastNotifiedAt: null,
|
||||
enabled: true,
|
||||
deletedAt: null,
|
||||
...base,
|
||||
nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)),
|
||||
user: { id: "u1", timezone: tz, locale: "en" },
|
||||
};
|
||||
const prisma = {
|
||||
measurementReminder: {
|
||||
findMany: vi.fn(async () =>
|
||||
(row.nextDueAt as Date | null) === null ? [] : [row],
|
||||
),
|
||||
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
Object.assign(row, data);
|
||||
return row;
|
||||
}),
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
measurement: { findFirst: vi.fn(async () => null) },
|
||||
labResult: { findFirst: vi.fn(async () => null) },
|
||||
};
|
||||
const sent: string[] = [];
|
||||
const dispatch = vi.fn(async () => ({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}));
|
||||
|
||||
let last = at(tz, 14, 0);
|
||||
for (
|
||||
let t = at(tz, 14, 0).getTime();
|
||||
t < at(tz, 18, 0).getTime();
|
||||
t += 15 * 60_000
|
||||
) {
|
||||
const now = new Date(t);
|
||||
last = now;
|
||||
const before = dispatch.mock.calls.length;
|
||||
await runMeasurementReminderTick(prisma as never, now, {
|
||||
dispatch: dispatch as never,
|
||||
isModuleEnabled: async () => true,
|
||||
});
|
||||
if (dispatch.mock.calls.length > before) {
|
||||
const p = wallClockInTz(now, tz);
|
||||
sent.push(
|
||||
`${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
|
||||
// The course rolled off its end: no next slot, which is exactly the
|
||||
// shape the expired-course cleanup tombstones on the following tick.
|
||||
expect(row.nextDueAt).toBeNull();
|
||||
expect((row.endsOn as Date).getTime()).toBeLessThan(last.getTime());
|
||||
const cleanup = prisma.measurementReminder.updateMany.mock.calls.find(
|
||||
(call: unknown[]) => JSON.stringify(call[0]).includes('"origin":"COACH"'),
|
||||
);
|
||||
expect(cleanup).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -704,6 +704,7 @@ type RollableReminder = {
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
endsOn: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -733,6 +734,7 @@ function nextSlotAfterReminder(
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
endsOn: reminder.endsOn,
|
||||
};
|
||||
return computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ function reminder(
|
||||
lastSatisfiedAt: null,
|
||||
lastSkippedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
endsOn: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -255,6 +256,59 @@ describe("satisfyReminder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("course window", () => {
|
||||
// A twice-daily course whose last day is 2026-06-15 (Berlin). Satisfying
|
||||
// or skipping the final evening slot must end the course, not roll it on
|
||||
// to an open-ended 2026-06-16 morning.
|
||||
const course = {
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=DAILY;BYHOUR=7,19",
|
||||
notifyHour: 7,
|
||||
createdAt: new Date("2026-06-13T10:00:00Z"),
|
||||
endsOn: new Date("2026-06-15T10:00:00Z"),
|
||||
};
|
||||
const lastEvening = new Date("2026-06-15T17:30:00Z");
|
||||
|
||||
it("satisfy past the last slot leaves no next slot", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder(course),
|
||||
TZ,
|
||||
lastEvening,
|
||||
"manual",
|
||||
);
|
||||
expect(result.satisfied).toBe(true);
|
||||
expect(result.nextDueAt).toBeNull();
|
||||
expect(updates[0].data.nextDueAt).toBeNull();
|
||||
});
|
||||
|
||||
it("skip past the last slot leaves no next slot", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const result = await skipReminder(
|
||||
prisma as never,
|
||||
reminder(course),
|
||||
TZ,
|
||||
lastEvening,
|
||||
);
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.nextDueAt).toBeNull();
|
||||
expect(updates[0].data.nextDueAt).toBeNull();
|
||||
});
|
||||
|
||||
it("still rolls within the window", async () => {
|
||||
const { prisma } = makePrisma();
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder(course),
|
||||
TZ,
|
||||
new Date("2026-06-15T05:30:00Z"),
|
||||
"manual",
|
||||
);
|
||||
expect(result.nextDueAt).toEqual(new Date("2026-06-15T17:00:00Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipReminder", () => {
|
||||
it("stamps lastSkippedAt, increments skipCount, clears snooze — and never touches lastSatisfiedAt", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface SatisfiableReminder {
|
||||
lastSatisfiedAt: Date | null;
|
||||
lastSkippedAt: Date | null;
|
||||
createdAt: Date;
|
||||
/** Course-window end; without it a finite course reschedules forever. */
|
||||
endsOn: Date | null;
|
||||
}
|
||||
|
||||
export interface SatisfyResult {
|
||||
@@ -151,6 +153,7 @@ export async function satisfyReminder(
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: anchorAt,
|
||||
createdAt: reminder.createdAt,
|
||||
endsOn: reminder.endsOn,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, anchorAt);
|
||||
|
||||
@@ -239,6 +242,7 @@ export async function skipReminder(
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: skippedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
endsOn: reminder.endsOn,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(
|
||||
scheduleInput,
|
||||
|
||||
@@ -877,6 +877,7 @@ export async function handleCallback(update: TelegramUpdate) {
|
||||
lastSatisfiedAt: true,
|
||||
lastSkippedAt: true,
|
||||
createdAt: true,
|
||||
endsOn: true,
|
||||
},
|
||||
});
|
||||
if (!reminder) {
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function satisfyBoostersForDose(
|
||||
lastSatisfiedAt: true,
|
||||
lastSkippedAt: true,
|
||||
createdAt: true,
|
||||
endsOn: true,
|
||||
},
|
||||
});
|
||||
if (reminders.length === 0) return EMPTY;
|
||||
@@ -148,6 +149,7 @@ export async function satisfyBoostersForDose(
|
||||
lastSatisfiedAt: reminder.lastSatisfiedAt,
|
||||
lastSkippedAt: reminder.lastSkippedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
endsOn: reminder.endsOn,
|
||||
};
|
||||
// `satisfyReminder` types its client as the full `PrismaClient` because
|
||||
// every other caller hands it one; the only member it touches is
|
||||
|
||||
Reference in new issue
Block a user