From 1b64c142805885250b8d576d50f5abb24f46fcfe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:36:49 +0200 Subject: [PATCH] fix: treat CalDAV tasks marked COMPLETED, NEEDS_ACTION or canceled as such Tasks created over CalDAV can come back with their iCalendar STATUS spelling or as `canceled` (stalwartlabs/calcard#20). The app compared against the JSCalendar values only, so a completed CalDAV task counted as pending, still raised its reminder, and a tap on it completed it instead of reopening it. - normalize the known spellings when events are read, like webmail's `normalizeCalendarTask` (5f1d2795), and keep unknown values as they are - reopen a cancelled task on tap, since the tasks sheet shows it ticked --- src/api/__tests__/calendar.test.ts | 49 +++++++++++++++++++++ src/api/calendar.ts | 28 +++++++++++- src/stores/__tests__/calendar-store.test.ts | 16 +++++++ src/stores/calendar-store.ts | 3 +- 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 5a89531b..9880b53e 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -209,6 +209,55 @@ describe('calendar operations', () => { const [event] = await getEvents(['ev1']); expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]); }); + + it('normalizes the CalDAV spellings of task progress (calcard#20)', async () => { + const list = [ + { id: 't1', progress: 'COMPLETED' }, + { id: 't2', progress: 'NEEDS_ACTION' }, + { id: 't3', progress: 'IN_PROCESS' }, + { id: 't4', progress: 'canceled' }, + { id: 't5', progress: 'CANCELLED' }, + { id: 't6', progress: 'needs-action' }, + ]; + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { list }, '0']], + }); + + const result = await getEvents(list.map((t) => t.id)); + expect(result.map((t) => t.progress)).toEqual([ + 'completed', + 'needs-action', + 'in-process', + 'cancelled', + 'cancelled', + 'needs-action', + ]); + }); + + it('leaves unknown and non-string task progress untouched', async () => { + const list = [ + { id: 't1', progress: 'constructor' }, + { id: 't2', progress: '__proto__' }, + { id: 't3', progress: 'failed' }, + { id: 't4', progress: 42 }, + { id: 't5', progress: null }, + { id: 'ev1', title: 'Event' }, + ]; + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { list }, '0']], + }); + + const result = await getEvents(list.map((t) => t.id)); + expect(result.map((t) => t.progress as unknown)).toEqual([ + 'constructor', + '__proto__', + 'failed', + 42, + null, + undefined, + ]); + expect(result[5]).not.toHaveProperty('progress'); + }); }); describe('createEvent', () => { diff --git a/src/api/calendar.ts b/src/api/calendar.ts index a8655a2a..ede1875c 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -147,6 +147,32 @@ export function normalizeRecurrenceProperties>( return result; } +const TASK_PROGRESS_NORMALIZATION = new Map([ + ['needs-action', 'needs-action'], + ['needs_action', 'needs-action'], + ['in-process', 'in-process'], + ['in_process', 'in-process'], + ['completed', 'completed'], + ['cancelled', 'cancelled'], + ['canceled', 'cancelled'], +]); + +/** + * CalDAV-created tasks can come back with their iCalendar STATUS spelling + * (`COMPLETED`, `NEEDS_ACTION`) or as `canceled` (stalwartlabs/calcard#20). + * Map the known spellings onto the JSCalendar values the task filters, the + * checkbox and the reminders compare against; leave unknown values alone. + * Mirrors webmail's normalizeCalendarTask. + */ +export function normalizeTaskProgress>(event: T): T { + const progress = (event as { progress?: unknown }).progress; + if (typeof progress !== 'string') return event; + const normalized = TASK_PROGRESS_NORMALIZATION.get(progress.toLowerCase()); + return normalized === undefined || normalized === progress + ? event + : { ...event, progress: normalized }; +} + /** * Convert the client's plural recurrence arrays to the singular JSCalendar 2.0 * properties Stalwart expects on write, dropping null rule fields. An empty @@ -357,7 +383,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise(res).list ?? []; - all.push(...list.map(normalizeRecurrenceProperties)); + all.push(...list.map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)))); } return all; } diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 502c1aa3..1843f3cb 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -674,6 +674,22 @@ describe('calendar-store', () => { expect(useCalendarStore.getState().tasks[0].progress).toBe('needs-action'); }); + it('reopens a cancelled task, which the tasks sheet shows as ticked', async () => { + useCalendarStore.setState({ + tasks: [{ id: 't1', progress: 'cancelled' } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().toggleTaskComplete('t1'); + + expect(mockUpdateEvent).toHaveBeenCalledWith( + 't1', + { progress: 'needs-action', percentComplete: 0 }, + undefined, + undefined, + ); + }); + 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], diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index b0808a63..d180442f 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -711,7 +711,8 @@ export const useCalendarStore = create()( toggleTaskComplete: async (id) => { const task = get().tasks.find((t) => t.id === id); if (!task) return; - const completed = task.progress === 'completed'; + // The tasks sheet ticks cancelled tasks too, so a tap on one reopens it. + const completed = task.progress === 'completed' || task.progress === 'cancelled'; // 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).