From 346583fe783bfab8f575c56fb0c5ffd9f7173e02 Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 24 Mar 2026 10:16:05 +0100 Subject: [PATCH] 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 --- package.json | 2 +- src/lib/calendar-events.ts | 43 ++++++++++++++++++++++++++----------- src/lib/outlook-calendar.ts | 18 ++++++++++++---- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index d9bcecf..d04583d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "main": "index.js", "scripts": { diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index 50d8883..e01a443 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -847,11 +847,15 @@ export const createCalendarEvent = async ( // Map to Google format 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 = { summary: event.title, description: event.description, - start: event.start, - end: event.end, + start: startObj, + end: endObj, location: event.location, ...(rrule ? { recurrence: [rrule] } : {}), ...(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 outlookTz = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const createdEvent = await createOutlookEvent(accessToken, calendarId, { summary: event.title, description: event.description, - start: event.start, - end: event.end, + start: { ...event.start, timeZone: outlookTz }, + end: { ...event.end, timeZone: outlookTz }, location: event.location, allDay: event.allDay, 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 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 = {}; if (event.title !== undefined) googleEvent.summary = event.title; if (event.description !== undefined) googleEvent.description = event.description; - if (event.start !== undefined) googleEvent.start = event.start; - if (event.end !== undefined) googleEvent.end = event.end; + if (event.start !== undefined) googleEvent.start = rrule ? { ...event.start, timeZone: timezone } : event.start; + if (event.end !== undefined) googleEvent.end = rrule ? { ...event.end, timeZone: timezone } : event.end; if (event.location !== undefined) googleEvent.location = event.location; if (rrule) googleEvent.recurrence = [rrule]; 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 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, { summary: event.title, description: event.description, - start: event.start, - end: event.end, + start: event.start ? { ...event.start, timeZone: outlookUpdateTz } : undefined, + end: event.end ? { ...event.end, timeZone: outlookUpdateTz } : undefined, location: event.location, allDay: event.allDay, 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'); } - const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId; - // Outlook: for simplicity, always delete the series for now - // (Outlook instance deletion requires fetching specific occurrence IDs) - await deleteOutlookEvent(accessToken, calendarId, baseEventId); + // Outlook event ID format for recurring: "seriesMasterId::instanceId" + const hasInstanceId = eventId.includes('::'); + const seriesMasterId = hasInstanceId ? eventId.split('::')[0] : eventId; + 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; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); diff --git a/src/lib/outlook-calendar.ts b/src/lib/outlook-calendar.ts index f5a32ef..79e511f 100644 --- a/src/lib/outlook-calendar.ts +++ b/src/lib/outlook-calendar.ts @@ -253,10 +253,20 @@ export const getUpcomingEvents = async ( const ensureTimeZone = (dateTimeObj: any) => { if (!dateTimeObj) return dateTimeObj; - return { - dateTime: dateTimeObj.dateTime, - timeZone: dateTimeObj.timeZone || 'UTC' - }; + const tz = dateTimeObj.timeZone || 'UTC'; + let dt = dateTimeObj.dateTime; + // 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 }; }; /**