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 <address>" 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).
This commit is contained in:
Linus Rath
2026-11-04 13:48:47 +01:00
parent 49286649dd
commit 109866dd4c
49 changed files with 991 additions and 113 deletions
@@ -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 }) => <div>{children}</div>,
}));
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> = {}): 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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['pim-b@example.org']} />);
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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['pim@example.org']} />);
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(<EventModal event={event} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['pim@example.org']} />);
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(
<QuickCreatePopover
draft={{ start: new Date(2026, 10, 5, 9), end: new Date(2026, 10, 5, 10), title: '', allDay: false, calendarId: bDefault.id }}
calendars={[aDefault, aWork, bDefault]}
onChange={vi.fn()} onSave={vi.fn()} onMoreOptions={vi.fn()} onClose={vi.fn()}
/>,
);
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(<ICalImportModal calendars={calendars} client={client as never} onClose={vi.fn()} initialFile={file} />);
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(<TaskModal calendars={[...calendars, bTodo]} onSave={vi.fn()} onClose={vi.fn()} />);
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(<CalendarSidebarPanel calendars={calendars} selectedCalendarIds={[]} onToggleVisibility={() => {}} 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 <address>" in the event menu', () => {
const onCopy = vi.fn();
const menuRef = { current: null };
render(
<EventContextMenu
event={event} position={{ x: 10, y: 10 }} isOpen onClose={vi.fn()} menuRef={menuRef}
onEdit={vi.fn()} onDuplicate={vi.fn()} onExportICS={vi.fn()} onCopyTitle={vi.fn()} onDelete={vi.fn()}
copyTo={[{ address: 'pim@example.org', onCopy }]}
/>,
);
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(<ContactForm addressBooks={books} onSave={vi.fn()} onCancel={vi.fn()} />);
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(<AddressBookPickerDialog isOpen onClose={vi.fn()} addressBooks={books} count={1} onPick={vi.fn()} />);
expect(screen.getAllByTestId('address-book-account').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']);
});
});
@@ -123,8 +123,10 @@ describe('where a new task goes (pim-8)', () => {
});
it('opens the task form on the task-only list', () => {
render(<TaskModal calendars={[home, work, todo]} onSave={vi.fn()} onClose={() => {}} />);
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(<TaskModal calendars={[home, work, todo, errands]} onSave={vi.fn()} onClose={() => {}} />);
expect((screen.getByLabelText('task_list_label') as HTMLSelectElement).value).toBe('todo');
});
});
@@ -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(<TaskModal calendars={calendars} isSubscriptionCalendar={isSubscriptionCalendar} onSave={vi.fn()} onClose={vi.fn()} />);
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(<TaskModal task={task} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement;
const select = screen.getByLabelText('task_list_label') as HTMLSelectElement;
expect(select.value).toBe('birthdays');
});
});
+56 -11
View File
@@ -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 <other account>" (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); },
}))}
/>
)}
+65
View File
@@ -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 <select>.
*/
export function AccountGroupedOptions<T extends { id: string; localAccountId?: string }>({ items, label }: {
items: T[];
label: (item: T) => string;
}) {
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAccountStore((s) => s.activeAccountId);
const groups = useMemo(() => groupCalendarsByAccount(items, accounts, activeAccountId), [items, accounts, activeAccountId]);
const options = (list: T[]) => list.map((item) => <option key={item.id} value={item.id}>{label(item)}</option>);
return (
<>
{groups.map((group) => group.label === null
? options(group.calendars)
: <optgroup key={group.key} label={group.label}>{options(group.calendars)}</optgroup>)}
</>
);
}
interface CalendarSelectProps {
id?: string;
calendars: Calendar[];
value: string;
onChange: (calendarId: string) => void;
className?: string;
"aria-label"?: string;
title?: string;
/** For events: a task list that is listed only because the event already sits there gets a note. */
kind?: "event" | "task";
}
/** Every calendar and task list chooser: grouped by account, names as Settings shows them. */
export function CalendarSelect({ id, calendars, value, onChange, className, title, kind = "event", ...rest }: CalendarSelectProps) {
const shownName = useCollectionNames();
const t = useTranslations("ux_p2_pim_cal");
const label = (cal: Calendar) => {
const name = shownName.calendar(cal);
return kind === "event" && cal.isTasksOnly ? t("task_list_not_in_calendar", { name }) : name;
};
return (
<select
id={id}
value={value}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)}
aria-label={rest["aria-label"]}
title={title}
className={className}
data-testid="calendar-select"
>
<AccountGroupedOptions items={calendars} label={label} />
</select>
);
}
+10 -6
View File
@@ -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({
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-semibold text-foreground/90 truncate" title={group.label}>
<span className="text-xs font-semibold text-foreground/90 truncate" title={group.name && group.name !== group.label ? `${group.name} <${group.label}>` : group.label} data-testid="calendar-account-group">
{group.label}
</span>
{isActive && onCreateCalendar && (
@@ -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 <address>", 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 && <ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />}
<ContextMenuItem icon={CopyPlus} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
{copyTo.map((target) => (
<ContextMenuItem
key={target.address}
icon={CalendarPlus}
label={tP2("copy_to_account", { address: target.address })}
onClick={handle(target.onCopy)}
testId="event-copy-to-account"
/>
))}
<ContextMenuSeparator />
<ContextMenuItem
icon={Download}
+24 -22
View File
@@ -35,7 +35,11 @@ import {
participantFieldsToSave,
userOrganizes,
} from "@/lib/calendar-participants";
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
import { canUserRsvp, getEventEditability } from "@/lib/calendar-editability";
import { eventTargets, preferredTarget } from "@/lib/calendar-targets";
import { CalendarSelect } from "@/components/calendar/calendar-select";
import { useAccountStore } from "@/stores/account-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/recurrence-scope-edit";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
@@ -58,7 +62,6 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
import { useCollectionNames } from "@/hooks/use-collection-names";
export interface PendingEventPreview {
start: Date;
@@ -276,7 +279,6 @@ export function EventModal({
maxParticipants = null,
isMobile = false,
}: EventModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
// Ids that tie each visible label to its field.
@@ -437,28 +439,33 @@ export function EventModal({
};
const handleStartDateChange = (v: string) => { 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<string>(() => {
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<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
@@ -1521,18 +1528,13 @@ export function EventModal({
{selectableCalendars.length > 1 && (
<div>
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select
<CalendarSelect
id={labelFor("calendar_select")}
calendars={selectableCalendars}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
onChange={setCalendarId}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{shownName.calendar(cal)}
</option>
))}
</select>
/>
</div>
)}
+23 -19
View File
@@ -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<ImportStep>("select");
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
const [calendarId, setCalendarId] = useState<string>(() => {
const defaultCal = calendars.find((c) => c.isDefault);
return defaultCal?.id || calendars[0]?.id || "";
});
const [calendarId, setCalendarId] = useState<string>(preferredId);
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(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<CalendarEvent>): string => {
if (!event.start) return "";
@@ -459,18 +468,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia
<label htmlFor={targetId} className="text-sm font-medium mb-1 block">
{t("target_calendar")}
</label>
<select
<CalendarSelect
id={targetId}
calendars={calendars}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
onChange={setCalendarId}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{shownName.calendar(cal)}
</option>
))}
</select>
/>
</div>
)}
</>
+8 -9
View File
@@ -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({
<div className="flex items-center gap-2.5">
<span
className="w-4 h-4 rounded-sm flex-shrink-0"
style={{ backgroundColor: paint(calendars.find((c) => c.id === draft.calendarId)?.color || "#3b82f6") }}
style={{ backgroundColor: paint(selected?.color || "#3b82f6") }}
aria-hidden="true"
/>
<select
<CalendarSelect
calendars={calendars}
value={draft.calendarId}
onChange={(e) => onChange({ ...draft, calendarId: e.target.value })}
onChange={(calendarId) => onChange({ ...draft, calendarId })}
aria-label={t("form.calendar_select")}
title={calendars.find((c) => c.id === draft.calendarId)?.name}
title={selected ? shownName.calendar(selected) : undefined}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
>
{calendars.map((c) => (
<option key={c.id} value={c.id}>{shownName.calendar(c)}</option>
))}
</select>
/>
</div>
)}
</div>
+4 -2
View File
@@ -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);
}
/**
+24 -21
View File
@@ -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<HTMLDivElement | null>(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<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
const [progress, setProgress] = useState<CalendarTask["progress"]>(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<AlertOption>(() => {
if (!task?.alerts) return "none";
const first = Object.values(task.alerts)[0];
@@ -346,18 +351,16 @@ export function TaskModal({
{calendarOptions.length > 1 && (
<div className="space-y-2">
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
{t("tasks.calendar")}
{tP2("task_list_label")}
</label>
<select
<CalendarSelect
id={`${id}-calendar`}
kind="task"
calendars={calendarOptions}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
onChange={setCalendarId}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
{calendarOptions.map((cal) => (
<option key={cal.id} value={cal.id}>{shownName.calendar(cal)}</option>
))}
</select>
/>
</div>
)}
@@ -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<string | null>(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 (
<Dialog
@@ -47,7 +53,12 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
}
>
<div role="radiogroup" aria-label={t("address_books.title")} className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1">
{addressBooks.map((book) => {
{groups.map((group) => (
<Fragment key={group.key || "own"}>
{group.label && (
<div className="px-3 pt-2 pb-1 text-xs font-semibold text-muted-foreground truncate" data-testid="address-book-account" title={group.label}>{group.label}</div>
)}
{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
</button>
);
})}
</Fragment>
))}
</div>
</Dialog>
);
+6 -6
View File
@@ -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"
>
<option value="">{t("select_address_book")}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{/* 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)}
</option>
))}
{/* Under each account's address with several (pim-p2-3). A shared book says whose it is, unless its name already does. */}
<AccountGroupedOptions
items={addressBooks}
label={(book) => (book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))}
/>
</select>
</FormSection>
)}
@@ -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) => (
<option key={book.id} value={book.id}>
{book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book)}
</option>
))}
<AccountGroupedOptions
items={addressBooks}
label={(book) => (book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))}
/>
</select>
</label>
)}
@@ -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(<CalendarManagementSettings />);
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 });
});
});
@@ -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() {
<SettingsSection id="calendars" title={t('title')} description={touch ? t('description_touch') : t('description')}>
{renderRowMenu()}
<div className="space-y-2">
{listedCalendars.map((cal) => {
{calendarGroups.map((group) => (
<div key={group.key || 'own'} className="space-y-2" data-testid="calendar-settings-group">
{group.label && (
<h4 className="pt-2 text-xs font-semibold text-muted-foreground truncate" data-testid="calendar-settings-account" title={group.label}>
{group.label}
</h4>
)}
{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() {
</div>
);
})}
</div>
))}
{isCreating ? (
<CalendarEditForm
+234
View File
@@ -0,0 +1,234 @@
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { generateUUID } from '@/lib/utils';
import { canCreateEventsIn } from '@/lib/calendar-editability';
import { CALENDAR_COLORS } from '@/lib/shared-calendar-colors';
/**
* Which calendars something new can go into, and how a chooser groups them
* when several accounts are signed in (pim-p2-2, p2-3, p2-4, p2-13).
*
* In the aggregated view every calendar carries the local account it came
* from (`localAccountId`); with one account none does, and there is one group.
*/
/** What a chooser needs of a signed-in account. */
export interface TargetAccount {
id: string;
email?: string;
username?: string;
}
/** The account's address, as the account menu names it. Never the display name: two accounts of one person share it. */
export function accountAddress(account: TargetAccount | undefined): string {
return account?.email || account?.username || account?.id || '';
}
/** The local account a calendar belongs to; '' with one account. */
export function calendarAccountKey(calendar: Pick<Calendar, 'localAccountId'>): 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<string> } = {},
): 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<T extends Pick<Calendar, 'localAccountId'> = 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<T extends Pick<Calendar, 'localAccountId'>>(
calendars: T[],
accounts: TargetAccount[],
activeAccountId: string | null | undefined,
): CalendarGroup<T>[] {
const byKey = new Map<string, T[]>();
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<T>[] = [];
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<string, string>,
): 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<Calendar, 'localAccountId'>): 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<T extends Calendar>(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<string>();
const changed = new Map<string, string>();
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 <account>" 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<CalendarEvent, 'localAccountId'>,
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<CalendarEvent> {
const data: Partial<CalendarEvent> = {
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);
}
+7
View File
@@ -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": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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í."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。"
},
+7
View File
@@ -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": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요."
},
+7
View File
@@ -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ē."
},
+7
View File
@@ -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": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์"
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі."
},
+7
View File
@@ -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."
},
+7
View File
@@ -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": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。"
},
+7
View File
@@ -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": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。"
},
+20
View File
@@ -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<string, string>;
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<string, string>,
accountClients: null as CalendarAccountClient[] | null,
accountClientsActiveId: null as string | null,
selectedEventId: null as string | null,
@@ -1809,6 +1816,12 @@ export const useCalendarStore = create<CalendarStore>()(
}));
},
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<CalendarStore>()(
// 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<CalendarStore>()(
...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<CalendarStore>()(
calendarSelectionChosen: state.calendarSelectionChosen,
knownCalendarIds: state.knownCalendarIds,
hiddenTaskListIds: state.hiddenTaskListIds,
lastEventCalendarIds: state.lastEventCalendarIds,
viewMode: state.viewMode,
icalSubscriptions: state.icalSubscriptions,
deletedSubscriptionIds: state.deletedSubscriptionIds,