From b588954ea4b90c5a9e1772877f80b5a152a46454 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:42:36 +0100 Subject: [PATCH 1/7] feat: pick an event's time zone by city with search, and open Duplicate as an unsaved copy at the same time The editor's time zone test now picks a zone through the searchable picker instead of a select's change event, since the select is gone. --- .../calendar-duplicate-ux-p2-pim.test.tsx | 156 ++++++++++++++++++ .../event-modal-time-zone-ux-pim.test.tsx | 47 +++++- components/calendar/calendar-app.tsx | 94 +++++------ components/calendar/event-modal.tsx | 106 +++++++----- components/settings/time-zone-picker.tsx | 27 ++- components/ui/searchable-select.tsx | 7 +- lib/__tests__/calendar-duplicate.test.ts | 60 ------- lib/calendar-duplicate.ts | 35 ---- lib/event-time-zone.ts | 5 +- 9 files changed, 336 insertions(+), 201 deletions(-) create mode 100644 components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx delete mode 100644 lib/__tests__/calendar-duplicate.test.ts delete mode 100644 lib/calendar-duplicate.ts diff --git a/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx b/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx new file mode 100644 index 000000000..1902e8850 --- /dev/null +++ b/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useTaskStore } from '@/stores/task-store'; + +// The whole calendar page, with the shell around it stubbed out and the +// stores holding a fixed set of calendars and events. + +const media = vi.hoisted(() => ({ mobile: false, desktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => media.mobile, + useIsDesktop: () => !media.mobile, + // `desktop` is the full desktop: below it the sidebar opens over the page. + useIsFullDesktop: () => media.desktop, + useIsTablet: () => !media.mobile && !media.desktop, + useMediaQuery: () => false, +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) })); +vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} })); +vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] })); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); +const config = vi.hoisted(() => ({ demoMode: false })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => config })); +const lite = vi.hoisted(() => ({ on: false })); +vi.mock('@/lib/lite', async (importOriginal) => ({ + ...(await importOriginal()), + get IS_LITE() { return lite.on; }, +})); + +import { CalendarApp } from '../calendar-app'; + +// The first render loads the whole page and is slow under load. +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true }, + participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed', + ...overrides, + } as unknown as CalendarEvent; +} + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const store = { + fetchCalendars: vi.fn(), + fetchEvents: vi.fn(), + refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + rsvpEvent: vi.fn(), +}; + +function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') { + for (const fn of Object.values(store)) fn.mockReset(); + store.createEvent.mockImplementation(async (_c: unknown, data: Partial) => ({ ...makeEvent(), ...data, id: 'new' })); + store.updateEvent.mockResolvedValue(true); + store.deleteEvent.mockResolvedValue(true); + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode, + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + ...store, + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useToastStore.setState({ toasts: [] } as never); + useTaskStore.setState({ tasks: [] } as never); + client.getCalendarTasks.mockResolvedValue([]); +} + +function renderApp() { + return render(); +} + +beforeEach(() => { + media.mobile = false; + media.desktop = true; + setUp([makeEvent({ + description: 'Agenda in the doc', + locations: { l: { '@type': 'Location', name: 'Room 4' } }, + participants: { + me: { '@type': 'Participant', email: 'me@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as never)]); + useCalendarStore.setState({ participantIdentities: [{ id: 'pi', name: 'Me', calendarAddress: 'mailto:me@example.com', isDefault: true }] } as never); +}); + +// pim-p2-17: Duplicate saved a copy one day later at once and opened it to +// view, so a mistaken click left an event behind. Google and Outlook open the +// editor with an unsaved copy at the same time instead. +describe('Duplicate opens an unsaved copy in the editor', () => { + async function duplicateFromMenu() { + renderApp(); + fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'events.duplicate' })); + return screen.findByRole('dialog', { name: 'events.create' }); + } + + it('saves nothing until Save, and the form holds the copy at the same date and time', async () => { + await duplicateFromMenu(); + expect((screen.getByLabelText('form.title') as HTMLInputElement).value).toBe('Standup'); + expect((screen.getByLabelText('form.start_date') as HTMLInputElement).value).toBe('2026-10-12'); + expect((screen.getByLabelText('form.start_time') as HTMLInputElement).value).toBe('10:00'); + expect(store.createEvent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1)); + const data = store.createEvent.mock.calls[0][1] as Partial; + expect(data).toMatchObject({ title: 'Standup', start: '2026-10-12T10:00:00', calendarIds: { cal: true }, description: 'Agenda in the doc' }); + expect(data.uid).toBeTruthy(); + expect(data.uid).not.toBe('u1'); + expect(Object.values(data.locations ?? {})[0]?.name).toBe('Room 4'); + const emails = Object.values(data.participants ?? {}).map((p) => p.email); + expect(emails).toContain('ben@example.com'); + // The copy is the user's own: the user organizes it. + const owner = Object.values(data.participants ?? {}).find((p) => p.roles?.owner); + expect(owner?.email).toBe('me@example.com'); + }); + + it('leaves nothing behind when the copy is closed', async () => { + await duplicateFromMenu(); + fireEvent.click(screen.getByRole('button', { name: 'form.cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'events.create' })).toBeNull()); + expect(store.createEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx index 5ce88cfed..254543683 100644 --- a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx +++ b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx @@ -50,7 +50,16 @@ async function save(onSave: ReturnType): Promise<{ data: Partial screen.getByLabelText(label) as HTMLInputElement; +// The picker is Settings' searchable one (pim-p2-5): type a city, pick it. +async function pickZone(city: string) { + const search = await screen.findByRole('combobox', { name: 'time_zone_search' }); + fireEvent.change(search, { target: { value: city } }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`^${city}`) })); +} + beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +// jsdom has no layout; the picker scrolls its active option into view. +beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); }); afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); describe('the event editor keeps the event in its own time zone (pim-2)', () => { @@ -109,7 +118,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => // A new event is in the user's zone, behind a small link. expect(screen.queryByText(/^time_zone_line/)).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'time_zone' })); - fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + await pickZone('Tokyo'); const { data, state } = await save(onSave); expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); // 10:00 in Tokyo is 01:00 UTC. @@ -119,7 +128,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => it('tells the series save when the user picked another zone', async () => { const onSave = editor(newYork); fireEvent.click(screen.getByText(/^time_zone_line:/)); - fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + await pickZone('Tokyo'); const { data, state } = await save(onSave); expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' }); expect(state.timeZoneChanged).toBe(true); @@ -145,6 +154,40 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => }); }); +describe('the time zone picker is the one Settings has (pim-p2-5)', () => { + it('lists cities with their offset, common ones first, and no "Automatic"', async () => { + editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + const options = await screen.findAllByRole('option'); + const names = options.map((o) => o.textContent ?? ''); + // Common zones lead: UTC, then Los Angeles; no raw ids like "Africa/Abidjan" first. + expect(names[0]).toMatch(/^UTC/); + expect(names.some((n) => n.startsWith('New York') && /GMT-0[45]:00/.test(n))).toBe(true); + expect(names.some((n) => n.includes('/'))).toBe(false); + expect(screen.queryByText(/time_zone\.auto/)).toBeNull(); + }); + + it('finds a zone by typing its city and saves the IANA id', async () => { + const onSave = editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + await pickZone('Kolkata'); + const { data } = await save(onSave); + expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(data.timeZone); + }); + + it("shows an event's old zone id by its current city name", () => { + editor({ ...newYork, timeZone: 'Europe/Kiev' } as CalendarEvent); + expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"Kyiv"}'); + }); + + it('gives the "Time zone" link the link colour, not the near-white primary of the dark theme (pim-p2-19)', () => { + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + const link = screen.getByRole('button', { name: 'time_zone' }); + expect(link.className).toContain('dark:text-blue-400'); + expect(link.className).not.toContain('text-primary'); + }); +}); + describe('the event form order (pim-7)', () => { it('asks for the time right after the title and for the description last', () => { editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3ce9695bb..c5ea1a0af 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -60,7 +60,6 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { useEventUserEmails } from "@/hooks/use-event-user-emails"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { descriptionWithNote } from "@/lib/calendar-description"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; @@ -68,7 +67,7 @@ import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/l import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { getClientByLocalAccountId } from "@/stores/client-registry"; import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; -import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; +import { getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover"; @@ -280,6 +279,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [quickDraft, setQuickDraft] = useState(null); const [defaultModalTitle, setDefaultModalTitle] = useState(); const [modalStartsInEdit, setModalStartsInEdit] = useState(false); + // Duplicate opens the editor on an unsaved copy of this event (pim-p2-17). + const [copySource, setCopySource] = useState(null); + useEffect(() => { + if (!showEventModal) setCopySource(null); + }, [showEventModal]); const [showTaskModal, setShowTaskModal] = useState(false); const [editTask, setEditTask] = useState(null); const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false); @@ -740,6 +744,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setQuickDraft(null); setDefaultModalTitle(undefined); setEditEvent(null); + setCopySource(null); const d = date || selectedDate; setDefaultModalDate(d); setDefaultModalEndDate(endDate); @@ -752,6 +757,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => whenEditorsDiscarded(() => { setModalStartsInEdit(startInForm); + setCopySource(null); setEditEvent(event); setDefaultModalDate(undefined); setShowTaskModal(false); @@ -1335,24 +1341,24 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }; }, [quickDraft, pendingPreview, t]); - const handleDuplicateEvent = useCallback(async (data: Partial) => { - if (!client) { toast.error(t("notifications.event_error")); return; } - try { - const created = await createEvent(client, data); - if (!created) { - toast.error(t("notifications.event_error")); - return; - } - focusCalendarOnEvent(created); - toast.success(t("notifications.event_duplicated")); - setEditEvent(created); - setDefaultModalDate(undefined); - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - setShowEventModal(false); - setEditEvent(null); - } - }, [client, createEvent, focusCalendarOnEvent, describeError, t]); + // Duplicate, from every place that offers it: the editor opens on a copy at + // the same date and time, as in Google and Outlook. Nothing is saved until + // Save, so a mistaken click leaves nothing behind (pim-p2-17). The copy goes + // to the original's calendar when the user can write there. + const handleDuplicateEvent = useCallback((event: CalendarEvent) => whenEditorsDiscarded(() => { + const primary = getPrimaryCalendarId(event); + const calendar = calendars.find((c) => c.id === primary); + setDefaultCalendarIdForCreate(calendar && !calendar.isTasksOnly && canCreateEventsIn(calendar, isSubscriptionCalendar) ? calendar.id : undefined); + setEditEvent(null); + setCopySource(event); + setModalStartsInEdit(true); + setDefaultModalDate(undefined); + setDefaultModalEndDate(undefined); + setDefaultModalTitle(undefined); + setShowTaskModal(false); + setEditTask(null); + setShowEventModal(true); + }), [calendars, isSubscriptionCalendar, whenEditorsDiscarded]); const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => { if (!client) { toast.error(t("notifications.event_error")); return; } @@ -1540,20 +1546,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleDeleteEvent(detailEvent.id, hasParticipants || undefined); }, [detailEvent, closeDetail, handleDeleteEvent]); - const handleDuplicateFromDetail = useCallback(async () => { - if (!detailEvent || !client) return; - const data = buildDuplicateEventData(detailEvent); + const handleDuplicateFromDetail = useCallback(() => { + if (!detailEvent) return; + const source = detailEvent; closeDetail(); - try { - const created = await createEvent(client, data); - if (created) { - toast.success(t("notifications.event_duplicated")); - openEditModal(created); - } - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - } - }, [detailEvent, client, createEvent, closeDetail, openEditModal, describeError, t]); + handleDuplicateEvent(source); + }, [detailEvent, closeDetail, handleDuplicateEvent]); const handleSaveNoteFromDetail = useCallback(async (note: string) => { if (!detailEvent || !client) return; @@ -1568,19 +1566,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }, [detailEvent, client, updateEvent, describeError, t]); - const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => { - if (!client) { toast.error(t("notifications.event_error")); return; } - const data = buildDuplicateEventData(event); - try { - const created = await createEvent(client, data); - if (created) { - toast.success(t("notifications.event_duplicated")); - openEditModal(created); - } - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - } - }, [client, createEvent, openEditModal, describeError, t]); + const handleDuplicateContextMenu = useCallback((event: CalendarEvent) => { + handleDuplicateEvent(event); + }, [handleDuplicateEvent]); const handleExportICS = useCallback((event: CalendarEvent) => { try { @@ -2426,8 +2414,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {!isMobile && showEventModal && (
openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} - currentUserEmails={userEmailsFor(editEvent)} + currentUserEmails={userEmailsFor(editEvent ?? copySource)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={false} @@ -2593,8 +2582,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {showEventModal && isMobile && ( openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} - onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} - currentUserEmails={userEmailsFor(editEvent)} + onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + currentUserEmails={userEmailsFor(editEvent ?? copySource)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={true} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 5fe1e9335..e9e17d4e6 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -19,7 +19,8 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { TimeZonePicker } from "@/components/settings/time-zone-picker"; import { addToWall, compareWall, displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, wallFields, zonedTimesText, zoneFieldsToDisplay, zoneWallDurationMs, zoneWallToDisplay } from "@/lib/event-time-zone"; import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; @@ -40,7 +41,6 @@ import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/r import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; -import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; @@ -85,7 +85,13 @@ interface EventModalProps { */ onSave: (data: Partial, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; - onDuplicate?: (data: Partial) => void; + /** Opens the editor on an unsaved copy of the event (copyOf). */ + onDuplicate?: (event: CalendarEvent) => void; + /** + * A new event that starts as a copy of this one: same date and time, text, + * places, repeat, alerts and guests. Nothing is saved until Save (pim-p2-17). + */ + copyOf?: CalendarEvent | null; /** `sentBy`: answering for the calendar's owner (lib/calendar-delegate.ts). */ onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => void; onClose: () => void; @@ -266,6 +272,7 @@ export function EventModal({ onSave, onDelete, onDuplicate, + copyOf = null, onRsvp, onClose, onPreviewChange, @@ -299,6 +306,8 @@ export function EventModal({ ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"; const isEdit = !!event; + // What the form starts from: the event, or for a copy the event copied. + const seed = event ?? copyOf; const formatEventDate = useFormatEventDate(); const { formatDateRangeLabel } = useCalendarLocale(); const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit"); @@ -346,9 +355,9 @@ export function EventModal({ }, [event, currentUserEmails]); const existingParticipants = useMemo(() => { - if (!event) return []; - return getParticipantList(event, { resolveName: resolveContactName }); - }, [event, resolveContactName]); + if (!seed) return []; + return getParticipantList(seed, { resolveName: resolveContactName }); + }, [seed, resolveContactName]); const organizerInfo = useMemo(() => { if (!event?.participants) return null; @@ -357,7 +366,7 @@ export function EventModal({ }, [event, existingParticipants]); const getInitialStart = (): Date => { - if (event?.start) return getEventStartDate(event); + if (seed?.start) return getEventStartDate(seed); if (defaultDate) { const d = new Date(defaultDate); if (defaultEndDate) return d; @@ -371,31 +380,31 @@ export function EventModal({ }; const getInitialEnd = (): Date => { - if (event?.start) { - if (event.showWithoutTime) { - return getEventDisplayEndDate(event); + if (seed?.start) { + if (seed.showWithoutTime) { + return getEventDisplayEndDate(seed); } - return getEventEndDate(event); + return getEventEndDate(seed); } if (defaultEndDate) return new Date(defaultEndDate); return addHours(getInitialStart(), 1); }; - const [title, setTitle] = useState(event?.title || defaultTitle || ""); + const [title, setTitle] = useState(seed?.title || defaultTitle || ""); // An HTML description is shown and edited as its text. - const eventDescription = useMemo(() => (event ? descriptionAsText(event) : ""), [event]); + const eventDescription = useMemo(() => (seed ? descriptionAsText(seed) : ""), [seed]); const [description, setDescription] = useState(eventDescription); const [location, setLocation] = useState( - event?.locations ? Object.values(event.locations)[0]?.name || "" : "" + seed?.locations ? Object.values(seed.locations)[0]?.name || "" : "" ); const [virtualLocation, setVirtualLocation] = useState( - event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : "" + seed?.virtualLocations ? Object.values(seed.virtualLocations)[0]?.uri || "" : "" ); // The zone the date and time fields are in, and a save writes: the // event's own, the user's for a new event, none for a floating one. An // edit used to save every event in the user's zone (pim-2). const userTimeZone = getEffectiveTimeZone(); - const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(event, userTimeZone)); + const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(seed, userTimeZone)); const [showZonePicker, setShowZonePicker] = useState(false); const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone)); const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone)); @@ -403,14 +412,9 @@ export function EventModal({ const [startTime, setStartTime] = useState(formatTimeInput(initialStartWall)); const [endDate, setEndDate] = useState(formatDateInput(initialEndWall)); const [endTime, setEndTime] = useState(formatTimeInput(initialEndWall)); - const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false); + const [allDay, setAllDay] = useState(seed?.showWithoutTime || defaultAllDay || false); // The zone the fields are on: none for an all-day event. const fieldZone = allDay ? null : timeZone; - const zoneOptions = useMemo(() => { - if (!showZonePicker) return []; - const zones = listTimeZones(); - return timeZone && !zones.includes(timeZone) ? [timeZone, ...zones] : zones; - }, [showZonePicker, timeZone]); // The form's times on the user's clock, beside another zone's. const formYourTimes = useMemo(() => { if (!fieldZone || fieldZone === userTimeZone) return null; @@ -461,13 +465,13 @@ export function EventModal({ [calendars, isSubscriptionCalendar, calendarId, eventLogin] ); const [recurrence, setRecurrence] = useState(() => { - if (!event?.recurrenceRules?.length) return "none"; - const rule = event.recurrenceRules[0]; + if (!seed?.recurrenceRules?.length) return "none"; + const rule = seed.recurrenceRules[0]; return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom"; }); const [customRule, setCustomRule] = useState(() => { - if (!event?.recurrenceRules?.length) return null; - const rule = event.recurrenceRules[0]; + if (!seed?.recurrenceRules?.length) return null; + const rule = seed.recurrenceRules[0]; return isSimpleRecurrenceRule(rule) ? null : rule; }); // The recurrence the editor opened with: a rule the user did not touch is @@ -495,11 +499,11 @@ export function EventModal({ [customRule, t, locale] ); // A new event starts on the calendar's default alerts. - const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true)); + const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (seed ? seed.useDefaultAlerts === true : true)); // Alerts the rows can't represent (absolute triggers, offsets after the // start or relative to the end) are kept here so they survive a save. const [initialAlerts] = useState(() => - event && !event.useDefaultAlerts ? alertRowsOf(event.alerts) : { rows: [], preserved: {} } + seed && !seed.useDefaultAlerts ? alertRowsOf(seed.alerts) : { rows: [], preserved: {} } ); const [preservedAlerts, setPreservedAlerts] = useState>(initialAlerts.preserved); const [alertRows, setAlertRows] = useState(initialAlerts.rows); @@ -538,7 +542,19 @@ export function EventModal({ // The attendees the editor opened with: a save for every occurrence of a // series applies only those the user added or removed. const [initialAttendees] = useState(() => { - if (!event?.participants) return []; + if (!seed?.participants) return []; + // A copy is the user's own event: everyone else is invited, its old + // organizer too, and the user isn't a guest of it. + if (!event) { + const mine = new Set(currentUserEmails.map((e) => e.toLowerCase())); + return existingParticipants.reduce<{ name: string; email: string }[]>((acc, p) => { + const key = p.email.trim().toLowerCase(); + if (!key || mine.has(key)) return acc; + mine.add(key); + acc.push({ name: p.name, email: p.email.trim() }); + return acc; + }, []); + } // Never seed the invite list with the organizer or with a repeated address: // handleSave writes the organizer separately, so either would round-trip // into a duplicate entry every time the event is saved (#731). The @@ -852,9 +868,13 @@ export function EventModal({ // invitation e-mails then render as a "Description" heading over blank space. // An HTML description the user did not touch stays as it was; one they // edited is saved as the plain text it now is. - const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim(); + const keepsHtml = !!seed && isHtmlDescription(seed) && trimmedDescription === eventDescription.trim(); if ((trimmedDescription || event) && !keepsHtml) { Object.assign(data, plainDescriptionFields(event, trimmedDescription)); + } else if (keepsHtml && !event) { + // A copy keeps the formatted description it was made from. + data.description = seed.description; + data.descriptionContentType = seed.descriptionContentType; } if (!event) { @@ -1004,7 +1024,7 @@ export function EventModal({ savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, seed, eventDescription, onSave, isSaving, t]); // The user's own answer, and the owner's in a calendar shared with them // (lib/calendar-delegate.ts). @@ -1021,7 +1041,7 @@ export function EventModal({ const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; - onDuplicate(buildDuplicateEventData(event)); + onDuplicate(event); }, [event, onDuplicate]); const titleRef = useRef(null); @@ -1419,17 +1439,16 @@ export function EventModal({
{showZonePicker ? ( <> - - + onChange={setTimeZone} + withAuto={false} + label={tEvent("time_zone")} + className="w-full" + defaultOpen + /> ) : fieldZone !== userTimeZone ? (
@@ -1441,7 +1460,8 @@ export function EventModal({ )}
) : ( - )} diff --git a/components/settings/time-zone-picker.tsx b/components/settings/time-zone-picker.tsx index cf772ea58..fa0b4f93c 100644 --- a/components/settings/time-zone-picker.tsx +++ b/components/settings/time-zone-picker.tsx @@ -12,7 +12,23 @@ import { getEffectiveLocale } from '@/i18n/detect-locale'; * GMT+01:00"), the common zones first. Searching finds a zone by city, id, * offset ("+05:30", "UTC-5") or its name in the UI language. */ -export function TimeZonePicker({ value, onChange }: { value: string; onChange: (value: string) => void }) { +export function TimeZonePicker({ + value, + onChange, + withAuto = true, + label, + className = 'w-64 max-w-full', + defaultOpen, +}: { + value: string; + onChange: (value: string) => void; + /** "Automatic ()" first. Off where a zone has to be named, as for an event. */ + withAuto?: boolean; + /** The field's name, when it isn't Settings' "Time zone". */ + label?: string; + className?: string; + defaultOpen?: boolean; +}) { const t = useTranslations('settings.language_region'); const tz = useTranslations('uxs_general'); @@ -40,11 +56,11 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: ( group, }); return [ - { value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }, + ...(withAuto ? [{ value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }] : []), ...common.map(toOption(tz('time_zone_common'))), ...all.map(toOption(tz('time_zone_all'))), ]; - }, [zones, value, browserTimeZone, t, tz, offsets]); + }, [zones, value, browserTimeZone, t, tz, offsets, withAuto]); // "CET" and "GMT+1" find their zones first (settings-p2-20). const rank = useCallback( (query: string, option: SearchableOption, wordsMatch: boolean) => @@ -55,13 +71,14 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: ( const current = options.find((option) => option.value === value); return ( ( diff --git a/components/ui/searchable-select.tsx b/components/ui/searchable-select.tsx index 8eea60a78..3130ff67a 100644 --- a/components/ui/searchable-select.tsx +++ b/components/ui/searchable-select.tsx @@ -40,6 +40,8 @@ interface SearchableSelectProps { * their order. */ rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null; + /** Opens the list on mount, for a field that appears because the user asked to change it. */ + defaultOpen?: boolean; } /** @@ -58,10 +60,11 @@ export function SearchableSelect({ className, menuClassName, rank, + defaultOpen = false, }: SearchableSelectProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); const [query, setQuery] = useState(''); - const [active, setActive] = useState(0); + const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, options.findIndex((o) => o.value === value)) : 0)); const containerRef = useRef(null); const buttonRef = useRef(null); const inputRef = useRef(null); diff --git a/lib/__tests__/calendar-duplicate.test.ts b/lib/__tests__/calendar-duplicate.test.ts deleted file mode 100644 index d6b739f3b..000000000 --- a/lib/__tests__/calendar-duplicate.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { CalendarEvent } from '@/lib/jmap/types'; -import { buildDuplicateEventData } from '../calendar-duplicate'; - -function makeEvent(overrides: Partial = {}): CalendarEvent { - return { - id: 'ev1', - uid: 'original-uid', - title: 'Standup', - description: 'Daily sync', - start: '2026-10-05T09:30:00', - duration: 'PT30M', - timeZone: 'Europe/Berlin', - showWithoutTime: false, - calendarIds: { cal1: true }, - status: 'tentative', - ...overrides, - } as CalendarEvent; -} - -describe('buildDuplicateEventData', () => { - it('keeps the meeting link (#1170)', () => { - const virtualLocations = { - v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' }, - } as unknown as CalendarEvent['virtualLocations']; - const data = buildDuplicateEventData(makeEvent({ virtualLocations })); - expect(data.virtualLocations).toEqual(virtualLocations); - expect(data.virtualLocations).not.toBe(virtualLocations); - }); - - // Without the type the copy's HTML would be stored, and shown, as text. - it('keeps the format of an HTML description', () => { - const data = buildDuplicateEventData(makeEvent({ description: '

Daily

', descriptionContentType: 'text/html' })); - expect(data).toMatchObject({ description: '

Daily

', descriptionContentType: 'text/html' }); - }); - - it('moves a timed event one day later in its own time zone with a fresh uid', () => { - const data = buildDuplicateEventData(makeEvent()); - expect(data).toMatchObject({ - title: 'Standup', - start: '2026-10-06T09:30:00', - duration: 'PT30M', - timeZone: 'Europe/Berlin', - calendarIds: { cal1: true }, - status: 'confirmed', - }); - expect(data.uid).toBeTruthy(); - expect(data.uid).not.toBe('original-uid'); - expect(data.id).toBeUndefined(); - }); - - it('sends all-day copies as floating midnight starts', () => { - const data = buildDuplicateEventData(makeEvent({ - start: '2026-10-05T00:00:00', - duration: 'P1D', - showWithoutTime: true, - })); - expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null }); - }); -}); diff --git a/lib/calendar-duplicate.ts b/lib/calendar-duplicate.ts deleted file mode 100644 index 437d08345..000000000 --- a/lib/calendar-duplicate.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addDays, format, parseISO } from "date-fns"; -import type { CalendarEvent } from "@/lib/jmap/types"; -import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; -import { generateUUID } from "@/lib/utils"; - -/** - * Create payload for a copy of `event` one day later. Shared by the editor, - * the detail popover and the context menu - each used to build its own copy - * and two of them dropped the meeting link (`virtualLocations`, #1170). - * The start stays in the event's own wall clock and time zone. - */ -export function buildDuplicateEventData(event: CalendarEvent): Partial { - const newStart = addDays(parseISO(event.start), 1); - const data: Partial = { - uid: generateUUID(), - title: event.title, - description: event.description, - start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: event.duration, - timeZone: event.timeZone, - showWithoutTime: event.showWithoutTime, - calendarIds: { ...event.calendarIds }, - status: "confirmed", - freeBusyStatus: event.freeBusyStatus, - privacy: event.privacy, - }; - // Without its type an HTML description would be stored as plain text. - if (event.descriptionContentType) data.descriptionContentType = event.descriptionContentType; - if (event.locations) data.locations = structuredClone(event.locations); - if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); - if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); - if (event.alerts) data.alerts = structuredClone(event.alerts); - if (event.participants) data.participants = structuredClone(event.participants); - return sanitizeOutgoingCalendarEventData(data); -} diff --git a/lib/event-time-zone.ts b/lib/event-time-zone.ts index 20fa089f3..fd4261736 100644 --- a/lib/event-time-zone.ts +++ b/lib/event-time-zone.ts @@ -1,5 +1,6 @@ import { format, isSameDay } from "date-fns"; import type { CalendarEvent } from "@/lib/jmap/types"; +import { timeZoneCity as friendlyCity } from "@/lib/time-zone-choices"; import { canonicalTimeZone, fromDisplayDate, @@ -101,8 +102,8 @@ export function zoneWallDurationMs(a: WallClock, b: WallClock, zone: string | nu /** "America/New_York" as people say it: "New York". */ export function timeZoneCity(zone: string): string { - const last = zone.split("/").pop() || zone; - return last.replace(/_/g, " "); + // Settings' names (pim-p2-5): current city names ("Kyiv", not "Kiev"). + return friendlyCity(zone); } /** Whether the clocks in `a` and `b` read alike at `instant`. */ From 2c8c58ce080445b6b0b132757ad5a5f53a01f721 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:45:25 +0100 Subject: [PATCH 2/7] fix: never call a done or cancelled task overdue, and count overdue tasks in the sidebar as the list does --- .../task-done-not-overdue-ux-p2-pim.test.tsx | 75 +++++++++++++++++++ .../calendar/calendar-sidebar-panel.tsx | 9 +-- components/calendar/task-list-view.tsx | 26 +++++-- 3 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx b/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx new file mode 100644 index 000000000..eb7eb5ce2 --- /dev/null +++ b/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; + +// pim-p2-12: a completed task still said "Overdue" in red, and the sidebar +// counted tasks due earlier today as overdue while the list didn't. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', options).format(d), + }), +})); + +import { TaskListView, isTaskOverdue } from '../task-list-view'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; + +const calendars = [{ id: 'cal', name: 'To-do', color: '#123456', isTasksOnly: true }] as unknown as Calendar[]; + +function task(overrides: Partial): CalendarTask { + return { + id: 't', title: 'Pay the dentist invoice', calendarIds: { cal: true }, progress: 'needs-action', + priority: 0, due: '2026-10-20', showWithoutTime: true, ...overrides, + } as unknown as CalendarTask; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 10, 4, 15, 0)); +}); +afterEach(() => { + vi.useRealTimers(); + useTaskStore.setState({ tasks: [] } as never); +}); + +describe('done tasks are never overdue', () => { + it('shows a completed task past its date with the plain date in grey', () => { + render( {}} onToggleComplete={() => {}} />); + expect(screen.queryByText('tasks.overdue')).toBeNull(); + const date = screen.getByText('Oct 20'); + expect(date.className).toContain('text-muted-foreground'); + expect(date.className).not.toMatch(/text-red/); + }); + + it('still warns about an open task past its date', () => { + render( {}} onToggleComplete={() => {}} />); + expect(screen.getByText('tasks.overdue')).toBeInTheDocument(); + }); + + it('says so for cancelled tasks too, and not for one due later today', () => { + expect(isTaskOverdue(task({ progress: 'cancelled' }))).toBe(false); + expect(isTaskOverdue(task({ progress: 'completed' }))).toBe(false); + expect(isTaskOverdue(task({}))).toBe(true); + expect(isTaskOverdue(task({ due: '2026-11-04T09:00:00', showWithoutTime: false }))).toBe(false); + }); + + it('counts overdue tasks in the sidebar by the same rule as the list', () => { + useSettingsStore.setState({ enableCalendarTasks: true } as never); + useTaskStore.setState({ + tasks: [ + task({ id: 'a' }), + task({ id: 'b', progress: 'completed' }), + // Due this morning: today, not overdue, as the list says. + task({ id: 'c', due: '2026-11-04T09:00:00', showWithoutTime: false }), + ], + } as never); + const client = { getAccountId: () => 'me' } as unknown as IJMAPClient; + render( {}} client={client} />); + expect(screen.getByText(/^1 tasks\.filter_overdue$/i)).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 98ad798c0..b48e67ebe 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -20,9 +20,8 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { taskListsOf } from "@/components/calendar/task-list-view"; +import { isTaskOverdue, taskListsOf } from "@/components/calendar/task-list-view"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import { displayNow } from "@/lib/timezone"; import { readableTextOn } from "@/lib/color-transform"; import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels"; import { useCvdColor } from "@/hooks/use-cvd-color"; @@ -124,10 +123,8 @@ export function CalendarSidebarPanel({ const setViewMode = useCalendarStore((s) => s.setViewMode); const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]); - const overdueTaskCount = useMemo(() => { - const now = displayNow(); - return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; - }, [tasks]); + // The same rule as the task list's Overdue filter (pim-p2-12). + const overdueTaskCount = useMemo(() => tasks.filter(isTaskOverdue).length, [tasks]); const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 8e2391ead..f907b4186 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -123,9 +123,24 @@ function getTaskPriorityIcon(priority: number) { type FormatDate = ReturnType["dateTime"]; -function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string, formatDate: FormatDate): { label: string; className: string } { +/** Done and cancelled tasks are never overdue (pim-p2-12); a task due today isn't yet. */ +export function isTaskOverdue(task: Pick): boolean { + if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; + const dueDate = parseISO(task.due); + return isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); +} + +export function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string, formatDate: FormatDate, done = false): { label: string; className: string } { const dueDate = parseISO(due); - const overdue = isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); + const overdue = !done && isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); + + // A finished task's date is a fact, not a warning: plain date, grey. + if (done) { + const formatted = showWithoutTime + ? formatDate(dueDate, { month: "short", day: "numeric" }) + : formatDate(dueDate, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: timeFormat === "12h" }); + return { label: formatted, className: "text-muted-foreground" }; + } if (isDisplayToday(dueDate)) { return { @@ -182,10 +197,7 @@ export function filterTasksForList( result = result.filter(task => task.progress === "completed"); break; case "overdue": - result = result.filter(task => { - if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; - return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due)); - }); + result = result.filter(isTaskOverdue); break; } @@ -258,7 +270,7 @@ export function TaskListView({ const cal = calendars.find(c => task.calendarIds[c.id]); const isCompleted = task.progress === "completed"; const priorityIcon = getTaskPriorityIcon(task.priority); - const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate) : null; + const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate, task.progress === "completed" || task.progress === "cancelled") : null; return (
Date: Wed, 4 Nov 2026 13:47:50 +0100 Subject: [PATCH 3/7] fix: write an event's length in the app's language in its popover, as 1 Std. in German --- .../event-popover-duration-ux-p2-pim.test.tsx | 55 +++++++++++++++++++ components/calendar/event-detail-popover.tsx | 20 +++++-- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx new file mode 100644 index 000000000..8968975ef --- /dev/null +++ b/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the German popover read "16:00 – 17:00 (1h)"; German writes "1 Std.". + +const lang = vi.hoisted(() => ({ locale: 'de' })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => lang.locale, + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); + +import { EventDetailPopover } from '../event-detail-popover'; + +const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar; + +function popover(duration: string) { + const event = { + id: 'e1', calendarIds: { cal: true }, title: 'Team sync', start: '2026-11-05T16:00:00', duration, + timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false, + } as unknown as CalendarEvent; + render( + , + ); +} + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => { + useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }); + lang.locale = 'de'; +}); + +describe('the popover writes the duration in the app language', () => { + it('in German', () => { + popover('PT1H'); + expect(screen.getByText('(1 Std.)')).toBeInTheDocument(); + expect(screen.queryByText('(1h)')).toBeNull(); + }); + + it('with hours and minutes', () => { + popover('PT1H30M'); + expect(screen.getByText('(1 Std. 30 Min.)')).toBeInTheDocument(); + }); + + it('in English', () => { + lang.locale = 'en'; + popover('PT45M'); + expect(screen.getByText('(45 min)')).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index a9c6529b0..b6cff7a0b 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -114,12 +114,20 @@ function computePosition( }; } -function formatDurationDisplay(minutes: number): string { - if (minutes < 60) return `${minutes}min`; +/** "1 hr 30 min", "1 Std. 30 Min.": the language's own short units (pim-p2-19). */ +export function formatDurationDisplay(minutes: number, locale: string): string { + const unit = (value: number, name: "hour" | "minute") => { + try { + return new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short" }).format(value); + } catch { + return `${value}${name === "hour" ? "h" : "min"}`; + } + }; const h = Math.floor(minutes / 60); const m = minutes % 60; - if (m === 0) return `${h}h`; - return `${h}h${m}min`; + if (h === 0) return unit(m, "minute"); + if (m === 0) return unit(h, "hour"); + return `${unit(h, "hour")} ${unit(m, "minute")}`; } function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { @@ -506,7 +514,7 @@ export function EventDetailPopover({
- ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes, locale)})
{zoned && zonedText && (
@@ -529,7 +537,7 @@ export function EventDetailPopover({ ) : (
{formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes, locale)})
)} From cf844ff4d7368713d3842d8c1317c1665ce90bb6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:51:08 +0100 Subject: [PATCH 4/7] fix: keep an event's title in sight while its block runs above the visible hours, as an overnight event's morning part --- .../event-block-title-ux-p2-pim.test.tsx | 37 +++++++++++++++++++ components/calendar/event-card.tsx | 10 ++++- hooks/use-display-hours.ts | 8 +++- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx new file mode 100644 index 000000000..fce16b53c --- /dev/null +++ b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx @@ -0,0 +1,37 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { EventCard } from '../event-card'; +import { useDisplayHours } from '@/hooks/use-display-hours'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the part of "Night train to Vienna" after midnight starts at the +// top of the day, scrolled out of view, so the week showed a block with no +// title. The title line now sticks under the grid's header while the block is +// in sight. A block that hid its overflow was a scroller of its own, and a +// sticky line inside one sticks to the block, not to the grid. + +const train = { + id: 'n1', '@type': 'Event', uid: 'n1', title: 'Night train to Vienna', start: '2026-11-07T00:00:00', + duration: 'PT7H40M', showWithoutTime: false, status: 'confirmed', calendarIds: { cal: true }, participants: null, +} as unknown as CalendarEvent; + +describe('the title of a long block stays in sight', () => { + it('sticks the title line under the header and clips the block without making it a scroller', () => { + render(); + const card = screen.getByRole('button', { name: /Night train to Vienna/ }); + expect(card.className).toContain('overflow-clip'); + expect(card.className).not.toContain('overflow-hidden'); + const line = card.querySelector('[data-event-title-line]') as HTMLElement; + expect(line.className).toContain('sticky'); + expect(line.className).toContain('top-[var(--grid-header-height,0px)]'); + expect(line.textContent).toContain('Night train to Vienna'); + }); + + it('tells the grid how tall its header is, for the sticky title', () => { + const pane = document.createElement('div'); + const header = document.createElement('div'); + Object.defineProperty(header, 'offsetHeight', { value: 96 }); + renderHook(() => useDisplayHours({ scrollRef: { current: pane }, headerRef: { current: header }, minHourHeight: 48 })); + expect(pane.style.getPropertyValue('--grid-header-height')).toBe('96px'); + }); +}); diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 4a31e76e9..8f9732155 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -450,8 +450,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM data-continues-after={continuesAfter || undefined} className={cn( // A block box: an inline one hangs its line's descent below the - // grid's last hour and makes the filled pane scroll. - "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", + // grid's last hour and makes the filled pane scroll. Clipped, not + // hidden: hidden makes the block a scroller and its title can't stick. + "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-clip", "hover:opacity-90 transition-opacity cursor-pointer", // Cut at the edge of the visible hours: a square edge and an arrow // say the event runs on past it. @@ -470,6 +471,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {/* Shorter blocks have no room for a second line at the 60px hour, so their start time follows the title on the same line, when the block is wide enough to keep the title readable. */} + {/* The title stays in sight under the grid's header while the block + is: the morning part of a night train started at midnight, scrolled + out of view, and showed as a block with no name (pim-p2-19). */} +
{event.title || t("events.no_title")} @@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)} +
{visibleCalendarName && (
{visibleCalendarName}
)} diff --git a/hooks/use-display-hours.ts b/hooks/use-display-hours.ts index 68a64e34c..39821c87f 100644 --- a/hooks/use-display-hours.ts +++ b/hooks/use-display-hours.ts @@ -58,7 +58,13 @@ export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt useLayoutEffect(() => { const root = scrollRef.current; if (!root) return; - const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0))); + const measure = () => { + const headerHeight = headerRef?.current?.offsetHeight ?? 0; + setAvailable(Math.max(0, root.clientHeight - headerHeight)); + // Where an event's title sticks while its block runs above the visible + // hours: an overnight event's morning part (pim-p2-19). + root.style?.setProperty("--grid-header-height", `${headerHeight}px`); + }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); From b269d2f2ed5c2184f29064238e999e208ee3f003 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:54:22 +0100 Subject: [PATCH 5/7] fix: show toasts beside the calendar's event and task panels instead of over their title and Save --- app/globals.css | 8 ++++ .../toast-beside-sheet-ux-p2-pim.test.tsx | 42 +++++++++++++++++++ components/calendar/calendar-app.tsx | 4 +- 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx diff --git a/app/globals.css b/app/globals.css index 6ecc0f095..b99ba0f7e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -638,6 +638,14 @@ html[data-window-controls] [data-own-window-close] { display: none; } +/* + * A sheet docked at the window's end (the calendar's event and task panels) has its title + * and close at the top and Save at the foot: toasts stand beside it, not on it (pim-p2-19). + */ +body:has([data-end-sheet]) [data-toast-container] { + inset-inline-end: calc(var(--end-sheet-width, 400px) + 1.25rem); +} + /* Toasts that step out of the focus's way go to the top end corner: below the controls there. */ html[data-window-controls] [data-toast-container][data-dodging] { top: calc(var(--titlebar-row-height) + 0.75rem) !important; diff --git a/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx new file mode 100644 index 000000000..148a6a4e5 --- /dev/null +++ b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx @@ -0,0 +1,42 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +// pim-p2-19: toasts at the top right covered the event sheet's title and +// close button; at the bottom right they covered its Save. They stand beside +// a sheet docked at the window's end now. jsdom has no layout, so the rule is +// checked through the cascade: the stylesheet's own rule, applied in jsdom. + +const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8'); + +function endSheetRule(): string { + const match = /body:has\(\[data-end-sheet\]\) \[data-toast-container\] \{[^}]*\}/.exec(css); + expect(match, 'no end-sheet rule for toasts in globals.css').not.toBeNull(); + return match![0]; +} + +describe('toasts beside an end sheet', () => { + it('move the stack past the sheet while one is open', () => { + const style = document.createElement('style'); + style.textContent = endSheetRule(); + document.head.appendChild(style); + const container = document.createElement('div'); + container.setAttribute('data-toast-container', ''); + document.body.appendChild(container); + expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).not.toContain('400px'); + + const sheet = document.createElement('div'); + sheet.setAttribute('data-end-sheet', ''); + document.body.appendChild(sheet); + expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).toBe('calc(var(--end-sheet-width, 400px) + 1.25rem)'); + style.remove(); + container.remove(); + sheet.remove(); + }); + + it('marks the calendar event and task panels as end sheets', () => { + const app = readFileSync(path.join(process.cwd(), 'components', 'calendar', 'calendar-app.tsx'), 'utf8'); + expect(app).toMatch(/data-testid="event-panel" data-end-sheet=""/); + expect(app).toMatch(/data-end-sheet="" className="w-\[400px\][^"]*"[\s\S]{0,40} +
+
Date: Wed, 4 Nov 2026 14:03:08 +0100 Subject: [PATCH 6/7] fix: say that a series invitation repeats, answer the whole series, and name the weeks declined on their own --- .../invitation-series-ux-p2-pim.test.tsx | 262 ++++++++++++++++++ .../email/calendar-invitation-banner.tsx | 58 +++- .../series-answers-ux-p2-pim.test.ts | 48 ++++ lib/calendar-invitation.ts | 30 ++ locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/et/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/th/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/vi/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + 34 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/invitation-series-ux-p2-pim.test.tsx create mode 100644 lib/__tests__/series-answers-ux-p2-pim.test.ts diff --git a/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx new file mode 100644 index 000000000..df63353a9 --- /dev/null +++ b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx @@ -0,0 +1,262 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CalendarInvitationBanner } from '../calendar-invitation-banner'; +import type { Email } from '@/lib/jmap/types'; + +const mocks = vi.hoisted(() => { + const pushMock = vi.fn(); + const clientMock = { + parseCalendarEvents: vi.fn(), + getAccountId: vi.fn(() => 'mail-account'), + getCalendarsAccountId: vi.fn(() => 'calendar-account'), + getCalendarEvent: vi.fn(), + queryCalendarEvents: vi.fn(async (): Promise => []), + // Who owns a shared or group account (Principal/get). + getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team' + ? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' } + : { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))), + }; + + const sourceClientMock = { + parseCalendarEvents: vi.fn(), + fetchBlob: vi.fn(), + getAccountId: vi.fn(() => 'source-mail-account'), + getCalendarsAccountId: vi.fn(() => 'source-calendar-account'), + }; + + const authState = { + client: clientMock, + primaryIdentity: { email: 'user@example.com' }, + identities: [{ email: 'user@example.com' }] as Array<{ email: string }>, + getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)), + }; + + const emailState = { + viewingAccountId: null as string | null, + selectedMailbox: 'inbox', + mailboxes: [] as Array>, + accountMailboxes: {} as Record>>, + }; + + const settingsState = { + calendarInvitationParsingEnabled: true, + }; + + const calendarState = { + calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }], + supportsCalendar: true, + importEvents: vi.fn(), + rsvpEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + events: [] as Array>, + setSelectedDate: vi.fn(), + }; + + const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => ( + typeof selector === 'function' ? selector(calendarState) : calendarState + )) as { + (selector?: (state: typeof calendarState) => unknown): unknown; + getState: () => typeof calendarState; + setState: (updater: Partial | ((state: typeof calendarState) => Partial | typeof calendarState)) => void; + }; + + useCalendarStoreMock.getState = () => calendarState; + useCalendarStoreMock.setState = (updater) => { + const nextState = typeof updater === 'function' ? updater(calendarState) : updater; + Object.assign(calendarState, nextState); + }; + + return { + pushMock, + clientMock, + sourceClientMock, + authState, + emailState, + settingsState, + calendarState, + useCalendarStoreMock, + }; +}); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => { + const strings: Record = { + loading: 'Loading event details…', + title: 'Calendar Invitation', + organizer: 'Organized by {name}', + attendees: 'attendees', + add_to_calendar: 'Add to calendar', + added: 'Added to calendar', + rsvp_sent: 'Response sent', + already_in_calendar: 'Already in your calendar', + your_response: 'Your response: {status}', + response_accepted: 'Accepted', + response_needed: 'Needs response', + actor_response_info: '{name} responded {status}.', + actor_sent_info: 'Sent by {name}.', + actor_counter_info: '{name} proposed changes to this event.', + actor_refresh_info: '{name} asked for the latest event details.', + actor_declined_counter_info: '{name} declined the counter proposal.', + actor_note: 'Note: {comment}', + actor_unknown: 'Someone', + sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.', + attendee_mismatch_unverified_info: 'This response was sent from {sender}, while the attendee answering in the calendar data is {attendee}, and the message could not be verified.', + action_failed: 'Could not complete that calendar action.', + proposal_applied: 'Proposed changes applied.', + proposed_changes: 'Proposed changes', + change_title: 'Title', + change_time: 'Time', + change_location: 'Location', + change_description: 'Description', + change_empty: 'None', + change_from_to: '{before} -> {after}', + apply_proposal: 'Apply proposed changes', + review_proposal: 'Review proposal', + review_request: 'Review request', + view_in_calendar: 'View in calendar', + organizer_role: 'You organize this event', + accept: 'Accept', + maybe: 'Maybe', + decline: 'Decline', + select_calendar: 'Select calendar', + participant_limit: 'The server allows at most {max} participants per event.', + answered_accepted: 'You accepted on {date}.', + answered_declined: 'You declined on {date}.', + response_declined: 'Declined', + request_info: 'Respond to let the organizer know.', + repeats: 'Repeats: {rule}', + declined_dates: 'You declined {dates}.', + declined_many: 'You declined {count} meetings of this series.', + answers_series: 'Your answer is for every meeting in the series.', + 'recurrence.weekly': 'Weekly', + 'recurrence.on_days': 'on {days}', + }; + + let message = strings[key] ?? key; + if (values) { + for (const [name, value] of Object.entries(values)) { + message = message.replace(`{${name}}`, String(value)); + } + } + return message; + }, + useLocale: () => 'en-US', + useFormatter: () => ({ + dateTime: (date: Date) => date.toISOString(), + }), +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: mocks.pushMock }), +})); + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState), +})); + +vi.mock('@/stores/email-store', () => ({ + useEmailStore: (selector: (state: typeof mocks.emailState) => unknown) => selector(mocks.emailState), +})); + +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState), +})); + +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: mocks.useCalendarStoreMock, +})); + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: {}, + size: 512, + receivedAt: '2026-03-16T10:00:00Z', + hasAttachment: true, + attachments: [ + { + partId: '1', + blobId: 'blob-1', + size: 256, + type: 'text/calendar; method=REQUEST', + name: 'invite.ics', + }, + ], + ...overrides, + }; +} + +// pim-p2-8 (and pim-23): a weekly invitation where one week was declined said +// "Your response: Declined. You declined on Wed, Nov 4." while the calendar +// showed the following weeks unanswered, and nothing said it repeats. The +// card now reads the series as stored: its own answer, the declined weeks by +// date, that it repeats, and that the buttons answer every week. + +const me = { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' }; +const ben = { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }; +const rule = { '@type': 'RecurrenceRule', frequency: 'weekly', byDay: [{ '@type': 'NDay', day: 'tu' }] }; +const invitation = { + uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin', + recurrenceRules: [rule], participants: { me, ben }, +}; +// The series as Stalwart 1.0 stores it: Nov 3 declined on its own, as a pointer patch. +const master = { + id: 'series-1', uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', + timeZone: 'Europe/Berlin', recurrenceRules: [rule], participants: { me, ben }, + recurrenceOverrides: { '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }, +}; +// What the grid holds: the expanded occurrences, the declined one first. +const nov3 = { + ...master, id: 'occ-nov3', recurrenceId: '2026-11-03T16:00:00', recurrenceOverrides: null, + participants: { me: { ...me, participationStatus: 'declined' }, ben }, updated: '2026-11-04T09:00:00Z', +}; +const nov10 = { ...master, id: 'occ-nov10', recurrenceId: '2026-11-10T16:00:00', start: '2026-11-10T16:00:00', recurrenceOverrides: null }; + +describe('an invitation to a series', () => { + beforeEach(() => { + mocks.settingsState.calendarInvitationParsingEnabled = true; + mocks.clientMock.parseCalendarEvents.mockReset(); + mocks.clientMock.queryCalendarEvents.mockReset(); + mocks.calendarState.rsvpEvent.mockReset(); + mocks.calendarState.events = [nov3, nov10] as never; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]); + mocks.clientMock.queryCalendarEvents.mockImplementation(async () => [master]); + mocks.authState.identities = [{ email: 'user@example.com' }]; + }); + + it("shows the series' own answer and the declined week by its date", async () => { + render(); + expect(await screen.findByText('You declined Nov 3.')).toBeInTheDocument(); + expect(screen.queryByText('Your response: Declined')).not.toBeInTheDocument(); + expect(screen.queryByText(/^You declined on/)).not.toBeInTheDocument(); + expect(screen.getByText('Your response: Needs response')).toBeInTheDocument(); + expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'weekly-1' }); + }); + + it('says that it repeats, and that an answer is for every week', async () => { + render(); + expect(await screen.findByTestId('invitation-repeats')).toHaveTextContent('Repeats: Weekly on Tue'); + expect(await screen.findByText('Your answer is for every meeting in the series.')).toBeInTheDocument(); + }); + + it('answers the series, not the week the calendar held first', async () => { + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + render(); + await screen.findByText('You declined Nov 3.'); + fireEvent.click(screen.getByText('Accept')); + await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled()); + expect(mocks.calendarState.rsvpEvent.mock.calls[0][1]).toBe('series-1'); + expect(mocks.calendarState.rsvpEvent.mock.calls[0][3]).toBe('accepted'); + }); + + it('leaves a one-off invitation as it was', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ ...invitation, uid: 'once', recurrenceRules: null }]); + render(); + await screen.findByText('Weekly sync with Ben'); + expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument(); + expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument(); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5928868d5..c794eac84 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -18,6 +18,7 @@ import { AlertCircle, ChevronDown, ChevronUp, + Repeat, } from '@/components/icons'; import { useTranslations, useLocale } from 'next-intl'; import { InvitationPrintSync } from '@/lib/print/invitation-print'; @@ -39,6 +40,8 @@ import { invitationAnsweredAt, invitationAnsweredKey, findParticipantByEmails, + isSeriesInvitation, + seriesAnswers, extractMethodFromRawIcs, type EventSummary, type InvitationMethod, @@ -55,6 +58,7 @@ import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; import { useAccountOwner } from '@/components/calendar/answer-bars'; import { useCollectionNames } from "@/hooks/use-collection-names"; import { resolveTimeZone } from '@/lib/timezone'; +import { buildRecurrenceSummary } from '@/components/calendar/recurrence-editor'; interface InvitationChangeItem { label: string; @@ -384,6 +388,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const shownName = useCollectionNames(); const t = useTranslations('email_viewer.calendar_invitation'); const tEvent = useTranslations('ux_pim_event'); + const tCalendar = useTranslations('calendar'); + const tSeries = useTranslations('ux_p2_pim_calx'); const describeError = useCalendarErrorMessage(); const locale = useLocale(); const router = useRouter(); @@ -479,6 +485,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // What removing a cancelled meeting from the calendar takes, read from the // events stored with its uid; null until known. const [cancelRemoval, setCancelRemoval] = useState(null); + // An invitation to a series is answered as a series (pim-p2-8): the series as + // stored, with the occurrences answered on their own. The calendar holds the + // occurrences, and the first of them was read as the answer. + const [seriesMaster, setSeriesMaster] = useState(null); const attachment = findCalendarAttachment(email); @@ -577,6 +587,20 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod; const isCancellation = method === 'cancel'; + const seriesUid = isSeriesInvitation(parsedEvent) ? parsedEvent?.uid ?? null : null; + useEffect(() => { + setSeriesMaster(null); + if (!client || !supportsCalendar || !seriesUid || sharedMailAccount) return; + let current = true; + client.queryCalendarEvents({ uid: seriesUid }) + .then((stored) => { + const master = stored.find((e) => e.uid === seriesUid && !e.recurrenceId && !e.isShared) ?? null; + if (current) setSeriesMaster(master); + }) + .catch(() => { /* the occurrence the calendar holds stands in */ }); + return () => { current = false; }; + }, [client, supportsCalendar, seriesUid, sharedMailAccount]); + useEffect(() => { setCancelRemoval(null); if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return; @@ -634,9 +658,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // answering there would answer for them (or, on Stalwart 0.16, for // whichever address it picks; the duplicate attendees and wrong senders of // #634). - const existingEvent = parsedEvent?.uid + const storedCopy = parsedEvent?.uid ? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)) : null; + const existingEvent = seriesMaster ?? storedCopy; + const isSeries = isSeriesInvitation(parsedEvent); + const seriesRule = isSeries && parsedEvent?.recurrenceRules?.[0] + ? buildRecurrenceSummary(parsedEvent.recurrenceRules[0], tCalendar, locale) + : null; const currentUserParticipant = existingEvent ? findParticipantByEmails(existingEvent, actingEmails) @@ -679,6 +708,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }) : null; const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar); + // "You declined Nov 3": the weeks answered no on their own, by their date. + const declinedOccurrences = seriesMaster && method === 'request' && !rsvpStatus + ? seriesAnswers(seriesMaster, actingEmails).declined + : []; + const occurrenceDate = (recurrenceId: string) => { + const at = new Date(`${recurrenceId.slice(0, 19)}Z`); + return isNaN(at.getTime()) ? recurrenceId : new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(at); + }; + const declinedInfo = declinedOccurrences.length === 0 + ? null + : declinedOccurrences.length > 3 + ? tSeries('declined_many', { count: declinedOccurrences.length }) + : tSeries('declined_dates', { + dates: new Intl.ListFormat(locale, { type: 'conjunction' }).format(declinedOccurrences.map(occurrenceDate)), + }); const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null; const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null; const participationLabel = getParticipationLabel(t, currentRsvp); @@ -1043,6 +1087,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} + {seriesRule && ( + + + {tSeries('repeats', { rule: seriesRule })} + + )} {summary.location && ( @@ -1112,9 +1162,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} {/* Info / actor messages */} - {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && ( + {showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
{bannerInfo &&

{bannerInfo}

} + {declinedInfo &&

{declinedInfo}

} {cancellationOutdated &&

{t('cancel_outdated')}

} {actorMessage &&

{actorMessage}

} {actorSummary?.participationComment && ( @@ -1206,6 +1257,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {t('decline')} + {isSeries && ( + {tSeries('answers_series')} + )}
)} diff --git a/lib/__tests__/series-answers-ux-p2-pim.test.ts b/lib/__tests__/series-answers-ux-p2-pim.test.ts new file mode 100644 index 000000000..f1f66fe95 --- /dev/null +++ b/lib/__tests__/series-answers-ux-p2-pim.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { isSeriesInvitation, seriesAnswers } from '../calendar-invitation'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-8: the series' own answer, and the occurrences declined on their own. +// Stalwart 1.0 stores an occurrence's answer as a pointer patch, 0.16 as the +// whole participants map. + +const me = { '@type': 'Participant', email: 'me@example.org', roles: { attendee: true }, participationStatus: 'accepted' }; +const ben = { '@type': 'Participant', email: 'ben@example.org', roles: { owner: true }, participationStatus: 'accepted' }; + +function series(overrides: Record): Partial { + return { + uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }], + participants: { me, ben }, recurrenceOverrides: overrides, + } as unknown as Partial; +} + +describe('seriesAnswers', () => { + it('reads a pointer patch (Stalwart 1.0)', () => { + const result = seriesAnswers(series({ + '2026-11-17T16:00:00': { 'participants/me/participationStatus': 'declined' }, + '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' }, + '2026-11-10T16:00:00': { title: 'Moved room' }, + }), ['me@example.org']); + expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00', '2026-11-17T16:00:00'] }); + }); + + it('reads a whole participants map (Stalwart 0.16) and skips removed weeks', () => { + const result = seriesAnswers(series({ + '2026-11-03T16:00:00': { participants: { me: { ...me, participationStatus: 'declined' }, ben } }, + '2026-11-10T16:00:00': { excluded: true }, + }), ['ME@example.org']); + expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00'] }); + }); + + it('lists none when the whole series is declined', () => { + const declined = { ...me, participationStatus: 'declined' }; + const event = { ...series({ '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }), participants: { me: declined, ben } }; + expect(seriesAnswers(event as unknown as Partial, ['me@example.org'])).toEqual({ status: 'declined', declined: [] }); + }); + + it('tells a series invitation from one for a single occurrence', () => { + expect(isSeriesInvitation(series({}))).toBe(true); + expect(isSeriesInvitation({ ...series({}), recurrenceId: '2026-11-03T16:00:00' } as Partial)).toBe(false); + expect(isSeriesInvitation({ uid: 'x' })).toBe(false); + }); +}); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index 60fdff12b..0c16debea 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -2,6 +2,7 @@ import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPa import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; import { hasAlignedAuthenticationPass } from '@/lib/email-headers'; import { fromWallClock, getTimeZoneOffsetMs, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone'; +import { applyOverridePatch } from '@/lib/recurrence-instances'; export type InvitationMethod = | 'publish' @@ -797,6 +798,35 @@ export function invitationAnsweredAt( * user's identities, primary first: an invite may be addressed to an alias * identity rather than the primary address (#1283). */ +/** + * An invitation to a series is answered as a series: the answer is the series' + * own, and occurrences answered no on their own are listed apart (pim-p2-8). + * The card said "You declined" for a weekly meeting when one week was declined, + * read from whichever occurrence the calendar held first. `master` is the + * series as stored, with its recurrenceOverrides; `declined` holds the + * recurrence ids (wall clock in the event's zone), oldest first. + */ +export function seriesAnswers( + master: Partial, + emails: readonly string[], +): { status: string | null; declined: string[] } { + const own = findParticipantByEmails(master, emails); + const status = own?.participant.participationStatus ?? null; + const declined: string[] = []; + for (const [recurrenceId, patch] of Object.entries(master.recurrenceOverrides ?? {})) { + if (!patch || typeof patch !== 'object' || (patch as { excluded?: unknown }).excluded === true) continue; + const occurrence = applyOverridePatch(master, patch as Record); + const mine = findParticipantByEmails(occurrence, emails); + if (mine?.participant.participationStatus === 'declined' && status !== 'declined') declined.push(recurrenceId); + } + return { status, declined: declined.sort() }; +} + +/** Whether a parsed invitation is for a whole series, not one occurrence of it. */ +export function isSeriesInvitation(event: Partial | null | undefined): boolean { + return !!event?.recurrenceRules?.length && !event.recurrenceId; +} + export function findParticipantByEmails( event: Partial, emails: readonly string[] diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..b3d2379d4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "رفضت الدعوة.", "answered_tentative_nodate": "أجبت بـ\"ربما\"." }, + "ux_p2_pim_calx": { + "repeats": "يتكرر: {rule}", + "declined_dates": "رفضت موعد {dates}.", + "declined_many": "رفضت {count} من مواعيد هذه السلسلة.", + "answers_series": "ينطبق ردك على كل مواعيد السلسلة." + }, "calendar_event_notifications": { "someone": "شخص ما", "invited": "دعاك {name} إلى \"{title}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..bdfacf30b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Heu rebutjat.", "answered_tentative_nodate": "Heu respost potser." }, + "ux_p2_pim_calx": { + "repeats": "Es repeteix: {rule}", + "declined_dates": "Has rebutjat el {dates}.", + "declined_many": "Has rebutjat {count} reunions d'aquesta sèrie.", + "answers_series": "La teva resposta val per a totes les reunions de la sèrie." + }, "calendar_event_notifications": { "someone": "Algú", "invited": "{name} t'ha convidat a \"{title}\"", diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..40f6e5f45 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Odmítli jste.", "answered_tentative_nodate": "Odpověděli jste možná." }, + "ux_p2_pim_calx": { + "repeats": "Opakuje se: {rule}", + "declined_dates": "Odmítli jste {dates}.", + "declined_many": "Odmítli jste {count} schůzek této série.", + "answers_series": "Vaše odpověď platí pro všechny schůzky série." + }, "advanced_search": { "title": "Pokročilé hledání", "from": "Od", diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..d9cb6b76c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Du afslog.", "answered_tentative_nodate": "Du svarede måske." }, + "ux_p2_pim_calx": { + "repeats": "Gentages: {rule}", + "declined_dates": "Du afslog {dates}.", + "declined_many": "Du afslog {count} møder i denne serie.", + "answers_series": "Dit svar gælder alle møder i serien." + }, "calendar_event_notifications": { "someone": "Nogen", "invited": "{name} har inviteret dig til \"{title}\"", diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..ec24e06d4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Sie haben abgesagt.", "answered_tentative_nodate": "Sie haben mit Vielleicht geantwortet." }, + "ux_p2_pim_calx": { + "repeats": "Wiederholung: {rule}", + "declined_dates": "Sie haben am {dates} abgesagt.", + "declined_many": "Sie haben {count} Termine dieser Serie abgesagt.", + "answers_series": "Ihre Antwort gilt für alle Termine der Serie." + }, "advanced_search": { "title": "Erweiterte Suche", "from": "Von", diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..0b497c13d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "You declined.", "answered_tentative_nodate": "You answered maybe." }, + "ux_p2_pim_calx": { + "repeats": "Repeats: {rule}", + "declined_dates": "You declined {dates}.", + "declined_many": "You declined {count} meetings of this series.", + "answers_series": "Your answer counts for every meeting in the series." + }, "calendar_event_notifications": { "someone": "Someone", "invited": "{name} invited you to \"{title}\"", diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..9fefdd4b2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Rechazaste.", "answered_tentative_nodate": "Respondiste quizás." }, + "ux_p2_pim_calx": { + "repeats": "Se repite: {rule}", + "declined_dates": "Rechazaste el {dates}.", + "declined_many": "Rechazaste {count} reuniones de esta serie.", + "answers_series": "Tu respuesta vale para todas las reuniones de la serie." + }, "advanced_search": { "title": "Búsqueda avanzada", "from": "De", diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..fbcf3ab92 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Keeldusite.", "answered_tentative_nodate": "Vastasite võib-olla." }, + "ux_p2_pim_calx": { + "repeats": "Kordub: {rule}", + "declined_dates": "Lükkasite tagasi kuupäeva {dates}.", + "declined_many": "Lükkasite tagasi {count} selle sarja kohtumist.", + "answers_series": "Teie vastus kehtib sarja kõigile kohtumistele." + }, "calendar_event_notifications": { "someone": "Keegi", "invited": "{name} kutsus sind sündmusele \"{title}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..99f315963 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "رد کردید.", "answered_tentative_nodate": "پاسخ «شاید» دادید." }, + "ux_p2_pim_calx": { + "repeats": "تکرار: {rule}", + "declined_dates": "جلسهٔ {dates} را رد کردید.", + "declined_many": "{count} جلسه از این سری را رد کردید.", + "answers_series": "پاسخ شما برای همهٔ جلسه‌های این سری است." + }, "calendar_event_notifications": { "someone": "کسی", "invited": "{name} شما را به «{title}» دعوت کرد", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..f27b3f84e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Vous avez refusé.", "answered_tentative_nodate": "Vous avez répondu peut-être." }, + "ux_p2_pim_calx": { + "repeats": "Se répète : {rule}", + "declined_dates": "Vous avez refusé le {dates}.", + "declined_many": "Vous avez refusé {count} réunions de cette série.", + "answers_series": "Votre réponse vaut pour toutes les réunions de la série." + }, "advanced_search": { "title": "Recherche avancée", "from": "De", diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..2a2eae497 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5120,6 +5120,12 @@ "answered_declined_nodate": "דחית.", "answered_tentative_nodate": "ענית 'אולי'." }, + "ux_p2_pim_calx": { + "repeats": "חוזר: {rule}", + "declined_dates": "דחית את {dates}.", + "declined_many": "דחית {count} פגישות בסדרה הזו.", + "answers_series": "התשובה שלך חלה על כל הפגישות בסדרה." + }, "advanced_search": { "title": "חיפוש מתקדם", "from": "מ", diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..957a4985e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Ön elutasította.", "answered_tentative_nodate": "Ön talánnal válaszolt." }, + "ux_p2_pim_calx": { + "repeats": "Ismétlődik: {rule}", + "declined_dates": "Elutasította: {dates}.", + "declined_many": "A sorozat {count} alkalmát utasította el.", + "answers_series": "A válasza a sorozat minden alkalmára vonatkozik." + }, "calendar_event_notifications": { "someone": "Valaki", "invited": "{name} meghívott a(z) „{title}” eseményre", diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..9024cfbf1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Hai rifiutato.", "answered_tentative_nodate": "Hai risposto forse." }, + "ux_p2_pim_calx": { + "repeats": "Si ripete: {rule}", + "declined_dates": "Hai rifiutato il {dates}.", + "declined_many": "Hai rifiutato {count} riunioni di questa serie.", + "answers_series": "La tua risposta vale per tutte le riunioni della serie." + }, "advanced_search": { "title": "Ricerca avanzata", "from": "Da", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..2ade125ae 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "辞退しました。", "answered_tentative_nodate": "未定と回答しました。" }, + "ux_p2_pim_calx": { + "repeats": "繰り返し: {rule}", + "declined_dates": "{dates} は辞退しました。", + "declined_many": "このシリーズの {count} 件を辞退しました。", + "answers_series": "回答はシリーズのすべての会議に適用されます。" + }, "advanced_search": { "title": "詳細検索", "from": "差出人", diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..9a674b669 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "거절했습니다.", "answered_tentative_nodate": "미정으로 응답했습니다." }, + "ux_p2_pim_calx": { + "repeats": "반복: {rule}", + "declined_dates": "{dates} 일정을 거절했습니다.", + "declined_many": "이 시리즈의 일정 {count}개를 거절했습니다.", + "answers_series": "답변은 시리즈의 모든 일정에 적용됩니다." + }, "advanced_search": { "title": "상세 검색", "from": "보낸 사람", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..abe70ff84 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Jūs noraidījāt.", "answered_tentative_nodate": "Jūs atbildējāt varbūt." }, + "ux_p2_pim_calx": { + "repeats": "Atkārtojas: {rule}", + "declined_dates": "Jūs atteicāt {dates}.", + "declined_many": "Jūs atteicāt {count} šīs sērijas sapulces.", + "answers_series": "Jūsu atbilde attiecas uz visām sērijas sapulcēm." + }, "advanced_search": { "title": "Izvērstā meklēšana", "from": "No", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..eded34e01 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Та татгалзсан.", "answered_tentative_nodate": "Та магадгүй гэж хариулсан." }, + "ux_p2_pim_calx": { + "repeats": "Давтагдана: {rule}", + "declined_dates": "Та {dates}-ны уулзалтаас татгалзсан.", + "declined_many": "Та энэ цувралын {count} уулзалтаас татгалзсан.", + "answers_series": "Таны хариу цувралын бүх уулзалтад хамаарна." + }, "calendar_event_notifications": { "someone": "Хэн нэгэн", "invited": "{name} таныг \"{title}\"-д урилаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..a5d992a26 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Du avslo.", "answered_tentative_nodate": "Du svarte kanskje." }, + "ux_p2_pim_calx": { + "repeats": "Gjentas: {rule}", + "declined_dates": "Du avslo {dates}.", + "declined_many": "Du avslo {count} møter i denne serien.", + "answers_series": "Svaret ditt gjelder alle møtene i serien." + }, "calendar_event_notifications": { "someone": "Noen", "invited": "{name} har invitert deg til «{title}»", diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..436a8fd2b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Je hebt geweigerd.", "answered_tentative_nodate": "Je hebt misschien geantwoord." }, + "ux_p2_pim_calx": { + "repeats": "Herhaalt: {rule}", + "declined_dates": "U hebt {dates} afgeslagen.", + "declined_many": "U hebt {count} afspraken van deze reeks afgeslagen.", + "answers_series": "Uw antwoord geldt voor alle afspraken in de reeks." + }, "advanced_search": { "title": "Geavanceerd zoeken", "from": "Van", diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..2f576bb83 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Odrzucono.", "answered_tentative_nodate": "Odpowiedziano „może”." }, + "ux_p2_pim_calx": { + "repeats": "Powtarza się: {rule}", + "declined_dates": "Odrzucono {dates}.", + "declined_many": "Odrzucono {count} spotkań z tej serii.", + "answers_series": "Twoja odpowiedź dotyczy wszystkich spotkań z serii." + }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", "from": "Od", diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..c346a8c8d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Você recusou.", "answered_tentative_nodate": "Você respondeu talvez." }, + "ux_p2_pim_calx": { + "repeats": "Repete-se: {rule}", + "declined_dates": "Você recusou {dates}.", + "declined_many": "Você recusou {count} reuniões desta série.", + "answers_series": "Sua resposta vale para todas as reuniões da série." + }, "advanced_search": { "title": "Pesquisa avançada", "from": "De", diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..e305011c3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Ai refuzat.", "answered_tentative_nodate": "Ai răspuns poate." }, + "ux_p2_pim_calx": { + "repeats": "Se repetă: {rule}", + "declined_dates": "Ați refuzat {dates}.", + "declined_many": "Ați refuzat {count} întâlniri din această serie.", + "answers_series": "Răspunsul dvs. este pentru toate întâlnirile din serie." + }, "calendar_event_notifications": { "someone": "Cineva", "invited": "{name} te-a invitat la „{title}”", diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..4267d5329 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Вы отклонили.", "answered_tentative_nodate": "Вы ответили «может быть»." }, + "ux_p2_pim_calx": { + "repeats": "Повторяется: {rule}", + "declined_dates": "Вы отклонили встречу {dates}.", + "declined_many": "Вы отклонили встреч этой серии: {count}.", + "answers_series": "Ваш ответ относится ко всем встречам серии." + }, "advanced_search": { "title": "Расширенный поиск", "from": "От", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..23e545cd2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Odmietli ste.", "answered_tentative_nodate": "Odpovedali ste možno." }, + "ux_p2_pim_calx": { + "repeats": "Opakuje sa: {rule}", + "declined_dates": "Odmietli ste {dates}.", + "declined_many": "Odmietli ste {count} stretnutí tejto série.", + "answers_series": "Vaša odpoveď platí pre všetky stretnutia série." + }, "calendar_event_notifications": { "someone": "Niekto", "invited": "{name} vás pozval(a) na „{title}“", diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..4a785f5fa 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4638,6 +4638,12 @@ "answered_declined_nodate": "คุณปฏิเสธแล้ว", "answered_tentative_nodate": "คุณตอบว่าอาจจะ" }, + "ux_p2_pim_calx": { + "repeats": "ทำซ้ำ: {rule}", + "declined_dates": "คุณปฏิเสธวันที่ {dates}", + "declined_many": "คุณปฏิเสธ {count} การประชุมในชุดนี้", + "answers_series": "คำตอบของคุณใช้กับทุกการประชุมในชุดนี้" + }, "settings": { "title": "การตั้งค่า", "org_locked": "กำหนดโดยองค์กรของคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..e4419a9d8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Reddettiniz.", "answered_tentative_nodate": "Belki dediniz." }, + "ux_p2_pim_calx": { + "repeats": "Tekrarlanır: {rule}", + "declined_dates": "{dates} toplantısını reddettiniz.", + "declined_many": "Bu serideki {count} toplantıyı reddettiniz.", + "answers_series": "Yanıtınız serideki tüm toplantılar için geçerlidir." + }, "calendar_event_notifications": { "someone": "Birisi", "invited": "{name} sizi \"{title}\" etkinliğine davet etti", diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..0b2dc73fe 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Ви відхилили.", "answered_tentative_nodate": "Ви відповіли «можливо»." }, + "ux_p2_pim_calx": { + "repeats": "Повторюється: {rule}", + "declined_dates": "Ви відхилили зустріч {dates}.", + "declined_many": "Ви відхилили зустрічей цієї серії: {count}.", + "answers_series": "Ваша відповідь стосується всіх зустрічей серії." + }, "advanced_search": { "title": "Розширений пошук", "from": "Від", diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..89d844cd4 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5238,6 +5238,12 @@ "answered_declined_nodate": "Bạn đã từ chối.", "answered_tentative_nodate": "Bạn đã trả lời có thể." }, + "ux_p2_pim_calx": { + "repeats": "Lặp lại: {rule}", + "declined_dates": "Bạn đã từ chối ngày {dates}.", + "declined_many": "Bạn đã từ chối {count} cuộc họp trong chuỗi này.", + "answers_series": "Câu trả lời của bạn áp dụng cho mọi cuộc họp trong chuỗi." + }, "calendar_event_notifications": { "someone": "Ai đó", "invited": "{name} đã mời bạn tham gia \"{title}\"", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..254ba30ee 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "你已拒絕。", "answered_tentative_nodate": "你已回覆暫定。" }, + "ux_p2_pim_calx": { + "repeats": "重複:{rule}", + "declined_dates": "您已拒絕 {dates} 的會議。", + "declined_many": "您已拒絕此系列中的 {count} 場會議。", + "answers_series": "您的回覆適用於此系列的所有會議。" + }, "calendar_event_notifications": { "someone": "有人", "invited": "{name} 邀請您參加「{title}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..1f9dbdb79 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "你已拒绝。", "answered_tentative_nodate": "你已答复暂定。" }, + "ux_p2_pim_calx": { + "repeats": "重复:{rule}", + "declined_dates": "您已拒绝 {dates} 的会议。", + "declined_many": "您已拒绝此系列中的 {count} 次会议。", + "answers_series": "您的回复适用于此系列的所有会议。" + }, "advanced_search": { "title": "高级搜索", "from": "发件人", From 84b8aef1a9b7b141953bcb3b77a83671e7a72779 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:04:51 +0100 Subject: [PATCH 7/7] test: check the time zone picker, Duplicate and a series invitation on the server --- integration/tests/26-ux-p2-pim-calx.spec.ts | 269 ++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 integration/tests/26-ux-p2-pim-calx.spec.ts diff --git a/integration/tests/26-ux-p2-pim-calx.spec.ts b/integration/tests/26-ux-p2-pim-calx.spec.ts new file mode 100644 index 000000000..d1c3cb5ff --- /dev/null +++ b/integration/tests/26-ux-p2-pim-calx.spec.ts @@ -0,0 +1,269 @@ +import { test, expect, type Page } from '@playwright/test'; +import { addDays, format } from 'date-fns'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2, calendar fixes pim-p2-5 (the event's time zone picker), + * pim-p2-17 (Duplicate opens an unsaved copy) and pim-p2-8 (an invitation to + * a series), against a VM 106 Stalwart (1.0 and 0.16). Every UI step is + * checked on the server. + * + * Accounts: dso-ux-pim (A) and dso-ux-pim-b (B, the organiser of the series), + * password IT_DSO_PASSWORD. The spec makes its own items, titled + * "UXP2-calx …", and removes them again on both accounts. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const CAL = 'urn:ietf:params:jmap:calendars'; +const MAIL = 'urn:ietf:params:jmap:mail'; +const RUN = Date.now().toString(36); +const TZ = 'Europe/Berlin'; +const PREFIX = `UXP2-calx ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Participant = { calendarAddress?: string; participationStatus?: string }; +type Obj = Record & { + id: string; uid?: string; title?: string; start?: string; timeZone?: string | null; duration?: string; + recurrenceOverrides?: Record> | null; + participants?: Record | null; +}; + +async function defaultCalendar(c: JmapClient): Promise { + const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]); + const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>; + return (list.find((cal) => cal.isDefault) ?? list[0]).id; +} + +/** Events whose title starts with `prefix`, as the server keeps them (base events, not expanded). */ +async function objects(c: JmapClient, prefix: string): Promise { + const r = await c.request([ + ['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'uid', 'title', 'start', 'timeZone', 'duration', 'participants'], + }, 'g'], + ], [CORE, CAL]); + return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix)); +} + +/** Stalwart 1.0 refuses recurrenceOverrides in a get with other properties: on their own. */ +async function overrides(c: JmapClient, id: string): Promise>> { + const r = await c.request([['CalendarEvent/get', { accountId: c.accountId, ids: [id], properties: ['id', 'recurrenceOverrides'] }, '0']], [CORE, CAL]); + return (r.methodResponses[0][1].list?.[0]?.recurrenceOverrides ?? {}) as Record>; +} + +async function destroyAll(c: JmapClient, prefix: string): Promise { + const ids = (await objects(c, prefix)).map((o) => o.id); + if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]); +} + +async function mails(c: JmapClient, word: string): Promise { + const r = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: word }, limit: 50 }, 'q']], [CORE, MAIL]); + return r.methodResponses[0][1].ids as string[]; +} + +async function destroyMails(c: JmapClient, word: string): Promise { + const ids = await mails(c, word); + if (ids.length) await c.request([['Email/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, MAIL]); +} + +async function createEvent(c: JmapClient, event: Record): Promise { + const r = await c.request([['CalendarEvent/set', { + accountId: c.accountId, + create: { e: { '@type': 'Event', calendarIds: { [await defaultCalendar(c)]: true }, ...event } }, + }, '0']], [CORE, CAL]); + const id = r.methodResponses[0][1].created?.e?.id as string | undefined; + expect(id, JSON.stringify(r.methodResponses[0][1].notCreated ?? {})).toBeTruthy(); + return id!; +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', email); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +// Event chips are buttons named ", <time>, <calendar>". +const card = (page: Page, title: string) => page.locator(`button[aria-label^="${title},"]`).first(); +const day = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd'); + +test.afterAll(async () => { + if (!PASSWORD) return; + for (const who of [A, B]) { + const c = await JmapClient.connect(who, PASSWORD); + await destroyAll(c, PREFIX); + await destroyMails(c, PREFIX); + } +}); + +test.describe('The event editor (pim-p2-5, pim-p2-17)', () => { + test.describe.configure({ mode: 'serial' }); + + test('picks a time zone by typing its city and saves the zone id', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const when = day(10); + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/calendar/week/${when}`); + await expect(page.locator('[data-testid="calendar-item"]').first()).toBeVisible({ timeout: 60000 }); + const title = page.getByLabel('Title', { exact: true }); + await expect(async () => { + await page.keyboard.press('n'); + await expect(title).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 60000 }); + await title.fill(`${PREFIX} Kolkata call`); + await page.getByLabel('Start date', { exact: true }).fill(when); + await page.getByLabel('Start time', { exact: true }).fill('10:00'); + await page.getByLabel('End date', { exact: true }).fill(when); + await page.getByLabel('End time', { exact: true }).fill('11:00'); + await page.getByRole('button', { name: 'Time zone', exact: true }).click(); + + // The list opens with a search: cities and offsets, no raw ids. + const search = page.getByRole('combobox', { name: 'Search time zones' }); + await expect(search).toBeFocused(); + await expect(page.getByRole('option', { name: /^New York/ }).first()).toBeVisible(); + await expect(page.getByRole('option', { name: /Africa\/Abidjan/ })).toHaveCount(0); + await search.fill('Kolkata'); + await expect(page.getByRole('option', { name: /^Kolkata.*GMT\+05:30/ })).toBeVisible(); + await search.press('Enter'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => { + const [e] = await objects(a, `${PREFIX} Kolkata call`); + return e && { start: e.start, timeZone: e.timeZone }; + }, { timeout: 30000 }).toMatchObject({ start: `${when}T10:00:00` }); + const [saved] = await objects(a, `${PREFIX} Kolkata call`); + expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(saved.timeZone); + await context.close(); + }); + + test('Duplicate creates nothing until Save, at the same date and time', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const when = day(11); + const name = `${PREFIX} retro`; + await createEvent(a, { title: name, uid: `uxp2-calx-dup-${RUN}`, start: `${when}T14:00:00`, timeZone: TZ, duration: 'PT1H' }); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/calendar/week/${when}`); + await expect(card(page, name)).toBeVisible({ timeout: 60000 }); + + // Closed without saving: nothing on the server. + await card(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + const editor = page.getByRole('dialog', { name: 'Create event' }); + await expect(editor).toBeVisible(); + await expect(page.getByLabel('Title', { exact: true })).toHaveValue(name); + await expect(page.getByLabel('Start date', { exact: true })).toHaveValue(when); + await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('14:00'); + await page.waitForTimeout(2000); + expect(await objects(a, name)).toHaveLength(1); + await editor.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(editor).toBeHidden(); + await page.waitForTimeout(2000); + expect(await objects(a, name)).toHaveLength(1); + + // Saved: a second event, same day and time. + await card(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await expect(editor).toBeVisible(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => (await objects(a, name)).length, { timeout: 30000 }).toBe(2); + const both = await objects(a, name); + expect(both.map((e) => e.start)).toEqual([`${when}T14:00:00`, `${when}T14:00:00`]); + expect(new Set(both.map((e) => e.uid)).size).toBe(2); + await context.close(); + }); +}); + +test.describe('An invitation to a series (pim-p2-8)', () => { + test('says it repeats, names the week declined on its own, and answers the series', async ({ browser }) => { + test.setTimeout(300_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const first = day(14); + const second = day(21); + const name = `${PREFIX} weekly sync`; + const uid = `uxp2-calx-series-${RUN}`; + + // B invites A to a weekly series; Stalwart delivers the invitation itself. + const invite = await b.request([['CalendarEvent/set', { + accountId: b.accountId, + sendSchedulingMessages: true, + create: { + i: { + '@type': 'Event', title: name, uid, start: `${first}T16:00:00`, duration: 'PT30M', timeZone: TZ, + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 4 }, + calendarIds: { [await defaultCalendar(b)]: true }, + participants: { + o: { '@type': 'Participant', calendarAddress: `mailto:${B}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + p: { '@type': 'Participant', calendarAddress: `mailto:${A}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true }, + }, + }, + }, + }, '0']], [CORE, CAL]); + expect(invite.methodResponses[0][1].created?.i?.id, JSON.stringify(invite.methodResponses[0][1].notCreated ?? {})).toBeTruthy(); + await expect.poll(async () => (await mails(a, PREFIX)).length, { timeout: 90000 }).toBeGreaterThan(0); + const [mailId] = await mails(a, PREFIX); + await expect.poll(async () => (await objects(a, name)).length, { timeout: 60000 }).toBeGreaterThan(0); + const [copy] = await objects(a, name); + const mine = Object.entries(copy.participants ?? {}).find(([, p]) => p.calendarAddress?.toLowerCase() === `mailto:${A}`); + expect(mine, JSON.stringify(copy.participants)).toBeTruthy(); + const [pid] = mine!; + + // A declines the second week only, as the calendar does: a pointer patch + // (Stalwart 1.0), or the whole participants map where that is refused. + const recurrenceId = `${second}T16:00:00`; + const pointer = await a.request([['CalendarEvent/set', { + accountId: a.accountId, + update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { [`participants/${pid}/participationStatus`]: 'declined' } } }, + }, '0']], [CORE, CAL]); + const [kind, answer] = pointer.methodResponses[0]; + if (kind === 'error' || answer.notUpdated?.[copy.id]) { + const participants = structuredClone(copy.participants ?? {}); + participants[pid] = { ...participants[pid], participationStatus: 'declined' }; + const whole = await a.request([['CalendarEvent/set', { + accountId: a.accountId, + update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { participants } } }, + }, '0']], [CORE, CAL]); + expect(whole.methodResponses[0][1].notUpdated, JSON.stringify(whole.methodResponses[0][1].notUpdated ?? {})).toBeFalsy(); + } + expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/mail/message/${mailId}`); + + // It repeats, the series is unanswered, and the declined week is named by its date. + await expect(page.getByTestId('invitation-repeats')).toContainText('Weekly', { timeout: 60000 }); + const declinedDate = format(new Date(`${second}T12:00:00`), 'MMM d'); + await expect(page.getByTestId('invitation-declined-occurrences')).toHaveText(`You declined ${declinedDate}.`, { timeout: 30000 }); + await expect(page.getByText('Your response: Declined')).toHaveCount(0); + await expect(page.getByText(/^You declined on /)).toHaveCount(0); + await expect(page.getByTestId('invitation-answers-series')).toBeVisible(); + + // Accept answers the series; the declined week stays declined. + await page.getByRole('button', { name: 'Accept', exact: true }).first().click(); + await expect.poll(async () => { + const [e] = await objects(a, name); + return Object.values(e?.participants ?? {}).find((p) => p.calendarAddress?.toLowerCase() === `mailto:${A}`)?.participationStatus; + }, { timeout: 30000 }).toBe('accepted'); + expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); + await context.close(); + }); +});