From fe549ef2b068ce3eba62b2f84167a7e144ba1825 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 22:36:31 -0400 Subject: [PATCH] feat(11-04): add reminder picker to EventForm (allDay swap, edit pre-population, payload mapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TIMED_REMINDER_PRESETS + ALLDAY_REMINDER_PRESETS sets for preset classification - humanizeReminderLead() + deriveReminderValue() helpers for off-list synthetic options - reminderValue state (__none__ / numeric string / __custom__ sentinels) - allDay toggle resets reminderValue to __none__ (D-03 — no carry-over) - Reset effect derives reminderValue from occurrence.reminderLeadMinutes on mount/cache update - Reminder elements both have a "None" option). + const noneOptions = screen.getAllByText('None'); + expect(noneOptions.length).toBeGreaterThanOrEqual(1); expect(screen.getByText('Daily')).toBeDefined(); expect(screen.getByText('Weekly')).toBeDefined(); expect(screen.getByText('Monthly')).toBeDefined(); @@ -986,3 +989,245 @@ describe('EventForm — Plan 06-06 end-tracking + recurrence-bound', () => { }); }); }); + +// ── Phase 11 Plan 04: Reminder picker tests ─────────────────────────────────── + +/** + * Fixture: edit occurrence with timed reminder (30 minutes before). + */ +const TIMED_REMINDER_OCCURRENCE: CalendarOccurrence = { + id: 'reminder-uid-001::2026-06-15T10:00:00', + uid: 'reminder-uid-001', + calendarId: 1, + calendarName: 'My Calendar', + ownerUserId: 1, + ownerName: 'Alice', + color: '#4A90D9', + isShared: false, + title: 'Meeting with reminder', + start: '2026-06-15T10:00:00-04:00', + end: '2026-06-15T11:00:00-04:00', + allDay: false, + location: null, + description: null, + hasRrule: false, + reminderLeadMinutes: 30, +}; + +/** + * Fixture: edit occurrence with all-day reminder (1440 min = 1 day before). + */ +const ALLDAY_REMINDER_OCCURRENCE: CalendarOccurrence = { + id: 'reminder-uid-002::2026-06-15', + uid: 'reminder-uid-002', + calendarId: 1, + calendarName: 'My Calendar', + ownerUserId: 1, + ownerName: 'Alice', + color: '#4A90D9', + isShared: false, + title: 'All-day event with reminder', + start: '2026-06-15', + end: '2026-06-16', + allDay: true, + location: null, + description: null, + hasRrule: false, + reminderLeadMinutes: 1440, +}; + +/** + * Fixture: edit occurrence with off-list timed reminder (45 min). + */ +const OFFLIST_REMINDER_OCCURRENCE: CalendarOccurrence = { + id: 'reminder-uid-003::2026-06-15T10:00:00', + uid: 'reminder-uid-003', + calendarId: 1, + calendarName: 'My Calendar', + ownerUserId: 1, + ownerName: 'Alice', + color: '#4A90D9', + isShared: false, + title: 'Meeting with off-list reminder', + start: '2026-06-15T10:00:00-04:00', + end: '2026-06-15T11:00:00-04:00', + allDay: false, + location: null, + description: null, + hasRrule: false, + reminderLeadMinutes: 45, +}; + +describe('EventForm — Phase 11 reminder picker (Plan 04)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEventFormOpen = true; + mockEventFormMode = 'create'; + mockEventFormUid = null; + }); + + // ── D-01: default None in create mode ───────────────────────────────────── + + it('D-01: reminder picker present with id=event-reminder and defaults to None in create mode', () => { + renderForm({ mode: 'create' }); + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelect).not.toBeNull(); + expect(reminderSelect.value).toBe('__none__'); + }); + + it('D-01: timed preset labels visible in create mode (allDay=false)', () => { + renderForm({ mode: 'create' }); + // Timed preset labels should be present + expect(screen.getByText('30 minutes before')).toBeDefined(); + expect(screen.getByText('1 hour before')).toBeDefined(); + expect(screen.getByText('1 day before')).toBeDefined(); + }); + + // ── D-02/D-03: allDay toggle swaps preset set and resets to None ────────── + + it('D-02/D-03: toggling All-day swaps picker to day-granularity labels', () => { + renderForm({ mode: 'create' }); + + // Before toggle: timed preset + expect(screen.getByText('30 minutes before')).toBeDefined(); + + // Toggle all-day ON + const allDaySwitch = screen.getByRole('switch'); + fireEvent.click(allDaySwitch); + + // After toggle: all-day presets present + expect(screen.getByText('Same day (9 AM)')).toBeDefined(); + expect(screen.getByText('1 day before (9 AM)')).toBeDefined(); + expect(screen.getByText('1 week before (9 AM)')).toBeDefined(); + }); + + it('D-03: toggling All-day resets picker selection to None (no carry-over)', () => { + renderForm({ mode: 'create' }); + + // Select a timed preset + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + fireEvent.change(reminderSelect, { target: { value: '30' } }); + expect(reminderSelect.value).toBe('30'); + + // Toggle all-day ON — picker must reset to None + const allDaySwitch = screen.getByRole('switch'); + fireEvent.click(allDaySwitch); + + const reminderSelectAfter = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelectAfter.value).toBe('__none__'); + }); + + // ── Edit-mode pre-population ─────────────────────────────────────────────── + + it('edit mode: reminderLeadMinutes=30 (timed) pre-selects "30 minutes before"', () => { + renderForm({ + mode: 'edit', + uid: 'reminder-uid-001', + eventOccurrence: TIMED_REMINDER_OCCURRENCE, + }); + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelect).not.toBeNull(); + expect(reminderSelect.value).toBe('30'); + // Option label should be "30 minutes before" + const selectedOption = reminderSelect.options[reminderSelect.selectedIndex]; + expect(selectedOption.text).toBe('30 minutes before'); + }); + + it('edit mode: reminderLeadMinutes=1440 all-day pre-selects "1 day before (9 AM)"', () => { + renderForm({ + mode: 'edit', + uid: 'reminder-uid-002', + eventOccurrence: ALLDAY_REMINDER_OCCURRENCE, + }); + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelect).not.toBeNull(); + expect(reminderSelect.value).toBe('1440'); + const selectedOption = reminderSelect.options[reminderSelect.selectedIndex]; + expect(selectedOption.text).toBe('1 day before (9 AM)'); + }); + + it('edit mode: reminderLeadMinutes=45 (off-list) shows synthetic "45 min before" option', () => { + renderForm({ + mode: 'edit', + uid: 'reminder-uid-003', + eventOccurrence: OFFLIST_REMINDER_OCCURRENCE, + }); + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelect).not.toBeNull(); + expect(reminderSelect.value).toBe('45'); + const selectedOption = reminderSelect.options[reminderSelect.selectedIndex]; + expect(selectedOption.text).toBe('45 min before'); + }); + + // ── Payload mapping ──────────────────────────────────────────────────────── + + it('payload mapping: None selection → reminderLeadMinutes: null', async () => { + renderForm({ mode: 'create' }); + fireEvent.change(screen.getByPlaceholderText('Event title'), { + target: { value: 'Test Event' }, + }); + // Picker already at None (default) + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelect.value).toBe('__none__'); + + fireEvent.click(screen.getByText('Create Event')); + + await waitFor(() => { + expect(mockCreateEvent).toHaveBeenCalledWith( + expect.objectContaining({ reminderLeadMinutes: null }), + ); + }); + }); + + it('payload mapping: preset selection (30) → reminderLeadMinutes: 30', async () => { + renderForm({ mode: 'create' }); + fireEvent.change(screen.getByPlaceholderText('Event title'), { + target: { value: 'Test Event' }, + }); + + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + fireEvent.change(reminderSelect, { target: { value: '30' } }); + + fireEvent.click(screen.getByText('Create Event')); + + await waitFor(() => { + expect(mockCreateEvent).toHaveBeenCalledWith( + expect.objectContaining({ reminderLeadMinutes: 30 }), + ); + }); + }); + + it('payload mapping: Custom (kept) selection → reminderLeadMinutes field omitted (D-08)', async () => { + // We cannot reach __custom__ via occurrence (occurrence only has number|null), + // but we can set it directly via the state simulation — programmatically + // select __custom__ via a forced renderForm with custom occurrence scenario. + // The plan notes: with only number|null, __custom__ is unreachable from occurrence; + // we test the omit-behavior by directly firing a change to __custom__ value. + renderForm({ + mode: 'edit', + uid: 'reminder-uid-001', + eventOccurrence: TIMED_REMINDER_OCCURRENCE, + }); + fireEvent.change(screen.getByPlaceholderText('Event title'), { + target: { value: 'Meeting with reminder' }, + }); + + // Force the select to __custom__ sentinel (simulates a custom alarm preserved state) + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + // We need to inject the __custom__ option and select it + const customOption = document.createElement('option'); + customOption.value = '__custom__'; + customOption.text = 'Custom (kept)'; + reminderSelect.appendChild(customOption); + fireEvent.change(reminderSelect, { target: { value: '__custom__' } }); + + fireEvent.click(screen.getByText('Save Changes')); + + await waitFor(() => { + expect(mockUpdateEvent).toHaveBeenCalled(); + const callPayload = mockUpdateEvent.mock.calls[0][1] as Record; + // D-08: field must be absent (not null, not 0 — truly missing) + 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 aaa62b2..37a50eb 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -52,11 +52,45 @@ import { useFocusTrap } from '../hooks/useFocusTrap.js'; const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'; +// Phase 11: reminder preset values (minutes) for each event type. +// Used for edit-mode classification and rendering. +const TIMED_REMINDER_PRESETS = new Set([5, 10, 15, 30, 60, 120, 1440, 2880]); +const ALLDAY_REMINDER_PRESETS = new Set([0, 1440, 2880, 10080]); + // IN-03: todayIso is now imported from calendarStore (single source of truth). // getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites. // ── Helpers ──────────────────────────────────────────────────────────────────── +/** + * Humanize an off-list reminder lead (minutes) for the synthetic picker option label. + * Per UI-SPEC Copywriting Contract thresholds: + * < 60 min → "N min before" + * ≥ 60 min → "N hours before" + */ +function humanizeReminderLead(minutes: number): string { + if (minutes < 60) return `${minutes} min before`; + const hours = minutes / 60; + return `${hours} hour${hours !== 1 ? 's' : ''} before`; +} + +/** + * 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. + */ +function deriveReminderValue( + leadMinutes: number | null, + isAllDay: boolean, +): string { + if (leadMinutes === null) return '__none__'; + const presets = isAllDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS; + if (presets.has(leadMinutes)) return String(leadMinutes); + // Off-list positive value — use the numeric string; a synthetic option will be rendered + return String(leadMinutes); +} + /** Determine if we're on phone breakpoint. */ function isPhoneBreakpoint(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; @@ -219,6 +253,11 @@ export function EventForm() { const [endDate, setEndDate] = useState(initEndDate); const [endTime, setEndTime] = useState(initEnd.time); const [recurrence, setRecurrence] = useState('none'); + // Phase 11: reminder picker state. Sentinel values: + // '__none__' = None (no reminder) + // '__custom__' = read-only "Custom (kept)" (absolute/multi VALARM, D-07/D-08) + // numeric str = preset or synthetic off-list lead in minutes + const [reminderValue, setReminderValue] = useState('__none__'); // D-06: recurrence bound state — "Ends" control const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never'); const [recurrenceUntil, setRecurrenceUntil] = useState(''); @@ -280,6 +319,9 @@ export function EventForm() { setRecurrenceCount(1); setLocation(occurrence?.location ?? ''); setDescription(occurrence?.description ?? ''); + // Phase 11: derive reminder picker value from occurrence (edit-mode pre-population, D-01/D-07) + const occAllDay = occurrence?.allDay ?? false; + setReminderValue(deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay)); } }, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps @@ -316,6 +358,8 @@ export function EventForm() { const handleAllDayToggle = () => { const next = !allDay; setAllDay(next); + // D-03 (Phase 11): reset reminder to None on allDay toggle — no carry-over between preset sets + setReminderValue('__none__'); if (!next) { // Turning off all-day: restore default times setStartTime('09:00'); @@ -418,6 +462,23 @@ export function EventForm() { // outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On // CREATE the user explicitly chose a recurrence, so it is always sent. const isEdit = eventFormMode === 'edit' && !!eventFormUid; + + // Phase 11: map reminder picker value to payload field (D-08). + // '__none__' → null (explicit clear) + // '__custom__' → omit (unchanged custom alarm — server preserves original VALARM) + // numeric str → integer (preset or synthetic off-list lead) + let reminderPayload: { reminderLeadMinutes?: number | null } = {}; + if (reminderValue === '__none__') { + reminderPayload = { reminderLeadMinutes: null }; + } else if (reminderValue !== '__custom__') { + const parsed = parseInt(reminderValue, 10); + if (Number.isFinite(parsed) && parsed >= 0) { + reminderPayload = { reminderLeadMinutes: parsed }; + } + // If parse fails (should not happen), omit field (safe default: no-change) + } + // __custom__ → reminderPayload stays {} (field absent = no-change, D-08) + const payload: CreateEventPayload = { title: title.trim(), allDay, @@ -433,6 +494,7 @@ export function EventForm() { : {}), ...(location.trim() ? { location: location.trim() } : {}), ...(description.trim() ? { description: description.trim() } : {}), + ...reminderPayload, ...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}), }; @@ -884,6 +946,96 @@ export function EventForm() { )} + {/* Phase 11: Reminder picker (allDay-aware swap, D-01/D-02/D-03/D-07/D-08). + NOT disabled in edit mode — reminders are editable (unlike Repeat/WR-01). */} +
+ + + {/* Helper text: shown in edit mode when value is __custom__ or synthetic off-list (D-07) */} + {eventFormMode === 'edit' && + (reminderValue === '__custom__' || + (reminderValue !== '__none__' && + !TIMED_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) && + !ALLDAY_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) && + Number.isFinite(parseInt(reminderValue, 10)))) && ( +
+ {/* Plain text — XSS guard (T-11-10 / T-03-15) */} + Custom reminder kept — select a preset to replace it. +
+ )} +
+ {/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */} {eventFormMode !== 'edit' && recurrence !== 'none' && (