Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19

Merged
luckberg merged 41 commits from gsd/phase-11-per-event-reminders into main 2026-06-14 14:06:45 -04:00
Showing only changes of commit 57f9d67685 - Show all commits
@@ -567,6 +567,91 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
});
});
// ── D-09: humanizeLeadMinutes body formatter ─────────────────────────────────
describe('humanizeLeadMinutes — D-09 humanized push body formatter', () => {
it('maps 30 min → "Starts in 30 min"', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min');
});
it('maps 59 min → "Starts in 59 min" (< 60 bucket)', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(59)).toBe('Starts in 59 min');
});
it('maps 60 min → "Starts in 1 hour"', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(60)).toBe('Starts in 1 hour');
});
it('maps 90 min → "Starts in 1 hour" (60119 bucket, not 2 hours)', async () => {
// Math.round(90/60)=2 would give "2 hours" — branch ordering must prevent this
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(90)).toBe('Starts in 1 hour');
});
it('maps 120 min → "Starts in 2 hours"', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(120)).toBe('Starts in 2 hours');
});
it('maps 1440 min → "Starts in 1 day"', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(1440)).toBe('Starts in 1 day');
});
it('maps 2880 min → "Starts in 2 days"', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(2880)).toBe('Starts in 2 days');
});
it('maps 10080 min → "Starts in 7 days"', async () => {
const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js');
expect(humanizeLeadMinutes(10080)).toBe('Starts in 7 days');
});
});
describe('reminderScheduler — D-09: humanized push body in dispatch', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('dispatched notification body is humanized from configured lead (not live minutes-to-start)', async () => {
// D-09: body driven by event.reminderLeadMinutes (DB ground truth), not by (dtstart - now)
// A 1440-min (1 day) lead event should produce "Starts in 1 day", not "Starts in 30 min"
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// Event exactly 1440 min (1 day) from now → fire time = dtstartUtc - 1440min = now
const dtstartUtc = new Date('2026-06-16T10:00:00Z'); // tomorrow 10:00Z
const row = makeEventRow({
uid: 'humanize-body-uid',
title: 'Meeting Tomorrow',
dtstartUtc,
reminderLeadMinutes: 1440,
});
mockTwoQueries(vi.mocked(db), [row]);
await runReminderCheck(now);
expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce();
const [, payload] = vi.mocked(dispatchPush).mock.calls[0]!;
// Body must be "Starts in 1 day" (configured lead), not "Starts in 1470 min" (live delta)
expect((payload as { body: string }).body).toBe('Starts in 1 day');
});
});
// ── T-05-19: per-subscription error isolation ────────────────────────────────
describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {