From f6b47ebf1e898008747bbca0cc532a24040fc0ef Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 08:11:46 -0400 Subject: [PATCH] fix(11-05): CR-01 surface reminderIsCustom to preserve custom VALARMs on edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expand.ts: add reminderIsCustom:boolean to CalendarOccurrence interface; derived from classifyValarms kind==='custom'; propagated to both non-recurring and recurring occurrence branches - client.ts: mirror reminderIsCustom on CalendarOccurrence (atomic mirror) - EventForm.tsx: extend deriveReminderValue to accept isCustom flag; returns '__custom__' when true, making the existing D-08 preserve branch live — editing a custom-alarm event now omits reminderLeadMinutes from the payload so outboxWorker extractValarms keeps the original VALARM - Fix existing test fixtures (EventForm.test.tsx, EventDetailPopover.test.tsx) to include reminderIsCustom:false on all CalendarOccurrence literals Fixes CAL-14 Pitfall 1: Apple Calendar absolute DATE-TIME / multi-VALARM alarms no longer silently stripped on any edit round-trip from the PWA. --- apps/api/src/broker/expand.ts | 14 ++++++++++ apps/pwa/src/api/client.ts | 8 ++++++ .../components/EventDetailPopover.test.tsx | 2 ++ apps/pwa/src/components/EventForm.test.tsx | 13 ++++++--- apps/pwa/src/components/EventForm.tsx | 27 ++++++++++++++----- 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index 7bab241..e09a8ab 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -80,6 +80,13 @@ export interface CalendarOccurrence { * positive — N minutes before event start (timed) or N/1440 days before (all-day) */ reminderLeadMinutes: number | null; + /** + * True when the master event's alarm is a custom/absolute/multi-VALARM not reducible + * to a single before-event lead (CAL-14, CR-01, Phase 11 Plan 05). + * When true, reminderLeadMinutes is always null and the edit form must initialize the + * picker to '__custom__' to emit an absent payload field and preserve the original VALARM. + */ + reminderIsCustom: boolean; } /** @@ -241,9 +248,14 @@ export function expandOccurrences( // 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. + // + // Phase 11 Plan 05 (CR-01): reminderIsCustom — surface the 'custom' classification so the + // edit form can initialize the picker to '__custom__' and emit an absent payload field, + // preserving the original VALARM via the outboxWorker D-08 preserve path. const alarmClass = classifyValarms(rawVevent); const reminderLeadMinutes: number | null = alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' ? alarmClass.leadMinutes : null; + const reminderIsCustom: boolean = alarmClass.kind === 'custom'; // --- 4. Non-recurring event: single occurrence check --- if (!isRecurring) { @@ -281,6 +293,7 @@ export function expandOccurrences( description: event.description ?? null, hasRrule: isRecurring, // always false in the non-recurring branch reminderLeadMinutes, // series-level (D-10) + reminderIsCustom, // CR-01 (Plan 05): true when alarm is absolute/multi-VALARM }); } return occurrences; @@ -329,6 +342,7 @@ export function expandOccurrences( description: event.description ?? null, hasRrule: isRecurring, // always true in the recurring branch reminderLeadMinutes, // series-level — all occurrences inherit the master's value (D-10) + reminderIsCustom, // CR-01 (Plan 05): true when alarm is absolute/multi-VALARM }); } diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 3776144..707a5e7 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -139,6 +139,14 @@ export interface CalendarOccurrence { * (atomic mirror, Plan 11-03). */ reminderLeadMinutes: number | null; + /** + * True when the event's alarm is custom/absolute/multi-VALARM (not reducible to a + * single before-event lead). When true, reminderLeadMinutes is always null and the + * form must initialize to '__custom__' to preserve the VALARM on edit (CR-01, Plan 11-05). + * Mirrors CalendarOccurrence.reminderIsCustom in apps/api/src/broker/expand.ts + * (atomic mirror, Plan 11-05). + */ + reminderIsCustom: boolean; } export interface OccurrencesResponse { diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx index 65dc7ac..f00dcb5 100644 --- a/apps/pwa/src/components/EventDetailPopover.test.tsx +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -60,6 +60,7 @@ const TIMED_OCCURRENCE: CalendarOccurrence = { description: 'Daily team sync meeting', hasRrule: false, reminderLeadMinutes: null, + reminderIsCustom: false, }; const OCCURRENCE_WITH_HTML: CalendarOccurrence = { @@ -88,6 +89,7 @@ const ALLDAY_OCCURRENCE: CalendarOccurrence = { description: null, hasRrule: false, reminderLeadMinutes: null, + reminderIsCustom: false, }; // ── Import component (after mocks are declared) ─────────────────────────────── diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index 1a726a8..1781d12 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -125,6 +125,7 @@ const EDIT_OCCURRENCE: CalendarOccurrence = { description: 'Weekly sync', hasRrule: false, reminderLeadMinutes: null, + reminderIsCustom: false, }; // ── Import component (after mocks) ──────────────────────────────────────────── @@ -511,6 +512,7 @@ const RECURRING_OCCURRENCE: CalendarOccurrence = { description: null, hasRrule: true, reminderLeadMinutes: null, + reminderIsCustom: false, // @ts-expect-error — recurrence is not on CalendarOccurrence type yet; the reset // effect reads it if present and defaults to 'none' when absent (WR-03, v1 comment) recurrence: 'weekly', @@ -538,6 +540,7 @@ const LATE_OCCURRENCE: CalendarOccurrence = { description: null, hasRrule: false, reminderLeadMinutes: null, + reminderIsCustom: false, }; describe('EventForm — Plan 03-12 gap closures', () => { @@ -786,6 +789,7 @@ const ALL_DAY_OCCURRENCE: CalendarOccurrence = { description: null, hasRrule: false, reminderLeadMinutes: null, + reminderIsCustom: false, }; describe('EventForm — Plan 06-06 end-tracking + recurrence-bound', () => { @@ -1007,6 +1011,7 @@ const TIMED_REMINDER_OCCURRENCE: CalendarOccurrence = { title: 'Meeting with reminder', start: '2026-06-15T10:00:00-04:00', end: '2026-06-15T11:00:00-04:00', + reminderIsCustom: false, allDay: false, location: null, description: null, @@ -1034,6 +1039,7 @@ const ALLDAY_REMINDER_OCCURRENCE: CalendarOccurrence = { description: null, hasRrule: false, reminderLeadMinutes: 1440, + reminderIsCustom: false, }; /** @@ -1056,6 +1062,7 @@ const OFFLIST_REMINDER_OCCURRENCE: CalendarOccurrence = { description: null, hasRrule: false, reminderLeadMinutes: 45, + reminderIsCustom: false, }; describe('EventForm — Phase 11 reminder picker (Plan 04)', () => { @@ -1315,10 +1322,8 @@ describe('EventForm — Phase 11 Plan 05 CR-01: custom alarm round-trip', () => const callPayload = mockUpdateEvent.mock.calls[0][1] as Record; // MUST be absent: the presence of reminderLeadMinutes:null would cause the outbox // worker to clear the VALARM — the CR-01 data-loss bug. - expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe( - false, - 'reminderLeadMinutes must be absent from payload when alarm is custom (D-08 preserve path)', - ); + // D-08: field must be absent — presence of reminderLeadMinutes:null clears the VALARM (CR-01) + expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe(false); }); }); }); diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index ccb1cb7..15c700b 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -75,12 +75,22 @@ function humanizeReminderLead(minutes: number): string { } /** - * Derive the initial reminder picker value from an occurrence's reminderLeadMinutes. - * Returns '__none__' for null, the matching preset string for a preset, or the numeric - * string for an off-list value (synthetic option will be rendered for this case). - * There is no '__custom__' path here — the occurrence only carries number|null. + * Derive the initial reminder picker value from an occurrence's reminder fields. + * + * CR-01 (Plan 11-05): When reminderIsCustom is true, the event carries an absolute + * DATE-TIME trigger or multiple VALARMs that cannot be reduced to a single lead. + * Returning '__custom__' makes the existing preserve branch live: the form emits an + * absent reminderLeadMinutes field, and the outboxWorker's D-08 preserve path keeps + * the original VALARM intact (no silent data loss on edit). + * + * Precedence: custom → '__custom__'; null → '__none__'; else preset/off-list string. */ -function deriveReminderValue(leadMinutes: number | null, isAllDay: boolean): string { +function deriveReminderValue( + leadMinutes: number | null, + isAllDay: boolean, + isCustom = false, +): string { + if (isCustom) return '__custom__'; if (leadMinutes === null) return '__none__'; const presets = isAllDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS; if (presets.has(leadMinutes)) return String(leadMinutes); @@ -317,8 +327,13 @@ export function EventForm() { setLocation(occurrence?.location ?? ''); setDescription(occurrence?.description ?? ''); // Phase 11: derive reminder picker value from occurrence (edit-mode pre-population, D-01/D-07) + // CR-01 (Plan 11-05): pass reminderIsCustom so custom alarms initialize to '__custom__' + // instead of '__none__', making the D-08 preserve path reachable on edit. const occAllDay = occurrence?.allDay ?? false; - setReminderValue(deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay)); + const occIsCustom = occurrence?.reminderIsCustom ?? false; + setReminderValue( + deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay, occIsCustom), + ); } }, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps