fix(11-05): CR-01 surface reminderIsCustom to preserve custom VALARMs on edit

- 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.
This commit is contained in:
Lucas Berger
2026-06-14 08:11:46 -04:00
parent 5d6cb47191
commit f6b47ebf1e
5 changed files with 54 additions and 10 deletions
@@ -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) ───────────────────────────────
+9 -4
View File
@@ -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<string, unknown>;
// 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);
});
});
});
+21 -6
View File
@@ -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