fix: keep iCal subscriptions on refresh, rename calendars of any colour

- send a calendar's colour only when the user changed it: Stalwart 1.0
  accepts only #rgb, #rrggbb or a CSS name, so renaming a calendar that
  another client coloured with an 8-digit hex (Apple Calendar) failed
- "Refresh cached data" kept nothing of the calendar store's persisted
  state, so it deleted the user's iCal subscriptions, which exist only
  there; keep them and drop the rest
- show a notice with a retry when the task list fails to load; it keeps
  the tasks it had
- treat a method-level error on creating a task as a failure with its
  JMAP type instead of "no id returned"
This commit is contained in:
Linus Rath
2026-09-25 19:41:00 +02:00
parent 81b196a6ca
commit 9041cbcd08
37 changed files with 188 additions and 18 deletions
@@ -14,4 +14,9 @@ describe('CalendarLoadErrorNotice', () => {
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('says the task list may be out of date for a failed task load', () => {
render(<CalendarLoadErrorNotice what="tasks" onRetry={vi.fn()} />);
expect(screen.getByRole('alert')).toHaveTextContent('tasks_load_failed');
});
});
+4 -1
View File
@@ -65,7 +65,7 @@ import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventStartDate } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import { useTaskStore } from "@/stores/task-store";
import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
@@ -1542,6 +1542,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
case "tasks":
return (
<div className="flex flex-col h-full">
{taskStore.error === TASKS_LOAD_ERROR && (
<CalendarLoadErrorNotice what="tasks" onRetry={() => { if (client) void fetchTasksFn(client); }} />
)}
<TaskToolbar
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
@@ -5,19 +5,21 @@ import { AlertTriangle } from "@/components/icons";
import { Button } from "@/components/ui/button";
interface CalendarLoadErrorNoticeProps {
/** What failed to load: the visible range of events, or the task list. */
what?: "events" | "tasks";
onRetry: () => void;
}
/**
* Shown above the grid while the visible range could not be loaded. The grid
* keeps the events it had, so this says why they may be out of date.
* Shown above the grid (or the task list) while it could not be loaded. It
* keeps what it had, so this says why that may be out of date.
*/
export function CalendarLoadErrorNotice({ onRetry }: CalendarLoadErrorNoticeProps) {
export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLoadErrorNoticeProps) {
const t = useTranslations("calendar.status");
return (
<div role="alert" className="flex items-center gap-2 border-b border-border bg-warning/10 px-4 py-2 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-warning" />
<span className="min-w-0 flex-1">{t("events_load_failed")}</span>
<span className="min-w-0 flex-1">{what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")}</span>
<Button variant="outline" size="sm" onClick={onRetry}>
{t("retry")}
</Button>
@@ -0,0 +1,56 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { CALENDAR_COLORS } from '@/lib/shared-calendar-colors';
import type { Calendar } from '@/lib/jmap/types';
// Stalwart 1.0 accepts a calendar colour only as #rgb, #rrggbb or a CSS name
// (calendar/set.rs); 0.16 took anything shorter than 16 characters. Renaming
// a calendar another client coloured with an 8-digit hex (Apple Calendar)
// sent that colour back and failed on 1.0.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const appleColored = {
id: 'cal', name: 'Home', color: '#FF2968FF', isDefault: true, isShared: false, shareWith: null,
myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
} as unknown as Calendar;
describe('CalendarManagementSettings rename', () => {
let updateCalendar: ReturnType<typeof vi.fn>;
beforeEach(() => {
updateCalendar = vi.fn().mockResolvedValue(undefined);
// No server URL: the CalDAV discovery stays out of it.
useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
useCalendarStore.setState({ calendars: [appleColored], icalSubscriptions: [], updateCalendar } as never);
});
function openEditor() {
render(<CalendarManagementSettings />);
fireEvent.click(screen.getByTitle('edit'));
}
it('sends the new name without the colour it did not change, for Stalwart 1.0', async () => {
openEditor();
fireEvent.change(screen.getByDisplayValue('Home'), { target: { value: 'Family' } });
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
expect(updateCalendar.mock.calls[0].slice(1)).toEqual(['cal', { name: 'Family' }]);
});
it('sends a colour the user picked', async () => {
openEditor();
fireEvent.click(screen.getByLabelText(CALENDAR_COLORS[0]));
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
expect(updateCalendar.mock.calls[0].slice(1)).toEqual(['cal', { name: 'Home', color: CALENDAR_COLORS[0] }]);
});
});
@@ -315,11 +315,21 @@ export function CalendarManagementSettings() {
}
};
const handleUpdate = async (calendarId: string, data: { name: string; color: string }) => {
const handleUpdate = async (
calendarId: string,
data: { name: string; color: string },
shownColor: string,
) => {
if (!client) return;
setIsLoading(true);
try {
await updateCalendar(client, calendarId, { name: data.name, color: data.color });
// The colour only when the user changed it: Stalwart 1.0 accepts only
// #rgb, #rrggbb or a CSS colour name, so sending back one another
// client stored (an 8-digit hex from Apple Calendar) failed the rename.
await updateCalendar(client, calendarId, {
name: data.name,
...(data.color !== shownColor ? { color: data.color } : {}),
});
setEditingId(null);
toast.success(t('calendar_updated'));
} catch {
@@ -417,7 +427,7 @@ export function CalendarManagementSettings() {
<CalendarEditForm
key={cal.id}
initial={{ name: cal.name, color }}
onSave={(data) => handleUpdate(cal.id, data)}
onSave={(data) => handleUpdate(cal.id, data, color)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
+18
View File
@@ -49,4 +49,22 @@ describe('clearCachedData', () => {
clearCachedData();
expect(reload).toHaveBeenCalledOnce();
});
it('keeps the iCal subscriptions, which exist nowhere else, and drops the rest of the calendar state', () => {
const subscription = { id: 's1', url: 'https://example.com/holidays.ics', calendarId: 'c9', name: 'Holidays', color: '#ff0000', refreshInterval: 60, lastRefreshed: null };
localStorage.setItem('calendar-storage', JSON.stringify({
state: { selectedCalendarIds: ['c1', 'c9'], viewMode: 'week', icalSubscriptions: [subscription] },
version: 0,
}));
clearCachedData();
expect(JSON.parse(localStorage.getItem('calendar-storage')!)).toEqual({ state: { icalSubscriptions: [subscription] }, version: 0 });
});
it('removes calendar state without subscriptions entirely', () => {
localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week', icalSubscriptions: [] }, version: 0 }));
clearCachedData();
expect(localStorage.getItem('calendar-storage')).toBeNull();
});
});
+12
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import type { CalendarTask } from '../jmap/types';
import { jmapErrorType } from '../jmap/jmap-error';
function makeSession() {
return {
@@ -158,4 +159,15 @@ describe('JMAPClient task operations (#958)', () => {
await expect(client.getCalendarTasks()).rejects.toThrow(/Store unavailable/);
});
it('createCalendarTask fails with the error type when the server refuses the whole call', async () => {
vi.mocked(globalThis.fetch).mockImplementation(async () => jsonResponse({
methodResponses: [['error', { type: 'invalidArguments', description: 'Invalid calendar id' }, '0']],
}));
const error = await client.createCalendarTask({ title: 'New Task' }).then(() => null, (e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('Invalid calendar id');
expect(jmapErrorType(error)).toBe('invalidArguments');
});
});
+27 -4
View File
@@ -16,12 +16,34 @@ const CACHE_STORAGE_KEYS = [
'calendar-notification-storage',
];
/**
* The user's iCal subscriptions exist only in the calendar store's persisted
* state, next to its caches: keep them and drop the rest.
*/
function clearCalendarStorage(storage: Storage): void {
let persisted: { state?: { icalSubscriptions?: unknown }; version?: unknown } | null = null;
try {
persisted = JSON.parse(storage.getItem('calendar-storage') ?? 'null');
} catch {
/* not ours to read: drop it */
}
const subscriptions = persisted?.state?.icalSubscriptions;
if (!Array.isArray(subscriptions) || subscriptions.length === 0) {
storage.removeItem('calendar-storage');
return;
}
storage.setItem('calendar-storage', JSON.stringify({
state: { icalSubscriptions: subscriptions },
version: persisted?.version ?? 0,
}));
}
/**
* Clear cached, server-derived data (contacts, calendars, identities, and the
* in-memory per-account snapshots), then reload so everything is re-fetched
* fresh for the active account. Accounts and sessions are preserved — this is
* the non-destructive alternative to the browser's "clear site data", which
* also wipes the account list.
* fresh for the active account. Accounts, sessions and iCal subscriptions are
* preserved — this is the non-destructive alternative to the browser's
* "clear site data", which also wipes the account list.
*/
export function clearCachedData(): void {
// Drop the in-memory per-account store snapshots so a reload can't restore
@@ -36,7 +58,8 @@ export function clearCachedData(): void {
for (const key of CACHE_STORAGE_KEYS) {
try {
window.localStorage.removeItem(key);
if (key === 'calendar-storage') clearCalendarStorage(window.localStorage);
else window.localStorage.removeItem(key);
} catch {
/* ignore storage access errors */
}
+13 -5
View File
@@ -7953,18 +7953,26 @@ export class JMAPClient implements IJMAPClient {
}, "0"]
], this.calendarUsing());
const result = response.methodResponses?.[0]?.[1];
let result: JMAPResponseResult;
try {
// A method-level error (unknown calendar, missing capability) means
// nothing was created.
result = methodResult(response, "0", "CalendarEvent/set");
} catch (error) {
debug.groupEnd();
throw error;
}
debug.log('tasks', 'CalendarTask/create raw set response', result);
if (result?.notCreated?.["new-task"]) {
if (result.notCreated?.["new-task"]) {
const error = result.notCreated["new-task"];
debug.warn('tasks', 'CalendarTask/create REJECTED by server', error);
debug.groupEnd();
throw new Error(error.description || "Failed to create task");
throw jmapSetError(error.description || "Failed to create task", error);
}
const createdId = result?.created?.["new-task"]?.id;
const serverCreated = result?.created?.["new-task"];
const createdId = result.created?.["new-task"]?.id;
const serverCreated = result.created?.["new-task"];
debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated });
if (!createdId) {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "جارٍ تحميل التقويمات...",
"loading_events": "جارٍ تحميل الأحداث...",
"events_load_failed": "تعذّر تحميل الأحداث. قد لا يكون التقويم محدَّثًا.",
"tasks_load_failed": "تعذّر تحميل المهام. قد لا تكون القائمة محدَّثة.",
"retry": "حاول مرة أخرى"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Carregant els calendaris...",
"loading_events": "Carregant els esdeveniments...",
"events_load_failed": "No s'han pogut carregar els esdeveniments. És possible que el calendari no estigui actualitzat.",
"tasks_load_failed": "No s'han pogut carregar les tasques. És possible que la llista no estigui actualitzada.",
"retry": "Torna-ho a provar"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Načítání kalendářů...",
"loading_events": "Načítání událostí...",
"events_load_failed": "Události se nepodařilo načíst. Kalendář nemusí být aktuální.",
"tasks_load_failed": "Úkoly se nepodařilo načíst. Seznam nemusí být aktuální.",
"retry": "Zkusit znovu"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Indlæser kalendere...",
"loading_events": "Indlæser begivenheder...",
"events_load_failed": "Begivenhederne kunne ikke indlæses. Kalenderen er muligvis ikke opdateret.",
"tasks_load_failed": "Opgaverne kunne ikke indlæses. Listen er muligvis ikke opdateret.",
"retry": "Prøv igen"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Kalender werden geladen...",
"loading_events": "Termine werden geladen...",
"events_load_failed": "Termine konnten nicht geladen werden. Der Kalender ist möglicherweise nicht aktuell.",
"tasks_load_failed": "Aufgaben konnten nicht geladen werden. Die Liste ist möglicherweise nicht aktuell.",
"retry": "Erneut versuchen"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Loading calendars...",
"loading_events": "Loading events...",
"events_load_failed": "Couldn't load events. The calendar may be out of date.",
"tasks_load_failed": "Couldn't load tasks. The list may be out of date.",
"retry": "Try again"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Cargando calendarios...",
"loading_events": "Cargando eventos...",
"events_load_failed": "No se pudieron cargar los eventos. Es posible que el calendario no esté actualizado.",
"tasks_load_failed": "No se pudieron cargar las tareas. Es posible que la lista no esté actualizada.",
"retry": "Intentar de nuevo"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "در حال بارگذاری تقویم‌ها...",
"loading_events": "در حال بارگذاری رویدادها...",
"events_load_failed": "بارگذاری رویدادها ممکن نشد. ممکن است تقویم به‌روز نباشد.",
"tasks_load_failed": "بارگذاری وظایف ممکن نشد. ممکن است فهرست به‌روز نباشد.",
"retry": "دوباره تلاش کنید"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Chargement des calendriers...",
"loading_events": "Chargement des événements...",
"events_load_failed": "Impossible de charger les événements. Le calendrier n'est peut-être pas à jour.",
"tasks_load_failed": "Impossible de charger les tâches. La liste n'est peut-être pas à jour.",
"retry": "Réessayer"
},
"quick_create": {
+1
View File
@@ -2969,6 +2969,7 @@
"loading_calendars": "טוען יומנים...",
"loading_events": "טוען אירועים...",
"events_load_failed": "לא ניתן לטעון את האירועים. ייתכן שהיומן אינו מעודכן.",
"tasks_load_failed": "לא ניתן לטעון את המשימות. ייתכן שהרשימה אינה מעודכנת.",
"retry": "נסה שוב"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Naptárak betöltése...",
"loading_events": "Események betöltése...",
"events_load_failed": "Nem sikerült betölteni az eseményeket. Előfordulhat, hogy a naptár nem naprakész.",
"tasks_load_failed": "Nem sikerült betölteni a feladatokat. Előfordulhat, hogy a lista nem naprakész.",
"retry": "Próbáld újra"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Caricamento calendari...",
"loading_events": "Caricamento eventi...",
"events_load_failed": "Impossibile caricare gli eventi. Il calendario potrebbe non essere aggiornato.",
"tasks_load_failed": "Impossibile caricare le attività. L'elenco potrebbe non essere aggiornato.",
"retry": "Riprova"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "カレンダーを読み込み中...",
"loading_events": "予定を読み込み中...",
"events_load_failed": "予定を読み込めませんでした。カレンダーが最新でない可能性があります。",
"tasks_load_failed": "タスクを読み込めませんでした。一覧が最新でない可能性があります。",
"retry": "再試行"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "캘린더를 불러오는 중...",
"loading_events": "일정을 불러오는 중...",
"events_load_failed": "일정을 불러오지 못했어요. 캘린더가 최신 상태가 아닐 수 있어요.",
"tasks_load_failed": "할 일을 불러오지 못했어요. 목록이 최신 상태가 아닐 수 있어요.",
"retry": "다시 시도"
},
"quick_create": {
+1
View File
@@ -3044,6 +3044,7 @@
"loading_calendars": "Ielādē kalendārus...",
"loading_events": "Ielādē pasākumus...",
"events_load_failed": "Neizdevās ielādēt pasākumus. Kalendārs var nebūt aktuāls.",
"tasks_load_failed": "Neizdevās ielādēt uzdevumus. Saraksts var nebūt aktuāls.",
"retry": "Mēģināt vēlreiz"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Хуанли ачаалж байна...",
"loading_events": "Арга хэмжээг ачаалж байна...",
"events_load_failed": "Арга хэмжээг ачаалж чадсангүй. Хуанли шинэчлэгдээгүй байж магадгүй.",
"tasks_load_failed": "Даалгавруудыг ачаалж чадсангүй. Жагсаалт шинэчлэгдээгүй байж магадгүй.",
"retry": "Дахин оролдоно уу"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Laster inn kalendere...",
"loading_events": "Laster inn hendelser...",
"events_load_failed": "Kunne ikke laste inn hendelser. Kalenderen er kanskje ikke oppdatert.",
"tasks_load_failed": "Kunne ikke laste inn oppgaver. Listen er kanskje ikke oppdatert.",
"retry": "Prøv igjen"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Agenda's laden...",
"loading_events": "Evenementen laden...",
"events_load_failed": "Evenementen konden niet worden geladen. De agenda is mogelijk niet actueel.",
"tasks_load_failed": "Taken konden niet worden geladen. De lijst is mogelijk niet actueel.",
"retry": "Opnieuw proberen"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Ładowanie kalendarzy...",
"loading_events": "Ładowanie wydarzeń...",
"events_load_failed": "Nie udało się wczytać wydarzeń. Kalendarz może być nieaktualny.",
"tasks_load_failed": "Nie udało się wczytać zadań. Lista może być nieaktualna.",
"retry": "Spróbuj ponownie"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Carregando calendários...",
"loading_events": "Carregando eventos...",
"events_load_failed": "Não foi possível carregar os eventos. O calendário pode estar desatualizado.",
"tasks_load_failed": "Não foi possível carregar as tarefas. A lista pode estar desatualizada.",
"retry": "Tentar novamente"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Se încarcă calendarele...",
"loading_events": "Se încarcă evenimente...",
"events_load_failed": "Nu s-au putut încărca evenimentele. Este posibil ca calendarul să nu fie actualizat.",
"tasks_load_failed": "Nu s-au putut încărca sarcinile. Este posibil ca lista să nu fie actualizată.",
"retry": "Încercați din nou"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Загрузка календарей...",
"loading_events": "Загрузка событий...",
"events_load_failed": "Не удалось загрузить события. Календарь может быть неактуален.",
"tasks_load_failed": "Не удалось загрузить задачи. Список может быть неактуален.",
"retry": "Попробовать снова"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Načítavanie kalendárov...",
"loading_events": "Načítavanie udalostí...",
"events_load_failed": "Udalosti sa nepodarilo načítať. Kalendár nemusí byť aktuálny.",
"tasks_load_failed": "Úlohy sa nepodarilo načítať. Zoznam nemusí byť aktuálny.",
"retry": "Skúsiť znova"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "Takvimler yükleniyor...",
"loading_events": "Etkinlikler yükleniyor...",
"events_load_failed": "Etkinlikler yüklenemedi. Takvim güncel olmayabilir.",
"tasks_load_failed": "Görevler yüklenemedi. Liste güncel olmayabilir.",
"retry": "Tekrar dene"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "Завантаження календарів...",
"loading_events": "Завантаження подій...",
"events_load_failed": "Не вдалося завантажити події. Календар може бути неактуальним.",
"tasks_load_failed": "Не вдалося завантажити завдання. Список може бути неактуальним.",
"retry": "Спробуйте знову"
},
"quick_create": {
+1
View File
@@ -3071,6 +3071,7 @@
"loading_calendars": "正在載入行事曆…",
"loading_events": "正在載入活動…",
"events_load_failed": "無法載入活動。行事曆可能不是最新狀態。",
"tasks_load_failed": "無法載入工作。清單可能不是最新狀態。",
"retry": "再試一次"
},
"quick_create": {
+1
View File
@@ -3045,6 +3045,7 @@
"loading_calendars": "正在加载日历...",
"loading_events": "正在加载事件...",
"events_load_failed": "无法加载事件。日历可能不是最新的。",
"tasks_load_failed": "无法加载任务。列表可能不是最新的。",
"retry": "再试一次"
},
"quick_create": {
+7 -1
View File
@@ -5,6 +5,12 @@ import { debug } from '@/lib/debug';
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
/**
* The `error` a failed task load leaves. The tasks loaded before stay; the
* task list shows a notice for it.
*/
export const TASKS_LOAD_ERROR = 'Failed to fetch tasks';
interface TaskStore {
tasks: CalendarTask[];
selectedTaskId: string | null;
@@ -52,7 +58,7 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
set({ tasks, isLoading: false });
} catch (error) {
debug.error('TaskStore/fetchTasks failed', error);
set({ isLoading: false, error: 'Failed to fetch tasks' });
set({ isLoading: false, error: TASKS_LOAD_ERROR });
}
},