Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / date / timezone.ts
blobb343092c17d46ee846b9f9aa7cb1360b4986b0ba
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';
9 import {
10     FALLBACK_ALLOWED_SUPPORTED_TIMEZONES_LIST,
11     MANUAL_TIMEZONE_EQUIVALENCE,
12     MANUAL_TIMEZONE_LINKS,
13     manualFindTimeZone,
14     unsupportedTimezoneLinks,
15 } from './timezoneDatabase';
17 export const toLocalDate = ({
18     year = 0,
19     month = 1,
20     day = 0,
21     hours = 0,
22     minutes = 0,
23     seconds = 0,
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) => {
33     return {
34         year: date.getFullYear(),
35         month: date.getMonth() + 1,
36         day: date.getDate(),
37         hours: date.getHours(),
38         minutes: date.getMinutes(),
39         seconds: date.getSeconds(),
40     };
43 export const fromUTCDate = (date: Date) => {
44     return {
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(),
51     };
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;
58 /**
59  * Load from API list of time zones that the BE allows
60  * */
61 export const loadAllowedTimeZones = async (api: Api) => {
62     if (timezonesLoaded) {
63         return;
64     }
65     timezonesLoaded = true;
66     const { Timezones } = await api<{ Code: number; Timezones: string[] }>(getAllowedTimeZones());
68     /*
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.
71      *
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
75      */
76     const supportedTimeZones = Timezones.map((tzid) => {
77         try {
78             findTimeZone(tzid);
79             return tzid;
80         } catch (e: any) {
81             console.error(`${tzid} not supported`);
82         }
83     }).filter(isTruthy);
85     ALLOWED_TIMEZONES_LIST = supportedTimeZones;
88 export const guessTimezone = (timezones: string[]) => {
89     try {
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');
96         }
97         return supportedTzid;
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;
104         });
105     }
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
111  */
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
116     if (!timezone) {
117         return ALLOWED_TIMEZONES_LIST[0];
118     }
119     return timezone;
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
126  */
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;
137     if (minutes > 0) {
138         const paddedMinutes = minutes < 10 ? `0${minutes}` : `${minutes}`;
139         return `${sign}${hours}:${paddedMinutes}`;
140     }
142     return `${sign}${hours}`;
145 export const formatGMTOffsetAbbreviation = (offset: number) => {
146     return `GMT${formatTimezoneOffset(offset)}`;
149 interface FormatterProps {
150     utcOffset: string;
151     name: string;
154 type GetTimeZoneOptions = (
155     date?: Date,
156     options?: { formatter?: (a1: FormatterProps) => string }
157 ) => {
158     text: string;
159     value: string;
160     key: string;
161 }[];
163 const getTimeZoneDisplayName = (ianaName: string) => {
164     if (ianaName === 'Europe/Kiev') {
165         // Update Kyiv name before fully transitioning to 2022g
166         return 'Europe/Kyiv';
167     }
168     return ianaName;
172  * @return {Array<Object>}      [{ text: 'Africa/Nairobi: UTC +03:00', value: 'Africa/Nairobi'}, ...]
173  */
174 export const getTimeZoneOptions: GetTimeZoneOptions = (
175     date = new Date(),
176     { formatter = ({ utcOffset, name }: FormatterProps) => `${utcOffset} • ${name}` } = {}
177 ) => {
178     return ALLOWED_TIMEZONES_LIST.map((name) => {
179         const { abbreviation, offset } = getUTCOffset(date, findTimeZone(name));
181         return {
182             name,
183             offset,
184             abbreviation,
185         };
186     })
187         .sort(({ offset: offsetA, name: nameA }, { offset: offsetB, name: nameB }) => {
188             const diff = offsetA - offsetB;
189             if (diff === 0) {
190                 return nameA.localeCompare(nameB);
191             }
192             return diff;
193         })
194         .map(({ name: ianaName, offset }) => {
195             const name = getTimeZoneDisplayName(ianaName);
197             return {
198                 text: formatter({ name, utcOffset: `GMT${formatTimezoneOffset(offset)}` }),
199                 value: ianaName,
200                 key: ianaName,
201             };
202         });
206  * Given two time zone ids, determine if they are equivalent.
207  * */
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
221  */
222 export const getSupportedTimezone = (tzid: string): string | undefined => {
223     try {
224         const timezone = findTimeZone(tzid).name;
225         return unsupportedTimezoneLinks[timezone] || timezone;
226     } catch (e: any) {
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];
234         if (timezone) {
235             return timezone;
236         }
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())
242         );
243         if (supportedTimezone) {
244             return supportedTimezone;
245         }
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];
253             }
254         }
255     }
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]) {
262             return i;
263         }
264     }
265     return max;
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
271  */
272 const findZoneTransitionIndex = ({
273     unixTime,
274     untils,
275     offsets,
276     moveAmbiguousForward = true,
277     moveInvalidForward = true,
278 }: {
279     unixTime: number;
280     untils: number[];
281     offsets: number[];
282     moveAmbiguousForward?: boolean;
283     moveInvalidForward?: boolean;
284 }) => {
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) {
293             offset = offsetNext;
294         } else if (offset > offsetPrev && moveInvalidForward) {
295             offset = offsetPrev;
296         }
298         if (unixTime < untils[i] - offset * 60000) {
299             return i;
300         }
301     }
303     return max;
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(
314         dateTime.year,
315         dateTime.month - 1,
316         dateTime.day,
317         dateTime.hours,
318         dateTime.minutes,
319         dateTime.seconds || 0
320     );
321     const idx = findZoneTransitionIndex({
322         ...options,
323         unixTime,
324         untils: timezone.untils,
325         offsets: timezone.offsets,
326     });
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(
335         dateTime.year,
336         dateTime.month - 1,
337         dateTime.day,
338         dateTime.hours,
339         dateTime.minutes,
340         dateTime.seconds || 0
341     );
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"
359  */
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"
370  */
371 export const getAbbreviatedTimezoneName = (
372     abbreviatedTimezone: AbbreviatedTimezone,
373     timezone: string | undefined,
374     date?: Date
375 ) => {
376     if (timezone) {
377         if (abbreviatedTimezone === 'offset') {
378             const timezoneOffset = getTimezoneOffset(date || new Date(), timezone).offset;
379             const abbreviatedTimezoneName = formatGMTOffsetAbbreviation(timezoneOffset);
380             return abbreviatedTimezoneName;
381         }
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;
390         }
391     }
394 export const getTimezoneAndOffset = (timezone?: string) => {
395     const timezoneOffset = getAbbreviatedTimezoneName('offset', timezone);
396     const timezoneName = getTimeZoneDisplayName(timezone || '');
398     return `${timezoneOffset} • ${timezoneName}`;