From dcdda5ed9fa87c98b5968116419fff2504318538 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:34:54 +0200 Subject: [PATCH] fix: let tasks be completed again and say when the server refuses #958 Ticking a task sent `progressUpdated`, which JSCalendar 2.0 dropped and Stalwart rejects with `invalidProperties`, so the whole update failed. The checkbox fired the toggle with `void`, so the task silently stayed open. - drop `progressUpdated` from the toggle patch, like webmail 6b30e91c - flip the checkbox optimistically and put it back when the update fails - show a "Failed to update task" alert with the server's reason --- locales/rn/en.json | 3 +- src/screens/CalendarScreen.tsx | 16 +++++- src/stores/__tests__/calendar-store.test.ts | 54 +++++++++++++++++++++ src/stores/calendar-store.ts | 20 ++++++-- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 415b2bdd..9f7c5c44 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -79,7 +79,8 @@ }, "tasks": { "add_placeholder": "Add a task…", - "add_time": "Add time" + "add_time": "Add time", + "update_error": "Failed to update task" } }, "common": { diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index aa94446d..874ac05f 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -609,6 +609,20 @@ export default function CalendarScreen() { [t], ); + // The store flips the checkbox optimistically and reverts it when the + // server refuses; say so instead of leaving the user guessing. + const handleToggleTask = React.useCallback( + (id: string) => { + toggleTaskComplete(id).catch((err: unknown) => { + Alert.alert( + t('calendar.tasks.update_error', 'Failed to update task'), + err instanceof Error ? err.message : undefined, + ); + }); + }, + [toggleTaskComplete, t], + ); + // Clone the event one day later and open it in the editor (webmail's // handleDuplicateFromDetail). const handleDuplicateFromDetail = React.useCallback( @@ -942,7 +956,7 @@ export default function CalendarScreen() { onClose={() => { setTasksVisible(false); setTasksInitialId(null); }} onCreate={createTask} onUpdate={updateTask} - onToggle={toggleTaskComplete} + onToggle={handleToggleTask} onDelete={deleteTask} /> diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 5ef5eabd..502c1aa3 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -638,4 +638,58 @@ describe('calendar-store', () => { expect(mockGetEvents).not.toHaveBeenCalled(); }); }); + + describe('toggleTaskComplete', () => { + it('completes a task without sending progressUpdated, which Stalwart rejects (#958)', async () => { + useCalendarStore.setState({ + tasks: [{ id: 't1', '@type': 'Task', progress: 'needs-action' } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().toggleTaskComplete('t1'); + + expect(mockUpdateEvent).toHaveBeenCalledWith( + 't1', + { progress: 'completed', percentComplete: 100 }, + undefined, + undefined, + ); + expect(useCalendarStore.getState().tasks[0].progress).toBe('completed'); + }); + + it('reopens a completed task as needs-action on its owning account', async () => { + useCalendarStore.setState({ + tasks: [{ id: 'acc-2:t1', originalId: 't1', accountId: 'acc-2', progress: 'completed' } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().toggleTaskComplete('acc-2:t1'); + + expect(mockUpdateEvent).toHaveBeenCalledWith( + 't1', + { progress: 'needs-action', percentComplete: 0 }, + undefined, + 'acc-2', + ); + expect(useCalendarStore.getState().tasks[0].progress).toBe('needs-action'); + }); + + it('flips the task at once and reverts it when the server refuses', async () => { + useCalendarStore.setState({ + tasks: [{ id: 't1', progress: 'needs-action', percentComplete: 20 } as any], + }); + let reject!: (err: Error) => void; + mockUpdateEvent.mockReturnValue(new Promise((_, r) => { reject = r; })); + + const pending = useCalendarStore.getState().toggleTaskComplete('t1'); + expect(useCalendarStore.getState().tasks[0].progress).toBe('completed'); + + reject(new Error('invalidProperties')); + await expect(pending).rejects.toThrow('invalidProperties'); + expect(useCalendarStore.getState().tasks[0]).toMatchObject({ + progress: 'needs-action', + percentComplete: 20, + }); + }); + }); }); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 76ec4376..b0808a63 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -712,12 +712,22 @@ export const useCalendarStore = create()( const task = get().tasks.find((t) => t.id === id); if (!task) return; const completed = task.progress === 'completed'; - // Un-completing goes back to needs-action (not in-process), like webmail; - // progressUpdated records when the state changed (RFC 8984 §5.2.4). + // Un-completing goes back to needs-action (not in-process), like webmail. + // No progressUpdated: JSCalendar 2.0 dropped it and Stalwart rejects the + // whole update with invalidProperties (#958). const next: Partial = completed - ? { progress: 'needs-action', percentComplete: 0, progressUpdated: new Date().toISOString() } - : { progress: 'completed', percentComplete: 100, progressUpdated: new Date().toISOString() }; - await get().updateTask(id, next); + ? { progress: 'needs-action', percentComplete: 0 } + : { progress: 'completed', percentComplete: 100 }; + // Flip the checkbox right away and put it back if the server refuses. + const patchTask = (changes: Partial) => + set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) }); + patchTask(next); + try { + await apiUpdateEvent(task.originalId || id, next, undefined, task.accountId); + } catch (err) { + patchTask({ progress: task.progress, percentComplete: task.percentComplete }); + throw err; + } }, deleteTask: async (id) => {