diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx new file mode 100644 index 000000000..74fbbded3 --- /dev/null +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; + +// 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. +// An invitation the user saved in their own calendar became the user's, and +// their answer was gone. It now keeps the event's organizer and the answers +// of attendees who stay. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, + myRights: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, + }, +}] as unknown as Calendar[]; + +// Participants as each version stores them: 0.16.23 drops an attendee's +// roles, 1.0 keeps them. +function participant(version: '0.16' | '1.0', address: string, owner: boolean, status: string) { + return { + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, + ...(owner ? { roles: { owner: true } } : version === '1.0' ? { roles: { attendee: true } } : {}), + participationStatus: status, scheduleAgent: 'server', expectReply: !owner, kind: 'individual', + }; +} + +function meeting(organizer: string, participants: Record): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true }, + organizerCalendarAddress: `mailto:${organizer}`, participants, alerts: null, recurrenceRules: null, + } as unknown as CalendarEvent; +} + +async function saveUnchanged(event: CalendarEvent): Promise> { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.click(screen.getByText('events.edit')); + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0][0] as Partial; +} + +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and answers on Stalwart %s', (version) => { + it('keeps the sender of an invitation and the user\'s answer', async () => { + const data = await saveUnchanged(meeting('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'), + })); + + expect(data).not.toHaveProperty('organizerCalendarAddress'); + const participants = data.participants as Record; + expect(Object.keys(participants).sort()).toEqual(['bob', 'boss', 'mine']); + expect(participants.boss).toMatchObject({ calendarAddress: 'mailto:boss@example.org', roles: { owner: true } }); + expect(participants.mine.participationStatus).toBe('accepted'); + expect(participants.bob.participationStatus).toBe('declined'); + }); + + it('keeps the letter case a series is organized under', async () => { + const data = await saveUnchanged(meeting('ME@Example.com', { + org: participant(version, 'ME@Example.com', true, 'accepted'), + guest: participant(version, 'guest@example.net', false, 'tentative'), + })); + + expect(data).not.toHaveProperty('organizerCalendarAddress'); + const participants = data.participants as Record; + expect(participants.org.calendarAddress).toBe('mailto:ME@Example.com'); + expect(participants.guest.participationStatus).toBe('tentative'); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 2be3f9b63..df42ff331 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -21,7 +21,7 @@ import { getUserStatus, getParticipantList, getStatusCounts, - buildParticipantMap, + participantFieldsToSave, } from "@/lib/calendar-participants"; import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability"; import { withOwnAlertIds } from "@/lib/calendar-alerts"; @@ -668,27 +668,13 @@ export function EventModal({ .flatMap((c) => [...Object.keys(c.defaultAlertsWithTime ?? {}), ...Object.keys(c.defaultAlertsWithoutTime ?? {})]); Object.assign(data, buildAlertsPatch(event, alertRows, preservedAlertsRef.current, defaultAlertIds)); - if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) { - const organizerEmail = currentUserEmails[0]; - // On create there are no existing participants, so a fresh event would - // store an empty organizer name — fall back to the contact card. - const organizerName = - existingParticipants.find(p => p.isOrganizer)?.name || resolveContactName(organizerEmail) || ""; - data.participants = buildParticipantMap( - { name: organizerName, email: organizerEmail }, - effectiveAttendees - ) as Record; - // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from - // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP - // scheduling is silently skipped (NoSchedulingInfo), so no invites are sent. - // The RFC 8984 replyTo property is retired in jscalendarbis and ignored. - data.organizerCalendarAddress = `mailto:${organizerEmail}`; - } else if (effectiveAttendees.length === 0 && event?.participants) { - data.participants = null; - // Also clear the retired replyTo that older releases (<= 1.7.6) wrote. - data.replyTo = null; - data.organizerCalendarAddress = null; - } + // 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. + Object.assign(data, participantFieldsToSave(event, effectiveAttendees, { + userEmail: currentUserEmails[0], + resolveName: resolveContactName, + })); const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations; setIsSaving(true); @@ -697,7 +683,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, resolveContactName, event, onSave, isSaving]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index ee7da5ad6..213a02cb1 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -9,6 +9,7 @@ import { getStatusCounts, getParticipantCount, buildParticipantMap, + participantFieldsToSave, participantLimitOf, } from '@/lib/calendar-participants'; import { jmapError, jmapSetError } from '@/lib/jmap/jmap-error'; @@ -642,6 +643,110 @@ describe('buildParticipantMap', () => { expect(Object.keys(map)).toHaveLength(2); expect(Object.values(map).some(p => p.name === 'Nobody')).toBe(false); }); + + it('keeps the id and answer of an address the event already lists, whatever its case', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [{ name: 'Bob', email: 'bob@example.com' }, { name: 'Carol', email: 'carol@example.com' }], + { + org: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com' }, + b1: { '@type': 'Participant', calendarAddress: 'mailto:BOB@Example.com', participationStatus: 'declined' }, + } as unknown as Record, + ); + + // The organizer, listed without an answer, stays accepted. + expect(map.org).toMatchObject({ calendarAddress: 'mailto:alice@example.com', participationStatus: 'accepted' }); + expect(map.b1).toMatchObject({ email: 'bob@example.com', participationStatus: 'declined' }); + const carol = Object.entries(map).find(([, p]) => p.email === 'carol@example.com')!; + expect(['org', 'b1']).not.toContain(carol[0]); + expect(carol[1].participationStatus).toBe('needs-action'); + }); + + it('takes the real answer of an address listed twice', () => { + const map = buildParticipantMap( + { name: 'Alice', email: 'alice@example.com' }, + [{ name: 'Bob', email: 'bob@example.com' }], + { + first: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', participationStatus: 'needs-action' }, + second: { '@type': 'Participant', email: 'bob@example.com', participationStatus: 'tentative' }, + } as unknown as Record, + ); + expect(map.first.participationStatus).toBe('tentative'); + expect(map).not.toHaveProperty('second'); + }); +}); + +// The editor rewrote the organizer on every save of an event with attendees: +// participants rebuilt around the user's first address, organizerCalendarAddress +// set to it, and every attendee back to needs-action. On an invitation that +// made the user its organizer; on a series organized under another address or +// letter case it gave one occurrence another owner (Stalwart 1.0 refuses the +// organizerCalendarAddress there: "This property cannot be modified on a +// single occurrence."), and every save dropped the answers. +describe('participantFieldsToSave', () => { + const me = { userEmail: 'me@example.com', resolveName: (email: string) => (email === 'me@example.com' ? 'Me Myself' : undefined) }; + const rows = (...emails: string[]) => emails.map((email) => ({ name: '', email })); + + it('keeps the sender of an invitation as its organizer and everyone\'s answer', () => { + const invitation = makeEvent({ + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org' }, + mine: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'declined' }, + }, { organizerCalendarAddress: 'mailto:boss@example.org' }); + + const fields = participantFieldsToSave(invitation, rows('me@example.com', 'bob@example.org'), me); + + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + expect(Object.keys(fields.participants!).sort()).toEqual(['bob', 'boss', 'mine']); + expect(fields.participants!.boss).toMatchObject({ calendarAddress: 'mailto:boss@example.org', roles: { owner: true } }); + expect(fields.participants!.mine.participationStatus).toBe('accepted'); + expect(fields.participants!.bob.participationStatus).toBe('declined'); + }); + + it('keeps the address a series is organized under, letter case included (Stalwart 1.0 checks it on one occurrence)', () => { + const series = makeEvent({ + org: { '@type': 'Participant', calendarAddress: 'mailto:ME@Example.com', roles: { owner: true }, participationStatus: 'accepted' }, + guest: { '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', roles: { attendee: true }, participationStatus: 'accepted' }, + }, { organizerCalendarAddress: 'mailto:ME@Example.com' }); + + const fields = participantFieldsToSave(series, rows('guest@example.net'), me); + + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + expect(fields.participants!.org).toMatchObject({ calendarAddress: 'mailto:ME@Example.com', roles: { owner: true } }); + expect(fields.participants!.guest.participationStatus).toBe('accepted'); + }); + + it('takes the organizer from organizerCalendarAddress when no participant is marked as one', () => { + const event = makeEvent({ + guest: { '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', roles: { attendee: true } }, + }, { organizerCalendarAddress: 'mailto:alias@example.com' }); + + const fields = participantFieldsToSave(event, rows('guest@example.net'), me); + + const organizer = Object.values(fields.participants!).find((p) => p.roles?.owner); + expect(organizer?.calendarAddress).toBe('mailto:alias@example.com'); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('makes the user the organizer of an event that has none', () => { + for (const event of [null, makeEvent(null)]) { + const fields = participantFieldsToSave(event, rows('guest@example.net'), me); + + expect(fields.organizerCalendarAddress).toBe('mailto:me@example.com'); + const organizer = Object.values(fields.participants!).find((p) => p.roles?.owner); + expect(organizer).toMatchObject({ name: 'Me Myself', calendarAddress: 'mailto:me@example.com', participationStatus: 'accepted' }); + } + }); + + it('removes the participants, the organizer and replyTo with the last attendee', () => { + const event = makeEvent({ + org: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { owner: true } }, + guest: { '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', roles: { attendee: true } }, + }, { organizerCalendarAddress: 'mailto:me@example.com', replyTo: { imip: 'mailto:me@example.com' } }); + + expect(participantFieldsToSave(event, [], me)).toEqual({ participants: null, organizerCalendarAddress: null, replyTo: null }); + expect(participantFieldsToSave(makeEvent(null), [], me)).toEqual({}); + }); }); describe('participantLimitOf', () => { diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts index f478417db..405d99e73 100644 --- a/lib/__tests__/recurrence-expansion.test.ts +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { parseISO } from 'date-fns'; import { expandRecurringEvents } from '../recurrence-expansion'; +import { computeFireTime } from '../calendar-alerts'; import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types'; /** @@ -427,6 +429,110 @@ describe('expandRecurringEvents', () => { }); }); + // ----------------------------------------------------------------------- + // A master's utcStart/utcEnd that are not its own + // ----------------------------------------------------------------------- + // 0.16.23 answered utcStart = utcEnd = "32548-12-04T15:30:07Z" for the + // master of a counted series once every occurrence was overridden or + // excluded, and shifted them to the next plain occurrence once the first + // one was. 1.0 answers the master's own times. The expansion used to take + // the occurrences' length from those instants, copy them onto occurrences + // it could not time otherwise, and drop a moved occurrence whose copied + // end did not parse. + describe("a master's instants that are not its own", () => { + const BOGUS = '32548-12-04T15:30:07Z'; + // Read back from the test servers: a daily series of five in Berlin + // (UTC+2 in October), every occurrence overridden or excluded. + const countedSeries = (instants: Pick) => makeEvent({ + start: '2026-10-05T10:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT1H', + ...instants, + recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 })], + recurrenceOverrides: { + '2026-10-05T10:00:00': { title: 'Device renamed' }, + '2026-10-06T10:00:00': { title: 'Renamed occurrence' }, + '2026-10-07T10:00:00': { excluded: true } as Partial & { excluded?: boolean }, + '2026-10-08T10:00:00': { start: '2026-10-08T14:00:00', title: 'Moved standup' }, + '2026-10-09T10:00:00': { title: 'With organizer' }, + }, + }); + + it.each<[string, Pick]>([ + ['0.16.23', { utcStart: BOGUS, utcEnd: BOGUS }], + ['1.0', { utcStart: '2026-10-05T08:00:00Z', utcEnd: '2026-10-05T09:00:00Z' }], + ])('times every occurrence from its own start and the duration on Stalwart %s', (_version, instants) => { + const result = expand(countedSeries(instants), '2026-10-01T00:00:00', '2026-10-31T00:00:00'); + + expect(result.map(e => [e.recurrenceId, e.title, e.utcStart, e.utcEnd])).toEqual([ + ['2026-10-05T10:00:00', 'Device renamed', '2026-10-05T08:00:00.000Z', '2026-10-05T09:00:00.000Z'], + ['2026-10-06T10:00:00', 'Renamed occurrence', '2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z'], + // Moved to 14:00; it was dropped while its end was the master's. + ['2026-10-08T10:00:00', 'Moved standup', '2026-10-08T12:00:00.000Z', '2026-10-08T13:00:00.000Z'], + ['2026-10-09T10:00:00', 'With organizer', '2026-10-09T08:00:00.000Z', '2026-10-09T09:00:00.000Z'], + ]); + }); + + it("gives no occurrence the master's instants when it cannot time one (0.16.23)", () => { + // No time zone: occurrences are timed from the master's offset, which + // these instants do not give. + const floating = { ...countedSeries({ utcStart: BOGUS, utcEnd: BOGUS }), timeZone: null }; + + const result = expand(floating, '2026-10-01T00:00:00', '2026-10-31T00:00:00'); + + expect(result.map(e => e.recurrenceId)).toEqual([ + '2026-10-05T10:00:00', '2026-10-06T10:00:00', '2026-10-08T10:00:00', '2026-10-09T10:00:00', + ]); + for (const occurrence of result) { + expect(occurrence).not.toHaveProperty('utcStart'); + expect(occurrence).not.toHaveProperty('utcEnd'); + } + }); + + it.each<[string, string]>([ + ['shifted a day to the next plain occurrence (0.16.23)', '2026-10-06T08:00:00Z'], + ['that parse but are far off', '2999-01-01T08:00:00Z'], + ])('takes no offset from instants %s', (_label, utcStart) => { + const floating = makeEvent({ + start: '2026-10-05T10:00:00', + timeZone: null, + utcStart, + utcEnd: utcStart.replace('T08', 'T09'), + recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 })], + }); + + const result = expand(floating, '2026-10-01T00:00:00', '2026-10-31T00:00:00'); + + expect(result.map(e => [e.start, e.utcStart, e.utcEnd])).toEqual([ + ['2026-10-05T10:00:00', undefined, undefined], + ['2026-10-06T10:00:00', undefined, undefined], + ['2026-10-07T10:00:00', undefined, undefined], + ]); + }); + + it("keeps the master's instants off the other occurrences of an all-day series", () => { + const event = makeEvent({ + start: '2025-01-06T00:00:00', + showWithoutTime: true, + duration: 'P1D', + utcStart: '2025-01-05T23:00:00Z', + utcEnd: '2025-01-06T23:00:00Z', + recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })], + }); + + const result = expand(event, '2025-01-06T00:00:00', '2025-01-21T00:00:00'); + + expect(result.map(e => [e.start, e.utcStart])).toEqual([ + ['2025-01-06T00:00:00', undefined], + ['2025-01-13T00:00:00', undefined], + ['2025-01-20T00:00:00', undefined], + ]); + // An alert goes off before its own day, not before the series' first. + expect(computeFireTime(result[1], { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' })) + .toBe(parseISO('2025-01-13T00:00:00').getTime() - 15 * 60_000); + }); + }); + // ----------------------------------------------------------------------- // excludedRecurrenceRules (EXRULE) // ----------------------------------------------------------------------- diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index 5b89843dd..9e79beb3d 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -246,18 +246,52 @@ export function getParticipantCount(event: CalendarEvent): number { return count; } +type ParticipationStatus = CalendarParticipant['participationStatus']; + +/** + * An event's participants by address (lowercased): the id each was first + * listed under, and its answer - a real one over a missing or needs-action + * one when an address is listed twice (see getParticipantList). + */ +function participantsByAddress( + participants: CalendarEvent['participants'] | null | undefined, +): Map { + const known = new Map(); + for (const [id, p] of Object.entries(participants ?? {})) { + const key = getParticipantEmail(p).trim().toLowerCase(); + if (!key) continue; + const seen = known.get(key); + if (!seen) known.set(key, { id, status: p.participationStatus }); + else if ((!seen.status || seen.status === 'needs-action') && p.participationStatus) seen.status = p.participationStatus; + } + return known; +} + +/** + * The participants an editor save writes: the organizer, then each attendee + * 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. + */ export function buildParticipantMap( organizer: { name: string; email: string }, - attendees: { name: string; email: string }[] + attendees: { name: string; email: string }[], + existing?: CalendarEvent['participants'] | null, ): Record> { const participants: Record> = {}; - - const generateId = () => generateUUID(); + const known = participantsByAddress(existing); + const idFor = (key: string) => { + const id = known.get(key)?.id; + return id && !(id in participants) ? id : generateUUID(); + }; // calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis // (implemented by Stalwart); the RFC 8984 sendTo property is retired there and // stored as an inert JSPROP, so it is intentionally not sent. - participants[generateId()] = { + const organizerKey = organizer.email.trim().toLowerCase(); + const organizerStatus = known.get(organizerKey)?.status; + participants[idFor(organizerKey)] = { '@type': 'Participant', name: organizer.name, email: organizer.email, @@ -266,7 +300,7 @@ export function buildParticipantMap( // scheduling emits the organizer as an ATTENDEE line in addition to the // ORGANIZER line, so the recipient sees the organizer listed twice. roles: { owner: true }, - participationStatus: 'accepted', + participationStatus: organizerStatus && organizerStatus !== 'needs-action' ? organizerStatus : 'accepted', scheduleAgent: 'server', expectReply: false, kind: 'individual', @@ -274,7 +308,7 @@ export function buildParticipantMap( // The organizer already has an entry above, and an address must not appear // twice in the invite list, so drop both cases case-insensitively (#731). - const seen = new Set([organizer.email.trim().toLowerCase()]); + const seen = new Set([organizerKey]); attendees.forEach((a) => { const email = a.email.trim(); @@ -282,13 +316,13 @@ export function buildParticipantMap( if (!key || seen.has(key)) return; seen.add(key); - participants[generateId()] = { + participants[idFor(key)] = { '@type': 'Participant', name: a.name, email, calendarAddress: `mailto:${email}`, roles: { attendee: true }, - participationStatus: 'needs-action', + participationStatus: known.get(key)?.status ?? 'needs-action', scheduleAgent: 'server', expectReply: true, kind: 'individual', @@ -297,3 +331,67 @@ export function buildParticipantMap( return participants; } + +/** + * The event's own organizer: the organizer participant, else the address in + * `organizerCalendarAddress` or the retired `replyTo`. Null for an event + * without one. + */ +function eventOrganizer(event: CalendarEvent): { name: string; email: string } | null { + const participant = getParticipantList(event).find((p) => p.isOrganizer && p.email.trim()); + if (participant) return { name: participant.name, email: participant.email.trim() }; + const address = event.organizerCalendarAddress || Object.values(event.replyTo ?? {})[0]; + const email = address?.replace(/^mailto:/i, '').trim(); + return email ? { name: '', email } : null; +} + +export interface ParticipantSaveContext { + /** The user's first calendar address: the organizer of an event that has none yet. */ + userEmail: string | undefined; + /** A name for an organizer the event data carries none for (the contact card). */ + resolveName?: (email: string) => string | undefined; +} + +/** + * The participant fields an editor save writes. + * + * With attendees, the participants are rebuilt around the event's own + * organizer: an invitation keeps whoever sent it, and a series the exact + * address it is organized under (Stalwart 1.0 checks it on single + * 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). + * + * Without attendees, an event that had participants loses them, its + * organizer and the retired `replyTo` older releases wrote. + */ +export function participantFieldsToSave( + event: CalendarEvent | null | undefined, + attendees: { name: string; email: string }[], + context: ParticipantSaveContext, +): Partial> { + const { userEmail, resolveName } = context; + if (attendees.length > 0 && userEmail) { + const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail }; + const fields: Partial> = { + participants: buildParticipantMap( + // A new event has no participants yet, so it would store an empty + // organizer name. + { name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email }, + attendees, + event?.participants, + ) as Record, + }; + if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`; + return fields; + } + if (attendees.length === 0 && event?.participants && Object.keys(event.participants).length > 0) { + return { + participants: null, + ...(event.replyTo ? { replyTo: null } : {}), + ...(event.organizerCalendarAddress ? { organizerCalendarAddress: null } : {}), + }; + } + return {}; +} diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 35dd39fa1..22bbb3b32 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -160,16 +160,58 @@ function isInRange( ): boolean { const moved = !!override && Object.keys(override).some((key) => TIMING_KEYS.has(key.split('/', 1)[0])); if (!moved) return date >= rangeStart && date < rangeEnd; - const timed = !occurrence.showWithoutTime; - const start = timed && occurrence.utcStart ? parseISO(occurrence.utcStart) : parseISO(occurrence.start); - if (isNaN(start.getTime())) return false; - const end = timed && occurrence.utcEnd - ? parseISO(occurrence.utcEnd) - : new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000); + const own = occurrence.showWithoutTime ? null : plausibleInstants(occurrence); + const start = own?.start ?? validDate(occurrence.start); + if (!start) return false; + const end = own?.end ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000); if (end <= start) return start >= rangeStart && start < rangeEnd; return start < rangeEnd && end > rangeStart; } +// UTC-12 to UTC+14: the furthest an instant is from the wall clock it names. +const MAX_ZONE_OFFSET_MS = 14 * 60 * 60 * 1000; + +/** `value` parsed, or null when it is missing or does not parse. */ +function validDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = parseISO(value); + return isNaN(date.getTime()) ? null : date; +} + +/** The wall-clock fields of a parsed LocalDateTime, read as UTC. */ +function wallAsUtc(wall: Date): number { + return Date.UTC( + wall.getFullYear(), wall.getMonth(), wall.getDate(), + wall.getHours(), wall.getMinutes(), wall.getSeconds(), + ); +} + +/** `value` parsed, when it is within a time zone's offset of the wall clock `wallMs`. */ +function instantNear(value: string | null | undefined, wallMs: number): Date | null { + const instant = validDate(value); + return instant && Math.abs(instant.getTime() - wallMs) <= MAX_ZONE_OFFSET_MS ? instant : null; +} + +/** + * An event's utcStart and utcEnd, where they can be its own: each within a + * time zone's offset of the wall-clock start and end, the end not before the + * start. `end` is null when only the start passes. For a series' master + * 0.16.23 has answered both with "32548-12-04T15:30:07Z" once every + * occurrence was overridden or excluded, and with the next plain + * occurrence's times once the first one was. + */ +function plausibleInstants( + event: Pick, +): { start: Date; end: Date | null } | null { + const wall = validDate(event.start); + if (!wall) return null; + const wallStart = wallAsUtc(wall); + const start = instantNear(event.utcStart, wallStart); + if (!start) return null; + const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000); + return { start, end: end && end >= start ? end : null }; +} + // Cache Intl formatters per IANA timezone id - constructing them is expensive // and expansion runs over hundreds of occurrences. `null` marks ids the // runtime rejected so we don't retry them. @@ -218,13 +260,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallAsUtc = Date.UTC( - wall.getFullYear(), wall.getMonth(), wall.getDate(), - wall.getHours(), wall.getMinutes(), wall.getSeconds(), - ); - let guess = wallAsUtc; + const wallMs = wallAsUtc(wall); + let guess = wallMs; for (let i = 0; i < 2; i++) { - guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess); + guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); } return new Date(guess); } @@ -255,14 +294,18 @@ function createOccurrence( let utcEnd: string | undefined; const wall = parseISO(start); if (!occurrence.showWithoutTime && !isNaN(wall.getTime())) { - // Unless the override sets its own duration, the master's length. + // Unless the override sets its own duration, the master's length: from + // its instants when they are its own, else from its duration. + const masterInstants = plausibleInstants(master); let durationMs: number | null = null; const ownDuration = override && 'duration' in override ? parseDurationSeconds(occurrence.duration) : null; if (ownDuration !== null) { durationMs = ownDuration * 1000; - } else if (master.utcStart && master.utcEnd) { - const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime(); - if (!isNaN(ms)) durationMs = ms; + } else if (masterInstants?.end) { + durationMs = masterInstants.end.getTime() - masterInstants.start.getTime(); + } else { + const seconds = parseDurationSeconds(master.duration); + if (seconds !== null) durationMs = seconds * 1000; } // Convert the occurrence's wall time using the event's own timezone, so @@ -275,12 +318,10 @@ function createOccurrence( if (durationMs !== null) { utcEnd = new Date(zoned.getTime() + durationMs).toISOString(); } - } else if (master.utcStart && master.start) { + } else if (masterInstants) { // Floating events (or an unrecognized timezone id): keep the master's // offset, which by definition doesn't vary. - const masterLocal = parseISO(master.start); - const masterUtc = parseISO(master.utcStart); - const offsetMs = masterUtc.getTime() - masterLocal.getTime(); + const offsetMs = masterInstants.start.getTime() - parseISO(master.start).getTime(); utcStart = new Date(wall.getTime() + offsetMs).toISOString(); if (durationMs !== null) { utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString(); @@ -288,8 +329,11 @@ function createOccurrence( } } + // The master's utcStart/utcEnd place its first occurrence, not this one: + // an occurrence carries the instants worked out for it, or none. + const { utcStart: _masterStart, utcEnd: _masterEnd, ...series } = occurrence; return { - ...occurrence, + ...series, id: `${master.id}:${recurrenceId}`, originalId: master.originalId || master.id, uid: master.uid, @@ -301,7 +345,7 @@ function createOccurrence( recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides, excludedRecurrenceRules: master.excludedRecurrenceRules, - }; + } as CalendarEvent; } // ---------------------------------------------------------------------------