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
This commit is contained in:
Ulas
2026-04-13 09:20:27 +02:00
parent 61e663ef72
commit d68226d11e
3 changed files with 81 additions and 17 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.16.0] - 2026-04-06
### Added ### Added
+2 -2
View File
@@ -13,7 +13,7 @@ let currentLocale = DEFAULT_LOCALE;
let translations = {}; let translations = {};
let fallbackTranslations = {}; let fallbackTranslations = {};
/** Resolve locale: manual override > navigator.language > default */ /** Resolve locale: manual override > navigator.language > English > default */
function resolveLocale() { function resolveLocale() {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LOCALES.includes(stored)) return stored; if (stored && SUPPORTED_LOCALES.includes(stored)) return stored;
@@ -23,7 +23,7 @@ function resolveLocale() {
const base = tag.split('-')[0].toLowerCase(); const base = tag.split('-')[0].toLowerCase();
if (SUPPORTED_LOCALES.includes(base)) return base; if (SUPPORTED_LOCALES.includes(base)) return base;
} }
return DEFAULT_LOCALE; return 'en';
} }
/** Lade eine Locale-JSON-Datei */ /** Lade eine Locale-JSON-Datei */
+73 -15
View File
@@ -139,19 +139,25 @@ function parseICS(ics) {
const location = get('LOCATION') || null; const location = get('LOCATION') || null;
const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null; const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
// DTSTART - mit optionalem TZID oder VALUE=DATE // DTSTART / DTEND - extract value and optional TZID parameter
const dtStartRaw = (() => { const parseDTLine = (prop) => {
const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block); const re = new RegExp(`^${prop}((?:;[^:]*)*):(.*)$`, 'im');
return m ? m[1].trim() : null; const m = block.match(re);
})(); if (!m) return { value: null, tzid: null };
const dtEndRaw = (() => { const params = m[1];
const m = /^DTEND(?:;[^:]*)?:(.*)$/im.exec(block); const value = m[2].trim();
return m ? m[1].trim() : null; 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 allDay = /^DTSTART;VALUE=DATE:/im.test(block);
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null; const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay, dtStartLine.tzid) : null;
let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null; let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay, dtEndLine.tzid) : null;
// RFC 5545: DTEND for VALUE=DATE is exclusive - subtract one day // RFC 5545: DTEND for VALUE=DATE is exclusive - subtract one day
if (allDay && dtend) { if (allDay && dtend) {
@@ -176,15 +182,56 @@ function parseICS(ics) {
return events; 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. * Konvertiert ICS-Datumswert in ISO-8601-String.
* Unterstützt: DATE (20240101), DATE-TIME lokal (20240101T120000), * 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 {string} val
* @param {boolean} allDay * @param {boolean} allDay
* @param {string|null} tzid - IANA timezone from TZID parameter, if present
* @returns {string} * @returns {string}
*/ */
function formatICSDate(val, allDay) { function formatICSDate(val, allDay, tzid) {
if (allDay || /^\d{8}$/.test(val)) { if (allDay || /^\d{8}$/.test(val)) {
// DATE: YYYYMMDD → YYYY-MM-DD // DATE: YYYYMMDD → YYYY-MM-DD
return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`; 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 h = val.slice(9, 11);
const mi = val.slice(11, 13); const mi = val.slice(11, 13);
const s = val.slice(13, 15) || '00'; 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}`;
} }
/** /**