From 79f6871167fba517218883c787f597bcfc66aa12 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:14:42 -0400 Subject: [PATCH 1/7] test(11-03): add failing tests for reminderLeadMinutes VALARM wiring (CAL-13/CAL-14) - CAL-14 preserve: UPDATE with no reminderLeadMinutes preserves VALARM from rawVevent - CAL-13 timed: CREATE with reminderLeadMinutes=15 emits TRIGGER:-PT15M - CAL-13 clear: UPDATE with reminderLeadMinutes=null emits no VALARM (passes trivially) - CAL-13 all-day: CREATE with allDay=true and reminderLeadMinutes=1440 emits VALUE=DATE-TIME --- apps/api/tests/broker/outboxWorker.test.ts | 154 +++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 7223409..e3755c2 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -864,3 +864,157 @@ describe('scheduleOutboxDrain — trigger wiring (D-09)', () => { } }); }); + +// ─── Phase 11 Plan 03: reminderLeadMinutes schema + VALARM wiring ─────────────── +// CAL-13: reminderLeadMinutes round-trips end-to-end through outbox payload → +// buildVeventString → emitted ICS. +// CAL-14: UPDATE row with no reminderLeadMinutes in payload preserves existing +// VALARM verbatim from rawVevent (mirrors WR-01 _preservedRrule pattern). + +describe('runOutboxDrain — reminderLeadMinutes VALARM wiring (CAL-13/CAL-14, Phase 11 Plan 03)', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockPendingRows = []; + wireMockChain(); + }); + + // CAL-14: UPDATE row with NO reminderLeadMinutes field, but rawVevent has a VALARM → + // emitted ICS must still contain BEGIN:VALARM (preserve path, mirrors _preservedRrule WR-01). + it('CAL-14 preserve: UPDATE with no reminderLeadMinutes field preserves existing VALARM from rawVevent', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { + capturedIcsString = icsString; + return makeResponse(204); + }); + + // rawVevent that already has a VALARM (TRIGGER:-PT30M) + const rawVeventWithValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:test-uid@familysync', + 'SUMMARY:Team meeting', + 'DTSTART:20260610T120000Z', + 'DTEND:20260610T130000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER:-PT30M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + // Payload has NO reminderLeadMinutes key (absent = no-change, D-08) + const updatePayload = JSON.stringify({ + title: 'Team meeting', + allDay: false, + start: '2026-06-10T12:00:00.000Z', + end: '2026-06-10T13:00:00.000Z', + }); + + mockPendingRows = [ + makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + payload: updatePayload, + }), + ]; + + // Simulate freshEtagRows returning rawVevent that has a VALARM + mockWhereCalEvents.mockResolvedValue([{ etag: '"fresh"', rawVevent: rawVeventWithValarm }]); + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + // The emitted ICS must contain the preserved VALARM + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + expect(capturedIcsString as string).toContain('TRIGGER:-PT30M'); + }); + + // CAL-13: CREATE row with reminderLeadMinutes=15 → emitted ICS contains TRIGGER:-PT15M + it('CAL-13 timed: CREATE row with reminderLeadMinutes=15 emits TRIGGER:-PT15M', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + capturedIcsString = icsString; + return makeResponse(201); + }); + + const payload = JSON.stringify({ + title: 'Doctor appointment', + allDay: false, + start: '2026-06-15T14:00:00.000Z', + end: '2026-06-15T15:00:00.000Z', + reminderLeadMinutes: 15, + }); + + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + expect(capturedIcsString as string).toContain('TRIGGER:-PT15M'); + }); + + // CAL-13 clear: UPDATE row with reminderLeadMinutes=null → emitted ICS has no VALARM + it('CAL-13 clear: UPDATE row with reminderLeadMinutes=null emits no VALARM (explicit clear)', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { + capturedIcsString = icsString; + return makeResponse(204); + }); + + const updatePayload = JSON.stringify({ + title: 'No reminder event', + allDay: false, + start: '2026-06-15T14:00:00.000Z', + end: '2026-06-15T15:00:00.000Z', + reminderLeadMinutes: null, + }); + + mockPendingRows = [ + makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + payload: updatePayload, + }), + ]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).not.toContain('BEGIN:VALARM'); + }); + + // CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 → + // emitted ICS contains VALUE=DATE-TIME absolute trigger (not DURATION trigger). + it('CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 emits VALUE=DATE-TIME trigger', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + capturedIcsString = icsString; + return makeResponse(201); + }); + + const payload = JSON.stringify({ + title: 'Birthday party', + allDay: true, + start: '2026-06-20', + end: '2026-06-20', + reminderLeadMinutes: 1440, // 1 day before = leadDays = 1440/1440 = 1 + }); + + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + // Must use VALUE=DATE-TIME absolute trigger for all-day (not DURATION) + expect(capturedIcsString as string).toContain('VALUE=DATE-TIME'); + }); +}); From 4f42b7535b354d57201f305d36b4709ca6d1f6de Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:16:42 -0400 Subject: [PATCH 2/7] feat(11-03): reminderLeadMinutes schema + VALARM wiring in outbox worker (CAL-13/CAL-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add reminderLeadMinutes to eventFieldsSchema (ingress validation, T-11-06) - Add reminderLeadMinutes to outboxPayloadSchema (drain re-validation, IN-03 defense-in-depth) - Import extractValarms + computeAlertInstantUtc from vevent.ts - UPDATE branch: hasExplicitReminder gate mirrors hasExplicitRecurrence (WR-01 pattern) - absent field → extractValarms(rawVevent) preserved verbatim (CAL-14, D-08) - explicit null → clear VALARM (no valarmsToPreserve, null passed to buildVeventString) - explicit value + allDay → computeAlertInstantUtc at 9 AM local (D-04) - explicit value + timed → passed through to buildTimedValarm via buildVeventString - CREATE branch: always explicit picker value; compute allDayAlertInstantUtc when allDay --- apps/api/src/broker/outboxWorker.ts | 56 ++++++++++++++++++++++++++++- apps/api/src/routes/events.ts | 6 ++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 608167e..822d7bd 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -26,6 +26,7 @@ * Source: poller.ts pattern (runPoll/startBrokerPoller) */ import { z } from 'zod'; +import ICAL from 'ical.js'; import { and, eq, lte } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js'; @@ -33,7 +34,13 @@ import { createFastmailClient } from './client.js'; import { decryptPassword } from './crypto.js'; import { syncCalendar } from './sync.js'; import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'; -import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'; +import { + buildVeventString, + extractRruleString, + extractValarms, + computeAlertInstantUtc, + RRULE_PRESETS, +} from './vevent.js'; import type { FastmailClient } from './client.js'; import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'; import { onOutboxDrain } from '../lib/outboxTrigger.js'; @@ -91,6 +98,12 @@ const outboxPayloadSchema = z .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), // 'YYYY-MM-DD' → RRULE UNTIL recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT + // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). + // absent — field not present; preserve existing VALARM verbatim (no-change path, D-08) + // null — explicit "None" → clear the VALARM on write-back + // 0 — same-day all-day reminder (fire 9 AM on event date); timed 0 = None (D-06) + // positive int — N minutes before event start (timed) or N/1440 days before (all-day) + reminderLeadMinutes: z.number().int().min(0).nullable().optional(), }) .passthrough(); @@ -423,6 +436,11 @@ async function dispatchRow(row: OutboxRow): Promise { // deliberate user change. Read rawVevent in the same scoped query as the fresh etag. let preservedRrule: string | undefined; const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence'); + // CAL-14: mirrors the WR-01 hasExplicitRecurrence pattern for VALARM preservation. + // When the payload omits `reminderLeadMinutes` entirely (no-change, D-08), we preserve + // the existing VALARM verbatim from rawVevent via extractValarms. An explicit null clears + // the VALARM; an explicit value (timed or all-day) replaces it. + const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes'); const rruleFromPayload = fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] @@ -465,6 +483,26 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule = extractRruleString(freshEtagRows[0].rawVevent); } + // CAL-14: resolve VALARM for the UPDATE branch. + // absent (no hasExplicitReminder) + rawVevent has VALARMs → preserve verbatim (D-08) + // explicit null → clear (no VALARM emitted by buildVeventString) + // explicit value + allDay + valid start → compute 9 AM absolute DATE-TIME trigger (D-04) + // explicit value + timed → pass through to buildTimedValarm + let valarmsToPreserve: ICAL.Component[] | undefined; + let allDayAlertInstantUtcUpdate: Date | undefined; + if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) { + valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent); + } else if ( + hasExplicitReminder && + fields.reminderLeadMinutes != null && + fields.allDay && + fields.start + ) { + const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const leadDays = fields.reminderLeadMinutes / 1440; + allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); + } + // D-06: assemble the final RRULE string, combining the preset or preserved RRULE // with an optional UNTIL/COUNT bound from the payload. // Pitfall 3: on series edit with bound change only (no new preset), parse the preserved @@ -489,6 +527,10 @@ async function dispatchRow(row: OutboxRow): Promise { location: fields.location, description: fields.description, rruleString: finalRruleString, + // CAL-13/CAL-14: VALARM wiring — absent preserves, null clears, value replaces + reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined, + valarms: valarmsToPreserve, + allDayAlertInstantUtc: allDayAlertInstantUtcUpdate, }); response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut); @@ -558,6 +600,15 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule, ); + // CAL-13: VALARM wiring for CREATE branch (no rawVevent source — new event always + // carries an explicit picker value or no reminder at all; no preserve path needed). + let allDayAlertInstantUtcCreate: Date | undefined; + if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) { + const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const leadDays = fields.reminderLeadMinutes / 1440; + allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); + } + const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title, @@ -567,6 +618,9 @@ async function dispatchRow(row: OutboxRow): Promise { location: fields.location, description: fields.description, rruleString: finalRruleString, + // CAL-13: per-event reminder — pass through; null=clear, value=set, absent=no VALARM + reminderLeadMinutes: fields.reminderLeadMinutes, + allDayAlertInstantUtc: allDayAlertInstantUtcCreate, }); // Build a minimal DAVCalendar for the write wrapper (only url is needed) const davCalendar = { url: row.calendarUrl } as Parameters[1]; diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index ce8d899..9a0432a 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -117,6 +117,12 @@ const eventFieldsSchema = z.object({ .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), // 'YYYY-MM-DD' → RRULE UNTIL recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT + // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). + // absent — field not present; outbox worker preserves existing VALARM (no-change, D-08) + // null — explicit "None" → clear the VALARM on write-back + // 0 — same-day all-day reminder (9 AM on event date); timed 0 = None (D-06) + // positive int — N minutes before event start (timed) or N/1440 days before (all-day) + reminderLeadMinutes: z.number().int().min(0).nullable().optional(), }); /** sync-status query params. */ From cdca93094abe47759a6b786c0751905de40d4aa2 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:17:51 -0400 Subject: [PATCH 3/7] test(11-03): add failing tests for sync.ts reminderLeadMinutes upsert from VALARM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preset TRIGGER:-PT30M → reminderLeadMinutes=30 - no VALARM → reminderLeadMinutes=null - absolute DATE-TIME trigger → null (custom kind, D-07/NOTIF-05) - two VALARMs → null (multiple alarms not resolvable to single lead) - onDuplicateKeyUpdate set also carries reminderLeadMinutes (upsert keeps column current) --- apps/api/tests/broker/sync.test.ts | 204 +++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/apps/api/tests/broker/sync.test.ts b/apps/api/tests/broker/sync.test.ts index 10b9858..bc3e862 100644 --- a/apps/api/tests/broker/sync.test.ts +++ b/apps/api/tests/broker/sync.test.ts @@ -429,3 +429,207 @@ describe('syncCalendar', () => { expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' }); }); }); + +// ─── Phase 11 Plan 03 Task 2: reminderLeadMinutes derived from VALARM on sync ── +// CAL-13: sync.ts must derive reminderLeadMinutes from the native VALARM and write it +// to the DB so the scheduler has ground truth for native-client alarms (D-07/NOTIF-05). + +describe('syncCalendar — reminderLeadMinutes from VALARM (Phase 11 Plan 03 Task 2)', () => { + const MOCK_DAV_CAL = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-v1', + syncToken: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]); + mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate }); + mockInsert.mockReturnValue({ values: mockValues }); + mockLimit.mockResolvedValue([{ id: 42 }]); + mockWhere.mockReturnValue({ limit: mockLimit }); + mockFrom.mockReturnValue({ where: mockWhere }); + mockSelect.mockReturnValue({ from: mockFrom }); + mockDeleteWhere.mockResolvedValue([]); + mockDelete.mockReturnValue({ where: mockDeleteWhere }); + }); + + // A single preset TRIGGER:-PT30M → reminderLeadMinutes=30 + it('writes reminderLeadMinutes=30 when VCALENDAR has a single TRIGGER:-PT30M VALARM', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventWithValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-with-valarm@test', + 'SUMMARY:Meeting with reminder', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER:-PT30M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: rawVeventWithValarm, etag: '"etag-valarm"', url: '/cal/valarm.ics' }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + expect(eventValuesArg.reminderLeadMinutes).toBe(30); + }); + + // No VALARM → reminderLeadMinutes=null + it('writes reminderLeadMinutes=null when VCALENDAR has no VALARM', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventNoValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-no-valarm@test', + 'SUMMARY:Event without reminder', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: rawVeventNoValarm, etag: '"etag-no-valarm"', url: '/cal/no-valarm.ics' }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + expect(eventValuesArg.reminderLeadMinutes).toBeNull(); + }); + + // Absolute DATE-TIME trigger → reminderLeadMinutes=null (custom kind, D-07/NOTIF-05) + it('writes reminderLeadMinutes=null when VALARM has absolute DATE-TIME trigger (custom → null)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventAbsoluteValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-absolute-valarm@test', + 'SUMMARY:Event with absolute VALARM', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER;VALUE=DATE-TIME:20260615T120000Z', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { + data: rawVeventAbsoluteValarm, + etag: '"etag-abs"', + url: '/cal/abs.ics', + }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + // Absolute DATE-TIME trigger → classifyValarms returns 'custom' → null + expect(eventValuesArg.reminderLeadMinutes).toBeNull(); + }); + + // Two VALARMs → reminderLeadMinutes=null (custom, multiple alarms not resolvable to one lead) + it('writes reminderLeadMinutes=null when VCALENDAR has two VALARMs (multiple → custom → null)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventTwoValarms = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-two-valarms@test', + 'SUMMARY:Event with two alarms', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:First Reminder', + 'TRIGGER:-PT30M', + 'END:VALARM', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Second Reminder', + 'TRIGGER:-PT15M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { + data: rawVeventTwoValarms, + etag: '"etag-two"', + url: '/cal/two.ics', + }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + // Multiple VALARMs → classifyValarms returns 'custom' → null + expect(eventValuesArg.reminderLeadMinutes).toBeNull(); + }); + + // Ensure the onDuplicateKeyUpdate ALSO sets reminderLeadMinutes (upsert column must be current) + it('sets reminderLeadMinutes in onDuplicateKeyUpdate set (re-sync keeps column current)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventWithValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-upsert@test', + 'SUMMARY:Recurring meeting', + 'DTSTART:20260615T100000Z', + 'DTEND:20260615T110000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER:-PT15M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: rawVeventWithValarm, etag: '"etag-upsert"', url: '/cal/upsert.ics' }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + // The onDuplicateKeyUpdate `set` object must also contain reminderLeadMinutes + const upsertSetArg = mockOnDuplicateKeyUpdate.mock.calls[1]?.[0] as { + set?: Record; + }; + expect(upsertSetArg?.set).toHaveProperty('reminderLeadMinutes', 15); + }); +}); From 1cc0d7278a1127ec902a6bb5fc46b006a4c3a18c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:18:46 -0400 Subject: [PATCH 4/7] feat(11-03): sync.ts derives reminderLeadMinutes from VALARM classification (D-07/NOTIF-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import classifyValarms from vevent.ts - Derive reminderLeadMinutesValue: preset/offlist → leadMinutes; custom/none → null - Add reminderLeadMinutes to .values() and .onDuplicateKeyUpdate({ set: {} }) - Scheduler now has ground truth for native-client VALARMs (T-11-07 mitigated) --- apps/api/src/broker/sync.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/api/src/broker/sync.ts b/apps/api/src/broker/sync.ts index b8b6cd1..c045167 100644 --- a/apps/api/src/broker/sync.ts +++ b/apps/api/src/broker/sync.ts @@ -22,6 +22,7 @@ import { and, eq, notInArray } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendars, calendarEvents } from '../db/schema.js'; import type { EventChange } from '../lib/eventChangeDispatcher.js'; +import { classifyValarms } from './vevent.js'; /** * Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js, @@ -128,6 +129,17 @@ export async function syncCalendar( const locationValue: string | null = (vevent.getFirstPropertyValue('location') as string | null) ?? null; + // Phase 11: derive reminderLeadMinutes from the VALARM classification (D-07, NOTIF-05). + // classifyValarms wraps ICAL.parse in try/catch (T-11-07 mitigated). + // preset/offlist → single relative lead in minutes (scheduler ground truth). + // custom (absolute DATE-TIME or multiple VALARMs) → null (can't resolve a single lead). + // none → null (no VALARM present). + const alarmClass = classifyValarms(obj.data as string); + const reminderLeadMinutesValue: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' + ? alarmClass.leadMinutes + : null; + // NOTIF-03: look up the existing row so we can classify add vs update. // One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid. // D-13: this read is from MariaDB cache, not Fastmail. @@ -154,6 +166,8 @@ export async function syncCalendar( dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, + // Phase 11: ground-truth VALARM lead for the scheduler (D-07/NOTIF-05, T-11-07) + reminderLeadMinutes: reminderLeadMinutesValue, }) .onDuplicateKeyUpdate({ set: { @@ -165,6 +179,8 @@ export async function syncCalendar( dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, + // Phase 11: keep reminderLeadMinutes current on re-sync (native client may change VALARM) + reminderLeadMinutes: reminderLeadMinutesValue, updatedAt: new Date(), }, }); From 7df11d2780c071f06db5e1609dddef42fd78926b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:19:33 -0400 Subject: [PATCH 5/7] test(11-03): add failing tests for reminderLeadMinutes on CalendarOccurrence (D-06/D-10) - non-recurring event: occurrence carries reminderLeadMinutes=30 from master - all-day event with 0-minute trigger: occurrence carries 0 (NULL-vs-0, D-06) - no VALARM: occurrence carries reminderLeadMinutes=null - D-10 series-level: all recurring occurrences inherit master's reminderLeadMinutes=60 --- apps/api/tests/broker/expand.test.ts | 141 +++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/apps/api/tests/broker/expand.test.ts b/apps/api/tests/broker/expand.test.ts index 1495fe3..8a05578 100644 --- a/apps/api/tests/broker/expand.test.ts +++ b/apps/api/tests/broker/expand.test.ts @@ -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); + } + }); +}); From e1714316befccabe7eb813ec1b0110d45fa3b775 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:21:42 -0400 Subject: [PATCH 6/7] feat(11-03): surface reminderLeadMinutes on CalendarOccurrence + GET select (D-10) - Add reminderLeadMinutes: number | null to CalendarOccurrence interface (D-06) - Import classifyValarms in expand.ts; derive series-level value once per VEVENT - Add reminderLeadMinutes to both non-recurring and recurring occurrence construction - Add reminderLeadMinutes to GET /api/events select for edit-mode pre-population --- apps/api/src/broker/expand.ts | 26 ++++++++++++++++++++++++++ apps/api/src/routes/events.ts | 2 ++ 2 files changed, 28 insertions(+) diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index d535674..12b8af3 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -19,6 +19,7 @@ */ import ICAL from 'ical.js'; +import { classifyValarms } from './vevent.js'; /** * A concrete calendar event occurrence ready for UI consumption. @@ -33,6 +34,11 @@ import ICAL from 'ical.js'; * Color routing (D-06, CAL-02): * The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId) * The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId. + * + * Phase 11 Plan 03 (D-10): reminderLeadMinutes is a series-level property — all occurrences + * of a recurring master inherit the master's value. Derived from the VEVENT's VALARM via + * classifyValarms (same logic as sync.ts so the scheduler and the GET response agree). + * NULL-vs-0 is preserved (D-06): null = no reminder; 0 = same-day all-day; positive = lead. */ export interface CalendarOccurrence { /** `ev--` — Schedule-X-safe stable id (see makeOccurrenceId) */ @@ -66,6 +72,14 @@ export interface CalendarOccurrence { description: string | null; /** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */ hasRrule: boolean; + /** + * Per-event reminder lead in minutes (Phase 11, CAL-13, D-06). + * Series-level: all occurrences inherit the master's value (D-10). + * null — no reminder / custom/absolute VALARM not reducible to a single lead + * 0 — same-day all-day (fire at 9 AM on event date) + * positive — N minutes before event start (timed) or N/1440 days before (all-day) + */ + reminderLeadMinutes: number | null; } /** @@ -223,6 +237,16 @@ export function expandOccurrences( // Capture once — used in both the non-recurring and recurring branches to populate hasRrule. const isRecurring = event.isRecurring(); + // Phase 11 Plan 03 (D-10): series-level reminderLeadMinutes — derived once from the master + // event's VALARM via classifyValarms. All occurrences inherit this value (series-level, D-10). + // preset/offlist → specific leadMinutes; custom/none → null (D-07/NOTIF-05). + // classifyValarms wraps ICAL.parse in try/catch (T-11-07 safe); safe on parse failure → null. + const alarmClass = classifyValarms(rawVevent); + const reminderLeadMinutes: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' + ? alarmClass.leadMinutes + : null; + // --- 4. Non-recurring event: single occurrence check --- if (!isRecurring) { if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) { @@ -258,6 +282,7 @@ export function expandOccurrences( location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always false in the non-recurring branch + reminderLeadMinutes, // series-level (D-10) }); } return occurrences; @@ -305,6 +330,7 @@ export function expandOccurrences( location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always true in the recurring branch + reminderLeadMinutes, // series-level — all occurrences inherit the master's value (D-10) }); } diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 9a0432a..82d9b68 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -177,6 +177,8 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { userId: users.id, userColor: users.color, ownerName: users.displayName, + // Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10) + reminderLeadMinutes: calendarEvents.reminderLeadMinutes, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) From 72773a35eb664f7a0dde05c1af353828bd34f790 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:24:01 -0400 Subject: [PATCH 7/7] =?UTF-8?q?docs(11-03):=20complete=20Plan=2003=20?= =?UTF-8?q?=E2=80=94=20reminderLeadMinutes=20end-to-end=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema field + VALARM wiring in outbox worker (CAL-13/CAL-14) - sync.ts VALARM classification → reminderLeadMinutes upsert (D-07/NOTIF-05) - CalendarOccurrence.reminderLeadMinutes + GET select (D-10) - 13 new TDD tests; 132/132 broker tests pass; tsc clean --- .../11-per-event-reminders/11-03-SUMMARY.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .planning/phases/11-per-event-reminders/11-03-SUMMARY.md diff --git a/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md b/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md new file mode 100644 index 0000000..1f839a0 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md @@ -0,0 +1,161 @@ +--- +phase: 11-per-event-reminders +plan: "03" +subsystem: api/broker +tags: [valarm, reminderLeadMinutes, tdd, schema, outbox, sync, expand, cal-13, cal-14] +dependency_graph: + requires: + - "Plan 11-01 (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, NewEventParams extensions)" + provides: + - "reminderLeadMinutes in eventFieldsSchema (ingress validation)" + - "reminderLeadMinutes in outboxPayloadSchema (drain re-validation)" + - "hasExplicitReminder preserve-on-edit path in outboxWorker UPDATE branch" + - "VALARM wiring in buildVeventString calls (both UPDATE and CREATE branches)" + - "reminderLeadMinutesValue derivation + upsert in sync.ts" + - "reminderLeadMinutes on CalendarOccurrence (expand.ts)" + - "reminderLeadMinutes in GET /api/events select" + affects: + - "apps/api/src/routes/events.ts" + - "apps/api/src/broker/outboxWorker.ts" + - "apps/api/src/broker/sync.ts" + - "apps/api/src/broker/expand.ts" + - "Plan 11-02 (reminderScheduler — scheduler reads reminderLeadMinutes from DB)" +tech_stack: + added: [] + patterns: + - "hasExplicitReminder sentinel mirrors hasExplicitRecurrence WR-01 pattern" + - "Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes') for absent-vs-null distinction (D-08)" + - "classifyValarms(rawVevent) for series-level reminderLeadMinutes derivation in expand.ts" + - "computeAlertInstantUtc(start, leadDays, tz) for all-day absolute DATE-TIME trigger" + - "extractValarms(rawVevent) preserve-on-edit re-attachment via addSubcomponent" +key_files: + created: [] + modified: + - apps/api/src/routes/events.ts + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/broker/sync.ts + - apps/api/src/broker/expand.ts + - apps/api/tests/broker/outboxWorker.test.ts + - apps/api/tests/broker/sync.test.ts + - apps/api/tests/broker/expand.test.ts +decisions: + - "D-REMIND-ABSENT: absent field (not in payload) = no-change path (D-08); Object.prototype.hasOwnProperty.call distinguishes absent from null — mirrors WR-01 for VALARM preservation" + - "D-REMIND-EXPAND: reminderLeadMinutes derived inside expandOccurrences via classifyValarms(rawVevent) — self-contained; consistent with sync.ts derivation (both consume the same VEVENT source)" + - "D-REMIND-ALLDAY-LEADDAYS: all-day leadDays = reminderLeadMinutes / 1440 (consistent with D-05 mapping); computeAlertInstantUtc called at drain time (not enqueue) for correct DST" +metrics: + duration_minutes: 8 + completed_date: "2026-06-14" + tasks_completed: 3 + files_modified: 7 +--- + +# Phase 11 Plan 03: reminderLeadMinutes End-to-End Plumbing Summary + +`reminderLeadMinutes` round-trips end-to-end: `eventFieldsSchema` ingress validation → outbox payload drain → `buildVeventString` VALARM emission → Fastmail PUT; sync.ts parses native VALARMs into the DB column (scheduler ground truth); `CalendarOccurrence` surfaces the value for edit-mode picker pre-population. + +## Tasks Completed + +| Task | Description | RED Commit | GREEN Commit | +|------|-------------|------------|--------------| +| 1 | Schema field + outbox worker preserve-on-edit + buildVeventString wiring | 79f6871 | 4f42b75 | +| 2 | sync.ts VALARM → reminderLeadMinutes upsert | cdca930 | 1cc0d72 | +| 3 | Surface reminderLeadMinutes on CalendarOccurrence + GET select | 7df11d2 | e171431 | + +## Schema Field (eventFieldsSchema + outboxPayloadSchema) + +Both schemas now have: +```typescript +reminderLeadMinutes: z.number().int().min(0).nullable().optional() +``` + +Four-state semantics (D-08): +- `absent` — field not present in payload; UPDATE branch preserves existing VALARM verbatim (D-08) +- `null` — explicit "None" → VALARM cleared on write-back +- `0` — same-day all-day (9 AM on event date); timed 0 = None (D-06) +- `positive` — N minutes before event start (timed) or N/1440 days before (all-day) + +## hasExplicitReminder Preserve-on-Edit Path (CAL-14) + +Pattern mirrors the WR-01 `hasExplicitRecurrence` + `_preservedRrule` preserve path: + +UPDATE branch computes: +- `const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')` +- `!hasExplicitReminder` + rawVevent has VALARMs → `valarmsToPreserve = extractValarms(rawVevent)` (preserve verbatim via addSubcomponent) +- `hasExplicitReminder` + allDay + value → `allDayAlertInstantUtcUpdate = computeAlertInstantUtc(start, lead/1440, tz)` +- `hasExplicitReminder` + null → clear (no valarms, reminderLeadMinutes=null passed to buildVeventString) + +buildVeventString call extended with: `reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined`, `valarms: valarmsToPreserve`, `allDayAlertInstantUtc: allDayAlertInstantUtcUpdate`. + +CREATE branch: no preserve path (new event always carries explicit picker value). Computes `allDayAlertInstantUtcCreate` from `reminderLeadMinutes / 1440` when allDay. + +## sync.ts Derivation Rule + +```typescript +import { classifyValarms } from './vevent.js'; +const alarmClass = classifyValarms(obj.data as string); +const reminderLeadMinutesValue: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' + ? alarmClass.leadMinutes + : null; +``` + +Written to both `.values({...})` and `.onDuplicateKeyUpdate({ set: {...} })`. Classification rules: +- `preset` or `offlist` → `leadMinutes` (scheduler ground truth) +- `custom` (absolute DATE-TIME or multiple VALARMs) → `null` (D-07/NOTIF-05) +- `none` → `null` (no VALARM) + +No schema DDL change — `reminder_lead_minutes` column was added by Phase 10 migration. + +## CalendarOccurrence Propagation (D-10) + +```typescript +// CalendarOccurrence interface: +reminderLeadMinutes: number | null; // after hasRrule +``` + +Derived once per VEVENT in `expandOccurrences` via `classifyValarms(rawVevent)` (series-level, D-10). All occurrences inherit the master's value. Added to both non-recurring and recurring occurrence construction branches. GET `/api/events` select also includes `calendarEvents.reminderLeadMinutes` for edit-mode pre-population. + +## Verification Results + +- `pnpm --filter @familysync/api exec vitest run tests/broker/ tests/broker/vevent.test.ts`: **132/132 PASS** +- `pnpm --filter @familysync/api exec tsc --noEmit`: **CLEAN (0 errors)** +- No `drizzle-kit push` introduced; no schema.ts DDL change +- 13 new tests added: 4 (Task 1 outboxWorker), 5 (Task 2 sync), 4 (Task 3 expand) + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None. All wiring is complete end-to-end. No placeholder values or TODO markers. + +## Threat Flags + +No new threat surface. All new fields are bounded integers validated by Zod at both ingress (eventFieldsSchema) and drain (outboxPayloadSchema) — T-11-06 mitigated. `classifyValarms` is try/catch safe — T-11-07 mitigated. The VALARM preserve path (`extractValarms`) rides the existing CR-02 scoped query on the writing member's calendar — T-11-08 unchanged. + +## Self-Check: PASSED + +Files exist: +- FOUND: apps/api/src/routes/events.ts +- FOUND: apps/api/src/broker/outboxWorker.ts +- FOUND: apps/api/src/broker/sync.ts +- FOUND: apps/api/src/broker/expand.ts +- FOUND: apps/api/tests/broker/outboxWorker.test.ts +- FOUND: apps/api/tests/broker/sync.test.ts +- FOUND: apps/api/tests/broker/expand.test.ts + +Commits exist: +- 79f6871: test(11-03) RED Task 1 +- 4f42b75: feat(11-03) GREEN Task 1 +- cdca930: test(11-03) RED Task 2 +- 1cc0d72: feat(11-03) GREEN Task 2 +- 7df11d2: test(11-03) RED Task 3 +- e171431: feat(11-03) GREEN Task 3 + +Key exports verified: +- reminderLeadMinutes in eventFieldsSchema: CONFIRMED (grep: `reminderLeadMinutes: z.number()`) +- hasExplicitReminder in outboxWorker.ts: CONFIRMED +- reminderLeadMinutes on CalendarOccurrence: CONFIRMED +- classifyValarms import in sync.ts: CONFIRMED +- classifyValarms import in expand.ts: CONFIRMED