From 109866dd4c6ed501ec4292f9f6f298a5e68e8770 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 13:48:47 +0100
Subject: [PATCH] fix: tell two accounts' calendars apart, keep events out of
task lists, and start new events in the account on screen
Calendar, task list and address book choosers are grouped under each
account's address, the sidebar and Settings head each account by its
address, and a second account's default calendar gets a colour of its
own. Events can no longer go into a task list from the editor, quick
create or import; the task editor's "List" offers task lists only. New
events preselect the active account's last-used or default calendar,
and the event menu has "Copy to
" for the other account.
Two task editor tests change their fixtures and label: the select now
lists task lists only and is labelled "List", so plain calendars no
longer appear in it (pim-p2-13).
---
.../calendar-targets-ux-p2-pim.test.tsx | 220 ++++++++++++++++
.../__tests__/task-lists-ux-pim.test.tsx | 6 +-
.../__tests__/task-modal-calendars.test.tsx | 16 +-
components/calendar/calendar-app.tsx | 67 ++++-
components/calendar/calendar-select.tsx | 65 +++++
.../calendar/calendar-sidebar-panel.tsx | 16 +-
components/calendar/event-context-menu.tsx | 14 ++
components/calendar/event-modal.tsx | 46 ++--
components/calendar/ical-import-modal.tsx | 42 ++--
components/calendar/quick-create-popover.tsx | 17 +-
components/calendar/task-list-view.tsx | 6 +-
components/calendar/task-modal.tsx | 45 ++--
.../contacts/address-book-picker-dialog.tsx | 17 +-
components/contacts/contact-form.tsx | 12 +-
components/contacts/contact-import-dialog.tsx | 10 +-
.../calendar-management-settings.test.tsx | 26 ++
.../settings/calendar-management-settings.tsx | 15 +-
lib/calendar-targets.ts | 234 ++++++++++++++++++
locales/ar/common.json | 7 +
locales/ca/common.json | 7 +
locales/cs/common.json | 7 +
locales/da/common.json | 7 +
locales/de/common.json | 7 +
locales/en/common.json | 7 +
locales/es/common.json | 7 +
locales/et/common.json | 7 +
locales/fa/common.json | 7 +
locales/fr/common.json | 7 +
locales/he/common.json | 7 +
locales/hu/common.json | 7 +
locales/it/common.json | 7 +
locales/ja/common.json | 7 +
locales/ko/common.json | 7 +
locales/lv/common.json | 7 +
locales/mn/common.json | 7 +
locales/nb/common.json | 7 +
locales/nl/common.json | 7 +
locales/pl/common.json | 7 +
locales/pt/common.json | 7 +
locales/ro/common.json | 7 +
locales/ru/common.json | 7 +
locales/sk/common.json | 7 +
locales/th/common.json | 7 +
locales/tr/common.json | 7 +
locales/uk/common.json | 7 +
locales/vi/common.json | 7 +
locales/zh-TW/common.json | 7 +
locales/zh/common.json | 7 +
stores/calendar-store.ts | 20 ++
49 files changed, 991 insertions(+), 113 deletions(-)
create mode 100644 components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx
create mode 100644 components/calendar/calendar-select.tsx
create mode 100644 lib/calendar-targets.ts
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,