From 629781ffd406a6d10d784d12927a979baa80fa8b Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Fri, 16 Oct 2026 17:29:54 +0200 Subject: [PATCH] fix(tasks): send an all-day task's due and start as a LocalDateTime The task modal and the plugin API send an all-day due as a bare date ("2026-10-14"). A task's due and start are LocalDateTimes (RFC 8984). Stalwart accepts the bare date and echoes it back over JMAP, but the stored iCalendar has no DUE, so CalDAV clients (phones, Thunderbird) show the task without a date. "2026-10-14T00:00:00" with showWithoutTime is stored as DUE;VALUE=DATE. createCalendarTask and updateCalendarTask now complete a bare due or start to midnight. Every write goes through them, so a task saved by an older client gets a proper due when it moves to another account. Tests: create, update and the cross-account move against round trips recorded on Stalwart v0.16.24 (JMAP set, then CalDAV GET of the stored iCalendar); times and a cleared due stay as they are. Co-Authored-By: Claude Opus 5.5 --- .../fixtures/stalwart-task-due-wire.json | 25 +++++++ lib/__tests__/jmap-calendar-tasks.test.ts | 65 ++++++++++++++++++- lib/jmap/client.ts | 24 +++++-- 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/fixtures/stalwart-task-due-wire.json 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 0ed5c8f82..7da41220d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -568,6 +568,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"). @@ -7793,7 +7807,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); @@ -7876,10 +7890,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); }