diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b24f5016a..4fe0a5c6d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -50,7 +50,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; +import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, @@ -1171,24 +1171,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleDuplicateFromDetail = useCallback(async () => { if (!detailEvent || !client) return; - const start = parseISO(detailEvent.start); - const newStart = addDays(start, 1); - const data = sanitizeOutgoingCalendarEventData>({ - title: detailEvent.title, - description: detailEvent.description, - start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: detailEvent.duration, - timeZone: detailEvent.timeZone, - showWithoutTime: detailEvent.showWithoutTime, - calendarIds: { ...detailEvent.calendarIds }, - status: "confirmed", - freeBusyStatus: detailEvent.freeBusyStatus, - privacy: detailEvent.privacy, - }); - if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations); - if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules); - if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts); - if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants); + const data = buildDuplicateEventData(detailEvent); closeDetail(); try { const created = await createEvent(client, data); @@ -1219,24 +1202,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => { if (!client) { toast.error(t("notifications.event_error")); return; } - const start = parseISO(event.start); - const newStart = addDays(start, 1); - const data = sanitizeOutgoingCalendarEventData>({ - 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, - }); - if (event.locations) data.locations = structuredClone(event.locations); - if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); - if (event.alerts) data.alerts = structuredClone(event.alerts); - if (event.participants) data.participants = structuredClone(event.participants); + const data = buildDuplicateEventData(event); try { const created = await createEvent(client, data); if (created) { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7108cdf59..e2eccfe67 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons"; -import { format, parseISO, addHours, addDays, isSameDay } from "date-fns"; +import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; @@ -28,6 +28,7 @@ import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calen 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 { useIsPaneScoped } from "@/hooks/use-pane-context"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; @@ -657,28 +658,7 @@ export function EventModal({ const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; - const start = getEventStartDate(event); - const newStart = addDays(start, 1); - const newUid = generateUUID(); - const data: Partial = { - uid: newUid, - title: event.title, - description: event.description, - start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : 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, - }; - 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); - onDuplicate(data); + onDuplicate(buildDuplicateEventData(event)); }, [event, onDuplicate]); const modalRef = useRef(null); diff --git a/lib/__tests__/calendar-duplicate.test.ts b/lib/__tests__/calendar-duplicate.test.ts new file mode 100644 index 000000000..fae3b804d --- /dev/null +++ b/lib/__tests__/calendar-duplicate.test.ts @@ -0,0 +1,54 @@ +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); + }); + + 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 new file mode 100644 index 000000000..19895c9b5 --- /dev/null +++ b/lib/calendar-duplicate.ts @@ -0,0 +1,33 @@ +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, + }; + 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); +}