Merge pull request #1256 from sanitz/fix/task-due-localdatetime

fix(tasks): send an all-day task's due and start as a LocalDateTime
This commit is contained in:
Linus Rath
2026-10-18 14:30:08 +02:00
committed by GitHub
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
@@ -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<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").
@@ -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<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);
}