diff --git a/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx b/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx
new file mode 100644
index 000000000..8a96c1e40
--- /dev/null
+++ b/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx
@@ -0,0 +1,220 @@
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { EventModal } from '../event-modal';
+import { TaskModal } from '../task-modal';
+import { QuickCreatePopover } from '../quick-create-popover';
+import { ICalImportModal } from '../ical-import-modal';
+import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
+import { EventContextMenu } from '../event-context-menu';
+import { useAccountStore } from '@/stores/account-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useTaskStore } from '@/stores/task-store';
+import { buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors } from '@/lib/calendar-targets';
+import type { AddressBook, Calendar, CalendarEvent } from '@/lib/jmap/types';
+import { ContactForm } from '@/components/contacts/contact-form';
+import { AddressBookPickerDialog } from '@/components/contacts/address-book-picker-dialog';
+
+// Usability pass 2, pim-p2-2, p2-3, p2-4, p2-13 and p2-16: with two accounts
+// (A and B, both signed in) the calendar choosers were one flat list by
+// display name, offered A's task-only "To-do" for events, and preselected
+// A's calendar while B was on screen.
+
+vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}));
+
+const rights = {
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
+};
+function cal(id: string, name: string, login: string, extra: Partial = {}): Calendar {
+ return {
+ id: `${login}::${id}`, originalId: id, name, color: '#3b82f6', isDefault: false, isVisible: true, sortOrder: 0,
+ myRights: rights, localAccountId: login, ...extra,
+ } as unknown as Calendar;
+}
+const aDefault = cal('d', 'Stalwart Calendar (pim@example.org)', 'login-a', { isDefault: true });
+const aWork = cal('w', 'Work', 'login-a');
+const aTodo = cal('t', 'To-do', 'login-a', { isTasksOnly: true });
+const bDefault = cal('d', 'Stalwart Calendar (pim-b@example.org)', 'login-b', { isDefault: true });
+const calendars = [aDefault, aWork, aTodo, bDefault];
+
+beforeEach(() => {
+ useAccountStore.setState({
+ accounts: [
+ { id: 'login-a', email: 'pim@example.org', username: 'pim@example.org', displayName: 'Pim Audit', label: 'Pim Audit' },
+ { id: 'login-b', email: 'pim-b@example.org', username: 'pim-b@example.org', displayName: 'Pim Audit', label: 'Pim Audit' },
+ ] as never,
+ activeAccountId: 'login-b',
+ });
+ useCalendarStore.setState({ lastEventCalendarIds: {} });
+ useTaskStore.setState({ tasks: [] } as never);
+});
+
+afterEach(() => {
+ useAccountStore.setState({ accounts: [], activeAccountId: null });
+});
+
+function groupsOf(select: HTMLElement): Array<{ label: string; options: string[] }> {
+ return Array.from(select.querySelectorAll('optgroup')).map((g) => ({
+ label: g.getAttribute('label') ?? '',
+ options: Array.from(g.querySelectorAll('option')).map((o) => o.textContent ?? ''),
+ }));
+}
+
+const editorSelect = () => document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement;
+
+describe('event editor (pim-p2-2, p2-3, p2-4)', () => {
+ it("offers no task list, groups by account address, and starts in the active account's default calendar", () => {
+ render();
+ const select = editorSelect();
+ expect(groupsOf(select)).toEqual([
+ { label: 'pim-b@example.org', options: ['default_calendar'] },
+ { label: 'pim@example.org', options: ['default_calendar', 'Work'] },
+ ]);
+ expect(select.value).toBe(bDefault.id);
+ });
+
+ it('starts in the calendar last picked in that account', () => {
+ useAccountStore.setState({ activeAccountId: 'login-a' });
+ useCalendarStore.setState({ lastEventCalendarIds: { 'login-a': aWork.id } });
+ render();
+ expect(editorSelect().value).toBe(aWork.id);
+ });
+
+ it('keeps the one task list an existing event already sits in, and says so', () => {
+ const event = {
+ id: 'e1', uid: 'u1', '@type': 'Event', title: 'Lost', description: '', start: '2026-11-05T07:00:00',
+ duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { [aTodo.id]: true },
+ participants: null, alerts: null, recurrenceRules: null, localAccountId: 'login-a',
+ } as unknown as CalendarEvent;
+ render();
+ fireEvent.click(screen.getByText('events.edit'));
+ const names = within(editorSelect()).getAllByRole('option').map((o) => o.textContent);
+ expect(names).toEqual(['default_calendar', 'Work', 'task_list_not_in_calendar']);
+ });
+});
+
+describe('quick create (pim-p2-3)', () => {
+ it('groups the calendars by account address', () => {
+ render(
+ ,
+ );
+ expect(groupsOf(screen.getByLabelText('form.calendar_select')).map((g) => g.label)).toEqual(['pim-b@example.org', 'pim@example.org']);
+ });
+});
+
+describe('import (pim-p2-2, p2-4)', () => {
+ it("leaves the task list out and preselects the active account's calendar", async () => {
+ const client = {
+ getServerUrl: () => 'https://mail.example.org',
+ getCalendarsAccountId: () => 'acc',
+ uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })),
+ parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]),
+ };
+ const file = new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' });
+ render();
+ await screen.findByText('Standup');
+ const select = screen.getByLabelText('target_calendar') as HTMLSelectElement;
+ expect(within(select).getAllByRole('option').map((o) => o.textContent)).not.toContain('To-do');
+ expect(select.value).toBe(bDefault.id);
+ });
+});
+
+describe('task editor (pim-p2-13)', () => {
+ it('is labelled List and offers task lists only, grouped by account', () => {
+ const bTodo = cal('t', 'Errands', 'login-b', { isTasksOnly: true });
+ render();
+ const select = screen.getByLabelText('task_list_label') as HTMLSelectElement;
+ expect(groupsOf(select)).toEqual([
+ { label: 'pim-b@example.org', options: ['Errands'] },
+ { label: 'pim@example.org', options: ['To-do'] },
+ ]);
+ // A new task goes to the account on screen.
+ expect(select.value).toBe(bTodo.id);
+ });
+});
+
+describe('sidebar (pim-p2-3)', () => {
+ it("heads each account's group with its address, not the shared display name", () => {
+ useCalendarStore.setState({ viewMode: 'week' } as never);
+ render( {}} multiAccountMode />);
+ expect(screen.getAllByTestId('calendar-account-group').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']);
+ });
+
+ it("gives the second account's default calendar a colour of its own", () => {
+ const shown = distinctAccountDefaultColors(calendars);
+ const a = shown.find((c) => c.id === aDefault.id)!;
+ const b = shown.find((c) => c.id === bDefault.id)!;
+ expect(a.color).toBe('#3b82f6');
+ expect(b.color).not.toBe(a.color);
+ // Nothing changes with one account.
+ expect(distinctAccountDefaultColors([aDefault, aWork])).toEqual([aDefault, aWork]);
+ });
+});
+
+describe('copy to the other account (pim-p2-16)', () => {
+ const event = {
+ id: 'login-b::e1', originalId: 'e1', uid: 'u1', '@type': 'Event', title: 'Dentist', description: 'Bring card',
+ start: '2026-11-05T07:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false,
+ calendarIds: { [bDefault.id]: true }, localAccountId: 'login-b', status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public',
+ participants: { p1: { calendarAddress: 'mailto:pim-b@example.org', roles: { owner: true } } },
+ alerts: null, recurrenceRules: null,
+ } as unknown as CalendarEvent;
+
+ it('offers the other account by address, into its default calendar', () => {
+ const targets = copyTargetsFor(event, [aDefault, aWork, bDefault], useAccountStore.getState().accounts);
+ expect(targets.map((x) => [x.account.email, x.calendar.id])).toEqual([['pim@example.org', aDefault.id]]);
+ });
+
+ it('copies the same time into the target calendar without its participants', () => {
+ const data = buildCopyToCalendarData(event, aDefault.id);
+ expect(data).toMatchObject({ title: 'Dentist', start: '2026-11-05T07:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [aDefault.id]: true } });
+ expect(data.uid).not.toBe('u1');
+ expect(data.participants).toBeUndefined();
+ });
+
+ it('shows "Copy to " in the event menu', () => {
+ const onCopy = vi.fn();
+ const menuRef = { current: null };
+ render(
+ ,
+ );
+ act(() => { fireEvent.click(screen.getByText('copy_to_account')); });
+ expect(onCopy).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('remembering the last calendar (pim-p2-4)', () => {
+ it('keeps each account its own pick across an account switch', () => {
+ useCalendarStore.getState().rememberEventCalendar(aWork);
+ useCalendarStore.getState().clearState();
+ expect(useCalendarStore.getState().lastEventCalendarIds).toEqual({ 'login-a': aWork.id });
+ });
+});
+
+describe('address book choosers (pim-p2-3)', () => {
+ const books = [
+ { id: 'login-a::ab', name: 'Stalwart Address Book (pim@example.org)', isDefault: true, localAccountId: 'login-a', myRights: { mayWrite: true } },
+ { id: 'login-b::ab', name: 'Stalwart Address Book (pim-b@example.org)', isDefault: true, localAccountId: 'login-b', myRights: { mayWrite: true } },
+ ] as unknown as AddressBook[];
+
+ it('groups the contact form\'s address books by account address', () => {
+ render();
+ expect(groupsOf(screen.getByLabelText('section_address_book')).map((g) => g.label)).toEqual(['pim-b@example.org', 'pim@example.org']);
+ });
+
+ it('heads the Move to picker with each account address', () => {
+ render();
+ expect(screen.getAllByTestId('address-book-account').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']);
+ });
+});
diff --git a/components/calendar/__tests__/task-lists-ux-pim.test.tsx b/components/calendar/__tests__/task-lists-ux-pim.test.tsx
index 5521596eb..0fc8d9ac9 100644
--- a/components/calendar/__tests__/task-lists-ux-pim.test.tsx
+++ b/components/calendar/__tests__/task-lists-ux-pim.test.tsx
@@ -123,8 +123,10 @@ describe('where a new task goes (pim-8)', () => {
});
it('opens the task form on the task-only list', () => {
- render( {}} />);
- expect((screen.getByLabelText('tasks.calendar') as HTMLSelectElement).value).toBe('todo');
+ // The select lists task lists only (pim-p2-13), so a second one makes it show.
+ const errands = { ...todo, id: 'errands', name: 'Errands' } as Calendar;
+ render( {}} />);
+ expect((screen.getByLabelText('task_list_label') as HTMLSelectElement).value).toBe('todo');
});
});
diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx
index 15eb01375..40aa0fd13 100644
--- a/components/calendar/__tests__/task-modal-calendars.test.tsx
+++ b/components/calendar/__tests__/task-modal-calendars.test.tsx
@@ -4,22 +4,24 @@ import { TaskModal } from '../task-modal';
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
// The task editor's Calendar select offered the read-only Birthdays
-// calendar, which the event editor leaves out.
+// calendar, which the event editor leaves out. Since pim-p2-13 it is "List"
+// and offers task lists only.
const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true };
const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false };
const calendars = [
- { id: 'work', name: 'Work', isShared: false, myRights: writable },
- { id: 'personal', name: 'Personal', isShared: false, myRights: writable },
+ { id: 'work', name: 'Work', isShared: false, isTasksOnly: true, myRights: writable },
+ { id: 'personal', name: 'Personal', isShared: false, isTasksOnly: true, myRights: writable },
+ { id: 'events', name: 'Events', isShared: false, isDefault: true, myRights: writable },
{ id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly },
- { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable },
+ { id: 'feed', name: 'Holidays feed', isShared: false, isTasksOnly: true, myRights: writable },
] as unknown as Calendar[];
const isSubscriptionCalendar = (id: string) => id === 'feed';
describe('task editor calendar select', () => {
- it('lists only the calendars a task can go into', () => {
+ it('lists only the task lists a task can go into', () => {
render();
- const select = screen.getByLabelText('tasks.calendar');
+ const select = screen.getByLabelText('task_list_label');
const names = within(select).getAllByRole('option').map((o) => o.textContent);
expect(names).toEqual(['Work', 'Personal']);
});
@@ -30,7 +32,7 @@ describe('task editor calendar select', () => {
progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null,
} as unknown as CalendarTask;
render();
- const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement;
+ const select = screen.getByLabelText('task_list_label') as HTMLSelectElement;
expect(select.value).toBe('birthdays');
});
});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 3ce9695bb..5bbef9add 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -70,7 +70,8 @@ import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
-import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
+import { accountAddress, buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors, eventTargets, preferredTarget, taskTargets } from "@/lib/calendar-targets";
+import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover";
import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
@@ -172,6 +173,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// mail server for and the Lite static build does not have at all.
const canSubscribe = !demoMode && !IS_LITE;
const tDeepLink = useTranslations("deep_link");
+ const tP2Cal = useTranslations("ux_p2_pim_cal");
const isMobile = useIsMobile();
// Below the full desktop the sidebar opens over the page.
const isFullDesktop = useIsFullDesktop();
@@ -212,6 +214,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
+ const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
+ const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds);
+ const rememberEventCalendar = useCalendarStore((s) => s.rememberEventCalendar);
usePrintListener();
const printing = usePrinting();
const printTarget = usePrintStore((s) => s.target);
@@ -1193,6 +1198,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
toast.error(t("notifications.event_error"));
return;
}
+ // The next new event in this account starts in the same calendar (pim-p2-4).
+ const pickedId = Object.keys(data.calendarIds ?? {})[0];
+ const picked = pickedId ? useCalendarStore.getState().calendars.find((c) => c.id === pickedId) : undefined;
+ if (picked) rememberEventCalendar(picked);
focusCalendarOnEvent(created);
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
@@ -1218,17 +1227,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
};
await save(requestedScheduling);
- }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t]);
+ }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t, rememberEventCalendar]);
- // Calendars a new task can go into: writable, not a subscription.
+ // Lists a new task can go into: task lists, writable, not a subscription (pim-p2-13).
const writableTaskCalendars = useMemo(
- () => calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)),
- [calendars, isSubscriptionCalendar],
+ () => taskTargets(calendars, isSubscriptionCalendar, { holdingTasks: taskStore.tasks.flatMap((x) => Object.keys(x.calendarIds ?? {})) }),
+ [calendars, isSubscriptionCalendar, taskStore.tasks],
);
- // Calendars a new event can go into: never a subscription or read-only one (#762).
+ // Calendars a new event can go into: never a subscription or read-only one
+ // (#762), nor a task list (pim-p2-2). Coloured as the grid shows them.
const writableCalendars = useMemo(
- () => calendars.filter((c) => !c.isTasksOnly && canCreateEventsIn(c, isSubscriptionCalendar)),
+ () => eventTargets(distinctAccountDefaultColors(calendars), isSubscriptionCalendar),
[calendars, isSubscriptionCalendar],
);
@@ -1240,7 +1250,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return;
}
const finalEnd = end ?? (allDay ? start : new Date(start.getTime() + 60 * 60 * 1000));
- const preferred = writableCalendars.find((c) => c.isDefault) ?? writableCalendars[0];
+ // The account on screen's calendar, not the first account's (pim-p2-4).
+ const preferred = preferredTarget(writableCalendars, activeLocalAccountId, lastEventCalendarIds);
setShowEventModal(false);
setEditEvent(null);
setShowTaskModal(false);
@@ -1255,7 +1266,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
? prev.calendarId
: preferred?.id ?? "",
}));
- }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded]);
+ }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded, activeLocalAccountId, lastEventCalendarIds]);
// A day in the month view or the all-day row: an all-day event, the same
// quick editor as a slot in week and day view.
@@ -1616,6 +1627,35 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
handleDeleteEvent(event.id, hasParticipants || undefined);
}, [handleDeleteEvent]);
+ // "Copy to " (pim-p2-16): an event can't move between
+ // accounts, so the other one gets a copy, and the toast offers to delete
+ // the original. A series is copied whole, read from its own server.
+ const localAccounts = useAccountStore((s) => s.accounts);
+ const copyTargetsOf = useCallback(
+ (event: CalendarEvent) => copyTargetsFor(event, writableCalendars, localAccounts),
+ [writableCalendars, localAccounts],
+ );
+ const handleCopyToAccount = useCallback(async (event: CalendarEvent, target: { account: { email?: string; username?: string; id: string }; calendar: Calendar }) => {
+ if (!client) { toast.error(t("notifications.event_error")); return; }
+ let source = event;
+ if (event.recurrenceId && event.recurrenceRules?.length && event.originalId) {
+ const sourceClient = (event.localAccountId ? useAuthStore.getState().getClientForAccount(event.localAccountId) : null) ?? client;
+ const isSharedSource = calendars.some((c) => event.calendarIds?.[c.id] && c.isShared);
+ const master = await sourceClient.getCalendarEvent(event.originalId, isSharedSource ? event.accountId : undefined).catch(() => null);
+ if (master) source = master;
+ }
+ const address = accountAddress(target.account);
+ try {
+ const created = await createEvent(client, buildCopyToCalendarData(source, target.calendar.id));
+ if (!created) return;
+ toast.success(tP2Cal("copied_to_account", { address }), {
+ action: { label: tP2Cal("delete_original"), onClick: () => handleDeleteContextMenu(event) },
+ });
+ } catch (error) {
+ toast.error(describeError(error, t("notifications.event_error")));
+ }
+ }, [client, calendars, createEvent, describeError, t, tP2Cal, handleDeleteContextMenu]);
+
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus'], target?: AnswerTarget) => {
if (!detailEvent || !client) return;
// A target names the event and participant: the owner's in a calendar
@@ -1740,7 +1780,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
// calendars are passed through untouched.
const displayCalendars = useMemo(() => {
- return calendars.map((cal) => {
+ // Two accounts' default calendars come in one blue: the second gets a colour of its own (pim-p2-3).
+ return distinctAccountDefaultColors(calendars).map((cal) => {
if (!cal.isShared) return cal;
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
if (!override) return cal;
@@ -2087,7 +2128,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onQuickCreate={(title, due) => {
// Into the first task-only list, else the default calendar,
// else the first the user may write to (pim-8).
- const target = defaultTaskList(writableTaskCalendars);
+ const target = defaultTaskList(writableTaskCalendars, activeLocalAccountId);
if (!client || !target) {
toast.error(t("notifications.task_save_error"));
return;
@@ -2519,6 +2560,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onRespond={gridAnswerFor(eventContextMenu.data) ? (status) => handleRespondFromGrid(eventContextMenu.data!, status) : undefined}
responseStatus={gridAnswerFor(eventContextMenu.data)?.status}
answersOnly={answersOnlyMenu}
+ copyTo={answersOnlyMenu ? [] : copyTargetsOf(eventContextMenu.data).map((target) => ({
+ address: accountAddress(target.account),
+ onCopy: () => { void handleCopyToAccount(eventContextMenu.data!, target); },
+ }))}
/>
)}
diff --git a/components/calendar/calendar-select.tsx b/components/calendar/calendar-select.tsx
new file mode 100644
index 000000000..a64617446
--- /dev/null
+++ b/components/calendar/calendar-select.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useMemo, type ChangeEvent } from "react";
+import { useTranslations } from "next-intl";
+import { useCollectionNames } from "@/hooks/use-collection-names";
+import { groupCalendarsByAccount } from "@/lib/calendar-targets";
+import type { Calendar } from "@/lib/jmap/types";
+import { useAccountStore } from "@/stores/account-store";
+
+/**
+ * The options of any calendar or address book select, under each account's
+ * address when several accounts are signed in (pim-p2-3), the account on
+ * screen first. Goes inside the caller's own
)}
diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx
index 9886cf576..bb63eb8b7 100644
--- a/components/contacts/contact-import-dialog.tsx
+++ b/components/contacts/contact-import-dialog.tsx
@@ -15,6 +15,7 @@ import type { AddressBook, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { isOfflineNow } from "@/lib/offline/state";
import { useCollectionNames } from "@/hooks/use-collection-names";
+import { AccountGroupedOptions } from "@/components/calendar/calendar-select";
interface ContactImportDialogProps {
existingContacts: ContactCard[];
@@ -354,11 +355,10 @@ export function ContactImportDialog({
onChange={(e) => setBookId(e.target.value)}
className="flex-1 min-w-0 text-sm bg-transparent border border-input rounded-md px-2.5 py-1.5 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors"
>
- {addressBooks.map((book) => (
-
- ))}
+ (book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))}
+ />
)}
diff --git a/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx
index 33c632ab4..7b8a25c5b 100644
--- a/components/settings/__tests__/calendar-management-settings.test.tsx
+++ b/components/settings/__tests__/calendar-management-settings.test.tsx
@@ -161,3 +161,29 @@ describe('CalendarManagementSettings row actions', () => {
expect(screen.getByRole('button', { name: 'share_open' })).toBeInTheDocument();
});
});
+
+// pim-p2-3: with two accounts signed in, both accounts' calendars were one
+// list with two rows reading "Calendar · Default".
+describe('CalendarManagementSettings with two accounts', () => {
+ it("lists each account's calendars under its address", async () => {
+ const { useAccountStore } = await import('@/stores/account-store');
+ useAccountStore.setState({
+ accounts: [
+ { id: 'login-a', email: 'a@example.org', username: 'a@example.org', displayName: 'Sam', label: 'Sam' },
+ { id: 'login-b', email: 'b@example.org', username: 'b@example.org', displayName: 'Sam', label: 'Sam' },
+ ] as never,
+ activeAccountId: 'login-a',
+ });
+ useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
+ const a = { ...appleColored, id: 'login-a::cal', name: 'Home', localAccountId: 'login-a' } as Calendar;
+ const b = { ...appleColored, id: 'login-b::cal', name: 'Office', localAccountId: 'login-b' } as Calendar;
+ useCalendarStore.setState({ calendars: [b, a], icalSubscriptions: [], updateCalendar: vi.fn() } as never);
+
+ render();
+
+ const groups = screen.getAllByTestId('calendar-settings-group');
+ expect(groups.map((g) => g.querySelector('[data-testid="calendar-settings-account"]')?.textContent)).toEqual(['a@example.org', 'b@example.org']);
+ expect(groups.map((g) => Array.from(g.querySelectorAll('[data-testid="calendar-settings-item"]')).map((el) => el.getAttribute('data-calendar-name')))).toEqual([['Home'], ['Office']]);
+ useAccountStore.setState({ accounts: [], activeAccountId: null });
+ });
+});
diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx
index 7d7d659c0..c274a7282 100644
--- a/components/settings/calendar-management-settings.tsx
+++ b/components/settings/calendar-management-settings.tsx
@@ -24,6 +24,7 @@ import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-m
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useAccountStore } from '@/stores/account-store';
+import { groupCalendarsByAccount } from '@/lib/calendar-targets';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
import { IS_LITE } from '@/lib/lite';
@@ -332,6 +333,9 @@ export function CalendarManagementSettings() {
// Principal ids belong to one server: only calendars of the account
// `client` talks to get their recipients named.
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
+ const localAccounts = useAccountStore((s) => s.accounts);
+ // Under each account's address when several are signed in (pim-p2-3).
+ const calendarGroups = groupCalendarsByAccount(listedCalendars, localAccounts, activeLocalAccountId);
const resolvesShares = (cal: Calendar) => !cal.localAccountId || cal.localAccountId === activeLocalAccountId;
const shareLabels = useShareRecipientLabels(
client,
@@ -651,7 +655,14 @@ export function CalendarManagementSettings() {
{renderRowMenu()}
+ )}
+ {group.calendars.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
// Up and down step over the neighbours of the same account.
const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal));
@@ -819,6 +830,8 @@ export function CalendarManagementSettings() {
);
})}
+
+ ))}
{isCreating ? (
): string {
+ return calendar.localAccountId ?? '';
+}
+
+/**
+ * Calendars a new event can go into: they show in the grid (not a task
+ * list), are writable and not a feed. `keepId` stays listed whatever it is,
+ * so an event that already sits in a task list keeps its one option.
+ */
+export function eventTargets(
+ calendars: Calendar[],
+ isSubscriptionCalendar?: (id: string) => boolean,
+ keepId?: string,
+): Calendar[] {
+ return calendars.filter((c) => c.id === keepId || (!c.isTasksOnly && !c.isCompany && canCreateEventsIn(c, isSubscriptionCalendar)));
+}
+
+/**
+ * Calendars a task can go into: task lists. A task list is a calendar that
+ * holds only tasks, or one that holds tasks already (`holdingTasks`). An
+ * account with neither keeps its default calendar, which is where both
+ * Stalwarts put a task when nothing else is chosen; without it that account
+ * couldn't take a task at all.
+ */
+export function taskTargets(
+ calendars: Calendar[],
+ isSubscriptionCalendar?: (id: string) => boolean,
+ options: { keepId?: string; holdingTasks?: Iterable } = {},
+): Calendar[] {
+ const holding = new Set(options.holdingTasks ?? []);
+ const writable = calendars.filter((c) => !c.isCompany && canCreateEventsIn(c, isSubscriptionCalendar));
+ const lists = writable.filter((c) => c.isTasksOnly || holding.has(c.id));
+ const covered = new Set(lists.map(calendarAccountKey));
+ const fallbacks: Calendar[] = [];
+ for (const cal of writable) {
+ const key = calendarAccountKey(cal);
+ if (covered.has(key) || cal.isShared) continue;
+ const own = writable.filter((c) => calendarAccountKey(c) === key && !c.isShared);
+ const pick = own.find((c) => c.isDefault) ?? own[0];
+ if (pick) fallbacks.push(pick);
+ covered.add(key);
+ }
+ const chosen = new Set([...lists, ...fallbacks].map((c) => c.id));
+ return calendars.filter((c) => chosen.has(c.id) || c.id === options.keepId);
+}
+
+export interface CalendarGroup = Calendar> {
+ /** The local account id, '' for calendars of the one signed-in account. */
+ key: string;
+ /** The account's address; null when there's only one group. */
+ label: string | null;
+ calendars: T[];
+}
+
+/**
+ * Calendars by account: the active one first, then the others in the order
+ * they were added, each named by its address. One group without a label
+ * when only one account is there.
+ */
+export function groupCalendarsByAccount>(
+ calendars: T[],
+ accounts: TargetAccount[],
+ activeAccountId: string | null | undefined,
+): CalendarGroup[] {
+ const byKey = new Map();
+ for (const cal of calendars) {
+ const key = calendarAccountKey(cal);
+ byKey.set(key, [...(byKey.get(key) ?? []), cal]);
+ }
+ if (byKey.size <= 1) {
+ const [key = ''] = byKey.keys();
+ return calendars.length > 0 ? [{ key, label: null, calendars }] : [];
+ }
+ const order = [
+ ...(activeAccountId ? [activeAccountId] : []),
+ ...accounts.map((a) => a.id).filter((id) => id !== activeAccountId),
+ ...byKey.keys(),
+ ];
+ const groups: CalendarGroup[] = [];
+ for (const key of order) {
+ const list = byKey.get(key);
+ if (!list) continue;
+ byKey.delete(key);
+ const account = accounts.find((a) => a.id === key);
+ groups.push({ key, label: key ? accountAddress(account) || key : null, calendars: list });
+ }
+ return groups;
+}
+
+/** Whether a calendar belongs to the account on screen. With one account, all do. */
+function ofAccount(cal: Calendar, activeAccountId: string | null | undefined): boolean {
+ return !cal.localAccountId || cal.localAccountId === activeAccountId;
+}
+
+/**
+ * The calendar a new event preselects: the one last picked for the account
+ * on screen, else that account's default calendar, else its first own one.
+ * Another account's calendar only when the active one has none.
+ */
+export function preferredTarget(
+ targets: Calendar[],
+ activeAccountId: string | null | undefined,
+ lastPicked?: Record,
+): Calendar | undefined {
+ const accountKey = targets.some((c) => c.localAccountId) ? (activeAccountId ?? '') : '';
+ const remembered = lastPicked?.[accountKey];
+ const mine = targets.filter((c) => ofAccount(c, activeAccountId));
+ return (remembered ? mine.find((c) => c.id === remembered) : undefined)
+ ?? mine.find((c) => c.isDefault && !c.isShared)
+ ?? mine.find((c) => !c.isShared)
+ ?? mine[0]
+ ?? targets.find((c) => c.isDefault)
+ ?? targets[0];
+}
+
+/** The key `preferredTarget` remembers a pick under. */
+export function rememberKey(calendar: Pick): string {
+ return calendarAccountKey(calendar);
+}
+
+/**
+ * Each account's default calendar in a colour of its own (pim-p2-3). Both
+ * Stalwarts give every new account's default calendar the same blue, so two
+ * accounts' meetings looked alike. Only for display: the server keeps its
+ * colour, and a colour the user set on the server that differs stays.
+ */
+export function distinctAccountDefaultColors(calendars: T[]): T[] {
+ const accounts = new Set(calendars.map(calendarAccountKey));
+ if (accounts.size < 2) return calendars;
+ const norm = (c: string | null | undefined) => (c ?? CALENDAR_COLORS[0]).toLowerCase();
+ const used = new Set(calendars.map((c) => norm(c.color)));
+ const taken = new Set();
+ const changed = new Map();
+ for (const cal of calendars) {
+ if (!cal.isDefault || cal.isShared || !cal.localAccountId) continue;
+ const color = norm(cal.color);
+ if (!taken.has(color)) {
+ taken.add(color);
+ continue;
+ }
+ const next = CALENDAR_COLORS.find((c) => !used.has(c.toLowerCase()))
+ ?? CALENDAR_COLORS.find((c) => !taken.has(c.toLowerCase()))
+ ?? CALENDAR_COLORS[0];
+ used.add(next.toLowerCase());
+ taken.add(next.toLowerCase());
+ changed.set(cal.id, next);
+ }
+ if (changed.size === 0) return calendars;
+ return calendars.map((c) => (changed.has(c.id) ? { ...c, color: changed.get(c.id)! } : c));
+}
+
+/**
+ * Where "Copy to " puts an event (pim-p2-16): for every other
+ * signed-in account with a calendar for events, that account's own default
+ * one (or its first). An event moves only within its own account, since the
+ * update goes to its own server, so the other account gets a copy.
+ */
+export function copyTargetsFor(
+ event: Pick,
+ targets: Calendar[],
+ accounts: TargetAccount[],
+): Array<{ account: TargetAccount; calendar: Calendar }> {
+ const from = event.localAccountId;
+ if (!from) return [];
+ const result: Array<{ account: TargetAccount; calendar: Calendar }> = [];
+ for (const account of accounts) {
+ if (account.id === from) continue;
+ const own = targets.filter((c) => c.localAccountId === account.id && !c.isShared);
+ const calendar = own.find((c) => c.isDefault) ?? own[0];
+ if (calendar) result.push({ account, calendar });
+ }
+ return result;
+}
+
+/**
+ * The new event for a copy in another account: the same time, place, text
+ * and repeat rule, under a new UID. Not its participants: the copy is the
+ * user's own entry, and creating it must not invite anyone or answer for
+ * the other account. Skipped occurrences stay skipped; changed ones don't
+ * carry over, since they hold the participants' answers.
+ */
+export function buildCopyToCalendarData(event: CalendarEvent, calendarId: string): Partial {
+ const data: Partial = {
+ uid: generateUUID(),
+ title: event.title,
+ description: event.description,
+ start: event.start,
+ duration: event.duration,
+ timeZone: event.timeZone,
+ showWithoutTime: event.showWithoutTime,
+ calendarIds: { [calendarId]: true },
+ status: event.status === 'cancelled' ? 'cancelled' : 'confirmed',
+ freeBusyStatus: event.freeBusyStatus,
+ privacy: event.privacy,
+ };
+ 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.alerts) data.alerts = structuredClone(event.alerts);
+ if (event.recurrenceRules?.length) {
+ data.recurrenceRules = structuredClone(event.recurrenceRules);
+ if (event.excludedRecurrenceRules?.length) data.excludedRecurrenceRules = structuredClone(event.excludedRecurrenceRules);
+ const skipped = Object.entries(event.recurrenceOverrides ?? {}).filter(([, patch]) => (patch as { excluded?: boolean } | null)?.excluded === true);
+ if (skipped.length) data.recurrenceOverrides = Object.fromEntries(skipped.map(([id]) => [id, { excluded: true }])) as CalendarEvent['recurrenceOverrides'];
+ }
+ return sanitizeOutgoingCalendarEventData(data);
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index db3f91ff9..c292bd340 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "مشارَك من {name}:",
"freebusy_description": "من تشارك معه يرى متى تكون مشغولًا، لا تفاصيل أحداثك. تستخدمه ميزة اقتراح الأوقات."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "القائمة",
+ "task_list_not_in_calendar": "{name} (قائمة مهام، لا تظهر في التقويم)",
+ "copy_to_account": "نسخ إلى {address}",
+ "copied_to_account": "تم النسخ إلى {address}",
+ "delete_original": "حذف الأصل"
+ },
"calendar_offline": {
"split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال."
},
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 370dd4902..75155c3d9 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Compartit per {name}:",
"freebusy_description": "Qui rebi la compartició veu quan esteu ocupat, no quins esdeveniments teniu. «Suggereix horaris» ho fa servir."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Llista",
+ "task_list_not_in_calendar": "{name} (llista de tasques, no es mostra al calendari)",
+ "copy_to_account": "Copia a {address}",
+ "copied_to_account": "S'ha copiat a {address}",
+ "delete_original": "Suprimeix l'original"
+ },
"calendar_offline": {
"split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat."
},
diff --git a/locales/cs/common.json b/locales/cs/common.json
index c77cddf2a..1ca5b0b98 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Sdílí {name}:",
"freebusy_description": "Ten, s kým sdílíte, vidí, kdy jste obsazeni, ne jaké máte události. Používá to funkce Navrhnout časy."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Seznam",
+ "task_list_not_in_calendar": "{name} (seznam úkolů, v kalendáři se nezobrazuje)",
+ "copy_to_account": "Kopírovat do {address}",
+ "copied_to_account": "Zkopírováno do {address}",
+ "delete_original": "Smazat originál"
+ },
"calendar_offline": {
"split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení."
},
diff --git a/locales/da/common.json b/locales/da/common.json
index 89522ce74..b475e94b9 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Delt af {name}:",
"freebusy_description": "Dem, du deler med, ser, hvornår du er optaget, ikke hvad dine begivenheder er. Foreslå tidspunkter bruger det."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Liste",
+ "task_list_not_in_calendar": "{name} (opgaveliste, vises ikke i kalenderen)",
+ "copy_to_account": "Kopiér til {address}",
+ "copied_to_account": "Kopieret til {address}",
+ "delete_original": "Slet originalen"
+ },
"calendar_offline": {
"split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online."
},
diff --git a/locales/de/common.json b/locales/de/common.json
index 5f077d7fd..34b925621 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Freigegeben von {name}:",
"freebusy_description": "Wer die Freigabe erhält, sieht, wann Sie belegt sind, aber nicht, welche Termine Sie haben. „Zeiten vorschlagen“ nutzt das."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Liste",
+ "task_list_not_in_calendar": "{name} (Aufgabenliste, nicht im Kalender sichtbar)",
+ "copy_to_account": "In {address} kopieren",
+ "copied_to_account": "In {address} kopiert",
+ "delete_original": "Original löschen"
+ },
"calendar_offline": {
"split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind."
},
diff --git a/locales/en/common.json b/locales/en/common.json
index ea30812aa..be8994d30 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Shared by {name}:",
"freebusy_description": "Whoever you share with sees when you’re busy, not what your events are. Suggest times uses it."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "List",
+ "task_list_not_in_calendar": "{name} (task list, not shown in the calendar)",
+ "copy_to_account": "Copy to {address}",
+ "copied_to_account": "Copied to {address}",
+ "delete_original": "Delete original"
+ },
"calendar_offline": {
"split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online."
},
diff --git a/locales/es/common.json b/locales/es/common.json
index e28d12f5f..394d9c365 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Compartido por {name}:",
"freebusy_description": "Quien reciba lo compartido ve cuándo estás ocupado, no qué eventos tienes. «Sugerir horarios» lo usa."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Lista",
+ "task_list_not_in_calendar": "{name} (lista de tareas, no se muestra en el calendario)",
+ "copy_to_account": "Copiar a {address}",
+ "copied_to_account": "Copiado a {address}",
+ "delete_original": "Eliminar el original"
+ },
"calendar_offline": {
"split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión."
},
diff --git a/locales/et/common.json b/locales/et/common.json
index 584c4f2b7..c3fdc5b00 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Jagas {name}:",
"freebusy_description": "Kellega jagad, näeb, millal oled hõivatud, aga mitte sinu sündmusi. Seda kasutab „Soovita aegu“."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Loend",
+ "task_list_not_in_calendar": "{name} (ülesannete loend, kalendris ei näidata)",
+ "copy_to_account": "Kopeeri kontole {address}",
+ "copied_to_account": "Kopeeritud kontole {address}",
+ "delete_original": "Kustuta originaal"
+ },
"calendar_offline": {
"split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus."
},
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 1c49696fe..16fc7870f 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "به اشتراک گذاشتهشده توسط {name}:",
"freebusy_description": "کسی که با او به اشتراک میگذارید میبیند چه زمانی مشغول هستید، نه اینکه رویدادهایتان چیست. «پیشنهاد زمان» از آن استفاده میکند."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "فهرست",
+ "task_list_not_in_calendar": "{name} (فهرست کارها، در تقویم نشان داده نمیشود)",
+ "copy_to_account": "رونوشت در {address}",
+ "copied_to_account": "در {address} رونوشت شد",
+ "delete_original": "حذف نسخهٔ اصلی"
+ },
"calendar_offline": {
"split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید."
},
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 1a6a2f71c..0f1592299 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Partagé par {name} :",
"freebusy_description": "Les personnes avec qui vous partagez voient quand vous êtes occupé, pas le contenu de vos événements. « Suggérer des créneaux » s’en sert."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Liste",
+ "task_list_not_in_calendar": "{name} (liste de tâches, non affichée dans le calendrier)",
+ "copy_to_account": "Copier vers {address}",
+ "copied_to_account": "Copié vers {address}",
+ "delete_original": "Supprimer l'original"
+ },
"calendar_offline": {
"split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne."
},
diff --git a/locales/he/common.json b/locales/he/common.json
index e15809a86..6fa171bbc 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -5099,6 +5099,13 @@
"caldav_shared_by": "משותף על ידי {name}:",
"freebusy_description": "מי שאתה משתף איתו רואה מתי אתה תפוס, לא מה האירועים שלך. \"הצע זמנים\" משתמש בזה."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "רשימה",
+ "task_list_not_in_calendar": "{name} (רשימת משימות, לא מוצגת בלוח השנה)",
+ "copy_to_account": "העתקה אל {address}",
+ "copied_to_account": "הועתק אל {address}",
+ "delete_original": "מחיקת המקור"
+ },
"calendar_offline": {
"split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור."
},
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 23c5c88d4..8dc46b9d4 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Megosztotta: {name}",
"freebusy_description": "Akivel megoszt, látja, mikor foglalt, de az eseményeit nem. Az Időpontok javaslata ezt használja."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Lista",
+ "task_list_not_in_calendar": "{name} (feladatlista, a naptárban nem látszik)",
+ "copy_to_account": "Másolás ide: {address}",
+ "copied_to_account": "Átmásolva ide: {address}",
+ "delete_original": "Eredeti törlése"
+ },
"calendar_offline": {
"split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz."
},
diff --git a/locales/it/common.json b/locales/it/common.json
index 73fda3f89..d5e753b8b 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Condiviso da {name}:",
"freebusy_description": "Chi riceve la condivisione vede quando sei occupato, non quali eventi hai. «Suggerisci orari» lo usa."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Elenco",
+ "task_list_not_in_calendar": "{name} (elenco attività, non visibile nel calendario)",
+ "copy_to_account": "Copia in {address}",
+ "copied_to_account": "Copiato in {address}",
+ "delete_original": "Elimina l'originale"
+ },
"calendar_offline": {
"split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online."
},
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 6b2f3e82b..5606b1815 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "{name} が共有:",
"freebusy_description": "共有した相手には予定がある時間だけが見え、予定の内容は見えません。「時間を提案」で使われます。"
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "リスト",
+ "task_list_not_in_calendar": "{name}(タスクリスト、カレンダーには表示されません)",
+ "copy_to_account": "{address} にコピー",
+ "copied_to_account": "{address} にコピーしました",
+ "delete_original": "元の予定を削除"
+ },
"calendar_offline": {
"split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。"
},
diff --git a/locales/ko/common.json b/locales/ko/common.json
index fe9560d3f..f9eaaa275 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "{name} 님이 공유:",
"freebusy_description": "공유받은 사람은 내가 바쁜 시간만 보고 일정 내용은 보지 못합니다. 시간 제안에 사용됩니다."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "목록",
+ "task_list_not_in_calendar": "{name} (할 일 목록, 캘린더에 표시되지 않음)",
+ "copy_to_account": "{address}(으)로 복사",
+ "copied_to_account": "{address}(으)로 복사됨",
+ "delete_original": "원본 삭제"
+ },
"calendar_offline": {
"split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요."
},
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 6c2792f2a..0d67fba90 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Kopīgoja {name}:",
"freebusy_description": "Tas, ar kuru kopīgojat, redz, kad esat aizņemts, bet ne jūsu notikumus. To izmanto “Ieteikt laikus”."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Saraksts",
+ "task_list_not_in_calendar": "{name} (uzdevumu saraksts, kalendārā netiek rādīts)",
+ "copy_to_account": "Kopēt uz {address}",
+ "copied_to_account": "Nokopēts uz {address}",
+ "delete_original": "Dzēst oriģinālu"
+ },
"calendar_offline": {
"split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē."
},
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 5638c0661..ca0a9bb8c 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "{name} хуваалцсан:",
"freebusy_description": "Таны хуваалцсан хүн таныг хэзээ завгүйг хардаг ч үйл явдлыг тань хардаггүй. “Цаг санал болгох” үүнийг ашигладаг."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Жагсаалт",
+ "task_list_not_in_calendar": "{name} (ажлын жагсаалт, хуанлид харагдахгүй)",
+ "copy_to_account": "{address} руу хуулах",
+ "copied_to_account": "{address} руу хуулсан",
+ "delete_original": "Эх хувийг устгах"
+ },
"calendar_offline": {
"split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу."
},
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 875523efe..ac96cf9ae 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Delt av {name}:",
"freebusy_description": "De du deler med, ser når du er opptatt, ikke hva hendelsene dine er. Foreslå tidspunkter bruker det."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Liste",
+ "task_list_not_in_calendar": "{name} (oppgaveliste, vises ikke i kalenderen)",
+ "copy_to_account": "Kopier til {address}",
+ "copied_to_account": "Kopiert til {address}",
+ "delete_original": "Slett originalen"
+ },
"calendar_offline": {
"split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet."
},
diff --git a/locales/nl/common.json b/locales/nl/common.json
index c24f86f62..136446aa7 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Gedeeld door {name}:",
"freebusy_description": "Wie je deelt, ziet wanneer je bezet bent, niet wat je afspraken zijn. ‘Tijden voorstellen’ gebruikt dit."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Lijst",
+ "task_list_not_in_calendar": "{name} (takenlijst, niet zichtbaar in de agenda)",
+ "copy_to_account": "Kopiëren naar {address}",
+ "copied_to_account": "Gekopieerd naar {address}",
+ "delete_original": "Origineel verwijderen"
+ },
"calendar_offline": {
"split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent."
},
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 90a292bbd..9b11143a8 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Udostępnia {name}:",
"freebusy_description": "Osoby, którym udostępniasz, widzą, kiedy jesteś zajęty, ale nie widzą Twoich wydarzeń. Korzysta z tego funkcja proponowania terminów."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Lista",
+ "task_list_not_in_calendar": "{name} (lista zadań, niewidoczna w kalendarzu)",
+ "copy_to_account": "Kopiuj do {address}",
+ "copied_to_account": "Skopiowano do {address}",
+ "delete_original": "Usuń oryginał"
+ },
"calendar_offline": {
"split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online."
},
diff --git a/locales/pt/common.json b/locales/pt/common.json
index f38b2bc69..8c5c2731b 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Compartilhado por {name}:",
"freebusy_description": "Quem recebe o compartilhamento vê quando está ocupado, não quais são os seus eventos. «Sugerir horários» usa isto."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Lista",
+ "task_list_not_in_calendar": "{name} (lista de tarefas, não aparece no calendário)",
+ "copy_to_account": "Copiar para {address}",
+ "copied_to_account": "Copiado para {address}",
+ "delete_original": "Eliminar o original"
+ },
"calendar_offline": {
"split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online."
},
diff --git a/locales/ro/common.json b/locales/ro/common.json
index e0e5ef680..d3f91819a 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Partajat de {name}:",
"freebusy_description": "Cei cu care partajați văd când sunteți ocupat, nu ce evenimente aveți. „Sugerează ore” folosește asta."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Listă",
+ "task_list_not_in_calendar": "{name} (listă de sarcini, nu apare în calendar)",
+ "copy_to_account": "Copiază în {address}",
+ "copied_to_account": "Copiat în {address}",
+ "delete_original": "Șterge originalul"
+ },
"calendar_offline": {
"split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online."
},
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 761f4d4dd..ccfa97d4b 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Поделился {name}:",
"freebusy_description": "Те, с кем вы поделились, видят, когда вы заняты, но не видят ваши события. Это использует функция «Предложить время»."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Список",
+ "task_list_not_in_calendar": "{name} (список задач, не отображается в календаре)",
+ "copy_to_account": "Копировать в {address}",
+ "copied_to_account": "Скопировано в {address}",
+ "delete_original": "Удалить оригинал"
+ },
"calendar_offline": {
"split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети."
},
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 5b1572ca2..f3cc075e5 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Zdieľa {name}:",
"freebusy_description": "Ten, s kým zdieľate, vidí, kedy ste obsadení, nie aké máte udalosti. Používa to funkcia Navrhnúť časy."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Zoznam",
+ "task_list_not_in_calendar": "{name} (zoznam úloh, v kalendári sa nezobrazuje)",
+ "copy_to_account": "Kopírovať do {address}",
+ "copied_to_account": "Skopírované do {address}",
+ "delete_original": "Odstrániť originál"
+ },
"calendar_offline": {
"split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online."
},
diff --git a/locales/th/common.json b/locales/th/common.json
index 4e7043c82..283d08bf7 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -4617,6 +4617,13 @@
"caldav_shared_by": "แชร์โดย {name}:",
"freebusy_description": "ผู้ที่คุณแชร์ด้วยจะเห็นว่าคุณไม่ว่างเมื่อใด แต่ไม่เห็นรายละเอียดกิจกรรม ฟีเจอร์แนะนำเวลาใช้ข้อมูลนี้"
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "รายการ",
+ "task_list_not_in_calendar": "{name} (รายการงาน ไม่แสดงในปฏิทิน)",
+ "copy_to_account": "คัดลอกไปที่ {address}",
+ "copied_to_account": "คัดลอกไปที่ {address} แล้ว",
+ "delete_original": "ลบต้นฉบับ"
+ },
"calendar_offline": {
"split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์"
},
diff --git a/locales/tr/common.json b/locales/tr/common.json
index ac8dd7b92..670c1b18a 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Paylaşan: {name}",
"freebusy_description": "Paylaştığınız kişiler ne zaman meşgul olduğunuzu görür, etkinliklerinizi görmez. Saat öner bunu kullanır."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Liste",
+ "task_list_not_in_calendar": "{name} (görev listesi, takvimde gösterilmez)",
+ "copy_to_account": "{address} hesabına kopyala",
+ "copied_to_account": "{address} hesabına kopyalandı",
+ "delete_original": "Orijinali sil"
+ },
"calendar_offline": {
"split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin."
},
diff --git a/locales/uk/common.json b/locales/uk/common.json
index b0d90b0f8..9bb1e5f17 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "Поділився {name}:",
"freebusy_description": "Ті, з ким ви поділилися, бачать, коли ви зайняті, але не бачать ваших подій. Цим користується «Запропонувати час»."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Список",
+ "task_list_not_in_calendar": "{name} (список завдань, не показується в календарі)",
+ "copy_to_account": "Копіювати до {address}",
+ "copied_to_account": "Скопійовано до {address}",
+ "delete_original": "Видалити оригінал"
+ },
"calendar_offline": {
"split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі."
},
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 367c03dd8..c65d3ad2e 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -5217,6 +5217,13 @@
"caldav_shared_by": "Được chia sẻ bởi {name}:",
"freebusy_description": "Người được chia sẻ thấy khi nào bạn bận, không thấy nội dung sự kiện. Tính năng gợi ý thời gian dùng thông tin này."
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "Danh sách",
+ "task_list_not_in_calendar": "{name} (danh sách việc cần làm, không hiện trong lịch)",
+ "copy_to_account": "Sao chép sang {address}",
+ "copied_to_account": "Đã sao chép sang {address}",
+ "delete_original": "Xóa bản gốc"
+ },
"calendar_offline": {
"split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại."
},
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 0b3d88f49..23f97a00e 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "{name} 分享:",
"freebusy_description": "分享對象能看到你何時忙碌,看不到活動內容。「建議時間」會用到它。"
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "清單",
+ "task_list_not_in_calendar": "{name}(工作清單,不會顯示在行事曆中)",
+ "copy_to_account": "複製到 {address}",
+ "copied_to_account": "已複製到 {address}",
+ "delete_original": "刪除原始活動"
+ },
"calendar_offline": {
"split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。"
},
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 7ac44e08e..c90ff4296 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -5205,6 +5205,13 @@
"caldav_shared_by": "{name} 共享:",
"freebusy_description": "共享对象能看到你何时忙碌,看不到活动内容。“建议时间”会用到它。"
},
+ "ux_p2_pim_cal": {
+ "task_list_label": "列表",
+ "task_list_not_in_calendar": "{name}(任务列表,不在日历中显示)",
+ "copy_to_account": "复制到 {address}",
+ "copied_to_account": "已复制到 {address}",
+ "delete_original": "删除原日程"
+ },
"calendar_offline": {
"split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。"
},
diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts
index 7b0f8dbca..d53cad78d 100644
--- a/stores/calendar-store.ts
+++ b/stores/calendar-store.ts
@@ -928,6 +928,12 @@ interface CalendarStore {
*/
hiddenTaskListIds: string[];
toggleTaskListVisibility: (calendarId: string) => void;
+ /**
+ * The calendar a new event last went into, per local account ('' with one
+ * account): new events, quick create and imports preselect it (pim-p2-4).
+ */
+ lastEventCalendarIds: Record;
+ rememberEventCalendar: (calendar: { id: string; localAccountId?: string }) => void;
setSelectedEventId: (id: string | null) => void;
clearState: () => void;
@@ -960,6 +966,7 @@ const initialState = {
calendarSelectionChosen: false,
knownCalendarIds: null as string[] | null,
hiddenTaskListIds: [] as string[],
+ lastEventCalendarIds: {} as Record,
accountClients: null as CalendarAccountClient[] | null,
accountClientsActiveId: null as string | null,
selectedEventId: null as string | null,
@@ -1809,6 +1816,12 @@ export const useCalendarStore = create()(
}));
},
+ rememberEventCalendar: (calendar) => {
+ const key = calendar.localAccountId ?? '';
+ if (get().lastEventCalendarIds[key] === calendar.id) return;
+ set((state) => ({ lastEventCalendarIds: { ...state.lastEventCalendarIds, [key]: calendar.id } }));
+ },
+
setSelectedEventId: (id) => set({ selectedEventId: id }),
// iCal subscriptions
@@ -2185,11 +2198,14 @@ export const useCalendarStore = create()(
// here would lose them from localStorage on every switch.
const preservedSubs = get().icalSubscriptions;
const preservedTombstones = get().deletedSubscriptionIds;
+ // Keyed by account, so a switch keeps each account's last pick.
+ const preservedPicks = get().lastEventCalendarIds;
set({
...initialState,
selectedDate: displayNow(),
icalSubscriptions: preservedSubs,
deletedSubscriptionIds: preservedTombstones,
+ lastEventCalendarIds: preservedPicks,
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
useCalendarNotificationStore.getState().clearAll();
@@ -2208,6 +2224,9 @@ export const useCalendarStore = create()(
...mergedState,
selectedDate: displayNow(),
viewMode: getSafeCalendarViewMode(mergedState.viewMode),
+ lastEventCalendarIds: mergedState.lastEventCalendarIds && typeof mergedState.lastEventCalendarIds === 'object' && !Array.isArray(mergedState.lastEventCalendarIds)
+ ? mergedState.lastEventCalendarIds
+ : {},
};
},
partialize: (state) => ({
@@ -2215,6 +2234,7 @@ export const useCalendarStore = create()(
calendarSelectionChosen: state.calendarSelectionChosen,
knownCalendarIds: state.knownCalendarIds,
hiddenTaskListIds: state.hiddenTaskListIds,
+ lastEventCalendarIds: state.lastEventCalendarIds,
viewMode: state.viewMode,
icalSubscriptions: state.icalSubscriptions,
deletedSubscriptionIds: state.deletedSubscriptionIds,