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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user