fix: keep the meeting link when duplicating an event #1170

This commit is contained in:
Linus Rath
2026-10-05 23:09:48 +02:00
parent 7e1a659c06
commit 85b3bb86ed
4 changed files with 93 additions and 60 deletions
+3 -37
View File
@@ -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<Partial<CalendarEvent>>({
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<Partial<CalendarEvent>>({
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) {
+3 -23
View File
@@ -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<CalendarEvent> = {
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<HTMLDivElement>(null);
+54
View File
@@ -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> = {}): 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 });
});
});
+33
View File
@@ -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<CalendarEvent> {
const newStart = addDays(parseISO(event.start), 1);
const data: Partial<CalendarEvent> = {
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);
}