diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index dcba6c7..8e72344 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -108,6 +108,7 @@ const EDIT_OCCURRENCE: CalendarOccurrence = { allDay: false, location: 'Office', description: 'Weekly sync', + hasRrule: false, } // ── Import component (after mocks) ──────────────────────────────────────────── @@ -487,6 +488,7 @@ const RECURRING_OCCURRENCE: CalendarOccurrence = { allDay: false, location: null, description: null, + hasRrule: true, // @ts-expect-error — recurrence is not on CalendarOccurrence type yet; the reset // effect reads it if present and defaults to 'none' when absent (WR-03, v1 comment) recurrence: 'weekly', @@ -512,6 +514,7 @@ const LATE_OCCURRENCE: CalendarOccurrence = { allDay: false, location: null, description: null, + hasRrule: false, } describe('EventForm — Plan 03-12 gap closures', () => { @@ -734,3 +737,224 @@ describe('EventForm — Plan 03-12 gap closures', () => { expect(document.activeElement).toBe(lastElement) }) }) + +// ── Plan 06-06: end-tracking wiring + recurrence-bound control (TDD RED) ───── + +/** + * All-day occurrence for D-05 round-trip verification. + * The `end` field is the EXCLUSIVE end (the day AFTER the last day), as stored by the API. + * The form must pre-fill the INCLUSIVE end (day before the exclusive end) so re-saving + * does not grow the event. + */ +const ALL_DAY_OCCURRENCE: CalendarOccurrence = { + id: 'allday-uid-001::2026-06-10', + uid: 'allday-uid-001', + calendarId: 1, + calendarName: 'My Calendar', + ownerUserId: 1, + ownerName: 'Alice', + color: '#4A90D9', + isShared: false, + title: 'All Day Event', + start: '2026-06-10', + end: '2026-06-11', // exclusive end (single-day event → end = start + 1 day) + allDay: true, + location: null, + description: null, + hasRrule: false, +} + +describe('EventForm — Plan 06-06 end-tracking + recurrence-bound', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEventFormOpen = true + mockEventFormMode = 'create' + mockEventFormUid = null + }) + + // ── D-04: end-tracking — start date change ──────────────────────────────── + + it('D-04 timed: changing start date recomputes end to preserve 1h duration', () => { + renderForm({ mode: 'create' }) + + const dateInputs = document.querySelectorAll('input[type="date"]') + const timeInputs = document.querySelectorAll('input[type="time"]') + expect(dateInputs.length).toBeGreaterThanOrEqual(2) + expect(timeInputs.length).toBeGreaterThanOrEqual(2) + + // Set start to 2026-06-10 09:00, end to 2026-06-10 10:00 (1h span) + fireEvent.change(dateInputs[0], { target: { value: '2026-06-10' } }) + fireEvent.change(timeInputs[0], { target: { value: '09:00' } }) + fireEvent.change(dateInputs[1], { target: { value: '2026-06-10' } }) + fireEvent.change(timeInputs[1], { target: { value: '10:00' } }) + + // Move start to 2026-06-11 09:00 → end should follow to 2026-06-11 10:00 + fireEvent.change(dateInputs[0], { target: { value: '2026-06-11' } }) + + const endDateAfter = (document.querySelectorAll('input[type="date"]')[1] as HTMLInputElement).value + expect(endDateAfter).toBe('2026-06-11') + }) + + it('D-04 timed: changing start time recomputes end to preserve duration', () => { + renderForm({ mode: 'create' }) + + const dateInputs = document.querySelectorAll('input[type="date"]') + const timeInputs = document.querySelectorAll('input[type="time"]') + + // Establish a 1h span on the same date + fireEvent.change(dateInputs[0], { target: { value: '2026-06-10' } }) + fireEvent.change(timeInputs[0], { target: { value: '09:00' } }) + fireEvent.change(dateInputs[1], { target: { value: '2026-06-10' } }) + fireEvent.change(timeInputs[1], { target: { value: '10:00' } }) + + // Move start time to 14:00 → end should become 15:00 (still 1h) + fireEvent.change(timeInputs[0], { target: { value: '14:00' } }) + + const endTimeAfter = (document.querySelectorAll('input[type="time"]')[1] as HTMLInputElement).value + expect(endTimeAfter).toBe('15:00') + }) + + it('D-04 all-day: changing start date preserves day-span', () => { + renderForm({ mode: 'create' }) + + // Toggle all-day ON + const allDaySwitch = screen.getByRole('switch') + fireEvent.click(allDaySwitch) + + const dateInputs = document.querySelectorAll('input[type="date"]') + // Set a 2-day span: start=2026-06-10, end=2026-06-11 (inclusive, 2 days) + fireEvent.change(dateInputs[0], { target: { value: '2026-06-10' } }) + fireEvent.change(dateInputs[1], { target: { value: '2026-06-11' } }) + + // Move start to 2026-06-20 → end should become 2026-06-21 (same 2-day span) + fireEvent.change(dateInputs[0], { target: { value: '2026-06-20' } }) + + const endDateAfter = (document.querySelectorAll('input[type="date"]')[1] as HTMLInputElement).value + expect(endDateAfter).toBe('2026-06-21') + }) + + // ── D-05: all-day edit round-trip — no drift ────────────────────────────── + + it('D-05 all-day edit: pre-fills inclusive end (no +1 drift on round-trip)', () => { + // ALL_DAY_OCCURRENCE has exclusive end '2026-06-11' (single day 2026-06-10) + // The form should pre-fill 2026-06-10 (inclusive), not 2026-06-11 (exclusive) + renderForm({ + mode: 'edit', + uid: 'allday-uid-001', + eventOccurrence: ALL_DAY_OCCURRENCE, + }) + + const dateInputs = document.querySelectorAll('input[type="date"]') + // End input should show the inclusive date 2026-06-10, not the exclusive 2026-06-11 + expect((dateInputs[1] as HTMLInputElement).value).toBe('2026-06-10') + }) + + // ── D-06: recurrence bound control ─────────────────────────────────────── + + it('D-06: "Ends" control is hidden when recurrence is "None"', () => { + renderForm({ mode: 'create' }) + // Ends label should not be visible when recurrence=none (default) + expect(screen.queryByText('Ends')).toBeNull() + }) + + it('D-06: "Ends" control appears when recurrence is set to weekly', () => { + renderForm({ mode: 'create' }) + + const recurrenceSelect = document.querySelector('#event-recurrence') as HTMLSelectElement + fireEvent.change(recurrenceSelect, { target: { value: 'weekly' } }) + + expect(screen.getByText('Ends')).toBeDefined() + }) + + it('D-06: selecting "On date" reveals a date input labeled "End date"', () => { + renderForm({ mode: 'create' }) + + const recurrenceSelect = document.querySelector('#event-recurrence') as HTMLSelectElement + fireEvent.change(recurrenceSelect, { target: { value: 'weekly' } }) + + const endsSelect = document.querySelector('#recurrence-bound') as HTMLSelectElement + expect(endsSelect).not.toBeNull() + fireEvent.change(endsSelect, { target: { value: 'until' } }) + + expect(screen.getByText('End date')).toBeDefined() + }) + + it('D-06: selecting "After N times" reveals a number input labeled "Occurrences"', () => { + renderForm({ mode: 'create' }) + + const recurrenceSelect = document.querySelector('#event-recurrence') as HTMLSelectElement + fireEvent.change(recurrenceSelect, { target: { value: 'weekly' } }) + + const endsSelect = document.querySelector('#recurrence-bound') as HTMLSelectElement + expect(endsSelect).not.toBeNull() + fireEvent.change(endsSelect, { target: { value: 'count' } }) + + expect(screen.getByText('Occurrences')).toBeDefined() + }) + + it('D-06: validation error when count < 1', async () => { + renderForm({ mode: 'create' }) + + fireEvent.change(screen.getByPlaceholderText('Event title'), { target: { value: 'Test' } }) + + const recurrenceSelect = document.querySelector('#event-recurrence') as HTMLSelectElement + fireEvent.change(recurrenceSelect, { target: { value: 'weekly' } }) + + const endsSelect = document.querySelector('#recurrence-bound') as HTMLSelectElement + fireEvent.change(endsSelect, { target: { value: 'count' } }) + + const countInput = document.querySelector('#recurrence-count') as HTMLInputElement + expect(countInput).not.toBeNull() + fireEvent.change(countInput, { target: { value: '0' } }) + + fireEvent.click(screen.getByText('Create Event')) + + await waitFor(() => { + expect(screen.getByText('Must be at least 1 occurrence')).toBeDefined() + }) + }) + + it('D-06: payload includes recurrenceCount when bound=count and count >= 1', async () => { + renderForm({ mode: 'create' }) + + fireEvent.change(screen.getByPlaceholderText('Event title'), { target: { value: 'Weekly Event' } }) + + const recurrenceSelect = document.querySelector('#event-recurrence') as HTMLSelectElement + fireEvent.change(recurrenceSelect, { target: { value: 'weekly' } }) + + const endsSelect = document.querySelector('#recurrence-bound') as HTMLSelectElement + fireEvent.change(endsSelect, { target: { value: 'count' } }) + + const countInput = document.querySelector('#recurrence-count') as HTMLInputElement + fireEvent.change(countInput, { target: { value: '5' } }) + + fireEvent.click(screen.getByText('Create Event')) + + await waitFor(() => { + expect(mockCreateEvent).toHaveBeenCalledWith( + expect.objectContaining({ recurrenceCount: 5 }), + ) + }) + }) + + it('D-06: payload does NOT include recurrenceUntil/recurrenceCount when bound=never', async () => { + renderForm({ mode: 'create' }) + + fireEvent.change(screen.getByPlaceholderText('Event title'), { target: { value: 'Weekly Event' } }) + + const recurrenceSelect = document.querySelector('#event-recurrence') as HTMLSelectElement + fireEvent.change(recurrenceSelect, { target: { value: 'weekly' } }) + // Bound stays "Never" (default) + + fireEvent.click(screen.getByText('Create Event')) + + await waitFor(() => { + expect(mockCreateEvent).toHaveBeenCalledWith( + expect.not.objectContaining({ recurrenceUntil: expect.anything() }), + ) + expect(mockCreateEvent).toHaveBeenCalledWith( + expect.not.objectContaining({ recurrenceCount: expect.anything() }), + ) + }) + }) +})