diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx index 74fbbded3..fc054e74f 100644 --- a/components/calendar/__tests__/event-modal-organizer.test.tsx +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -1,7 +1,9 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventModal } from '../event-modal'; import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import { useSettingsStore } from '@/stores/settings-store'; // The editor rewrote the organizer on every save of an event with attendees, // to the user's first address, and put every attendee back to needs-action. @@ -82,3 +84,82 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and a expect(participants.guest.participationStatus).toBe('tentative'); }); }); + +// RFC 5546: when the organizer moves an event, the attendees are asked +// again; every other save keeps their answers. The editor shows and writes +// the event in the user's zone (here Tokyo, UTC+9), which is no move by +// itself. +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a move on Stalwart %s', (version) => { + beforeEach(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' })); + afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); + + // 08:00-09:00 in New York (UTC-4 in October): 21:00-22:00 in Tokyo. + function inNewYork(organizer: string, participants: Record): CalendarEvent { + return { + ...meeting(organizer, participants), + start: '2026-10-12T08:00:00', timeZone: 'America/New_York', + utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z', + } as CalendarEvent; + } + const own = () => inNewYork('me@example.com', { + org: participant(version, 'me@example.com', true, 'accepted'), + bob: participant(version, 'bob@example.org', false, 'accepted'), + carol: participant(version, 'carol@example.org', false, 'declined'), + }); + + async function save(event: CalendarEvent, edit: (container: HTMLElement) => void): Promise> { + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + fireEvent.click(screen.getByText('events.edit')); + edit(container); + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0][0] as Partial; + } + const moveStartTo = (time: string) => (container: HTMLElement) => { + const [startTime] = container.querySelectorAll('input[type="time"]'); + fireEvent.change(startTime, { target: { value: time } }); + }; + const statuses = (data: Partial) => Object.fromEntries( + Object.entries(data.participants as Record).map(([id, p]) => [id, p.participationStatus]), + ); + + it('asks the attendees again when the user moves their event', async () => { + const data = await save(own(), moveStartTo('22:00')); + + expect(data.start).toBe('2026-10-12T22:00:00'); + expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' }); + }); + + it('keeps the answers when only the time zone is rewritten', async () => { + const data = await save(own(), () => {}); + + // Written in the user's zone, at the same instant. + expect(data).toMatchObject({ start: '2026-10-12T21:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' }); + }); + + it('keeps the answers when the title changes', async () => { + const data = await save(own(), () => { + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning, room 2' } }); + }); + + expect(data.title).toBe('Planning, room 2'); + expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' }); + }); + + it('never asks again when the user did not organize the event', async () => { + const invitation = inNewYork('boss@example.org', { + boss: participant(version, 'boss@example.org', true, 'accepted'), + mine: participant(version, 'me@example.com', false, 'accepted'), + bob: participant(version, 'bob@example.org', false, 'declined'), + }); + + const data = await save(invitation, moveStartTo('22:00')); + + expect(data.start).toBe('2026-10-12T22:00:00'); + expect(statuses(data)).toEqual({ boss: 'accepted', mine: 'accepted', bob: 'declined' }); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index df42ff331..fe5485b60 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -89,6 +89,27 @@ export function savedStatusFields( return { status: "confirmed", freeBusyStatus: "busy", privacy: "public" }; } +/** + * Whether the editor's dates change when `event` happens: it turns all-day + * or timed, or its start or end moves - for an all-day event its first or + * last day. The form's dates and the event's are display dates in the + * user's zone, compared to the minute the form shows: saving the event in + * the user's zone instead of its own is no change. Pure, for testing. + */ +export function changesWhen( + event: CalendarEvent, + next: { allDay: boolean; start: Date; end: Date }, +): boolean { + if (!!event.showWithoutTime !== next.allDay) return true; + if (next.allDay) { + return !isSameDay(getEventStartDate(event), next.start) + || !isSameDay(getEventDisplayEndDate(event), next.end); + } + const minute = (date: Date) => Math.floor(date.getTime() / 60_000); + return minute(getEventStartDate(event)) !== minute(next.start) + || minute(getEventEndDate(event)) !== minute(next.end); +} + function formatDateInput(d: Date): string { return format(d, "yyyy-MM-dd"); } @@ -587,6 +608,8 @@ export function EventModal({ const start = allDay ? parseISO(startStr) : new Date(startStr); let duration: string; + // The last day of an all-day event, the end of a timed one. + let end: Date; if (allDay) { let inclusiveEnd = new Date(`${endDate}T00:00:00`); @@ -594,9 +617,10 @@ export function EventModal({ inclusiveEnd = new Date(start); } duration = buildAllDayDuration(start, inclusiveEnd); + end = inclusiveEnd; } else { const endStr = `${endDate}T${endTime}:00`; - let end = new Date(endStr); + end = new Date(endStr); if (end <= start) { end = new Date(start.getTime() + 3600000); } @@ -670,10 +694,15 @@ export function EventModal({ // Keeps the event's own organizer - an invitation's sender, the exact // address a series is organized under - and the answers of attendees who - // stay; the user organizes an event that has no organizer yet. + // stay; the user organizes an event that has no organizer yet. Moved by + // the user as its organizer, the others are asked again. The dates are + // those of the event the editor was opened on - one occurrence of a + // series - so a move applies to whichever occurrences the save covers. Object.assign(data, participantFieldsToSave(event, effectiveAttendees, { userEmail: currentUserEmails[0], + userEmails: currentUserEmails, resolveName: resolveContactName, + rescheduled: !!event && changesWhen(event, { allDay, start, end }), })); const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations; diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index 213a02cb1..6fdded676 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -749,6 +749,60 @@ describe('participantFieldsToSave', () => { }); }); +// RFC 5546: an organizer who moves an event asks the attendees again, and +// Stalwart's update to them carries each attendee's stored answer (it adds +// needs-action only where there is none). So when the user, as organizer, +// changes when an event happens, the other attendees go back to +// needs-action; every other save keeps the answers. +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('participantFieldsToSave for a moved event on Stalwart %s', (version) => { + // As each version stores them: 0.16.23 drops an attendee's roles, 1.0 keeps them. + const attendee = (address: string, participationStatus: CalendarParticipant['participationStatus']) => ({ + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, participationStatus, + ...(version === '1.0' ? { roles: { attendee: true } } : {}), + }) as Partial; + const organizer = (address: string) => ({ + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, roles: { owner: true }, participationStatus: 'accepted', + }) as Partial; + const rows = (...emails: string[]) => emails.map((email) => ({ name: '', email })); + const me = { userEmail: 'me@example.com', userEmails: ['me@example.com', 'me-alias@example.com'] }; + + // Organized under the user's alias, in another letter case. + const own = () => makeEvent({ + org: organizer('Me-Alias@Example.com'), + bob: attendee('bob@example.org', 'accepted'), + carol: attendee('carol@example.org', 'declined'), + }, { organizerCalendarAddress: 'mailto:Me-Alias@Example.com' }); + + it('asks the other attendees again when the user organizes it', () => { + const fields = participantFieldsToSave(own(), rows('bob@example.org', 'carol@example.org'), { ...me, rescheduled: true }); + + expect(fields.participants!.org).toMatchObject({ calendarAddress: 'mailto:Me-Alias@Example.com', participationStatus: 'accepted' }); + expect(fields.participants!.bob.participationStatus).toBe('needs-action'); + expect(fields.participants!.carol.participationStatus).toBe('needs-action'); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('keeps the answers when the event is not moved', () => { + const fields = participantFieldsToSave(own(), rows('bob@example.org', 'carol@example.org'), { ...me, rescheduled: false }); + + expect(fields.participants!.bob.participationStatus).toBe('accepted'); + expect(fields.participants!.carol.participationStatus).toBe('declined'); + }); + + it('never asks again for an event someone else organizes', () => { + const invitation = makeEvent({ + boss: organizer('boss@example.org'), + mine: attendee('me@example.com', 'accepted'), + bob: attendee('bob@example.org', 'declined'), + }, { organizerCalendarAddress: 'mailto:boss@example.org' }); + + const fields = participantFieldsToSave(invitation, rows('me@example.com', 'bob@example.org'), { ...me, rescheduled: true }); + + expect(fields.participants!.mine.participationStatus).toBe('accepted'); + expect(fields.participants!.bob.participationStatus).toBe('declined'); + }); +}); + describe('participantLimitOf', () => { it('reads the limit from Stalwart 1.0’s refusal', () => { const refusal = jmapSetError('An event cannot have more than 20 participants.', { diff --git a/lib/__tests__/event-modal-reschedule.test.ts b/lib/__tests__/event-modal-reschedule.test.ts new file mode 100644 index 000000000..a4466e83b --- /dev/null +++ b/lib/__tests__/event-modal-reschedule.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { changesWhen } from '@/components/calendar/event-modal'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// Whether the event editor's dates change when an event happens - what makes +// an organizer's save ask the attendees again. The editor writes the event +// in the user's zone; that alone is no change. + +// 08:00-09:00 in New York (UTC-4 in October), as the server returns it. +const timed = { + id: 'e1', start: '2026-10-12T08:00:00', duration: 'PT1H', timeZone: 'America/New_York', showWithoutTime: false, + utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z', +} as CalendarEvent; + +const allDay = { + id: 'e2', start: '2026-10-12T00:00:00', duration: 'P2D', timeZone: null, showWithoutTime: true, +} as unknown as CalendarEvent; + +const at = (iso: string) => new Date(iso); +const day = (date: string) => new Date(`${date}T00:00:00`); + +describe('changesWhen', () => { + it('is no change at the same instants, whatever zone the form is in', () => { + expect(changesWhen(timed, { allDay: false, start: at('2026-10-12T12:00:00Z'), end: at('2026-10-12T13:00:00Z') })).toBe(false); + }); + + it('ignores the seconds the form cannot show', () => { + const withSeconds = { ...timed, utcStart: '2026-10-12T12:00:30Z', utcEnd: '2026-10-12T13:00:30Z' }; + expect(changesWhen(withSeconds, { allDay: false, start: at('2026-10-12T12:00:00Z'), end: at('2026-10-12T13:00:00Z') })).toBe(false); + }); + + it('is a change when the start or the end moves', () => { + expect(changesWhen(timed, { allDay: false, start: at('2026-10-12T13:00:00Z'), end: at('2026-10-12T14:00:00Z') })).toBe(true); + expect(changesWhen(timed, { allDay: false, start: at('2026-10-12T12:00:00Z'), end: at('2026-10-12T13:30:00Z') })).toBe(true); + }); + + it('is a change when the event turns all-day or timed', () => { + expect(changesWhen(timed, { allDay: true, start: day('2026-10-12'), end: day('2026-10-12') })).toBe(true); + expect(changesWhen(allDay, { allDay: false, start: day('2026-10-12'), end: day('2026-10-13') })).toBe(true); + }); + + it('compares the first and last day of an all-day event', () => { + expect(changesWhen(allDay, { allDay: true, start: day('2026-10-12'), end: day('2026-10-13') })).toBe(false); + expect(changesWhen(allDay, { allDay: true, start: day('2026-10-12'), end: day('2026-10-14') })).toBe(true); + expect(changesWhen(allDay, { allDay: true, start: day('2026-10-13'), end: day('2026-10-14') })).toBe(true); + }); +}); diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index 9e79beb3d..152a5bf8b 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -272,12 +272,14 @@ function participantsByAddress( * once. `existing` is the event's current map. An address already in it * keeps its participant id and its answer, so a save neither resets the * attendees' answers - the user's own on an invitation included - nor leaves - * a recurrence override pointing at a participant id that is gone. + * a recurrence override pointing at a participant id that is gone. With + * `askAgain` every attendee but the organizer answers anew (needs-action). */ export function buildParticipantMap( organizer: { name: string; email: string }, attendees: { name: string; email: string }[], existing?: CalendarEvent['participants'] | null, + options: { askAgain?: boolean } = {}, ): Record> { const participants: Record> = {}; const known = participantsByAddress(existing); @@ -322,7 +324,7 @@ export function buildParticipantMap( email, calendarAddress: `mailto:${email}`, roles: { attendee: true }, - participationStatus: known.get(key)?.status ?? 'needs-action', + participationStatus: (options.askAgain ? undefined : known.get(key)?.status) ?? 'needs-action', scheduleAgent: 'server', expectReply: true, kind: 'individual', @@ -348,8 +350,12 @@ function eventOrganizer(event: CalendarEvent): { name: string; email: string } | export interface ParticipantSaveContext { /** The user's first calendar address: the organizer of an event that has none yet. */ userEmail: string | undefined; + /** All of the user's calendar addresses (identities, aliases): whether the user organizes the event. */ + userEmails?: string[]; /** A name for an organizer the event data carries none for (the contact card). */ resolveName?: (email: string) => string | undefined; + /** The save changes when the event happens: its start or end instant, or whether it is all-day. */ + rescheduled?: boolean; } /** @@ -361,7 +367,11 @@ export interface ParticipantSaveContext { * occurrences). Only an event without an organizer gets the user, and only * then is `organizerCalendarAddress` written: Stalwart derives the * iCalendar ORGANIZER solely from it, so without one no invitations go out. - * Participants that stay keep their id and answer (buildParticipantMap). + * Participants that stay keep their id and answer (buildParticipantMap), + * unless the user as the organizer moves the event: then the other + * attendees are asked again, as RFC 5546 has an organizer do when an + * event is rescheduled, and the server's update asks them with + * needs-action. * * Without attendees, an event that had participants loses them, its * organizer and the retired `replyTo` older releases wrote. @@ -374,6 +384,8 @@ export function participantFieldsToSave( const { userEmail, resolveName } = context; if (attendees.length > 0 && userEmail) { const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail }; + const userAddresses = [userEmail, ...(context.userEmails ?? [])].map((address) => address.trim().toLowerCase()); + const askAgain = !!context.rescheduled && userAddresses.includes(organizer.email.toLowerCase()); const fields: Partial> = { participants: buildParticipantMap( // A new event has no participants yet, so it would store an empty @@ -381,6 +393,7 @@ export function participantFieldsToSave( { name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email }, attendees, event?.participants, + { askAgain }, ) as Record, }; if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`;