Add named reminders with multiple times and day schedules

This commit is contained in:
syntaxbullet
2026-09-08 07:36:49 +02:00
parent 8ba23961ef
commit 8e846fa11b
12 changed files with 1341 additions and 53 deletions

View File

@@ -98,3 +98,117 @@ test("reminder preferences require authentication, origin, valid clocks, and con
expect(f.db.select().from(reminderSettings).all()).toEqual([]);
} finally { f.close(); }
});
test("multiple times respect weekdays and dates, deduplicate overlaps, and stop after completion", async () => {
const { f, habit } = await setup(); const sent: string[] = [];
try {
f.db.update(reminderSettings).set({ reminders: [
{ type: "weekly", time: "10:00", days: [5] },
{ type: "date", time: "10:00", date: "2026-09-04" },
{ type: "date", time: "12:00", date: "2026-09-04" },
{ type: "weekly", time: "14:00", days: [1] },
{ type: "weekly", time: "18:00", days: [5] },
] }).run();
const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`));
await Promise.all([tick("08:00"), tick("08:00")]); expect(sent).toHaveLength(1);
await tick("10:00"); expect(sent).toHaveLength(2);
await tick("12:00"); expect(sent).toHaveLength(2);
f.setTime("2026-09-04T15:00:00Z");
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { done: true });
await tick("16:00"); expect(sent).toHaveLength(2);
expect(f.db.select().from(reminderDeliveries).all().map(row => row.slot)).toEqual(["10:00", "12:00"]);
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(2);
} finally { f.close(); }
});
test("overdue reminders coalesce after quiet hours and retain separate delivery records", async () => {
const { f } = await setup(); const sent: string[] = [];
try {
f.db.update(reminderSettings).set({ quietStart: "09:00", quietEnd: "13:00", reminders: [
{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "12:00", days: [5] }, { type: "weekly", time: "16:00", days: [5] },
] }).run();
const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`));
await tick("10:00"); expect(sent).toHaveLength(0);
await Promise.all([tick("11:00"), tick("11:00")]); expect(sent).toHaveLength(1);
expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(2);
await tick("14:00"); expect(sent).toHaveLength(2);
await tick("15:00"); expect(sent).toHaveLength(2);
} finally { f.close(); }
});
test("removing a due schedule while opening Discord cancels delivery", async () => {
const { f } = await setup(); let posts = 0;
try {
await sendDueReminders(f.db, config, async url => {
if (!url.endsWith("/users/@me/channels")) posts++;
f.db.update(reminderSettings).set({ reminders: [{ type: "date", time: "20:00", date: "2026-09-05" }] }).run();
return Response.json({ id: "223456789012345678" });
}, () => Date.parse("2026-09-04T18:00:00Z"));
expect(posts).toBe(0);
} finally { f.close(); }
});
test("schedule validation rejects empty days, invalid dates, and excessive entries", async () => {
const { reminderInput } = await import("./contracts");
const parse = (reminders: unknown[]) => reminderInput.safeParse({ enabled: true, quietStart: "22:00", quietEnd: "08:00", reminders }).success;
expect(parse([])).toBe(false);
expect(parse([{ type: "weekly", time: "12:00", days: [] }])).toBe(false);
expect(parse([{ type: "date", time: "12:00", date: "2026-02-30" }])).toBe(false);
expect(parse(Array.from({ length: 21 }, () => ({ type: "weekly", time: "12:00", days: [1] })))).toBe(false);
expect(parse([{ type: "weekly", time: "12:00", days: [1, 3] }, { type: "date", time: "18:00", date: "2026-09-08" }])).toBe(true);
});
test("named schedules round-trip through the API and account export", async () => {
const f = fixture();
try {
const reminders = [{ type: "weekly", name: "Morning check-in", time: "09:00", days: [1, 2, 3, 4, 5] }, { type: "date", name: "Evening review", time: "18:00", date: "2026-09-08" }];
await f.json("/reminders", "PUT", { enabled: false, reminders, quietStart: "22:00", quietEnd: "08:00" });
expect((await f.json("/reminders")).reminders).toEqual(reminders);
expect((await f.json("/account/export")).reminderSettings[0].reminders).toEqual(reminders);
expect((await f.request("/reminders", "PUT", { enabled: false, reminders: [{ ...reminders[0], name: "x".repeat(81) }], quietStart: "22:00", quietEnd: "08:00" })).status).toBe(422);
} finally { f.close(); }
});
test("legacy delivery records prevent upgrade duplicates and deferred records still retry", async () => {
const { f } = await setup(); const sent: string[] = [];
try {
const timestamp = Date.parse("2026-09-04T18:00:00Z");
f.db.insert(reminderDeliveries).values({ id: "old", userId: "alice", date: "2026-09-04", status: "sent", updatedAt: timestamp }).run();
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(0);
f.db.update(reminderDeliveries).set({ status: "deferred", retryAt: timestamp }).run();
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1);
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1);
} finally { f.close(); }
});
test("a stale pending send does not block a later reminder and is not retried", async () => {
const { f } = await setup(); const sent: string[] = [];
try {
const timestamp = Date.parse("2026-09-04T18:00:00Z");
f.db.update(reminderSettings).set({ reminders: [{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "20:00", days: [5] }] }).run();
f.db.insert(reminderDeliveries).values({ id: "stale", userId: "alice", date: "2026-09-04", slot: "10:00", status: "pending", updatedAt: timestamp - 120000 }).run();
await sendDueReminders(f.db, config, transport(sent), () => timestamp);
expect(sent).toHaveLength(1);
expect(f.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.id, "stale")).get()?.status).toBe("uncertain");
} finally { f.close(); }
});
test("messages include names only for the applicable reminders in the delivery batch", async () => {
const { f } = await setup(); const sent: string[] = [];
try {
f.db.update(reminderSettings).set({ reminders: [
{ type: "weekly", name: "Morning check-in", time: "09:00", days: [5] },
{ type: "date", name: "Midday review", time: "12:00", date: "2026-09-04" },
{ type: "date", name: "Tomorrow's review", time: "12:00", date: "2026-09-05" },
{ type: "weekly", name: "Evening review", time: "20:00", days: [5] },
] }).run();
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T10:00:00Z"));
const first = JSON.parse(sent[0]!).content;
expect(first).toContain("Reminder: Morning check-in; Midday review\n");
expect(first).not.toContain("Tomorrow"); expect(first).not.toContain("Evening review"); expect(first).not.toContain("Private habit name");
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z"));
const second = JSON.parse(sent[1]!).content;
expect(second).toContain("Reminder: Evening review\n"); expect(second).not.toContain("Morning check-in");
expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]);
} finally { f.close(); }
});