From f084b484b786113c1edf48f350c36c222e266451 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:51:34 +0100 Subject: [PATCH] fix: create all-day calendar events with JSCalendar midnight start and full fetch verification --- components/calendar/event-modal.tsx | 2 +- .../calendar-event-normalization.test.ts | 2 +- lib/calendar-event-normalization.ts | 6 +- lib/jmap/client.ts | 123 +++++++++++++++++- stores/calendar-store.ts | 60 +++++++-- 5 files changed, 180 insertions(+), 13 deletions(-) diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 2751ccac..d69d5dcb 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -262,7 +262,7 @@ export function EventModal({ if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return; const startStr = allDay - ? startDate + ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`; const start = allDay ? parseISO(startStr) : new Date(startStr); diff --git a/lib/__tests__/calendar-event-normalization.test.ts b/lib/__tests__/calendar-event-normalization.test.ts index 084a8a32..04c60f08 100644 --- a/lib/__tests__/calendar-event-normalization.test.ts +++ b/lib/__tests__/calendar-event-normalization.test.ts @@ -39,7 +39,7 @@ describe('calendar event normalization', () => { duration: 'PT24H', timeZone: 'UTC', }))).toMatchObject({ - start: '2026-03-16', + start: '2026-03-16T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true, diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index d9899ed0..3ed03758 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -92,9 +92,13 @@ export function sanitizeOutgoingCalendarEventData | null | undefined): Record | 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 { for (const email of emails) { if (!email.mailboxIds) continue; @@ -2962,6 +3039,7 @@ export class JMAPClient implements IJMAPClient { ["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/get", { accountId, + properties: [...CALENDAR_EVENT_PROPERTIES], "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, }, "1"] ], this.calendarUsing()); @@ -3046,6 +3124,7 @@ export class JMAPClient implements IJMAPClient { ["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/get", { accountId, + properties: [...CALENDAR_EVENT_PROPERTIES], "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, }, "1"] ], this.calendarUsing()); @@ -3067,6 +3146,7 @@ export class JMAPClient implements IJMAPClient { const response = await this.request([ ["CalendarEvent/get", { accountId, + properties: [...CALENDAR_EVENT_PROPERTIES], ids: [id], }, "0"] ], this.calendarUsing()); @@ -3088,6 +3168,13 @@ export class JMAPClient implements IJMAPClient { // Strip client-only shared fields before sending to JMAP 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 = { accountId, create: { @@ -3102,21 +3189,55 @@ export class JMAPClient implements IJMAPClient { ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); + debug.log('CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null); + if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (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"); } const createdId = result.created?.["new-event"]?.id; + debug.log('CalendarEvent/create server acknowledged created id', { + createdId, + created: result.created?.['new-event'] || null, + }); + if (createdId) { 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"); } diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index b9344b97..8f5c8ca5 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -62,6 +62,32 @@ function mapServerEventToStoreEvent( }; } +function getStoreEventDebugSnapshot(event: Partial | null | undefined): Record | 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 { id: string; url: string; @@ -206,28 +232,30 @@ export const useCalendarStore = create()( cleanEvent.calendarIds = event.originalCalendarIds; } debug.log('Calendar createEvent request', { - title: cleanEvent.title, - start: cleanEvent.start, - duration: cleanEvent.duration, + event: getStoreEventDebugSnapshot(cleanEvent), sendSchedulingMessages, targetAccountId, requestedCalendarIds: event.calendarIds, serverCalendarIds: cleanEvent.calendarIds, + currentDateRange: get().dateRange, + selectedCalendarIds: get().selectedCalendarIds, }); const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId); const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId); const selectedCalendarIds = get().selectedCalendarIds; const createdCalendarIds = Object.keys(mappedCreated.calendarIds || {}); 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', { - id: mappedCreated.id, - originalId: mappedCreated.originalId, - accountId: mappedCreated.accountId, - isShared: mappedCreated.isShared, - calendarIds: mappedCreated.calendarIds, - originalCalendarIds: mappedCreated.originalCalendarIds, + created: getStoreEventDebugSnapshot(created), + mappedCreated: getStoreEventDebugSnapshot(mappedCreated), isVisible, + currentDateRange, + inCurrentDateRange, }); if (!isVisible) { @@ -237,6 +265,20 @@ export const useCalendarStore = create()( }); } + 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] })); return mappedCreated; } catch (error) {