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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-24 10:42:13 +01:00
parent c69a7d92a8
commit c718aa584a
3 changed files with 70 additions and 9 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "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

@ -697,25 +697,60 @@ 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) {
if (eventData.start.date) { if (eventData.start.date) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true); event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
event.startDate.isDate = true; event.startDate.isDate = true;
} else if (eventData.start.dateTime) { } else if (eventData.start.dateTime) {
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 = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true);
event.startDate.isDate = false; event.startDate.isDate = false;
} }
} }
}
if (eventData.end) { if (eventData.end) {
if (eventData.end.date) { if (eventData.end.date) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true); event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true);
event.endDate.isDate = true; event.endDate.isDate = true;
} else if (eventData.end.dateTime) { } else if (eventData.end.dateTime) {
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 = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true);
event.endDate.isDate = false; event.endDate.isDate = false;
} }
} }
}
// Update reminders (VALARM) // Update reminders (VALARM)
if (eventData.reminders !== undefined) { if (eventData.reminders !== undefined) {

View File

@ -714,25 +714,51 @@ 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) {
if (eventData.start.date) { if (eventData.start.date) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true); event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
event.startDate.isDate = true; event.startDate.isDate = true;
} else if (eventData.start.dateTime) { } else if (eventData.start.dateTime) {
if (existingTzid) {
event.startDate = toLocalIcalTime(eventData.start.dateTime, existingTzid);
} else {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true); event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true);
event.startDate.isDate = false; event.startDate.isDate = false;
} }
} }
}
if (eventData.end) { if (eventData.end) {
if (eventData.end.date) { if (eventData.end.date) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true); event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true);
event.endDate.isDate = true; event.endDate.isDate = true;
} else if (eventData.end.dateTime) { } else if (eventData.end.dateTime) {
if (existingTzid) {
event.endDate = toLocalIcalTime(eventData.end.dateTime, existingTzid);
} else {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true); event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true);
event.endDate.isDate = false; event.endDate.isDate = false;
} }
} }
}
// Update reminders (VALARM) // Update reminders (VALARM)
if (eventData.reminders !== undefined) { if (eventData.reminders !== undefined) {