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
This commit is contained in:
Linus Rath
2026-09-24 19:12:02 +02:00
parent dcdda5ed9f
commit 1b64c14280
4 changed files with 94 additions and 2 deletions
+49
View File
@@ -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', () => {
+27 -1
View File
@@ -147,6 +147,32 @@ export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(
return result;
}
const TASK_PROGRESS_NORMALIZATION = new Map<string, string>([
['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<T extends Partial<CalendarEvent>>(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<Cale
USING,
);
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
all.push(...list.map(normalizeRecurrenceProperties));
all.push(...list.map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))));
}
return all;
}
@@ -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],
+2 -1
View File
@@ -711,7 +711,8 @@ export const useCalendarStore = create<CalendarState>()(
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).