fix: create all-day calendar events with JSCalendar midnight start and full fetch verification
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user