feat(calendar): order calendars by sortOrder and move them from the drawer

Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

The drawer's long-press panel gains Move up / Move down for the user's
own calendars. A move renumbers that account's calendars 1, 2, 3... and
writes sortOrder only where it changes; the panel stays open so a
calendar can be moved several places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucletoffe
2026-10-15 22:07:33 +02:00
co-authored by Claude Opus 5.5
parent 7108431dbb
commit 3c0af52c4e
6 changed files with 325 additions and 5 deletions
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(),
updateCalendar: vi.fn(),
createCalendar: vi.fn(),
isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false),
resetCalendarAccessDenied: vi.fn(),
supportsSyntheticCalendarIds: vi.fn(async () => false),
resetSyntheticIdSupport: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
}));
vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() }));
import * as calendarApi from '../../api/calendar';
import { useCalendarStore } from '../calendar-store';
import type { Calendar } from '../../api/types';
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
const mockUpdateCalendar = calendarApi.updateCalendar as ReturnType<typeof vi.fn>;
const cal = (overrides: Partial<Calendar>): Calendar => ({ id: 'c', name: 'Cal', sortOrder: 0, ...overrides });
const names = () => useCalendarStore.getState().calendars.map((c) => c.name);
beforeEach(() => {
vi.clearAllMocks();
useCalendarStore.setState({ calendars: [], error: null });
});
describe('calendar order', () => {
it('fetchCalendars lists the calendars by sortOrder, not in server order', async () => {
mockGetCalendars.mockResolvedValue([
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
]);
await useCalendarStore.getState().fetchCalendars();
expect(names()).toEqual(['Work', 'Default', 'Holidays']);
});
it('reorderCalendars writes only the sortOrders that change, to the owning account', async () => {
useCalendarStore.setState({
calendars: [
cal({ id: 'a', name: 'A', sortOrder: 1 }),
cal({ id: 'b', name: 'B', sortOrder: 2 }),
cal({ id: 'x:c', originalId: 'c', name: 'C', sortOrder: 0, isShared: true, accountId: 'x' }),
],
});
mockUpdateCalendar.mockResolvedValue(undefined);
const done = useCalendarStore.getState().reorderCalendars(['b', 'a']);
expect(names()).toEqual(['B', 'A', 'C']);
await done;
expect(mockUpdateCalendar.mock.calls).toEqual([
['b', { sortOrder: 1 }, undefined],
['a', { sortOrder: 2 }, undefined],
]);
});
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 }),
],
});
mockUpdateCalendar.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('forbidden'));
await expect(useCalendarStore.getState().reorderCalendars(['c', 'a', 'b'])).rejects.toThrow('forbidden');
expect(useCalendarStore.getState().calendars.map((c) => [c.id, c.sortOrder])).toEqual([
['a', 1], ['c', 1], ['b', 2],
]);
});
});
+31 -3
View File
@@ -48,6 +48,7 @@ import {
import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection';
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
import { requestDeviceSync } from '../device-sync/app/request';
import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort';
import { CALENDAR_AUTHORITY } from '../device-sync/types';
// extendRange merges up to twice the requested range (at least half a year,
@@ -266,6 +267,8 @@ export interface CalendarState {
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<ImportResult>;
createCalendar: (name: string, color?: string, description?: string) => Promise<Calendar>;
updateCalendar: (id: string, updates: CalendarUpdates) => Promise<void>;
// Store `orderedIds` in that order by renumbering their sortOrder.
reorderCalendars: (orderedIds: string[]) => Promise<void>;
removeCalendar: (id: string) => Promise<void>;
clearCalendarEvents: (id: string) => Promise<number>;
shareCalendar: (id: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
@@ -572,7 +575,7 @@ export const useCalendarStore = create<CalendarState>()(
if (calendarsInFlight) return calendarsInFlight;
calendarsInFlight = (async () => {
try {
const calendars = (await fetchCalendars()) ?? [];
const calendars = sortCalendars((await fetchCalendars()) ?? []);
set({ calendars });
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
@@ -1013,7 +1016,7 @@ export const useCalendarStore = create<CalendarState>()(
createCalendar: async (name, color, description) => {
const created = await apiCreateCalendar(name, color, description);
set({ calendars: [...get().calendars, created] });
set({ calendars: sortCalendars([...get().calendars, created]) });
requestDeviceSync(CALENDAR_AUTHORITY);
return created;
},
@@ -1022,11 +1025,36 @@ export const useCalendarStore = create<CalendarState>()(
const cal = get().calendars.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
set({
calendars: get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c)),
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
});
requestDeviceSync(CALENDAR_AUTHORITY);
},
reorderCalendars: async (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({ 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);
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, cal?.accountId);
saved.set(id, sortOrder);
}
} catch (err) {
// Show the order the server now holds: the moves saved before the
// failure stay, the rest are undone.
set({ calendars: withSortOrders(saved) });
throw err;
} finally {
if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY);
}
},
removeCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);