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
+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);
});
});
});