fix: Google recurring event timezone + Outlook DST shift + Outlook delete

- Google: add timeZone to start/end objects when recurrence is present
  (fixes "Missing time zone definition for start time" error)
- Outlook: pass user timezone instead of UTC for event create/update,
  convert UTC dateTime to local representation (fixes +1h DST shift
  on recurring instances)
- Outlook: use instance ID for single-occurrence delete instead of
  series master ID (fixes ErrorItemNotFound on delete)

v1.63.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-24 10:16:05 +01:00
parent 6584b9c266
commit 346583fe78
3 changed files with 46 additions and 17 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.63.0", "version": "1.63.1",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -847,11 +847,15 @@ export const createCalendarEvent = async (
// Map to Google format // Map to Google format
const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays); const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays);
// Google requires timeZone on start/end when recurrence is present
const timezone = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const startObj = rrule ? { ...event.start, timeZone: timezone } : event.start;
const endObj = rrule ? { ...event.end, timeZone: timezone } : event.end;
const googleEvent: any = { const googleEvent: any = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: startObj,
end: event.end, end: endObj,
location: event.location, location: event.location,
...(rrule ? { recurrence: [rrule] } : {}), ...(rrule ? { recurrence: [rrule] } : {}),
...(event.allDay ? { allDay: true } : {}), ...(event.allDay ? { allDay: true } : {}),
@ -891,11 +895,12 @@ export const createCalendarEvent = async (
} }
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date(); const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const outlookTz = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const createdEvent = await createOutlookEvent(accessToken, calendarId, { const createdEvent = await createOutlookEvent(accessToken, calendarId, {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: { ...event.start, timeZone: outlookTz },
end: event.end, end: { ...event.end, timeZone: outlookTz },
location: event.location, location: event.location,
allDay: event.allDay, allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays), recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays),
@ -1084,11 +1089,12 @@ export const updateCalendarEvent = async (
// Map to Google format // Map to Google format
const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays); const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays);
const timezone = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const googleEvent: any = {}; const googleEvent: any = {};
if (event.title !== undefined) googleEvent.summary = event.title; if (event.title !== undefined) googleEvent.summary = event.title;
if (event.description !== undefined) googleEvent.description = event.description; if (event.description !== undefined) googleEvent.description = event.description;
if (event.start !== undefined) googleEvent.start = event.start; if (event.start !== undefined) googleEvent.start = rrule ? { ...event.start, timeZone: timezone } : event.start;
if (event.end !== undefined) googleEvent.end = event.end; if (event.end !== undefined) googleEvent.end = rrule ? { ...event.end, timeZone: timezone } : event.end;
if (event.location !== undefined) googleEvent.location = event.location; if (event.location !== undefined) googleEvent.location = event.location;
if (rrule) googleEvent.recurrence = [rrule]; if (rrule) googleEvent.recurrence = [rrule];
if (event.url) googleEvent.source = { url: event.url, title: event.url }; if (event.url) googleEvent.source = { url: event.url, title: event.url };
@ -1129,11 +1135,12 @@ export const updateCalendarEvent = async (
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId; const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date(); const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
const outlookUpdateTz = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const updatedEvent = await updateOutlookEvent(accessToken, calendarId, baseEventId, { const updatedEvent = await updateOutlookEvent(accessToken, calendarId, baseEventId, {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: event.start, start: event.start ? { ...event.start, timeZone: outlookUpdateTz } : undefined,
end: event.end, end: event.end ? { ...event.end, timeZone: outlookUpdateTz } : undefined,
location: event.location, location: event.location,
allDay: event.allDay, allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays), recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays),
@ -1371,10 +1378,22 @@ export const deleteCalendarEvent = async (
else throw new Error('Failed to refresh token'); else throw new Error('Failed to refresh token');
} }
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId; // Outlook event ID format for recurring: "seriesMasterId::instanceId"
// Outlook: for simplicity, always delete the series for now const hasInstanceId = eventId.includes('::');
// (Outlook instance deletion requires fetching specific occurrence IDs) const seriesMasterId = hasInstanceId ? eventId.split('::')[0] : eventId;
await deleteOutlookEvent(accessToken, calendarId, baseEventId); const instanceId = hasInstanceId ? eventId.split('::')[1] : eventId;
if (deleteMode === 'this' && hasInstanceId) {
// Delete just this specific occurrence
await deleteOutlookEvent(accessToken, calendarId, instanceId);
} else if (deleteMode === 'all' || !hasInstanceId) {
// Delete the entire series (use series master ID)
await deleteOutlookEvent(accessToken, calendarId, seriesMasterId);
} else {
// 'future' or 'past' — Outlook doesn't support partial series delete easily
// Fall back to deleting the series
await deleteOutlookEvent(accessToken, calendarId, seriesMasterId);
}
return; return;
} else if (connection.provider === 'apple') { } else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':'); const [email, appPassword] = connection.accessToken.split(':');

View File

@ -253,10 +253,20 @@ export const getUpcomingEvents = async (
const ensureTimeZone = (dateTimeObj: any) => { const ensureTimeZone = (dateTimeObj: any) => {
if (!dateTimeObj) return dateTimeObj; if (!dateTimeObj) return dateTimeObj;
return { const tz = dateTimeObj.timeZone || 'UTC';
dateTime: dateTimeObj.dateTime, let dt = dateTimeObj.dateTime;
timeZone: dateTimeObj.timeZone || 'UTC' // If dateTime is UTC (ends with Z) but we have a real timezone, convert to local representation
}; if (dt && typeof dt === 'string' && dt.endsWith('Z') && tz !== 'UTC') {
const d = new Date(dt);
// Format as local time in the target timezone: YYYY-MM-DDTHH:mm:ss.0000000
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).formatToParts(d);
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
dt = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}.0000000`;
}
return { dateTime: dt, timeZone: tz };
}; };
/** /**