diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 916b9349f9..306ba46b4a 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -29,39 +29,93 @@ * e.g. 300 */ offsetMinutes: number; + + /** + * True iff the date is valid + * + * Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024). + * Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times + * are one second apart: + * + * - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799) + * - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800) + * + * Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination. + */ + valid: boolean; }; - const timezones: ZoneOption[] = Intl.supportedValuesOf('timeZone') - .map((zone) => DateTime.local({ zone })) - .sort((zoneA, zoneB) => { - let numericallyCorrect = zoneA.offset - zoneB.offset; - if (numericallyCorrect != 0) { - return numericallyCorrect; - } - return zoneA.zoneName.localeCompare(zoneB.zoneName, undefined, { sensitivity: 'base' }); - }) - .map((zone) => { - const offset = zone.toFormat('ZZ'); - return { - label: `${zone.zoneName} (${offset})`, - value: zone.zoneName, - offsetMinutes: zone.offset, - }; - }); + const knownTimezones = Intl.supportedValuesOf('timeZone'); - const initialOption = timezones.find((item) => item.offsetMinutes === initialDate.offset); + let timezones: ZoneOption[]; + $: timezones = knownTimezones + .map((zone) => zoneOptionForDate(zone, selectedDate)) + .filter((zone) => zone.valid) + .sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB)); - let selectedOption = initialOption && { - label: initialOption?.label || '', - offsetMinutes: initialOption?.offsetMinutes || 0, - value: initialOption?.value || '', - }; + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list + let selectedOption: ZoneOption | undefined; + $: selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, selectedOption); let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm"); // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it) $: date = DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }); + function zoneOptionForDate(zone: string, date: string) { + const dateAtZone: DateTime = DateTime.fromISO(date, { zone }); + const zoneOffsetAtDate = dateAtZone.toFormat('ZZ'); + const valid = dateAtZone.isValid && date.toString() === dateAtZone.toFormat("yyyy-MM-dd'T'HH:mm"); + return { + value: zone, + offsetMinutes: dateAtZone.offset, + label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'), + valid, + }; + } + + /* + * Find the time zone to select for a given time, date, and offset (e.g. +02:00). + * + * This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)" + * instead of just the raw offset or something like "UTC+02:00". + * + * The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about + * the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and + * Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone. + * + * If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting + * for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able + * to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre". + */ + function getPreferredTimeZone( + date: DateTime, + userTimeZone: string, + timezones: ZoneOption[], + selectedOption?: ZoneOption, + ) { + const offset = date.offset; + const previousSelection = timezones.find((item) => item.value === selectedOption?.value); + const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone); + const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset); + const utcFallback = { + label: 'UTC (+00:00)', + offsetMinutes: 0, + value: 'UTC', + valid: true, + }; + return previousSelection ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback; + } + + function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) { + let offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes; + if (offsetDifference != 0) { + return offsetDifference; + } + return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' }); + } + const dispatch = createEventDispatcher<{ cancel: void; confirm: string;