From c718aa584adfce44cefc56191b9aedca7a600d77 Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 24 Mar 2026 10:42:13 +0100 Subject: [PATCH] fix: respect TZID when updating CalDAV event start/end times When updating Apple/Synology events that have DTSTART with a TZID parameter, convert the incoming UTC dateTime to local time in that timezone before writing back. Prevents ICAL.js from storing UTC values under a local timezone, which caused +1h shifts on update. v1.63.4 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/lib/apple-calendar.ts | 43 ++++++++++++++++++++++++++++++++---- src/lib/synology-calendar.ts | 34 ++++++++++++++++++++++++---- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 401f4d1..de80678 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.63.3", + "version": "1.63.4", "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/apple-calendar.ts b/src/lib/apple-calendar.ts index 15d39c5..edd557a 100644 --- a/src/lib/apple-calendar.ts +++ b/src/lib/apple-calendar.ts @@ -697,13 +697,33 @@ export const updateEvent = async ( } } + // Check if existing event uses TZID (for recurring events with DST-safe times) + const existingDtStart = vevent.getFirstProperty('dtstart'); + const existingTzid = existingDtStart?.getParameter('tzid') as string | undefined; + if (eventData.start) { if (eventData.start.date) { event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true); event.startDate.isDate = true; } else if (eventData.start.dateTime) { - event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true); - event.startDate.isDate = false; + if (existingTzid) { + // Convert UTC dateTime to local time in the TZID + const d = new Date(eventData.start.dateTime); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: existingTzid, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + const localTime = ICAL.Time.fromData({ + year: parseInt(get('year')), month: parseInt(get('month')), day: parseInt(get('day')), + hour: parseInt(get('hour')), minute: parseInt(get('minute')), second: parseInt(get('second')), + isDate: false, + }); + event.startDate = localTime; + } else { + event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true); + event.startDate.isDate = false; + } } } @@ -712,8 +732,23 @@ export const updateEvent = async ( event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true); event.endDate.isDate = true; } else if (eventData.end.dateTime) { - event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true); - event.endDate.isDate = false; + if (existingTzid) { + const d = new Date(eventData.end.dateTime); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: existingTzid, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + const localTime = ICAL.Time.fromData({ + year: parseInt(get('year')), month: parseInt(get('month')), day: parseInt(get('day')), + hour: parseInt(get('hour')), minute: parseInt(get('minute')), second: parseInt(get('second')), + isDate: false, + }); + event.endDate = localTime; + } else { + event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true); + event.endDate.isDate = false; + } } } diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index 09b4d4e..e69279f 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -714,13 +714,35 @@ export const updateEvent = async ( } } + // Check if existing event uses TZID (for recurring events with DST-safe times) + const existingDtStart = vevent.getFirstProperty('dtstart'); + const existingTzid = existingDtStart?.getParameter('tzid') as string | undefined; + + const toLocalIcalTime = (isoStr: string, tzid: string) => { + const d = new Date(isoStr); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: tzid, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + return ICAL.Time.fromData({ + year: parseInt(get('year')), month: parseInt(get('month')), day: parseInt(get('day')), + hour: parseInt(get('hour')), minute: parseInt(get('minute')), second: parseInt(get('second')), + isDate: false, + }); + }; + if (eventData.start) { if (eventData.start.date) { event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true); event.startDate.isDate = true; } else if (eventData.start.dateTime) { - event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true); - event.startDate.isDate = false; + if (existingTzid) { + event.startDate = toLocalIcalTime(eventData.start.dateTime, existingTzid); + } else { + event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true); + event.startDate.isDate = false; + } } } @@ -729,8 +751,12 @@ export const updateEvent = async ( event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true); event.endDate.isDate = true; } else if (eventData.end.dateTime) { - event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true); - event.endDate.isDate = false; + if (existingTzid) { + event.endDate = toLocalIcalTime(eventData.end.dateTime, existingTzid); + } else { + event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true); + event.endDate.isDate = false; + } } }