Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19

Merged
luckberg merged 41 commits from gsd/phase-11-per-event-reminders into main 2026-06-14 14:06:45 -04:00
2 changed files with 398 additions and 1 deletions
Showing only changes of commit fe549ef2b0 - Show all commits
+246 -1
View File
@@ -239,7 +239,10 @@ describe('EventForm', () => {
it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => {
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('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<string, unknown>;
// D-08: field must be absent (not null, not 0 — truly missing)
expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe(false);
});
});
});
+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}>