diff --git a/lib/__tests__/fixtures/stalwart-task-due-wire.json b/lib/__tests__/fixtures/stalwart-task-due-wire.json new file mode 100644 index 000000000..6541e93dc --- /dev/null +++ b/lib/__tests__/fixtures/stalwart-task-due-wire.json @@ -0,0 +1,25 @@ +{ + "_source": "Stalwart v0.16.24, local server, 2026-10-01. Each row: a JMAP CalendarEvent/set (or a CalDAV PUT), then CalendarEvent/get and a CalDAV GET of the stored iCalendar. icalDue is the DUE line of that iCalendar, null when it has none.", + "allDayTaskDue": [ + { + "write": { "method": "update", "patch": { "due": "2026-10-14" } }, + "jmapAfter": { "due": "2026-10-14", "showWithoutTime": true }, + "icalDue": null + }, + { + "write": { "method": "update", "patch": { "due": "2026-10-14T00:00:00" } }, + "jmapAfter": { "due": "2026-10-14T00:00:00", "showWithoutTime": true }, + "icalDue": "DUE;VALUE=DATE:20261014" + }, + { + "write": { "method": "create", "patch": { "due": "2026-10-07", "showWithoutTime": true } }, + "jmapAfter": { "due": "2026-10-07", "showWithoutTime": true }, + "icalDue": null + }, + { + "write": { "method": "caldav-put", "ical": "DUE;VALUE=DATE:20261020" }, + "jmapAfter": { "due": "2026-10-20T00:00:00", "showWithoutTime": true }, + "icalDue": "DUE;VALUE=DATE:20261020" + } + ] +} diff --git a/lib/__tests__/jmap-calendar-tasks.test.ts b/lib/__tests__/jmap-calendar-tasks.test.ts index 3d83c6e00..a7cfc929c 100644 --- a/lib/__tests__/jmap-calendar-tasks.test.ts +++ b/lib/__tests__/jmap-calendar-tasks.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { JMAPClient } from '../jmap/client'; import type { CalendarTask } from '../jmap/types'; +import wire from './fixtures/stalwart-task-due-wire.json'; function makeSession() { return { @@ -22,6 +23,31 @@ function jsonResponse(body: unknown): Response { type Call = { method: string; args: Record }; +const isBareDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + +/** + * Whether Stalwart keeps a task due of this shape in the stored iCalendar, + * read from recorded round trips: it echoes a bare date over JMAP but writes + * no DUE, so CalDAV clients lose the date. + */ +function stalwartKeepsDue(due: string): boolean { + const rows = wire.allDayTaskDue.filter((row) => row.write.patch && isBareDate(row.write.patch.due) === isBareDate(due)); + if (rows.length === 0) throw new Error(`no recorded round trip for a due like ${due}`); + return rows.every((row) => row.icalDue !== null); +} + +/** What the task modal sends on save: every field, here for an all-day task. */ +const modalPayload = (due: string | null): Partial => ({ + '@type': 'Task', + title: 'Pay rent', + description: '', + due, + showWithoutTime: true, + priority: 0, + progress: 'needs-action', + calendarIds: { 'cal-1': true }, +}); + describe('JMAPClient task operations (#958)', () => { let client: JMAPClient; let sentCalls: Call[]; @@ -121,6 +147,32 @@ describe('JMAPClient task operations (#958)', () => { expect(createPayload.title).toBe('New Task'); expect(createPayload).not.toHaveProperty('progressUpdated'); }); + + const sentTask = (kind: 'create' | 'update', key: string) => { + const setCall = sentCalls.find((c) => c.method === 'CalendarEvent/set'); + return (setCall?.args[kind] as Record>)[key]; + }; + + it.each([ + ['createCalendarTask', 'create', 'new-task', (c: JMAPClient) => c.createCalendarTask(modalPayload('2026-10-07'))], + ['updateCalendarTask', 'update', 'task-1', (c: JMAPClient) => c.updateCalendarTask('task-1', modalPayload('2026-10-07'))], + ] as const)('%s sends an all-day due as a LocalDateTime, which Stalwart keeps in the iCalendar', async (_name, kind, key, write) => { + await write(client); + + const due = sentTask(kind, key).due as string; + expect(due).toBe('2026-10-07T00:00:00'); + expect(stalwartKeepsDue(due)).toBe(true); + }); + + it('completes a bare start date as well and leaves a due with a time alone', async () => { + await client.updateCalendarTask('task-1', { start: '2026-10-01', due: '2026-10-14T09:30:00' }); + expect(sentTask('update', 'task-1')).toMatchObject({ start: '2026-10-01T00:00:00', due: '2026-10-14T09:30:00' }); + }); + + it('keeps a cleared due cleared', async () => { + await client.updateCalendarTask('task-1', modalPayload(null)); + expect(sentTask('update', 'task-1').due).toBeNull(); + }); }); describe('JMAPClient tasks in shared calendars (#1204)', () => { @@ -129,7 +181,9 @@ describe('JMAPClient tasks in shared calendars (#1204)', () => { // Tasks per account as the server stores them (bare ids). const serverTasks: Record[]> = { - 'acct-1': [{ id: 'own-1', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-1': true }, created: '2026-10-01T00:00:00Z', isDraft: false, isOrigin: true, locations: { l1: { '@type': 'Location', name: 'Desk' } } }], + 'acct-1': [{ id: 'own-1', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-1': true }, created: '2026-10-01T00:00:00Z', isDraft: false, isOrigin: true, locations: { l1: { '@type': 'Location', name: 'Desk' } } }, + // Saved by a client that sent a bare date: Stalwart echoes it as such. + { id: 'own-2', '@type': 'Task', uid: 'uid-due', title: 'Dated', progress: 'needs-action', calendarIds: { 'cal-1': true }, due: wire.allDayTaskDue[0].jmapAfter.due, showWithoutTime: true }], 'acct-2': [{ id: 'shared-1', '@type': 'Task', uid: 'uid-shared', title: 'Theirs', progress: 'needs-action', calendarIds: { 'cal-s': true } }], }; @@ -195,6 +249,15 @@ describe('JMAPClient tasks in shared calendars (#1204)', () => { expect(newId).toBe('acct-2:new-id'); }); + it('moving a task with a bare due date to another account stores the due as a LocalDateTime', async () => { + await client.updateCalendarTask('own-2', { calendarIds: { 'acct-2:cal-s': true } }); + + const create = sentCalls.find((c) => c.method === 'CalendarEvent/set' && c.args.create); + const due = (create?.args.create as Record>)['new-task'].due as string; + expect(due).toBe('2026-10-14T00:00:00'); + expect(stalwartKeepsDue(due)).toBe(true); + }); + it('updates a shared task in place in its own account with real ids', async () => { const id = await client.updateCalendarTask('acct-2:shared-1', { progress: 'completed', calendarIds: { 'acct-2:cal-s': true } }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 4d692c51c..547a2882a 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -570,6 +570,20 @@ function getUserTimeZone(): string | undefined { } } +const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * A task's `due` and `start` are LocalDateTimes (RFC 8984). Stalwart accepts a + * bare date and echoes it over JMAP, but stores no DUE in the iCalendar, so + * CalDAV clients lose the date. Completes a bare date to midnight. + */ +function completeBareTaskDates>(task: T): T { + const result = { ...task }; + if (typeof result.due === 'string' && BARE_DATE.test(result.due)) result.due = `${result.due}T00:00:00`; + if (typeof result.start === 'string' && BARE_DATE.test(result.start)) result.start = `${result.start}T00:00:00`; + return result; +} + /** * Stalwart's calcard crate uses singular property names ("recurrenceRule") * instead of the RFC 8984 plural forms ("recurrenceRules"). @@ -7827,7 +7841,7 @@ export class JMAPClient implements IJMAPClient { accountId = resolved.accountId; taskData.calendarIds = resolved.calendarIds; } - const cleanTask = { ...taskData, '@type': 'Task' }; + const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' }; debug.group('CalendarTask/create', 'tasks'); debug.log('tasks', 'CalendarTask/create accountId', accountId); @@ -7910,10 +7924,10 @@ export class JMAPClient implements IJMAPClient { */ async updateCalendarTask(taskId: string, updates: Partial, targetAccountId?: string): Promise { const source = targetAccountId ? { accountId: targetAccountId, id: taskId } : this.splitCalendarObjectId(taskId); - let patch = updates; - if (!targetAccountId && updates.calendarIds) { - const dest = this.resolveTaskCalendarIds(updates.calendarIds); - patch = { ...updates, calendarIds: dest.calendarIds }; + let patch = completeBareTaskDates(updates); + if (!targetAccountId && patch.calendarIds) { + const dest = this.resolveTaskCalendarIds(patch.calendarIds); + patch = { ...patch, calendarIds: dest.calendarIds }; if (dest.accountId !== source.accountId) { return this.moveCalendarTaskToAccount(source, dest.accountId, patch); }