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:
@@ -239,7 +239,10 @@ describe('EventForm', () => {
|
|||||||
|
|
||||||
it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => {
|
it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => {
|
||||||
renderForm();
|
renderForm();
|
||||||
expect(screen.getByText('None')).toBeDefined();
|
// Use getAllByText since "None" now appears in both the recurrence picker and the
|
||||||
|
// reminder picker (Phase 11 — two <select> elements both have a "None" option).
|
||||||
|
const noneOptions = screen.getAllByText('None');
|
||||||
|
expect(noneOptions.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText('Daily')).toBeDefined();
|
expect(screen.getByText('Daily')).toBeDefined();
|
||||||
expect(screen.getByText('Weekly')).toBeDefined();
|
expect(screen.getByText('Weekly')).toBeDefined();
|
||||||
expect(screen.getByText('Monthly')).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<string, unknown>;
|
||||||
|
// D-08: field must be absent (not null, not 0 — truly missing)
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -52,11 +52,45 @@ import { useFocusTrap } from '../hooks/useFocusTrap.js';
|
|||||||
|
|
||||||
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl';
|
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).
|
// IN-03: todayIso is now imported from calendarStore (single source of truth).
|
||||||
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.
|
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── 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. */
|
/** Determine if we're on phone breakpoint. */
|
||||||
function isPhoneBreakpoint(): boolean {
|
function isPhoneBreakpoint(): boolean {
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
@@ -219,6 +253,11 @@ export function EventForm() {
|
|||||||
const [endDate, setEndDate] = useState(initEndDate);
|
const [endDate, setEndDate] = useState(initEndDate);
|
||||||
const [endTime, setEndTime] = useState(initEnd.time);
|
const [endTime, setEndTime] = useState(initEnd.time);
|
||||||
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none');
|
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
|
// D-06: recurrence bound state — "Ends" control
|
||||||
const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never');
|
const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never');
|
||||||
const [recurrenceUntil, setRecurrenceUntil] = useState('');
|
const [recurrenceUntil, setRecurrenceUntil] = useState('');
|
||||||
@@ -280,6 +319,9 @@ export function EventForm() {
|
|||||||
setRecurrenceCount(1);
|
setRecurrenceCount(1);
|
||||||
setLocation(occurrence?.location ?? '');
|
setLocation(occurrence?.location ?? '');
|
||||||
setDescription(occurrence?.description ?? '');
|
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
|
}, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -316,6 +358,8 @@ export function EventForm() {
|
|||||||
const handleAllDayToggle = () => {
|
const handleAllDayToggle = () => {
|
||||||
const next = !allDay;
|
const next = !allDay;
|
||||||
setAllDay(next);
|
setAllDay(next);
|
||||||
|
// D-03 (Phase 11): reset reminder to None on allDay toggle — no carry-over between preset sets
|
||||||
|
setReminderValue('__none__');
|
||||||
if (!next) {
|
if (!next) {
|
||||||
// Turning off all-day: restore default times
|
// Turning off all-day: restore default times
|
||||||
setStartTime('09:00');
|
setStartTime('09:00');
|
||||||
@@ -418,6 +462,23 @@ export function EventForm() {
|
|||||||
// outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On
|
// 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.
|
// CREATE the user explicitly chose a recurrence, so it is always sent.
|
||||||
const isEdit = eventFormMode === 'edit' && !!eventFormUid;
|
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 = {
|
const payload: CreateEventPayload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
allDay,
|
allDay,
|
||||||
@@ -433,6 +494,7 @@ export function EventForm() {
|
|||||||
: {}),
|
: {}),
|
||||||
...(location.trim() ? { location: location.trim() } : {}),
|
...(location.trim() ? { location: location.trim() } : {}),
|
||||||
...(description.trim() ? { description: description.trim() } : {}),
|
...(description.trim() ? { description: description.trim() } : {}),
|
||||||
|
...reminderPayload,
|
||||||
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
|
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -884,6 +946,96 @@ export function EventForm() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */}
|
||||||
{eventFormMode !== 'edit' && recurrence !== 'none' && (
|
{eventFormMode !== 'edit' && recurrence !== 'none' && (
|
||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
|
|||||||
Reference in New Issue
Block a user