fix: create all-day calendar events with JSCalendar midnight start and full fetch verification

This commit is contained in:
Linus Rath
2026-03-27 18:51:34 +01:00
parent b868ad591d
commit f084b484b7
5 changed files with 180 additions and 13 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ export function EventModal({
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return; if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
const startStr = allDay const startStr = allDay
? startDate ? `${startDate}T00:00:00`
: `${startDate}T${startTime}:00`; : `${startDate}T${startTime}:00`;
const start = allDay ? parseISO(startStr) : new Date(startStr); const start = allDay ? parseISO(startStr) : new Date(startStr);
@@ -39,7 +39,7 @@ describe('calendar event normalization', () => {
duration: 'PT24H', duration: 'PT24H',
timeZone: 'UTC', timeZone: 'UTC',
}))).toMatchObject({ }))).toMatchObject({
start: '2026-03-16', start: '2026-03-16T00:00:00',
duration: 'P1D', duration: 'P1D',
timeZone: null, timeZone: null,
showWithoutTime: true, showWithoutTime: true,
+5 -1
View File
@@ -92,9 +92,13 @@ export function sanitizeOutgoingCalendarEventData<T extends Partial<CalendarEven
return normalized; return normalized;
} }
const normalizedStart = normalized.start
? `${normalized.start.slice(0, 10)}T00:00:00`
: normalized.start;
return { return {
...normalized, ...normalized,
start: normalized.start ? normalized.start.slice(0, 10) : normalized.start, start: normalizedStart,
duration: normalizeAllDayDurationValue(normalized.duration), duration: normalizeAllDayDurationValue(normalized.duration),
timeZone: null, timeZone: null,
} as T; } as T;
+122 -1
View File
@@ -95,6 +95,83 @@ const EMAIL_LIST_PROPERTIES = [
"hasAttachment", "hasAttachment",
] as const; ] as const;
const CALENDAR_EVENT_PROPERTIES = [
'id',
'@type',
'uid',
'calendarIds',
'title',
'description',
'descriptionContentType',
'created',
'updated',
'sequence',
'start',
'duration',
'timeZone',
'showWithoutTime',
'utcStart',
'utcEnd',
'status',
'freeBusyStatus',
'privacy',
'color',
'keywords',
'categories',
'locale',
'replyTo',
'organizerCalendarAddress',
'participants',
'mayInviteSelf',
'mayInviteOthers',
'hideAttendees',
'recurrenceId',
'recurrenceIdTimeZone',
'recurrenceRules',
'recurrenceOverrides',
'excludedRecurrenceRules',
'useDefaultAlerts',
'alerts',
'locations',
'virtualLocations',
'links',
'relatedTo',
'isDraft',
'isOrigin',
] as const;
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
if (!event) {
return null;
}
return {
id: event.id,
originalId: event.originalId,
uid: event.uid,
'@type': event['@type'],
title: event.title,
start: event.start,
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
utcStart: event.utcStart,
utcEnd: event.utcEnd,
status: event.status,
freeBusyStatus: event.freeBusyStatus,
calendarIds: event.calendarIds,
originalCalendarIds: event.originalCalendarIds,
accountId: event.accountId,
accountName: event.accountName,
isShared: event.isShared,
recurrenceId: event.recurrenceId,
recurrenceRules: event.recurrenceRules,
sequence: event.sequence,
created: event.created,
updated: event.updated,
};
}
function namespaceMailboxIds(emails: Email[], accountId: string): void { function namespaceMailboxIds(emails: Email[], accountId: string): void {
for (const email of emails) { for (const email of emails) {
if (!email.mailboxIds) continue; if (!email.mailboxIds) continue;
@@ -2962,6 +3039,7 @@ export class JMAPClient implements IJMAPClient {
["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", { ["CalendarEvent/get", {
accountId, accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"] }, "1"]
], this.calendarUsing()); ], this.calendarUsing());
@@ -3046,6 +3124,7 @@ export class JMAPClient implements IJMAPClient {
["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", { ["CalendarEvent/get", {
accountId, accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"] }, "1"]
], this.calendarUsing()); ], this.calendarUsing());
@@ -3067,6 +3146,7 @@ export class JMAPClient implements IJMAPClient {
const response = await this.request([ const response = await this.request([
["CalendarEvent/get", { ["CalendarEvent/get", {
accountId, accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: [id], ids: [id],
}, "0"] }, "0"]
], this.calendarUsing()); ], this.calendarUsing());
@@ -3088,6 +3168,13 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP // Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
debug.group('CalendarEvent/create');
debug.log('CalendarEvent/create outgoing payload', {
accountId,
sendSchedulingMessages,
event: getCalendarEventDebugSnapshot(cleanEvent),
});
const setArgs: Record<string, unknown> = { const setArgs: Record<string, unknown> = {
accountId, accountId,
create: { create: {
@@ -3102,21 +3189,55 @@ export class JMAPClient implements IJMAPClient {
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing()); ], this.calendarUsing());
debug.log('CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1]; const result = response.methodResponses[0][1];
if (result.notCreated?.["new-event"]) { if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"]; const error = result.notCreated["new-event"];
debug.warn('CalendarEvent/create notCreated', error);
debug.groupEnd();
throw new Error(error.description || "Failed to create calendar event"); throw new Error(error.description || "Failed to create calendar event");
} }
const createdId = result.created?.["new-event"]?.id; const createdId = result.created?.["new-event"]?.id;
debug.log('CalendarEvent/create server acknowledged created id', {
createdId,
created: result.created?.['new-event'] || null,
});
if (createdId) { if (createdId) {
const created = await this.getCalendarEvent(createdId, targetAccountId); const created = await this.getCalendarEvent(createdId, targetAccountId);
if (created) return created; debug.log('CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created));
if (created?.uid) {
try {
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
debug.log('CalendarEvent/create verification query by uid', {
uid: created.uid,
matchCount: verificationMatches.length,
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
});
} catch (verificationError) {
debug.warn('CalendarEvent/create verification query failed', verificationError);
}
}
if (created) {
debug.groupEnd();
return created;
}
debug.warn('CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
createdId,
targetAccountId,
});
} }
} }
debug.groupEnd();
throw new Error("Failed to create calendar event"); throw new Error("Failed to create calendar event");
} }
+51 -9
View File
@@ -62,6 +62,32 @@ function mapServerEventToStoreEvent(
}; };
} }
function getStoreEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
if (!event) {
return null;
}
return {
id: event.id,
originalId: event.originalId,
uid: event.uid,
title: event.title,
start: event.start,
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
utcStart: event.utcStart,
utcEnd: event.utcEnd,
calendarIds: event.calendarIds,
originalCalendarIds: event.originalCalendarIds,
accountId: event.accountId,
accountName: event.accountName,
isShared: event.isShared,
created: event.created,
updated: event.updated,
};
}
export interface ICalSubscription { export interface ICalSubscription {
id: string; id: string;
url: string; url: string;
@@ -206,28 +232,30 @@ export const useCalendarStore = create<CalendarStore>()(
cleanEvent.calendarIds = event.originalCalendarIds; cleanEvent.calendarIds = event.originalCalendarIds;
} }
debug.log('Calendar createEvent request', { debug.log('Calendar createEvent request', {
title: cleanEvent.title, event: getStoreEventDebugSnapshot(cleanEvent),
start: cleanEvent.start,
duration: cleanEvent.duration,
sendSchedulingMessages, sendSchedulingMessages,
targetAccountId, targetAccountId,
requestedCalendarIds: event.calendarIds, requestedCalendarIds: event.calendarIds,
serverCalendarIds: cleanEvent.calendarIds, serverCalendarIds: cleanEvent.calendarIds,
currentDateRange: get().dateRange,
selectedCalendarIds: get().selectedCalendarIds,
}); });
const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId); const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId);
const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId); const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId);
const selectedCalendarIds = get().selectedCalendarIds; const selectedCalendarIds = get().selectedCalendarIds;
const createdCalendarIds = Object.keys(mappedCreated.calendarIds || {}); const createdCalendarIds = Object.keys(mappedCreated.calendarIds || {});
const isVisible = createdCalendarIds.some((calendarId) => selectedCalendarIds.includes(calendarId)); const isVisible = createdCalendarIds.some((calendarId) => selectedCalendarIds.includes(calendarId));
const currentDateRange = get().dateRange;
const inCurrentDateRange = currentDateRange
? mappedCreated.start >= currentDateRange.start && mappedCreated.start <= currentDateRange.end
: null;
debug.log('Calendar createEvent response', { debug.log('Calendar createEvent response', {
id: mappedCreated.id, created: getStoreEventDebugSnapshot(created),
originalId: mappedCreated.originalId, mappedCreated: getStoreEventDebugSnapshot(mappedCreated),
accountId: mappedCreated.accountId,
isShared: mappedCreated.isShared,
calendarIds: mappedCreated.calendarIds,
originalCalendarIds: mappedCreated.originalCalendarIds,
isVisible, isVisible,
currentDateRange,
inCurrentDateRange,
}); });
if (!isVisible) { if (!isVisible) {
@@ -237,6 +265,20 @@ export const useCalendarStore = create<CalendarStore>()(
}); });
} }
if (inCurrentDateRange === false) {
debug.warn('Created event is outside the currently loaded date range', {
currentDateRange,
createdStart: mappedCreated.start,
});
}
if (mappedCreated.showWithoutTime && mappedCreated.timeZone !== null) {
debug.warn('Created all-day event came back with a non-null timeZone', {
timeZone: mappedCreated.timeZone,
event: getStoreEventDebugSnapshot(mappedCreated),
});
}
set((state) => ({ events: [...state.events, mappedCreated] })); set((state) => ({ events: [...state.events, mappedCreated] }));
return mappedCreated; return mappedCreated;
} catch (error) { } catch (error) {