type GoParseToken = (typeof GO_PARSE_TOKENS)[number]
const GO_PARSE_TOKENS = [ 'Z07:00', '-07:00', '-0700', '2006', 'PM', 'pm', '15', '06', '01', '02', '03', '04', '05', '1', '2', '3', ] as const
type ParsedGoTime = { localMillis: number hasOffset: boolean offsetMinutes: number }
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const isTokenAt = (layout: string, index: number): GoParseToken | undefined => GO_PARSE_TOKENS.find((token) => layout.startsWith(token, index))
const tokenRegex = (token: GoParseToken): string => { switch (token) { case '2006': return '(\\d{4})' case '06': return '(\\d{2})' case '01': return '(0[1-9]|1[0-2])' case '1': return '([1-9]|1[0-2])' case '02': return '(0[1-9]|[12]\\d|3[01])' case '2': return '([1-9]|[12]\\d|3[01])' case '15': return '([01]\\d|2[0-3])' case '03': return '(0[1-9]|1[0-2])' case '3': return '([1-9]|1[0-2])' case '04': return '([0-5]\\d)' case '05': return '([0-5]\\d)' case 'PM': return '(AM|PM)' case 'pm': return '(am|pm)' case '-0700': return '([+-]\\d{4})' case '-07:00': return '([+-]\\d{2}:\\d{2})' case 'Z07:00': return '(Z|[+-]\\d{2}:\\d{2})' default: return escapeRegex(token) } }
const parseOffsetMinutes = (offset: string): number => { if (offset === 'Z') { return 0 }
const compact = offset.match(/^([+-])(\d{2})(\d{2})$/) if (compact?.[1] && compact[2] && compact[3]) { const sign = compact[1] === '-' ? -1 : 1 const hours = Number.parseInt(compact[2], 10) const minutes = Number.parseInt(compact[3], 10) if (hours > 23 || minutes > 59) { throw new TypeError('ParseInLocation(): invalid offset') } return sign * (hours * 60 + minutes) }
const colon = offset.match(/^([+-])(\d{2}):(\d{2})$/) if (colon?.[1] && colon[2] && colon[3]) { const sign = colon[1] === '-' ? -1 : 1 const hours = Number.parseInt(colon[2], 10) const minutes = Number.parseInt(colon[3], 10) if (hours > 23 || minutes > 59) { throw new TypeError('ParseInLocation(): invalid offset') } return sign * (hours * 60 + minutes) }
throw new TypeError('ParseInLocation(): invalid offset') }
const resolveTwoDigitYear = (year: number): number => (year >= 69 ? 1900 + year : 2000 + year)
const createUtcDate = ( year: number, month: number, day: number, hour: number, minute: number, second: number, ): Date => { const date = new Date(0) date.setUTCFullYear(year, month - 1, day) date.setUTCHours(hour, minute, second, 0) return date }
const parseGoTime = (layout: string, value: string): ParsedGoTime => { const resolvedLayout = String(layout) const resolvedValue = String(value)
const tokenOrder: GoParseToken[] = [] let regexSource = '^'
for (let i = 0; i < resolvedLayout.length; ) { const token = isTokenAt(resolvedLayout, i) if (token) { regexSource += tokenRegex(token) tokenOrder.push(token) i += token.length continue }
const char = resolvedLayout[i] if (char === undefined) { break }
regexSource += escapeRegex(char) i += 1 }
regexSource += '$' const match = new RegExp(regexSource).exec(resolvedValue) if (!match) { throw new TypeError('ParseInLocation(): input does not match layout') }
let year = 1970 let month = 1 let day = 1 let hour24 = 0 let minute = 0 let second = 0 let hour12: number | undefined let ampm: 'AM' | 'PM' | undefined let offsetMinutes = 0 let hasOffset = false
let matchIndex = 1 for (const token of tokenOrder) { const raw = match[matchIndex] matchIndex += 1 if (!raw) { continue }
switch (token) { case '2006': year = Number.parseInt(raw, 10) break case '06': year = resolveTwoDigitYear(Number.parseInt(raw, 10)) break case '01': case '1': month = Number.parseInt(raw, 10) break case '02': case '2': day = Number.parseInt(raw, 10) break case '15': hour24 = Number.parseInt(raw, 10) break case '03': case '3': hour12 = Number.parseInt(raw, 10) break case '04': minute = Number.parseInt(raw, 10) break case '05': second = Number.parseInt(raw, 10) break case 'PM': case 'pm': ampm = raw.toUpperCase() === 'PM' ? 'PM' : 'AM' break case '-0700': case '-07:00': case 'Z07:00': offsetMinutes = parseOffsetMinutes(raw) hasOffset = true break default: break } }
if (hour12 !== undefined) { if (ampm === 'PM' && hour12 < 12) { hour24 = hour12 + 12 } else if (ampm === 'AM' && hour12 === 12) { hour24 = 0 } else { hour24 = hour12 } }
if ( month < 1 || month > 12 || day < 1 || day > 31 || hour24 < 0 || hour24 > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 ) { throw new TypeError('ParseInLocation(): invalid datetime components') }
const localCheck = createUtcDate(year, month, day, hour24, minute, second) if ( localCheck.getUTCFullYear() !== year || localCheck.getUTCMonth() + 1 !== month || localCheck.getUTCDate() !== day || localCheck.getUTCHours() !== hour24 || localCheck.getUTCMinutes() !== minute || localCheck.getUTCSeconds() !== second ) { throw new TypeError('ParseInLocation(): invalid datetime components') }
const localMillis = localCheck.getTime()
return { localMillis, hasOffset, offsetMinutes, } }
const zonedFormatterCache = new Map<string, Intl.DateTimeFormat>()
const getZonedFormatter = (timeZone: string): Intl.DateTimeFormat => { let formatter = zonedFormatterCache.get(timeZone) if (!formatter) { formatter = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }) zonedFormatterCache.set(timeZone, formatter) } return formatter }
const assertValidLocation = (location: string): void => { try { getZonedFormatter(location).format(new Date(0)) } catch { throw new TypeError('ParseInLocation(): invalid location') } }
const getOffsetMinutesAt = (timeZone: string, utcMillis: number): number => { const formatter = getZonedFormatter(timeZone) const parts = formatter.formatToParts(new Date(utcMillis))
let year = 0 let month = 1 let day = 1 let hour = 0 let minute = 0 let second = 0
for (const part of parts) { switch (part.type) { case 'year': year = Number.parseInt(part.value, 10) break case 'month': month = Number.parseInt(part.value, 10) break case 'day': day = Number.parseInt(part.value, 10) break case 'hour': hour = Number.parseInt(part.value, 10) break case 'minute': minute = Number.parseInt(part.value, 10) break case 'second': second = Number.parseInt(part.value, 10) break default: break } }
const zonedAsUtcMillis = createUtcDate(year, month, day, hour, minute, second).getTime() return Math.round((zonedAsUtcMillis - utcMillis) / 60_000) }
const resolveWallTimeInLocation = (timeZone: string, localMillis: number): number => { let offsetMinutes = getOffsetMinutesAt(timeZone, localMillis)
if (offsetMinutes !== 0) { const utcMillis = localMillis - offsetMinutes * 60_000 const correctedOffsetMinutes = getOffsetMinutesAt(timeZone, utcMillis) if (correctedOffsetMinutes !== offsetMinutes) { offsetMinutes = correctedOffsetMinutes } }
return localMillis - offsetMinutes * 60_000 }
export function ParseInLocation(layout: string, value: string, location: string): Date {
const resolvedLocation = String(location) assertValidLocation(resolvedLocation)
const parsed = parseGoTime(layout, value) const utcMillis = parsed.hasOffset ? parsed.localMillis - parsed.offsetMinutes * 60_000 : resolveWallTimeInLocation(resolvedLocation, parsed.localMillis)
return new Date(utcMillis) }
|