feat(11-04): add reminder picker to EventForm (allDay swap, edit pre-population, payload mapping)

- 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 <select id=event-reminder> after Recurrence picker: allDay-conditional option swap (D-02), timed presets when !allDay, day-granularity presets when allDay; synthetic off-list option; Custom (kept) read-only disabled option
- Helper text under select in edit mode for off-list/custom states (D-07)
- executeSubmit payload: __none__ → null, numeric → integer, __custom__ → field omitted (D-08)
- 10 new Phase 11 reminder picker tests (create/allDay-swap/edit-pre-pop/payload-mapping); fix pre-existing None-ambiguity test
- pwa vitest 201/201 pass; tsc --noEmit clean
This commit is contained in:
Lucas Berger
2026-06-13 22:36:31 -04:00
parent 2c30afe8ff
commit fe549ef2b0
2 changed files with 398 additions and 1 deletions
+152
View File
@@ -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<RecurrencePreset>('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<string>('__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() {
)}
</div>
{/* 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). */}
<div style={fieldStyle}>
<label htmlFor="event-reminder" style={labelStyle}>
Reminder
</label>
<select
id="event-reminder"
value={reminderValue}
onChange={(e) => setReminderValue(e.target.value)}
style={{
...inputStyle,
padding: '0 var(--space-3)',
cursor: 'pointer',
}}
>
{allDay ? (
// D-02: all-day presets (day-granularity)
<>
<option value="__none__">None</option>
<option value="0">Same day (9 AM)</option>
<option value="1440">1 day before (9 AM)</option>
<option value="2880">2 days before (9 AM)</option>
<option value="10080">1 week before (9 AM)</option>
{/* D-07: synthetic option for off-list all-day value */}
{reminderValue !== '__none__' &&
reminderValue !== '__custom__' &&
!ALLDAY_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
Number.isFinite(parseInt(reminderValue, 10)) && (
<option value={reminderValue}>
{humanizeReminderLead(parseInt(reminderValue, 10))}
</option>
)}
{/* D-07: read-only Custom (kept) for absolute/multi alarm */}
{reminderValue === '__custom__' && (
<option value="__custom__" disabled>
Custom (kept)
</option>
)}
</>
) : (
// D-01: timed presets
<>
<option value="__none__">None</option>
<option value="5">5 minutes before</option>
<option value="10">10 minutes before</option>
<option value="15">15 minutes before</option>
<option value="30">30 minutes before</option>
<option value="60">1 hour before</option>
<option value="120">2 hours before</option>
<option value="1440">1 day before</option>
<option value="2880">2 days before</option>
{/* D-07: synthetic option for off-list timed value */}
{reminderValue !== '__none__' &&
reminderValue !== '__custom__' &&
!TIMED_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
Number.isFinite(parseInt(reminderValue, 10)) && (
<option value={reminderValue}>
{humanizeReminderLead(parseInt(reminderValue, 10))}
</option>
)}
{/* D-07: read-only Custom (kept) for absolute/multi alarm */}
{reminderValue === '__custom__' && (
<option value="__custom__" disabled>
Custom (kept)
</option>
)}
</>
)}
</select>
{/* 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)))) && (
<div
style={{
fontSize: 'var(--text-label-size)',
color: 'var(--color-text-secondary)',
marginTop: 'var(--space-1)',
}}
>
{/* Plain text — XSS guard (T-11-10 / T-03-15) */}
Custom reminder kept select a preset to replace it.
</div>
)}
</div>
{/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */}
{eventFormMode !== 'edit' && recurrence !== 'none' && (
<div style={fieldStyle}>