import { rawTimeZones } from '@vvo/tzdb' import { cityPopulation } from './cityPopulation' export interface DSTSignature { observesDST: boolean signature: string } export interface TimeZone { /** e.g. 'Chicago, Reynosa, Winnipeg' */ cityNames: string /** A signature of the Daylight Saving Time rules for the time zone. */ dstSignature: string /** The population of the most populous city in the time zone. */ leadCityPopulation: number /** Whether the time zone observes Daylight Saving Time. */ observesDST: boolean /** The offset when not in DST. e.g. '-06:00' */ offset: string /** e.g. -360 */ offsetMinutes: number /** e.g. 'CST' */ timeZoneAbbreviation: string /** e.g. 'Central Standard Time' */ timeZoneAlternativeName: string /** e.g. 'America/Chicago' */ timeZoneName: string } const thisYear = new Date().getFullYear() let cachedTimeZones: TimeZone[] | null = null /** * You don't need to call this. It's only for testing. */ export function clearTimeZoneCache(): void { cachedTimeZones = null } /** * Given a date that includes a time offset, find a matching IANA time zone name. * More of our users are in places that observe DST, so we bias towards choosing those: Chicago rather than Mexico City; London rather than Lagos. * Where there are still multiple matches, we choose the most populous city. * @param dateTime - e.g. '2025-01-01T00:00:00.000-06:00' */ export function getBestGuessTimeZoneCity(dateTimeISO: string): string | undefined { if (!dateTimeISO) { return undefined } const date = new Date(dateTimeISO) if (isNaN(date.getTime())) { return undefined } let offsetMinutes: number const z = /Z$/i.test(dateTimeISO) if (z) { offsetMinutes = 0 } else { const offsetMatch = dateTimeISO.match(/([+-])(\d{2}):?(\d{2})$/) if (!offsetMatch) { offsetMinutes = 0 } else { offsetMinutes = parseInt(offsetMatch[2], 10) * 60 + parseInt(offsetMatch[3], 10) if (offsetMatch[1] === '-') { offsetMinutes = -offsetMinutes } } } // Find all time zones that match the given date. const candidates: TimeZone[] = [] for (const timeZone of getTimeZones()) { // For each time zone, format the given datetime to its time zone and see if the offset matches. // This takes DST into account, as defined for that instant at that location. const dtf = new Intl.DateTimeFormat('en', { timeZone: timeZone.timeZoneName, timeZoneName: 'shortOffset' as any, // TypeScript doesn't know that 'shortOffset' is safe: https://caniuse.com/?search=shortOffset hour: '2-digit', // forces a time - needed for timeZoneName to appear in parts }) const parts = dtf.formatToParts(new Date(dateTimeISO)) const tzName = parts.find(p => p.type === 'timeZoneName')?.value if (!tzName) { continue } let tzOffsetMinutes = 0 if (tzName !== 'UTC' && tzName !== 'GMT') { const m = tzName.match(/^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/) if (!m) { continue } const h = parseInt(m[2], 10) const min = m[3] ? parseInt(m[3], 10) : 0 tzOffsetMinutes = h * 60 + min if (m[1] === '-') { tzOffsetMinutes = -tzOffsetMinutes } } if (tzOffsetMinutes === offsetMinutes) { candidates.push(timeZone) } } if (candidates.length === 0) { return undefined } // More of our users are in places that observe DST, so we should prefer those. Choose Chicago over Mexico City; London over Lagos. // Otherwise, choose the most populous city. candidates.sort((a, b) => { if (a.observesDST && !b.observesDST) { return -1 } if (!a.observesDST && b.observesDST) { return 1 } return b.leadCityPopulation - a.leadCityPopulation }) return candidates[0].timeZoneName } /** * Finds the time zone that matches the time zone and DST signature of the given time zone name. * @param timeZoneName - e.g. 'America/Chicago' */ export function getTimeZone(timeZoneName: string | null | undefined): TimeZone | undefined { if (!timeZoneName || timeZoneName.trim() === '') { return undefined } const timeZones = getTimeZones() const exactMatch = timeZones.find(tz => tz.timeZoneName === timeZoneName) if (exactMatch) { return exactMatch } // We've been given a time zone that isn't the headline for a group. Find the group that matches its DST signature. // e.g. given 'Europe/Isle_of_Man', return the time zone for 'Europe/London'. const samples = getDateSamples() const dstSignature = getDSTSignature(timeZoneName, samples) return timeZones.find(tz => tz.dstSignature === dstSignature.signature) } /** * Get a list of time zones with cities. */ export function getTimeZones(): TimeZone[] { // The browser will give us ~418 time zones with cities. There are a lot of duplicates! // The @vvo/tzdb package groups these into ~315 sets, with nicer city names. // There are still some duplicates. For example, it lists England and Ireland separately because they // handled Daylight Saving Time differently in 1971. We don't care about that and would prefer to group them. // The browser allows us to query the DST status of a given moment at a given location. For each time zone in // the @vvo/tzdb package, we sample a time in January and July, and check if DST is in effect. We do this // for a range of years, centred around the current year. If DST behaves the same way at all sampled times with // the same offset, we group the time zones together. // As of 2025, this reduces the number of time zones to a more manageable 67. This exact number may change in // future as browsers are updated with changes to time zones and DST rules. // For example, Cairo currently has its own entry, but we expect it to be merged with Eastern Europe in 2028, // when it will have been five years since they adopted the same DST rules. if (cachedTimeZones) { // Compiling the list takes about 20ms to run, so worth caching. return cachedTimeZones } interface TimeZoneCityGroup { cities: { city: string, country: string population: number timeZoneAbbreviation: string // 'CST' timeZoneAlternativeName: string // 'Central Standard Time' timeZoneName: string // 'America/Chicago' }[] dstSignature: string observesDST: boolean offset: string offsetMinutes: number } // Make a list of sample dates const samples = getDateSamples() // Get a DST signature for each timezone const timezonesWithDSTSignatures = rawTimeZones.map(timezone => { return { ...timezone, dstSignature: getDSTSignature(timezone.name, samples), } }) // Group timezones by DST signature const groups = new Map() for (const timezone of timezonesWithDSTSignatures) { const cityName = timezone.mainCities[0] ?? timezone.countryName const group = groups.get(timezone.dstSignature.signature) const population = cityPopulation.get(`${cityName}, ${timezone.countryName}`) ?? 0 if (!group) { const offsetHour = Math.floor(Math.abs(timezone.rawOffsetInMinutes) / 60) const offsetMinute = Math.abs(timezone.rawOffsetInMinutes) % 60 const offset = `${timezone.rawOffsetInMinutes < 0 ? '-' : '+'}${offsetHour.toString().padStart(2, '0')}:${offsetMinute.toString().padStart(2, '0')}` groups.set(timezone.dstSignature.signature, { cities: [{ city: cityName, country: timezone.countryName, population, timeZoneAbbreviation: timezone.abbreviation, timeZoneAlternativeName: timezone.alternativeName, timeZoneName: timezone.name, }], dstSignature: timezone.dstSignature.signature, observesDST: timezone.dstSignature.observesDST, offset, offsetMinutes: timezone.rawOffsetInMinutes, }) } else { group.cities.push({ city: cityName, country: timezone.countryName, population, timeZoneAbbreviation: timezone.abbreviation, timeZoneAlternativeName: timezone.alternativeName, timeZoneName: timezone.name, }) } } // Sort the cities so we use the time zone abbreviation and name of the most populous city. This ensures // the UK uses 'Europe/London' rather than 'Europe/Isle_of_Man', and Phoenix gets 'Mountain Time' instead // of 'Mexican Pacific Time'. for (const group of groups.values()) { group.cities.sort((a, b) => b.population - a.population) } const timeZones: TimeZone[] = Array.from(groups.values()) .sort((a, b) => a.offsetMinutes - b.offsetMinutes) .map(group => ({ cityNames: getCityList(group.cities), dstSignature: group.dstSignature, leadCityPopulation: group.cities[0].population, observesDST: group.observesDST, offset: group.offset, offsetMinutes: group.offsetMinutes, timeZoneAbbreviation: group.cities[0].timeZoneAbbreviation, timeZoneAlternativeName: group.cities[0].timeZoneAlternativeName, timeZoneName: group.cities[0].timeZoneName, })) cachedTimeZones = timeZones return timeZones } // Limit the number of cities to show. If there are too many, show the most populous ones. function getCityList(cities: { city: string, country: string }[]) { const maxLength = 40 let result = cities[0].city for (let i = 1; i < cities.length && result.length + cities[i].city.length + 2 <= maxLength; i++) { result += `, ${cities[i].city}` } return result } // To detect places with the same DST rules, make a signature for each timezone: a list of offset minutes at each sample time. function getDSTSignature(timezoneName: string, sampleTimes: Date[]): DSTSignature { try { let observesDST = false const formatter = new Intl.DateTimeFormat('en', { timeZone: timezoneName, timeZoneName: 'shortOffset' as any, // TypeScript doesn't know that 'shortOffset' is safe: https://caniuse.com/?search=shortOffset hour: '2-digit', minute: '2-digit', }) const sig: string[] = [] let prevOffset: string | undefined for (const sample of sampleTimes) { const parts = formatter.formatToParts(sample) const offset = parts.find(p => p.type === 'timeZoneName')?.value ?? '' sig.push(offset) // Only say "it observes DST" if it does so now or in the future. (Mexico stopped in 2022, for example.) if (prevOffset && prevOffset !== offset && sample.getFullYear() >= thisYear) { observesDST = true } prevOffset = offset } return { observesDST, signature: sig.join(','), } } catch (error) { return { observesDST: false, signature: '', } } } // These are the dates we sample to detect places with the same DST rules. This is imperfect: some places might switch DST on // slightly different dates. A more accurate signature would do a binary chop to identify the exact date of each switch. That // would be too slow, so we'll go with this for now. As of 2025, the only misidentified time zone is Asia/Hebron, which // misaligns with Europe/Athens for two weeks in Spring and one day in Autumn. I'm happy to call this good enough. function getDateSamples(): Date[] { const now = new Date() const offsets = [-5, -1, 0, 1, 5] const years = offsets.map(o => now.getFullYear() + o) const monthDay = [ { m: 0, d: 15 }, // Jan 15 { m: 6, d: 15 }, // Jul 15 ] const samples: Date[] = [] for (const y of years) { for (const { m, d } of monthDay) { samples.push(new Date(Date.UTC(y, m, d, 0, 0, 0))) } } return samples }