feat(calendar): order calendars by sortOrder and let users reorder them

Calendars were listed in the order the server returns them, which for
Stalwart is creation order, and the sortOrder property was never read.
Follow the JMAP Calendars rule instead: lower sortOrder first, equal
sortOrder by name (case- and accent-insensitive, numbers by value). The
store keeps the list sorted, so the sidebar, the calendar pickers and the
settings all show the same order; each account's calendars stay together.

Calendar settings gain move up / move down buttons, visible on touch
screens too, and drag and drop for the user's own calendars. A move
renumbers that account's calendars 1, 2, 3... and writes sortOrder only
where it changes. The renumbering also covers the account's calendars the
settings list does not show (iCal subscriptions), so they keep their
place relative to the others. Shared calendars are not offered for
reordering, their order is the owner's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucletoffe
2026-10-15 22:31:07 +02:00
co-authored by Claude Opus 5.5
parent c349e8e288
commit de388a1d12
33 changed files with 563 additions and 15 deletions
@@ -0,0 +1,79 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Calendar } from '@/lib/jmap/types';
function makeCalendar(overrides: Partial<Calendar>): Calendar {
return {
id: 'cal-1',
name: 'Work',
color: '#3b82f6',
sortOrder: 0,
isDefault: false,
isShared: false,
shareWith: null,
myRights: { mayWriteAll: true, mayWriteOwn: true, mayShare: true, mayDelete: true },
...overrides,
} as unknown as Calendar;
}
const client = { getAccountId: () => 'acct', getPrincipals: vi.fn().mockResolvedValue([]) };
let reorderCalendars: ReturnType<typeof vi.fn>;
beforeEach(() => {
reorderCalendars = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ client: client as never, serverUrl: null as never, username: null as never });
useCalendarStore.setState({
calendars: [
makeCalendar({ id: 'work', name: 'Work', sortOrder: 1 }),
makeCalendar({ id: 'home', name: 'Home', sortOrder: 2 }),
makeCalendar({ id: 'holidays', name: 'Holidays', sortOrder: 3 }),
makeCalendar({ id: 'x:team', name: 'Team', isShared: true, accountId: 'x' }),
],
icalSubscriptions: [],
reorderCalendars,
} as never);
});
const row = (name: string) => screen.getAllByTestId('calendar-settings-item').find((el) => el.dataset.calendarName === name)!;
const buttonIn = (name: string, label: string) =>
Array.from(row(name).querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === label)!;
describe('Calendar order in settings', () => {
it('moves a calendar up past its neighbour', async () => {
render(<CalendarManagementSettings />);
fireEvent.click(buttonIn('Holidays', 'move_up'));
await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['work', 'holidays', 'home']));
});
it('moves a calendar down past its neighbour', async () => {
render(<CalendarManagementSettings />);
fireEvent.click(buttonIn('Work', 'move_down'));
await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['home', 'work', 'holidays']));
});
it('disables moving the first calendar up and the last one down', () => {
render(<CalendarManagementSettings />);
expect(buttonIn('Work', 'move_up')).toBeDisabled();
expect(buttonIn('Holidays', 'move_down')).toBeDisabled();
});
it('offers no reordering for a calendar shared with the user', () => {
render(<CalendarManagementSettings />);
expect(buttonIn('Team', 'move_up')).toBeUndefined();
expect(row('Team')).not.toHaveAttribute('draggable', 'true');
});
it('reorders by drag and drop', async () => {
render(<CalendarManagementSettings />);
const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() };
fireEvent.dragStart(row('Holidays'), { dataTransfer });
// Firefox starts no drag unless data is set.
expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'holidays');
fireEvent.dragOver(row('Work'), { dataTransfer });
fireEvent.drop(row('Work'), { dataTransfer });
await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['holidays', 'work', 'home']));
});
});
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell } from '@/components/icons';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical } from '@/components/icons';
import { AlertListEditor } from '@/components/calendar/alert-list-editor';
import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts';
import { ShareCollectionDialog } from './share-collection-dialog';
@@ -22,6 +22,7 @@ import { useManagedAccountStore } from '@/stores/managed-account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
import { IS_LITE } from '@/lib/lite';
import { calendarAccountKey, moveCalendar } from '@/lib/calendar-sort';
function CalendarColorPicker({
value,
@@ -233,7 +234,7 @@ export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const { calendars, updateCalendar, reorderCalendars, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
const currentAccountId = client?.getAccountId();
@@ -264,6 +265,14 @@ export function CalendarManagementSettings() {
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef<HTMLDivElement>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const listedCalendars = calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
.filter(cal => !managedAccountId || cal.accountId === managedAccountId);
// Only the user's own calendars are offered for reordering; a shared
// calendar's order is left to its owner.
const orderableCalendars = listedCalendars.filter(cal => !cal.isShared);
// Load calendars if not yet loaded
useEffect(() => {
@@ -443,6 +452,19 @@ export function CalendarManagementSettings() {
setColorPickerId(null);
};
// Move a calendar next to `targetId` (before it when moving up, after it
// when moving down) and store the new order as sortOrder.
const handleMove = async (calendarId: string, targetId: string | undefined) => {
if (!client || !targetId) return;
const orderedIds = moveCalendar(calendars, calendarId, targetId);
if (!orderedIds) return;
try {
await reorderCalendars(client, orderedIds);
} catch {
toast.error(t('error_update'));
}
};
const handleDelete = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
@@ -500,11 +522,14 @@ export function CalendarManagementSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
.filter(cal => !managedAccountId || cal.accountId === managedAccountId)
.map((cal) => {
{listedCalendars.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));
const orderIndex = siblings.indexOf(cal);
const isOrderable = siblings.length > 1;
const previousId = siblings[orderIndex - 1]?.id;
const nextId = siblings[orderIndex + 1]?.id;
if (editingId === cal.id) {
return (
@@ -581,8 +606,27 @@ export function CalendarManagementSettings() {
return (
<div
key={cal.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
data-testid="calendar-settings-item"
data-calendar-name={cal.name}
draggable={isOrderable}
onDragStart={isOrderable ? (e) => {
setDraggingId(cal.id);
// Firefox starts no drag without data.
e.dataTransfer.setData('text/plain', cal.id);
e.dataTransfer.effectAllowed = 'move';
} : undefined}
onDragOver={isOrderable && draggingId ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } : undefined}
onDrop={isOrderable && draggingId ? (e) => { e.preventDefault(); void handleMove(draggingId, cal.id); setDraggingId(null); } : undefined}
onDragEnd={() => setDraggingId(null)}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group",
draggingId === cal.id && "opacity-50",
)}
>
{isOrderable && (
// No HTML drag and drop on touch screens: the arrows do it there.
<GripVertical className="w-4 h-4 -me-1.5 text-muted-foreground/60 cursor-grab flex-shrink-0 pointer-coarse:hidden" aria-hidden="true" />
)}
{/* Color swatch - clickable to change color */}
<div className="relative">
<button
@@ -657,6 +701,32 @@ export function CalendarManagementSettings() {
);
})()}
{/* Shown for touch too: there is no hover, and no HTML drag and drop. */}
{isOrderable && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => handleMove(cal.id, previousId)}
disabled={!previousId}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
title={t('move_up')}
aria-label={t('move_up')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleMove(cal.id, nextId)}
disabled={!nextId}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
title={t('move_down')}
aria-label={t('move_down')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { compareCalendars, moveCalendar, sortCalendars, sortOrderUpdates } from '../calendar-sort';
import type { Calendar } from '@/lib/jmap/types';
function cal(overrides: Partial<Calendar>): Calendar {
return {
id: 'c', name: 'Cal', color: null, sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: null,
...overrides,
} as unknown as Calendar;
}
const names = (list: Calendar[]) => list.map((c) => c.name);
const ids = (list: Calendar[]) => list.map((c) => c.id);
describe('sortCalendars', () => {
it('orders by sortOrder, whatever order the server listed them in', () => {
// Stalwart lists calendars in creation order.
const list = [
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
cal({ id: 'd', name: 'Personal', sortOrder: 10 }),
];
expect(names(sortCalendars(list))).toEqual(['Work', 'Personal', 'Default', 'Holidays']);
});
it('sorts equal sortOrder by name, ignoring case and accents, numbers by value', () => {
const list = [
cal({ id: '1', name: 'zeta' }),
cal({ id: '2', name: 'Team 10' }),
cal({ id: '3', name: 'Éclair' }),
cal({ id: '4', name: 'alpha' }),
cal({ id: '5', name: 'Team 2' }),
cal({ id: '6', name: 'Beta' }),
];
expect(names(sortCalendars(list))).toEqual(['alpha', 'Beta', 'Éclair', 'Team 2', 'Team 10', 'zeta']);
});
it('treats a missing sortOrder as 0', () => {
const list = [cal({ id: 'a', name: 'B', sortOrder: 1 }), cal({ id: 'b', name: 'A', sortOrder: undefined })];
expect(names(sortCalendars(list))).toEqual(['A', 'B']);
});
it('keeps each account together, in the order the accounts came', () => {
const list = [
cal({ id: 'own-2', name: 'Own 2', sortOrder: 2 }),
cal({ id: 'own-1', name: 'Own 1', sortOrder: 1 }),
cal({ id: 'x:b', name: 'Shared B', sortOrder: 0, isShared: true, accountId: 'x' }),
cal({ id: 'y:a', name: 'Other A', sortOrder: 0, isShared: true, accountId: 'y' }),
cal({ id: 'x:a', name: 'Shared A', sortOrder: 5, isShared: true, accountId: 'x' }),
];
expect(ids(sortCalendars(list))).toEqual(['own-1', 'own-2', 'x:b', 'x:a', 'y:a']);
});
it('keeps the calendars of each local account (Pro shell) together', () => {
const list = [
cal({ id: 'A::b', name: 'A2', sortOrder: 2, localAccountId: 'A' }),
cal({ id: 'B::b', name: 'B1', sortOrder: 1, localAccountId: 'B' }),
cal({ id: 'A::c', name: 'A1', sortOrder: 1, localAccountId: 'A' }),
];
expect(names(sortCalendars(list))).toEqual(['A1', 'A2', 'B1']);
});
it('does not change the list it is given', () => {
const list = [cal({ id: 'b', sortOrder: 2 }), cal({ id: 'a', sortOrder: 1 })];
sortCalendars(list);
expect(ids(list)).toEqual(['b', 'a']);
});
it('compareCalendars puts the lower sortOrder first', () => {
expect(compareCalendars(cal({ sortOrder: 1, name: 'Z' }), cal({ sortOrder: 2, name: 'A' }))).toBeLessThan(0);
});
});
describe('sortOrderUpdates', () => {
it('numbers the calendars 1, 2, 3 in the given order and skips unchanged ones', () => {
const list = [
cal({ id: 'a', sortOrder: 1 }),
cal({ id: 'b', sortOrder: 2 }),
cal({ id: 'c', sortOrder: 3 }),
];
expect(sortOrderUpdates(list, ['a', 'c', 'b'])).toEqual([
{ id: 'c', sortOrder: 2 },
{ id: 'b', sortOrder: 3 },
]);
});
it('ignores unknown ids', () => {
expect(sortOrderUpdates([cal({ id: 'a', sortOrder: 0 })], ['a', 'gone'])).toEqual([{ id: 'a', sortOrder: 1 }]);
});
});
describe('moveCalendar', () => {
const list = [
cal({ id: 'work', name: 'Work', sortOrder: 1 }),
cal({ id: 'home', name: 'Home', sortOrder: 10 }),
cal({ id: 'tasks', name: 'Tasks', sortOrder: 15, isTasksOnly: true }),
cal({ id: 'holidays', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'x:team', name: 'Team', sortOrder: 0, isShared: true, accountId: 'x' }),
];
it('moving up puts the calendar just before the target', () => {
expect(moveCalendar(list, 'holidays', 'home')).toEqual(['work', 'holidays', 'home', 'tasks']);
});
it('moving down puts the calendar just after the target, past calendars not shown in between', () => {
expect(moveCalendar(list, 'work', 'home')).toEqual(['home', 'work', 'tasks', 'holidays']);
});
it('refuses a move across accounts', () => {
expect(moveCalendar(list, 'work', 'x:team')).toBeNull();
});
it('refuses unknown ids and a move onto itself', () => {
expect(moveCalendar(list, 'work', 'gone')).toBeNull();
expect(moveCalendar(list, 'work', 'work')).toBeNull();
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { Calendar } from '@/lib/jmap/types';
// Names compare the way the user reads them: accents and case are ignored, and
// "Team 2" comes before "Team 10".
const nameCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
/**
* JMAP Calendars: a calendar with a lower `sortOrder` is shown first, and
* calendars with equal `sortOrder` are sorted by name.
*/
export function compareCalendars(a: Calendar, b: Calendar): number {
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || nameCollator.compare(a.name ?? '', b.name ?? '');
}
/**
* Calendars of one account (and, in the Pro shell, one local account) stay
* together: the user's own first, then each sharing account, as fetched.
*/
export function calendarAccountKey(calendar: Calendar): string {
return `${calendar.localAccountId ?? ''}\u0000${calendar.isShared ? calendar.accountId ?? '' : ''}`;
}
/**
* The calendars in display order: grouped by account in the order the
* accounts first appear, and by `sortOrder`, then name, within each account.
*/
export function sortCalendars(calendars: Calendar[]): Calendar[] {
const accountRank = new Map<string, number>();
for (const calendar of calendars) {
const key = calendarAccountKey(calendar);
if (!accountRank.has(key)) accountRank.set(key, accountRank.size);
}
return [...calendars].sort((a, b) =>
accountRank.get(calendarAccountKey(a))! - accountRank.get(calendarAccountKey(b))! || compareCalendars(a, b),
);
}
/**
* `sortOrder` values that put `orderedIds` in that order: 1, 2, 3... Only the
* calendars whose value changes are returned, so a move writes as little as
* possible.
*/
export function sortOrderUpdates(calendars: Calendar[], orderedIds: string[]): { id: string; sortOrder: number }[] {
const byId = new Map(calendars.map((calendar) => [calendar.id, calendar]));
const updates: { id: string; sortOrder: number }[] = [];
orderedIds.forEach((id, index) => {
const calendar = byId.get(id);
if (calendar && calendar.sortOrder !== index + 1) updates.push({ id, sortOrder: index + 1 });
});
return updates;
}
/**
* The ids of the calendar's account in display order once it is moved next to
* `targetId`: before the target when it moves up, after it when it moves down.
* Null when the move is not possible (unknown ids, or another account).
*/
export function moveCalendar(calendars: Calendar[], calendarId: string, targetId: string): string[] | null {
const sorted = sortCalendars(calendars);
const moving = sorted.find((calendar) => calendar.id === calendarId);
const target = sorted.find((calendar) => calendar.id === targetId);
if (!moving || !target || moving === target) return null;
const key = calendarAccountKey(moving);
if (calendarAccountKey(target) !== key) return null;
const group = sorted.filter((calendar) => calendarAccountKey(calendar) === key);
const movingDown = group.indexOf(moving) < group.indexOf(target);
const rest = group.filter((calendar) => calendar !== moving);
rest.splice(rest.indexOf(target) + (movingDown ? 1 : 0), 0, moving);
return rest.map((calendar) => calendar.id);
}
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "تم نسخ رابط CalDAV إلى الحافظة",
"share": "مشاركة التقويم",
"new_event_in_calendar": "حدث جديد في هذا التقويم",
"move_up": "تحريك لأعلى",
"move_down": "تحريك لأسفل",
"kind": "المحتوى",
"kind_events": "الأحداث",
"kind_tasks": "المهام",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "URL de CalDAV copiat al porta-retalls",
"share": "Comparteix el calendari",
"new_event_in_calendar": "Esdeveniment nou en aquest calendari",
"move_up": "Mou amunt",
"move_down": "Mou avall",
"kind": "Contingut",
"kind_events": "Esdeveniments",
"kind_tasks": "Tasques",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "URL CalDAV zkopírováno do schránky",
"share": "Sdílet kalendář",
"new_event_in_calendar": "Nová událost v tomto kalendáři",
"move_up": "Posunout nahoru",
"move_down": "Posunout dolů",
"kind": "Obsah",
"kind_events": "Události",
"kind_tasks": "Úkoly",
+2
View File
@@ -3457,6 +3457,8 @@
"url_copied": "CalDAV-URL kopieret til udklipsholder",
"share": "Del kalender",
"new_event_in_calendar": "Ny begivenhed i denne kalender",
"move_up": "Flyt op",
"move_down": "Flyt ned",
"kind": "Indhold",
"kind_events": "Begivenheder",
"kind_tasks": "Opgaver",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
"share": "Kalender freigeben",
"new_event_in_calendar": "Neuer Termin in diesem Kalender",
"move_up": "Nach oben",
"move_down": "Nach unten",
"kind": "Inhalt",
"kind_events": "Termine",
"kind_tasks": "Aufgaben",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL copied to clipboard",
"share": "Share calendar",
"new_event_in_calendar": "New event in this calendar",
"move_up": "Move up",
"move_down": "Move down",
"kind": "Contents",
"kind_events": "Events",
"kind_tasks": "Tasks",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "No se pudieron borrar los eventos del calendario",
"share": "Compartir calendario",
"new_event_in_calendar": "Nuevo evento en este calendario",
"move_up": "Mover arriba",
"move_down": "Mover abajo",
"kind": "Contenido",
"kind_events": "Eventos",
"kind_tasks": "Tareas",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "آدرس CalDAV در کلیپ‌بورد کپی شد",
"share": "اشتراک‌گذاری تقویم",
"new_event_in_calendar": "رویداد جدید در این تقویم",
"move_up": "انتقال به بالا",
"move_down": "انتقال به پایین",
"kind": "محتوا",
"kind_events": "رویدادها",
"kind_tasks": "وظایف",
+2
View File
@@ -3457,6 +3457,8 @@
"error_clear": "Impossible de supprimer les événements du calendrier",
"share": "Partager le calendrier",
"new_event_in_calendar": "Nouvel événement dans ce calendrier",
"move_up": "Déplacer vers le haut",
"move_down": "Déplacer vers le bas",
"kind": "Contenu",
"kind_events": "Événements",
"kind_tasks": "Tâches",
+2
View File
@@ -3345,6 +3345,8 @@
"error_default": "נכשל בהגדרת לוח שנה ברירת מחדל",
"share": "שתף לוח שנה",
"new_event_in_calendar": "אירוע חדש בלוח שנה זה",
"move_up": "עבור למעלה",
"move_down": "עבור למטה",
"kind": "תוכן",
"kind_events": "אירועים",
"kind_tasks": "משימות",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL a vágólapra másolva",
"share": "Naptár megosztása",
"new_event_in_calendar": "Új esemény ebben a naptárban",
"move_up": "Mozgatás fel",
"move_down": "Mozgatás le",
"kind": "Tartalom",
"kind_events": "Események",
"kind_tasks": "Feladatok",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "Impossibile cancellare gli eventi del calendario",
"share": "Condividi calendario",
"new_event_in_calendar": "Nuovo evento in questo calendario",
"move_up": "Sposta su",
"move_down": "Sposta giù",
"kind": "Contenuto",
"kind_events": "Eventi",
"kind_tasks": "Attività",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "カレンダーイベントの削除に失敗しました",
"share": "カレンダーを共有",
"new_event_in_calendar": "このカレンダーに新規イベント",
"move_up": "上に移動",
"move_down": "下に移動",
"kind": "内容",
"kind_events": "イベント",
"kind_tasks": "タスク",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
"share": "캘린더 공유",
"new_event_in_calendar": "이 캘린더에 새 일정",
"move_up": "위로 이동",
"move_down": "아래로 이동",
"kind": "내용",
"kind_events": "일정",
"kind_tasks": "할 일",
+2
View File
@@ -3430,6 +3430,8 @@
"url_copied": "CalDAV URL nokopēts starpliktuvē",
"share": "Kopīgot kalendāru",
"new_event_in_calendar": "Jauns notikums šajā kalendārā",
"move_up": "Pārvietot uz augšu",
"move_down": "Pārvietot uz leju",
"kind": "Saturs",
"kind_events": "Notikumi",
"kind_tasks": "Uzdevumi",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL-г санах ой руу хуулсан",
"share": "Хуанлигаа хуваалцах",
"new_event_in_calendar": "Энэ хуанли дахь шинэ арга хэмжээ",
"move_up": "Дээш хөдлөх",
"move_down": "Доошоо шилжих",
"kind": "Агуулга",
"kind_events": "Үйл явдлууд",
"kind_tasks": "Даалгаврууд",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV-URL kopiert til utklippstavlen",
"share": "Del kalender",
"new_event_in_calendar": "Ny hendelse i denne kalenderen",
"move_up": "Flytt opp",
"move_down": "Flytt ned",
"kind": "Innhold",
"kind_events": "Hendelser",
"kind_tasks": "Oppgaver",
+2
View File
@@ -3431,6 +3431,8 @@
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
"share": "Agenda delen",
"new_event_in_calendar": "Nieuwe afspraak in deze agenda",
"move_up": "Omhoog verplaatsen",
"move_down": "Omlaag verplaatsen",
"kind": "Inhoud",
"kind_events": "Afspraken",
"kind_tasks": "Taken",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "Adres URL CalDAV skopiowano do schowka",
"share": "Udostępnij kalendarz",
"new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu",
"move_up": "Przesuń w górę",
"move_down": "Przesuń w dół",
"kind": "Zawartość",
"kind_events": "Wydarzenia",
"kind_tasks": "Zadania",
+2
View File
@@ -3457,6 +3457,8 @@
"error_clear": "Falha ao limpar os eventos do calendário",
"share": "Compartilhar calendário",
"new_event_in_calendar": "Novo evento neste calendário",
"move_up": "Mover para cima",
"move_down": "Mover para baixo",
"kind": "Conteúdo",
"kind_events": "Eventos",
"kind_tasks": "Tarefas",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL copiat în clipboard",
"share": "Partajare calendar",
"new_event_in_calendar": "Eveniment nou în acest calendar",
"move_up": "Mutați în sus",
"move_down": "Mutați în jos",
"kind": "Conținut",
"kind_events": "Evenimente",
"kind_tasks": "Sarcini",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "CalDAV URL скопирован в буфер обмена",
"share": "Поделиться календарём",
"new_event_in_calendar": "Новое событие в этом календаре",
"move_up": "Переместить вверх",
"move_down": "Переместить вниз",
"kind": "Содержимое",
"kind_events": "События",
"kind_tasks": "Задачи",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "URL CalDAV skopírovaná do schránky",
"share": "Zdieľať kalendár",
"new_event_in_calendar": "Nová udalosť v tomto kalendári",
"move_up": "Posunúť nahor",
"move_down": "Posunúť nadol",
"kind": "Obsah",
"kind_events": "Udalosti",
"kind_tasks": "Úlohy",
+2
View File
@@ -3457,6 +3457,8 @@
"url_copied": "CalDAV URL'si panoya kopyalandı",
"share": "Takvimi paylaş",
"new_event_in_calendar": "Bu takvimde yeni etkinlik",
"move_up": "Yukarı taşı",
"move_down": "Aşağı taşı",
"kind": "İçerik",
"kind_events": "Etkinlikler",
"kind_tasks": "Görevler",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
"share": "Поділитися календарем",
"new_event_in_calendar": "Нова подія в цьому календарі",
"move_up": "Перемістити вгору",
"move_down": "Перемістити вниз",
"kind": "Вміст",
"kind_events": "Події",
"kind_tasks": "Завдання",
+2
View File
@@ -3458,6 +3458,8 @@
"url_copied": "CalDAV URL 已複製到剪貼簿",
"share": "分享行事曆",
"new_event_in_calendar": "在此行事曆中新增活動",
"move_up": "上移",
"move_down": "下移",
"kind": "內容",
"kind_events": "活動",
"kind_tasks": "任務",
+2
View File
@@ -3431,6 +3431,8 @@
"url_copied": "CalDAV URL 已复制到剪贴板",
"share": "共享日历",
"new_event_in_calendar": "在此日历中新建事件",
"move_up": "上移",
"move_down": "下移",
"kind": "内容",
"kind_events": "日程",
"kind_tasks": "任务",
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useCalendarStore } from '../calendar-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar } from '@/lib/jmap/types';
function cal(overrides: Partial<Calendar>): Calendar {
return {
id: 'c', name: 'Cal', color: null, sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: null,
...overrides,
} as unknown as Calendar;
}
const names = () => useCalendarStore.getState().calendars.map((c) => c.name);
beforeEach(() => {
useCalendarStore.setState({ calendars: [], selectedCalendarIds: [], error: null });
});
describe('calendar order', () => {
it('fetchCalendars lists the calendars by sortOrder, not in server order', async () => {
const client = {
getAllCalendarsWithFailures: vi.fn().mockResolvedValue({
calendars: [
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
],
failedAccountIds: [],
}),
} as unknown as IJMAPClient;
await useCalendarStore.getState().fetchCalendars(client);
expect(names()).toEqual(['Work', 'Default', 'Holidays']);
});
it('updateCalendar re-sorts when a calendar is renamed', async () => {
useCalendarStore.setState({
calendars: [cal({ id: 'a', name: 'Alpha' }), cal({ id: 'b', name: 'Beta' })],
});
const client = { updateCalendar: vi.fn().mockResolvedValue(undefined) } as unknown as IJMAPClient;
await useCalendarStore.getState().updateCalendar(client, 'a', { name: 'Zulu' });
expect(names()).toEqual(['Beta', 'Zulu']);
});
it('reorderCalendars writes only the sortOrders that change and re-sorts at once', async () => {
useCalendarStore.setState({
calendars: [
cal({ id: 'a', name: 'A', sortOrder: 1 }),
cal({ id: 'b', name: 'B', sortOrder: 2 }),
cal({ id: 'c', name: 'C', sortOrder: 3 }),
],
});
const updateCalendar = vi.fn().mockResolvedValue(undefined);
const client = { updateCalendar } as unknown as IJMAPClient;
const done = useCalendarStore.getState().reorderCalendars(client, ['c', 'a', 'b']);
// Optimistic: the new order shows before the server answers.
expect(names()).toEqual(['C', 'A', 'B']);
await done;
expect(updateCalendar.mock.calls).toEqual([
['c', { sortOrder: 1 }, undefined],
['a', { sortOrder: 2 }, undefined],
['b', { sortOrder: 3 }, undefined],
]);
expect(useCalendarStore.getState().calendars.map((c) => c.sortOrder)).toEqual([1, 2, 3]);
});
it('reorderCalendars sends shared-account calendars their raw id and account', async () => {
useCalendarStore.setState({
calendars: [
cal({ id: 'x:a', originalId: 'a', name: 'A', sortOrder: 1, isShared: true, accountId: 'x' }),
cal({ id: 'x:b', originalId: 'b', name: 'B', sortOrder: 2, isShared: true, accountId: 'x' }),
],
});
const updateCalendar = vi.fn().mockResolvedValue(undefined);
await useCalendarStore.getState().reorderCalendars({ updateCalendar } as unknown as IJMAPClient, ['x:b', 'x:a']);
expect(updateCalendar.mock.calls).toEqual([
['b', { sortOrder: 1 }, 'x'],
['a', { sortOrder: 2 }, 'x'],
]);
});
it('after a failed write, shows the order the server holds and rethrows', async () => {
useCalendarStore.setState({
calendars: [
cal({ id: 'a', name: 'A', sortOrder: 1 }),
cal({ id: 'b', name: 'B', sortOrder: 2 }),
cal({ id: 'c', name: 'C', sortOrder: 3 }),
],
});
const updateCalendar = vi.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('forbidden'));
const client = { updateCalendar } as unknown as IJMAPClient;
await expect(useCalendarStore.getState().reorderCalendars(client, ['c', 'a', 'b'])).rejects.toThrow('forbidden');
// c got sortOrder 1 (saved), a kept 1, b kept 2: c and a tie, sorted by name.
expect(useCalendarStore.getState().calendars.map((c) => [c.id, c.sortOrder])).toEqual([
['a', 1], ['c', 1], ['b', 2],
]);
expect(useCalendarStore.getState().error).toBe('Failed to reorder calendars');
});
it('reorderCalendars does nothing when the order is unchanged', async () => {
useCalendarStore.setState({ calendars: [cal({ id: 'a', sortOrder: 1 }), cal({ id: 'b', sortOrder: 2 })] });
const updateCalendar = vi.fn();
await useCalendarStore.getState().reorderCalendars({ updateCalendar } as unknown as IJMAPClient, ['a', 'b']);
expect(updateCalendar).not.toHaveBeenCalled();
});
});
+42 -8
View File
@@ -25,6 +25,7 @@ import { generateUUID } from '@/lib/utils';
import { apiFetch } from '@/lib/browser-navigation';
import { explainServerAuthError } from '@/lib/server-auth-status';
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
import { sortCalendars, sortOrderUpdates } from '@/lib/calendar-sort';
import { getClientByLocalAccountId } from './client-registry';
import { calendarHooks } from '@/lib/plugin-hooks';
import { eventToView } from '@/lib/plugin-projection';
@@ -595,6 +596,8 @@ interface CalendarStore {
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<boolean>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number | null>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
/** Store `orderedIds` in that order by renumbering their sortOrder. */
reorderCalendars: (client: IJMAPClient, orderedIds: string[]) => Promise<void>;
setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>, options?: CreateCalendarOptions) => Promise<Calendar | null>;
@@ -674,7 +677,8 @@ export const useCalendarStore = create<CalendarStore>()(
try {
// Throws when the primary account fails, so a failed fetch leaves the
// list and the selection as they were.
const { calendars, failedAccountIds } = await client.getAllCalendarsWithFailures();
const { calendars: fetched, failedAccountIds } = await client.getAllCalendarsWithFailures();
const calendars = sortCalendars(fetched);
// Default the visible selection to event calendars only - tasks-only
// calendars stay out of the event grid.
const defaultSelected = calendars.filter(c => !c.isTasksOnly).map(c => c.id);
@@ -752,7 +756,7 @@ export const useCalendarStore = create<CalendarStore>()(
}
}),
);
const calendars = results.flat();
const calendars = sortCalendars(results.flat());
set({
calendars,
isLoading: false,
@@ -1122,9 +1126,9 @@ export const useCalendarStore = create<CalendarStore>()(
client = resolveAccountClient(client, cal?.localAccountId);
await client.updateCalendar(realId, updates, targetAccountId);
set((state) => ({
calendars: state.calendars.map(c =>
calendars: sortCalendars(state.calendars.map(c =>
c.id === calendarId ? { ...c, ...updates } : c
),
)),
}));
void calendarHooks.onCalendarChange.emit({ calendarId, change: 'updated' });
} catch (error) {
@@ -1134,6 +1138,36 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
reorderCalendars: async (client, orderedIds) => {
const updates = sortOrderUpdates(get().calendars, orderedIds);
if (updates.length === 0) return;
const before = get().calendars;
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c
));
// Optimistic, so the list moves at once.
set({ error: null, calendars: withSortOrders(new Map(updates.map((u) => [u.id, u.sortOrder]))) });
const saved = new Map<string, number>();
try {
for (const { id, sortOrder } of updates) {
const cal = before.find(c => c.id === id);
const realId = cal?.originalId || stripLocalAccountPrefix(id, cal?.localAccountId);
await resolveAccountClient(client, cal?.localAccountId).updateCalendar(realId, { sortOrder }, cal?.accountId);
saved.set(id, sortOrder);
}
} catch (error) {
debug.error('Failed to reorder calendars:', error);
// Show the order the server now holds: the moves saved before the
// failure stay, the rest are undone.
set({ error: 'Failed to reorder calendars', calendars: withSortOrders(saved) });
throw error;
} finally {
for (const calendarId of saved.keys()) {
void calendarHooks.onCalendarChange.emit({ calendarId, change: 'updated' });
}
}
},
setDefaultCalendar: async (client, calendarId) => {
set({ error: null });
try {
@@ -1197,7 +1231,7 @@ export const useCalendarStore = create<CalendarStore>()(
// Added with its raw id; the next aggregated refetch namespaces it and
// reconcileSelectedIds() remaps the selection so it stays visible.
set((state) => ({
calendars: [...state.calendars, created],
calendars: sortCalendars([...state.calendars, created]),
selectedCalendarIds: [...state.selectedCalendarIds, created.id],
}));
void calendarHooks.onCalendarChange.emit({ calendarId: created.id, change: 'created' });
@@ -1368,7 +1402,7 @@ export const useCalendarStore = create<CalendarStore>()(
};
set((state) => ({
calendars: [...state.calendars, calendar!],
calendars: sortCalendars([...state.calendars, calendar!]),
selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id],
icalSubscriptions: [...state.icalSubscriptions, subscription],
}));
@@ -1432,14 +1466,14 @@ export const useCalendarStore = create<CalendarStore>()(
const updated = { ...sub, ...normalizedUpdates };
set((state) => ({
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
calendars: state.calendars.map(c => {
calendars: sortCalendars(state.calendars.map(c => {
if (c.id !== sub.calendarId) return c;
return {
...c,
...(normalizedUpdates.name ? { name: normalizedUpdates.name } : {}),
...(normalizedUpdates.color ? { color: normalizedUpdates.color } : {}),
};
}),
})),
}));
void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId, change: 'updated' });