From d68226d11eb426dea7068f289a3e20c8f3e32c1a Mon Sep 17 00:00:00 2001 From: Ulas Date: Mon, 13 Apr 2026 09:20:27 +0200 Subject: [PATCH] fix: timezone-aware CalDAV sync and English as i18n fallback (#43) - Apple CalDAV: ICS events with TZID parameter are now converted to UTC using the Intl API instead of being stored as floating local time, fixing wrong start times for events synced from iOS Calendar - i18n: fallback language for unsupported browser locales changed from German to English --- CHANGELOG.md | 6 +++ public/i18n.js | 4 +- server/services/apple-calendar.js | 88 +++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0b350..71a8db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.1] - 2026-04-13 + +### Fixed +- i18n: fallback language for unsupported browser locales changed from German to English (#43) +- Apple CalDAV sync: calendar events with a `TZID` parameter are now correctly converted to UTC instead of being treated as floating local time, fixing wrong start times for events synced from iOS Calendar (#43) + ## [0.16.0] - 2026-04-06 ### Added diff --git a/public/i18n.js b/public/i18n.js index 7d494ee..7bc78fc 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -13,7 +13,7 @@ let currentLocale = DEFAULT_LOCALE; let translations = {}; let fallbackTranslations = {}; -/** Resolve locale: manual override > navigator.language > default */ +/** Resolve locale: manual override > navigator.language > English > default */ function resolveLocale() { const stored = localStorage.getItem(STORAGE_KEY); if (stored && SUPPORTED_LOCALES.includes(stored)) return stored; @@ -23,7 +23,7 @@ function resolveLocale() { const base = tag.split('-')[0].toLowerCase(); if (SUPPORTED_LOCALES.includes(base)) return base; } - return DEFAULT_LOCALE; + return 'en'; } /** Lade eine Locale-JSON-Datei */ diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js index d5a5eb4..94bbf62 100644 --- a/server/services/apple-calendar.js +++ b/server/services/apple-calendar.js @@ -139,19 +139,25 @@ function parseICS(ics) { const location = get('LOCATION') || null; const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null; - // DTSTART - mit optionalem TZID oder VALUE=DATE - const dtStartRaw = (() => { - const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block); - return m ? m[1].trim() : null; - })(); - const dtEndRaw = (() => { - const m = /^DTEND(?:;[^:]*)?:(.*)$/im.exec(block); - return m ? m[1].trim() : null; - })(); + // DTSTART / DTEND - extract value and optional TZID parameter + const parseDTLine = (prop) => { + const re = new RegExp(`^${prop}((?:;[^:]*)*):(.*)$`, 'im'); + const m = block.match(re); + if (!m) return { value: null, tzid: null }; + const params = m[1]; + const value = m[2].trim(); + const tzMatch = params.match(/;TZID=([^;:]+)/i); + return { value, tzid: tzMatch ? tzMatch[1].trim() : null }; + }; + + const dtStartLine = parseDTLine('DTSTART'); + const dtEndLine = parseDTLine('DTEND'); + const dtStartRaw = dtStartLine.value; + const dtEndRaw = dtEndLine.value; const allDay = /^DTSTART;VALUE=DATE:/im.test(block); - const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null; - let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null; + const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay, dtStartLine.tzid) : null; + let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay, dtEndLine.tzid) : null; // RFC 5545: DTEND for VALUE=DATE is exclusive - subtract one day if (allDay && dtend) { @@ -176,15 +182,56 @@ function parseICS(ics) { return events; } +/** + * Converts a local datetime string in a named timezone to UTC ISO-8601. + * Uses the Intl API (Node.js 12+) without external dependencies. + * @param {string} localStr - "YYYY-MM-DDTHH:MM:SS" + * @param {string} tzid - IANA timezone name e.g. "Europe/Helsinki" + * @returns {string} UTC ISO string ending with 'Z', or localStr on error + */ +function tzLocalToUTC(localStr, tzid) { + try { + // Treat the local time as if it were UTC to create a reference point + const fakeUTC = new Date(localStr + 'Z'); + + // Find out what fakeUTC looks like in the target timezone + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tzid, + year: 'numeric', month: 'numeric', day: 'numeric', + hour: 'numeric', minute: 'numeric', second: 'numeric', + hour12: false, + }).formatToParts(fakeUTC); + + const get = (type) => { + const part = parts.find(p => p.type === type); + const v = part ? part.value : '0'; + return v === '24' ? 0 : parseInt(v, 10); + }; + + // Compute offset: how much the timezone differs from what we fed in + const tzDisplayedAsUTC = Date.UTC( + get('year'), get('month') - 1, get('day'), + get('hour'), get('minute'), get('second') + ); + const offsetMs = fakeUTC.getTime() - tzDisplayedAsUTC; + + const trueUTC = new Date(fakeUTC.getTime() + offsetMs); + return trueUTC.toISOString().replace('.000Z', 'Z'); + } catch { + return localStr; + } +} + /** * Konvertiert ICS-Datumswert in ISO-8601-String. * Unterstützt: DATE (20240101), DATE-TIME lokal (20240101T120000), - * DATE-TIME UTC (20240101T120000Z), DATE-TIME mit TZID (ignoriert TZID, behandelt als lokal). + * DATE-TIME UTC (20240101T120000Z), DATE-TIME mit TZID (konvertiert zu UTC). * @param {string} val * @param {boolean} allDay + * @param {string|null} tzid - IANA timezone from TZID parameter, if present * @returns {string} */ -function formatICSDate(val, allDay) { +function formatICSDate(val, allDay, tzid) { if (allDay || /^\d{8}$/.test(val)) { // DATE: YYYYMMDD → YYYY-MM-DD return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`; @@ -196,8 +243,19 @@ function formatICSDate(val, allDay) { const h = val.slice(9, 11); const mi = val.slice(11, 13); const s = val.slice(13, 15) || '00'; - const z = val.endsWith('Z') ? 'Z' : ''; - return `${y}-${mo}-${d}T${h}:${mi}:${s}${z}`; + + if (val.endsWith('Z')) { + // Already UTC + return `${y}-${mo}-${d}T${h}:${mi}:${s}Z`; + } + + if (tzid) { + // Convert from named timezone to UTC + return tzLocalToUTC(`${y}-${mo}-${d}T${h}:${mi}:${s}`, tzid); + } + + // Floating local time - store without timezone suffix + return `${y}-${mo}-${d}T${h}:${mi}:${s}`; } /**