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 7df11d2780 - Show all commits
+141
View File
@@ -350,3 +350,144 @@ describe('expandOccurrences', () => {
});
});
});
// ─── Phase 11 Plan 03 Task 3: reminderLeadMinutes on CalendarOccurrence ─────────
// D-06/D-10: reminderLeadMinutes is a series-level property — all occurrences of a
// recurring master inherit the master's lead. NULL-vs-0-vs-positive must survive
// through expansion.
describe('expandOccurrences — reminderLeadMinutes propagation (Phase 11 Plan 03 Task 3)', () => {
// Helper: minimal VCALENDAR/VEVENT string for tests
function makeVevent(overrides: {
uid?: string;
allDay?: boolean;
reminderMinutes?: number | 'absolute' | 'none';
rrule?: string;
}): string {
const uid = overrides.uid ?? 'test-uid@test';
const lines: string[] = ['BEGIN:VCALENDAR', 'VERSION:2.0'];
if (!overrides.allDay) {
lines.push('BEGIN:VEVENT');
lines.push(`UID:${uid}`);
lines.push('SUMMARY:Test event');
lines.push('DTSTART:20260615T140000Z');
lines.push('DTEND:20260615T150000Z');
} else {
lines.push('BEGIN:VEVENT');
lines.push(`UID:${uid}`);
lines.push('SUMMARY:All-day test');
lines.push('DTSTART;VALUE=DATE:20260615');
lines.push('DTEND;VALUE=DATE:20260616');
}
if (overrides.rrule) {
lines.push(`RRULE:${overrides.rrule}`);
}
if (overrides.reminderMinutes === 'absolute') {
lines.push('BEGIN:VALARM');
lines.push('ACTION:DISPLAY');
lines.push('DESCRIPTION:Reminder');
lines.push('TRIGGER;VALUE=DATE-TIME:20260615T120000Z');
lines.push('END:VALARM');
} else if (overrides.reminderMinutes !== 'none' && overrides.reminderMinutes !== undefined) {
lines.push('BEGIN:VALARM');
lines.push('ACTION:DISPLAY');
lines.push('DESCRIPTION:Reminder');
lines.push(`TRIGGER:-PT${overrides.reminderMinutes}M`);
lines.push('END:VALARM');
}
lines.push('END:VEVENT');
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}
const WINDOW_START = new Date('2026-06-01T00:00:00Z');
const WINDOW_END = new Date('2026-07-01T00:00:00Z');
// Non-recurring event with reminderLeadMinutes=30 → occurrence carries 30
it('non-recurring event: occurrence carries reminderLeadMinutes from master (30 minutes)', () => {
const raw = makeVevent({ reminderMinutes: 30 });
const occurrences = expandOccurrences(
raw,
WINDOW_START,
WINDOW_END,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
);
expect(occurrences).toHaveLength(1);
expect(occurrences[0].reminderLeadMinutes).toBe(30);
});
// NULL-vs-0: master with 0-minute all-day lead → occurrence carries 0, not null
it('non-recurring all-day event: occurrence carries reminderLeadMinutes=0 (same-day, D-06 NULL-vs-0)', () => {
// Use a 0-minute trigger (same-day all-day)
const raw = makeVevent({ allDay: true, reminderMinutes: 0 });
const occurrences = expandOccurrences(
raw,
new Date('2026-06-01T00:00:00Z'),
new Date('2026-07-01T00:00:00Z'),
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
);
expect(occurrences).toHaveLength(1);
// 0-minute all-day trigger → preset 0 → reminderLeadMinutes=0 (not null)
expect(occurrences[0].reminderLeadMinutes).toBe(0);
});
// NULL: no VALARM in master → occurrence carries null
it('non-recurring event with no VALARM: occurrence carries reminderLeadMinutes=null', () => {
const raw = makeVevent({ reminderMinutes: 'none' });
const occurrences = expandOccurrences(
raw,
WINDOW_START,
WINDOW_END,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
);
expect(occurrences).toHaveLength(1);
expect(occurrences[0].reminderLeadMinutes).toBeNull();
});
// D-10 series-level: recurring event with reminderLeadMinutes=60 → all occurrences carry 60
it('D-10 series-level: all recurring occurrences inherit the master reminderLeadMinutes=60', () => {
const raw = makeVevent({ reminderMinutes: 60, rrule: 'FREQ=WEEKLY;COUNT=3' });
const occurrences = expandOccurrences(
raw,
WINDOW_START,
WINDOW_END,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
);
expect(occurrences.length).toBeGreaterThan(0);
for (const occ of occurrences) {
expect(occ.reminderLeadMinutes).toBe(60);
}
});
});