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,