1 import { findTimeZone, getTimeZoneLinks, getUTCOffset, getZonedTime } from '@protontech/timezone-support';
3 import isTruthy from '@proton/utils/isTruthy';
5 import { getAllowedTimeZones } from '../api/calendars';
6 import { SECOND } from '../constants';
7 import type { Api } from '../interfaces';
8 import type { DateTime } from '../interfaces/calendar/Date';
10 FALLBACK_ALLOWED_SUPPORTED_TIMEZONES_LIST,
11 MANUAL_TIMEZONE_EQUIVALENCE,
12 MANUAL_TIMEZONE_LINKS,
14 unsupportedTimezoneLinks,
15 } from './timezoneDatabase';
17 export const toLocalDate = ({
24 }: Partial<DateTime>) => {
25 return new Date(year, month - 1, day, hours, minutes, seconds);
28 export const toUTCDate = ({ year = 0, month = 1, day = 0, hours = 0, minutes = 0, seconds = 0 }: Partial<DateTime>) => {
29 return new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds));
32 export const fromLocalDate = (date: Date) => {
34 year: date.getFullYear(),
35 month: date.getMonth() + 1,
37 hours: date.getHours(),
38 minutes: date.getMinutes(),
39 seconds: date.getSeconds(),
43 export const fromUTCDate = (date: Date) => {
45 year: date.getUTCFullYear(),
46 month: date.getUTCMonth() + 1,
47 day: date.getUTCDate(),
48 hours: date.getUTCHours(),
49 minutes: date.getUTCMinutes(),
50 seconds: date.getUTCSeconds(),
54 // The list of all IANA time zones that we support is fetched from the BE at app load
55 export let ALLOWED_TIMEZONES_LIST: string[] = [...FALLBACK_ALLOWED_SUPPORTED_TIMEZONES_LIST];
57 let timezonesLoaded = false;
59 * Load from API list of time zones that the BE allows
61 export const loadAllowedTimeZones = async (api: Api) => {
62 if (timezonesLoaded) {
65 timezonesLoaded = true;
66 const { Timezones } = await api<{ Code: number; Timezones: string[] }>(getAllowedTimeZones());
69 * We remove time zones that we cannot parse. In practice there should never be a need for this,
70 * but because time zone updating is a manual process involving multiple teams, better be extra safe to avoid app crashes.
72 * The time it takes to run this code is one order of magnitude less than the API call above,
73 * so it doesn't significatively decrease the performance of this function. If we ever need better
74 * performance, there's room for improvement
76 const supportedTimeZones = Timezones.map((tzid) => {
81 console.error(`${tzid} not supported`);
85 ALLOWED_TIMEZONES_LIST = supportedTimeZones;
88 export const guessTimezone = (timezones: string[]) => {
90 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
91 // Ensure it belongs in the list
92 const tzid = manualFindTimeZone(timeZone) || findTimeZone(timeZone).name;
93 const supportedTzid = unsupportedTimezoneLinks[tzid] || tzid;
94 if (!timezones.includes(supportedTzid)) {
95 throw new Error('Time zone not allowed');
98 } catch (error: any) {
99 const date = new Date();
100 const timezoneOffset = date.getTimezoneOffset();
101 return timezones.find((tz) => {
102 const { zone } = getZonedTime(date, findTimeZone(tz));
103 return zone ? zone.offset === timezoneOffset : false;
109 * Get current timezone id by using Intl
110 * if not available use timezone-support lib and pick the first timezone from the current date timezone offset
112 export const getTimezone = () => {
113 const ianaTimezones = ALLOWED_TIMEZONES_LIST;
114 const timezone = guessTimezone(ianaTimezones);
115 // If the guessed timezone is undefined, there's not much we can do
117 return ALLOWED_TIMEZONES_LIST[0];
123 * Given a date and a timezone, return an object that contains information about the
124 * UTC offset of that date in that timezone. Namely an offset abbreviation (e.g. 'CET')
125 * and the UTC offset itself in minutes
127 export const getTimezoneOffset = (nowDate: Date, tzid: string) => {
128 return getUTCOffset(nowDate, findTimeZone(tzid));
131 export const formatTimezoneOffset = (offset: number) => {
132 // offset comes with the opposite sign in the timezone-support library
133 const sign = Math.sign(offset) === 1 ? '-' : '+';
134 const minutes = Math.abs(offset % 60);
135 const hours = (Math.abs(offset) - minutes) / 60;
138 const paddedMinutes = minutes < 10 ? `0${minutes}` : `${minutes}`;
139 return `${sign}${hours}:${paddedMinutes}`;
142 return `${sign}${hours}`;
145 export const formatGMTOffsetAbbreviation = (offset: number) => {
146 return `GMT${formatTimezoneOffset(offset)}`;
149 interface FormatterProps {
154 type GetTimeZoneOptions = (
156 options?: { formatter?: (a1: FormatterProps) => string }
163 const getTimeZoneDisplayName = (ianaName: string) => {
164 if (ianaName === 'Europe/Kiev') {
165 // Update Kyiv name before fully transitioning to 2022g
166 return 'Europe/Kyiv';
172 * @return {Array<Object>} [{ text: 'Africa/Nairobi: UTC +03:00', value: 'Africa/Nairobi'}, ...]
174 export const getTimeZoneOptions: GetTimeZoneOptions = (
176 { formatter = ({ utcOffset, name }: FormatterProps) => `${utcOffset} • ${name}` } = {}
178 return ALLOWED_TIMEZONES_LIST.map((name) => {
179 const { abbreviation, offset } = getUTCOffset(date, findTimeZone(name));
187 .sort(({ offset: offsetA, name: nameA }, { offset: offsetB, name: nameB }) => {
188 const diff = offsetA - offsetB;
190 return nameA.localeCompare(nameB);
194 .map(({ name: ianaName, offset }) => {
195 const name = getTimeZoneDisplayName(ianaName);
198 text: formatter({ name, utcOffset: `GMT${formatTimezoneOffset(offset)}` }),
206 * Given two time zone ids, determine if they are equivalent.
208 export const getIsEquivalentTimeZone = (tzid1: string, tzid2: string) => {
209 const equivalentTimeZone1 = MANUAL_TIMEZONE_EQUIVALENCE[tzid1] || tzid1;
210 const equivalentTimeZone2 = MANUAL_TIMEZONE_EQUIVALENCE[tzid2] || tzid2;
212 return equivalentTimeZone1 === equivalentTimeZone2;
216 * Given a timezone id, try to convert it into an iana timezone supported by the API (cf. description of unsupportedTimezoneLinks function)
217 * No longer supported timezones are converted into supported ones
218 * Alias timezones are converted into canonical-and-supported ones
219 * We try to convert other possible strange timezones, like those produced by Outlook calendar
220 * If no conversion is possible, return undefined
222 export const getSupportedTimezone = (tzid: string): string | undefined => {
224 const timezone = findTimeZone(tzid).name;
225 return unsupportedTimezoneLinks[timezone] || timezone;
227 // clean tzid of offsets
228 const offsetRegex = /^\((?:UTC|GMT).*\) (.*)$|^(.*) \((?:UTC|GMT).*\)/i;
229 const match = offsetRegex.exec(tzid);
230 const strippedTzid = match ? match[1] || match[2] : tzid;
231 const normalizedTzid = strippedTzid.toLowerCase().replace(/\./g, '');
232 // try manual conversions
233 const timezone = MANUAL_TIMEZONE_LINKS[normalizedTzid];
237 // It might be a globally unique timezone identifier, whose specification is not addressed by the RFC.
238 // We try to match it with one of our supported list by brute force. We should fall here rarely
239 const lowerCaseStrippedTzid = strippedTzid.toLowerCase();
240 const supportedTimezone = ALLOWED_TIMEZONES_LIST.find((supportedTzid) =>
241 lowerCaseStrippedTzid.includes(supportedTzid.toLowerCase())
243 if (supportedTimezone) {
244 return supportedTimezone;
246 // Try alias timezones
247 const aliasMap = getTimeZoneLinks();
248 // some alias names have overlap (e.g. GB-Eire and Eire). To find the longest match, we sort them by decreasing length
249 const sortedAlias = Object.keys(aliasMap).sort((a: string, b: string) => b.length - a.length);
250 for (const alias of sortedAlias) {
251 if (lowerCaseStrippedTzid.includes(alias.toLowerCase())) {
252 return aliasMap[alias];
258 const findUTCTransitionIndex = ({ unixTime, untils }: { unixTime: number; untils: number[] }) => {
259 const max = untils.length - 1;
260 for (let i = 0; i < max; i++) {
261 if (unixTime < untils[i]) {
269 * @param moveAmbiguousForward move an ambiguous date like Sunday 27 October 2019 2:00 AM CET, which corresponds to two times because of DST change, to the latest of the two
270 * @param moveInvalidForward move an invalid date like Sunday 31 March 2019 2:00 AM CET, which does not correspond to any time because of DST change, to Sunday 31 March 2019 3:00 AM CET
272 const findZoneTransitionIndex = ({
276 moveAmbiguousForward = true,
277 moveInvalidForward = true,
282 moveAmbiguousForward?: boolean;
283 moveInvalidForward?: boolean;
285 const max = untils.length - 1;
287 for (let i = 0; i < max; i++) {
288 const offsetNext = offsets[i + 1];
289 const offsetPrev = offsets[i ? i - 1 : i];
291 let offset = offsets[i];
292 if (offset < offsetNext && moveAmbiguousForward) {
294 } else if (offset > offsetPrev && moveInvalidForward) {
298 if (unixTime < untils[i] - offset * 60000) {
306 interface ConvertZonedDateTimeOptions {
307 moveAmbiguousForward?: boolean;
308 moveInvalidForward?: boolean;
311 export const convertZonedDateTimeToUTC = (dateTime: DateTime, tzid: string, options?: ConvertZonedDateTimeOptions) => {
312 const timezone = findTimeZone(tzid);
313 const unixTime = Date.UTC(
319 dateTime.seconds || 0
321 const idx = findZoneTransitionIndex({
324 untils: timezone.untils,
325 offsets: timezone.offsets,
327 const offset = timezone.offsets[idx];
328 const date = new Date(unixTime + offset * 60000);
329 return fromUTCDate(date);
332 export const convertUTCDateTimeToZone = (dateTime: DateTime, tzid: string) => {
333 const timezone = findTimeZone(tzid);
334 const unixTime = Date.UTC(
340 dateTime.seconds || 0
342 const idx = findUTCTransitionIndex({ unixTime, untils: timezone.untils });
343 const offset = timezone.offsets[idx];
344 const date = new Date(unixTime - offset * 60000);
345 return fromUTCDate(date);
348 export const fromUTCDateToLocalFakeUTCDate = (utcDate: Date, isAllDay: boolean, tzid = 'UTC') => {
349 return isAllDay ? utcDate : toUTCDate(convertUTCDateTimeToZone(fromUTCDate(utcDate), tzid));
352 export const convertTimestampToTimezone = (timestamp: number, timezone: string) => {
353 return convertUTCDateTimeToZone(fromUTCDate(new Date(timestamp * SECOND)), timezone);
357 * Remove potential underscores from time zone city
358 * E.g. "Los_Angeles" should become "Los Angeles"
360 export const getReadableCityTimezone = (timezone: string = '') => {
361 return timezone.replaceAll('_', ' ');
364 export type AbbreviatedTimezone = 'offset' | 'city';
367 * Get an abbreviated time zone, from AbbreviatedTimezone type:
368 * - "offset": "Europe/Paris" should return "GMT+1" (winter time) or 'GMT+2' (summer time)
369 * - "city": "Europe/Paris" should return "Paris"
371 export const getAbbreviatedTimezoneName = (
372 abbreviatedTimezone: AbbreviatedTimezone,
373 timezone: string | undefined,
377 if (abbreviatedTimezone === 'offset') {
378 const timezoneOffset = getTimezoneOffset(date || new Date(), timezone).offset;
379 const abbreviatedTimezoneName = formatGMTOffsetAbbreviation(timezoneOffset);
380 return abbreviatedTimezoneName;
383 if (abbreviatedTimezone === 'city') {
384 // Return the city if found e.g "Europe/Paris" should return "Paris"
385 const match = getTimeZoneDisplayName(timezone).match(/(.+?)(?:\/|$)/g);
387 // However, we can also get longer time zones. That's why we need to take the last matched element
388 // e.g. "America/North_Dakota/New_Salem" should return "New Salem"
389 return getReadableCityTimezone(match?.[match?.length - 1]) || timezone;
394 export const getTimezoneAndOffset = (timezone?: string) => {
395 const timezoneOffset = getAbbreviatedTimezoneName('offset', timezone);
396 const timezoneName = getTimeZoneDisplayName(timezone || '');
398 return `${timezoneOffset} • ${timezoneName}`;