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 ) => onChange(e.target.value)} + aria-label={rest["aria-label"]} + title={title} + className={className} + data-testid="calendar-select" + > + + + ); +} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 98ad798c0..fb526ae2d 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -12,6 +12,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useTaskStore } from "@/stores/task-store"; import { useAccountStore } from "@/stores/account-store"; +import { accountAddress } from "@/lib/calendar-targets"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors"; import { toast } from "@/stores/toast-store"; @@ -209,13 +210,15 @@ export function CalendarSidebarPanel({ list.push(cal); byAccount.set(key, list); } - const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = []; + const ordered: { key: string; label: string; name: string; split: AccountCalendarSplit }[] = []; // Active account first. if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) { const acct = localAccounts.find(a => a.id === activeLocalAccountId); ordered.push({ key: activeLocalAccountId, - label: acct?.label || acct?.email || acct?.username || activeLocalAccountId, + // By address (pim-p2-3): two accounts of one person share a name. + label: accountAddress(acct) || activeLocalAccountId, + name: acct?.displayName || acct?.label || '', split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!), }); byAccount.delete(activeLocalAccountId); @@ -225,7 +228,8 @@ export function CalendarSidebarPanel({ if (!byAccount.has(acct.id)) continue; ordered.push({ key: acct.id, - label: acct.label || acct.email || acct.username, + label: accountAddress(acct), + name: acct.displayName || acct.label || '', split: splitAccountCalendars(byAccount.get(acct.id)!), }); byAccount.delete(acct.id); @@ -235,7 +239,7 @@ export function CalendarSidebarPanel({ const fallbackLabel = key === '__other__' ? t('my_calendars') : list[0]?.accountName || key; - ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) }); + ordered.push({ key, label: fallbackLabel, name: '', split: splitAccountCalendars(list) }); } return ordered; }, [multiAccountMode, eventCalendars, localAccounts, activeLocalAccountId, t]); @@ -295,7 +299,7 @@ export function CalendarSidebarPanel({ const taskLists = useMemo(() => (inTasksView ? taskListsOf(calendars, tasks) : []), [inTasksView, calendars, tasks]); const accountLabel = (localAccountId: string) => { const acct = localAccounts.find(a => a.id === localAccountId); - return acct?.label || acct?.email || acct?.username || localAccountId; + return accountAddress(acct) || localAccountId; }; const taskListGroups = useMemo(() => { if (!multiAccountMode) return [{ key: '__all__', label: '', lists: taskLists }]; @@ -615,7 +619,7 @@ export function CalendarSidebarPanel({ )} - + ` : group.label} data-testid="calendar-account-group"> {group.label} {isActive && onCreateCalendar && ( diff --git a/components/calendar/event-context-menu.tsx b/components/calendar/event-context-menu.tsx index ff3483879..7b8148e27 100644 --- a/components/calendar/event-context-menu.tsx +++ b/components/calendar/event-context-menu.tsx @@ -18,6 +18,7 @@ import { Check, HelpCircle, X, + CalendarPlus, } from "@/components/icons"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { buildCalendarPath } from "@/lib/deep-links"; @@ -54,6 +55,8 @@ interface EventContextMenuProps { responseStatus?: CalendarParticipant["participationStatus"] | null; /** Only the answers: the menu of the "?" / "Reply" marker. */ answersOnly?: boolean; + /** "Copy to
", one per other signed-in account (pim-p2-16). */ + copyTo?: Array<{ address: string; onCopy: () => void }>; } export function EventContextMenu({ @@ -73,7 +76,9 @@ export function EventContextMenu({ onRespond, responseStatus, answersOnly = false, + copyTo = [], }: EventContextMenuProps) { + const tP2 = useTranslations("ux_p2_pim_cal"); const t = useTranslations("calendar"); const tDeepLink = useTranslations("deep_link"); const tCommon = useTranslations("common"); @@ -118,6 +123,15 @@ export function EventContextMenu({ )} {editable && } + {copyTo.map((target) => ( + + ))} { setStartDate(v); shiftEndKeepingDuration(v, startTime); }; const handleStartTimeChange = (v: string) => { setStartTime(v); shiftEndKeepingDuration(startDate, v); }; + // New events go to the account on screen (pim-p2-4): the calendar last + // picked there, else its default one. Never a task list (pim-p2-2). + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); const [calendarId, setCalendarId] = useState(() => { if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; - if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; - const defaultCal = calendars.find(c => c.isDefault); + const targets = eventTargets(calendars, isSubscriptionCalendar); + if (defaultCalendarId && targets.some(c => c.id === defaultCalendarId)) return defaultCalendarId; // Never default new events into a subscription / read-only calendar (#762). - return defaultCal?.id - || calendars.find(c => canCreateEventsIn(c, isSubscriptionCalendar))?.id + return preferredTarget(targets, activeLocalAccountId, lastEventCalendarIds)?.id || calendars[0]?.id || ""; }); - // Calendars offered as create/move targets: writable, non-subscription, plus - // the event's current calendar so an in-place edit never blanks the select. + // Calendars offered as create/move targets: writable, non-subscription, + // holding events (a task list never shows in the grid, so an event saved + // there seemed lost, pim-p2-2), plus the event's current calendar so an + // in-place edit never blanks the select. // An existing event moves only within its own login: the update goes to // that login's server, which doesn't know another login's calendar ids. const eventLogin = useMemo(() => { const primary = event?.calendarIds ? getPrimaryCalendarId(event) : undefined; - return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId } : { known: false, login: undefined }; + return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId, primary } : { known: false, login: undefined, primary: undefined }; }, [event, calendars]); const selectableCalendars = useMemo( - () => calendars.filter(c => (canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId) - && (!eventLogin.known || c.localAccountId === eventLogin.login)), - [calendars, isSubscriptionCalendar, calendarId, eventLogin] + () => eventTargets(calendars, isSubscriptionCalendar, eventLogin.primary) + .filter(c => !eventLogin.known || c.localAccountId === eventLogin.login), + [calendars, isSubscriptionCalendar, eventLogin] ); const [recurrence, setRecurrence] = useState(() => { if (!event?.recurrenceRules?.length) return "none"; @@ -1521,18 +1528,13 @@ export function EventModal({ {selectableCalendars.length > 1 && (
- + />
)} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index a66c77c94..8544c776d 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useRef, useId } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef, useId } from "react"; import { useLocale, useTranslations } from "next-intl"; import { buildRecurrenceSummary } from "./recurrence-editor"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,9 @@ import { apiFetch } from "@/lib/browser-navigation"; import { explainServerAuthError } from "@/lib/server-auth-status"; import { IS_LITE } from "@/lib/lite"; import { isOfflineNow } from "@/lib/offline/state"; -import { useCollectionNames } from "@/hooks/use-collection-names"; +import { eventTargets, preferredTarget } from "@/lib/calendar-targets"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; +import { useAccountStore } from "@/stores/account-store"; interface ICalImportModalProps { calendars: Calendar[]; @@ -34,8 +36,15 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { - const shownName = useCollectionNames(); +export function ICalImportModal({ calendars: allCalendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { + const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); + // Events go only where the grid shows them: never a task list or a feed (pim-p2-2). + const calendars = useMemo(() => eventTargets(allCalendars, isSubscriptionCalendar), [allCalendars, isSubscriptionCalendar]); + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const rememberEventCalendar = useCalendarStore((s) => s.rememberEventCalendar); + // The account on screen's calendar, the one last picked there first (pim-p2-4). + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); + const preferredId = () => preferredTarget(calendars, activeLocalAccountId, lastEventCalendarIds)?.id ?? ""; const t = useTranslations("calendar.import"); const tLaunch = useTranslations("pwaLaunch"); const tCal = useTranslations("calendar"); @@ -47,10 +56,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia const [step, setStep] = useState("select"); const [parsedEvents, setParsedEvents] = useState[]>([]); const [selectedIndices, setSelectedIndices] = useState>(new Set()); - const [calendarId, setCalendarId] = useState(() => { - const defaultCal = calendars.find((c) => c.isDefault); - return defaultCal?.id || calendars[0]?.id || ""; - }); + const [calendarId, setCalendarId] = useState(preferredId); const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); @@ -69,7 +75,8 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia // Opened before the calendars had loaded, from a launch: the default once they have. useEffect(() => { if (calendarId || calendars.length === 0) return; - setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id); + setCalendarId(preferredId()); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preferredId reads the same calendars }, [calendars, calendarId]); const validateFile = useCallback((file: File): string | null => { @@ -244,13 +251,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia setStep("preview"); return; } + const target = calendars.find((c) => c.id === calendarId); + if (target) rememberEventCalendar?.(target); toast.success(t("success", { count })); onClose(); } catch { toast.error(t("error")); setStep("preview"); } - }, [parsedEvents, selectedIndices, importEvents, client, calendarId, t, onClose]); + }, [parsedEvents, selectedIndices, importEvents, client, calendarId, calendars, rememberEventCalendar, t, onClose]); const formatEventDate = (event: Partial): string => { if (!event.start) return ""; @@ -459,18 +468,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia - + /> )} diff --git a/components/calendar/quick-create-popover.tsx b/components/calendar/quick-create-popover.tsx index 9951c9324..913afae41 100644 --- a/components/calendar/quick-create-popover.tsx +++ b/components/calendar/quick-create-popover.tsx @@ -12,6 +12,7 @@ import type { Calendar } from "@/lib/jmap/types"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; export interface QuickCreateDraft { start: Date; @@ -71,6 +72,7 @@ export function QuickCreatePopover({ onClose, }: QuickCreatePopoverProps) { const shownName = useCollectionNames(); + const selected = calendars.find((c) => c.id === draft.calendarId); const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); @@ -198,20 +200,17 @@ export function QuickCreatePopover({
c.id === draft.calendarId)?.color || "#3b82f6") }} + style={{ backgroundColor: paint(selected?.color || "#3b82f6") }} aria-hidden="true" /> - + />
)} diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 8e2391ead..f754dbdb0 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -32,9 +32,11 @@ interface TaskListViewProps { * The user's own calendars of the active login come first: a colleague's * shared list or another login's is never picked over them. */ -export function defaultTaskList(calendars: Calendar[]): Calendar | undefined { +export function defaultTaskList(calendars: Calendar[], activeAccountId?: string | null): Calendar | undefined { const pick = (list: Calendar[]) => list.find((c) => c.isTasksOnly) ?? list.find((c) => c.isDefault) ?? list[0]; - return pick(calendars.filter((c) => !c.isShared && !c.localAccountId)) ?? pick(calendars); + // With several accounts every calendar names its login: the one on screen counts as the user's own (pim-p2-4). + const own = (c: Calendar) => !c.isShared && (!c.localAccountId || (!!activeAccountId && c.localAccountId === activeAccountId)); + return pick(calendars.filter(own)) ?? pick(calendars); } /** diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 3f85531f8..eab0f174f 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -15,8 +15,10 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { canCreateEventsIn } from "@/lib/calendar-editability"; -import { useCollectionNames } from "@/hooks/use-collection-names"; +import { taskTargets } from "@/lib/calendar-targets"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; +import { useAccountStore } from "@/stores/account-store"; +import { useTaskStore } from "@/stores/task-store"; import { defaultTaskList } from "@/components/calendar/task-list-view"; interface TaskModalProps { @@ -62,9 +64,9 @@ export function TaskModal({ isMobile = false, isSubscriptionCalendar, }: TaskModalProps) { - const shownName = useCollectionNames(); const t = useTranslations("calendar"); const tUx = useTranslations("ux_pim_calendar"); + const tP2 = useTranslations("ux_p2_pim_cal"); const tCommon = useTranslations("common"); // The header's close goes to its start when the window's own close is at its end. const [header, setHeader] = useState(null); @@ -75,11 +77,17 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - // The calendars a task can go into: not read-only ones (Birthdays, - // feeds), the same rule as the event editor. - const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); - // A new task goes to the first task-only list (pim-8). - const defaultCalendarId = defaultTaskList(writableCalendars)?.id ?? calendars[0]?.id ?? ""; + // The lists a task can go into: task lists only, never a calendar of + // events or a read-only one (pim-p2-13). An account without one keeps + // its default calendar, where its tasks already are. + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const tasks = useTaskStore((s) => s.tasks); + const taskCalendarId = task ? Object.keys(task.calendarIds)[0] : undefined; + const writableCalendars = useMemo(() => taskTargets(calendars, isSubscriptionCalendar, { + holdingTasks: tasks.flatMap((x) => Object.keys(x.calendarIds ?? {})), + }), [calendars, isSubscriptionCalendar, tasks]); + // A new task goes to the first task-only list of the account on screen (pim-8). + const defaultCalendarId = defaultTaskList(writableCalendars, activeLocalAccountId)?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); const [description, setDescription] = useState(task?.description ?? ""); @@ -89,14 +97,11 @@ export function TaskModal({ const [priority, setPriority] = useState(priorityToLevel(task?.priority ?? 0)); const [progress, setProgress] = useState(task?.progress ?? "needs-action"); const [calendarId, setCalendarId] = useState(() => { - if (task) { - const ids = Object.keys(task.calendarIds); - return ids[0] ?? defaultCalendarId; - } + if (task) return taskCalendarId ?? defaultCalendarId; return defaultCalendarId; }); // A task already in a read-only calendar keeps it listed. - const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); + const calendarOptions = calendars.filter((c) => c.id === calendarId || c.id === taskCalendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -346,18 +351,16 @@ export function TaskModal({ {calendarOptions.length > 1 && (
- + />
)} diff --git a/components/contacts/address-book-picker-dialog.tsx b/components/contacts/address-book-picker-dialog.tsx index 20bdd1594..f5ce9c02c 100644 --- a/components/contacts/address-book-picker-dialog.tsx +++ b/components/contacts/address-book-picker-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useTranslations } from "next-intl"; import { Book, Check } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,8 @@ import { Dialog } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { AddressBook } from "@/lib/jmap/types"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { groupCalendarsByAccount } from "@/lib/calendar-targets"; +import { useAccountStore } from "@/stores/account-store"; interface AddressBookPickerDialogProps { isOpen: boolean; @@ -26,6 +28,10 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current const t = useTranslations("contacts"); const [selected, setSelected] = useState(null); const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null; + // Under each account's address with several signed in (pim-p2-3). + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAccountStore((s) => s.activeAccountId); + const groups = groupCalendarsByAccount(addressBooks, accounts, activeAccountId); return (
- {addressBooks.map((book) => { + {groups.map((group) => ( + + {group.label && ( +
{group.label}
+ )} + {group.calendars.map((book) => { const isCurrent = book.id === currentId; const isSelected = selected === book.id; return ( @@ -74,6 +85,8 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current ); })} +
+ ))}
); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 5fd5b28c9..e02b79c0e 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -15,6 +15,7 @@ import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, t import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountGroupedOptions } from "@/components/calendar/calendar-select"; // Each row of a list field keeps the `key` of the card's entry it was read // from, so Save can send an entry the user didn't touch back unchanged. @@ -908,12 +909,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-hidden" > - {addressBooks.map((book) => ( - - ))} + {/* Under each account's address with several (pim-p2-3). A shared book says whose it is, unless its name already does. */} + (book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))} + /> )} 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()}
- {listedCalendars.map((cal) => { + {calendarGroups.map((group) => ( +
+ {group.label && ( +

+ {group.label} +

+ )} + {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,