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
This commit is contained in:
Linus Rath
2026-09-24 19:12:02 +02:00
parent 548b647f15
commit dcdda5ed9f
4 changed files with 86 additions and 7 deletions
+2 -1
View File
@@ -79,7 +79,8 @@
}, },
"tasks": { "tasks": {
"add_placeholder": "Add a task…", "add_placeholder": "Add a task…",
"add_time": "Add time" "add_time": "Add time",
"update_error": "Failed to update task"
} }
}, },
"common": { "common": {
+15 -1
View File
@@ -609,6 +609,20 @@ export default function CalendarScreen() {
[t], [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 // Clone the event one day later and open it in the editor (webmail's
// handleDuplicateFromDetail). // handleDuplicateFromDetail).
const handleDuplicateFromDetail = React.useCallback( const handleDuplicateFromDetail = React.useCallback(
@@ -942,7 +956,7 @@ export default function CalendarScreen() {
onClose={() => { setTasksVisible(false); setTasksInitialId(null); }} onClose={() => { setTasksVisible(false); setTasksInitialId(null); }}
onCreate={createTask} onCreate={createTask}
onUpdate={updateTask} onUpdate={updateTask}
onToggle={toggleTaskComplete} onToggle={handleToggleTask}
onDelete={deleteTask} onDelete={deleteTask}
/> />
@@ -638,4 +638,58 @@ describe('calendar-store', () => {
expect(mockGetEvents).not.toHaveBeenCalled(); 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,
});
});
});
}); });
+15 -5
View File
@@ -712,12 +712,22 @@ export const useCalendarStore = create<CalendarState>()(
const task = get().tasks.find((t) => t.id === id); const task = get().tasks.find((t) => t.id === id);
if (!task) return; if (!task) return;
const completed = task.progress === 'completed'; const completed = task.progress === 'completed';
// Un-completing goes back to needs-action (not in-process), like webmail; // Un-completing goes back to needs-action (not in-process), like webmail.
// progressUpdated records when the state changed (RFC 8984 §5.2.4). // No progressUpdated: JSCalendar 2.0 dropped it and Stalwart rejects the
// whole update with invalidProperties (#958).
const next: Partial<CalendarEvent> = completed const next: Partial<CalendarEvent> = completed
? { progress: 'needs-action', percentComplete: 0, progressUpdated: new Date().toISOString() } ? { progress: 'needs-action', percentComplete: 0 }
: { progress: 'completed', percentComplete: 100, progressUpdated: new Date().toISOString() }; : { progress: 'completed', percentComplete: 100 };
await get().updateTask(id, next); // Flip the checkbox right away and put it back if the server refuses.
const patchTask = (changes: Partial<CalendarEvent>) =>
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) => { deleteTask: async (id) => {