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 <noreply@anthropic.com>
This commit is contained in:
Sebastian Sanitz
2026-10-16 17:30:39 +02:00
co-authored by Claude Opus 5.5
parent fc22f111ef
commit 629781ffd4
3 changed files with 108 additions and 6 deletions
@@ -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"
}
]
}
+64 -1
View File
@@ -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<string, unknown> };
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<CalendarTask> => ({
'@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<string, Record<string, unknown>>)[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<string, Record<string, unknown>[]> = {
'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<string, Record<string, unknown>>)['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 } });
+19 -5
View File
@@ -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<T extends Partial<CalendarTask>>(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<CalendarTask>, targetAccountId?: string): Promise<string> {
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);
}