// The JVM time behaviour the platform products show: java.time's ZoneId.of // (JDK 17.0.12, tzdata 2024a) with its exception messages, and Joda-Time's // pattern formatting and DateTime.toString() (joda-time 2.14.0), each checked // against the real classes. import timeZones from './platform/time-zones.json' with { type: 'json' }; // ZoneId.getAvailableZoneIds(), sorted as String does (VALID_TIME_ZONE). export const JVM_ZONE_IDS: readonly string[] = timeZones.zones; const REGIONS = new Set(JVM_ZONE_IDS); // A JSON value as Kotlin's toString() prints what Jackson read it as. export function kotlinString(value: unknown): string { if (Array.isArray(value)) return `[${value.map(kotlinString).join(', ')}]`; if (value !== null && typeof value === 'object') { return `{${Object.entries(value) .map(([key, item]) => `${key}=${kotlinString(item)}`) .join(', ')}}`; } return String(value); } class DateTimeError extends Error {} const OFFSET_HOURS = 18; const MINUTES = 59; // ZoneOffset.of's id lengths (±hh, ±hhmm, ±hh:mm, ±hhmmss, ±hh:mm:ss): where // the minutes and the seconds start, the hours always at 1, and whether a // colon comes before each. const OFFSET_LAYOUTS: Record = { 3: {}, 5: { minutes: 3 }, 6: { minutes: 4, colons: true }, 7: { minutes: 3, seconds: 5 }, 9: { minutes: 4, seconds: 7, colons: true }, }; // ZoneOffset.of: Z (its id cache), ±h, or one of the layouts. function zoneOffset(offsetId: string): void { if (offsetId === 'Z') return; const id = offsetId.length === 2 ? `${offsetId[0]}0${offsetId[1]}` : offsetId; const invalid = (why: string) => new DateTimeError(`Invalid ID for ZoneOffset, ${why}: ${id}`); const number = (pos: number | undefined, precededByColon: boolean) => { if (pos === undefined) return 0; if (precededByColon && id[pos - 1] !== ':') throw invalid('colon not found when expected'); const pair = id.slice(pos, pos + 2); if (!/^\d\d$/.test(pair)) throw invalid('non numeric characters found'); return Number(pair); }; const layout = OFFSET_LAYOUTS[id.length]; if (!layout) throw invalid('invalid format'); const hours = number(1, false); const minutes = number(layout.minutes, layout.colons === true); const seconds = number(layout.seconds, layout.colons === true); if (id[0] !== '+' && id[0] !== '-') throw invalid('plus/minus not found when expected'); // ZoneOffset.validate, on the signed values. const sign = id[0] === '-' ? -1 : 1; const [h, m, s] = [hours * sign, minutes * sign, seconds * sign]; if (h < -OFFSET_HOURS || h > OFFSET_HOURS) { throw new DateTimeError( `Zone offset hours not in valid range: value ${h} is not in the range -18 to 18`, ); } if (Math.abs(m) > MINUTES) { throw new DateTimeError( `Zone offset minutes not in valid range: value ${m} is not in the range -59 to 59`, ); } if (Math.abs(s) > MINUTES) { throw new DateTimeError( `Zone offset seconds not in valid range: value ${s} is not in the range -59 to 59`, ); } if (Math.abs(h) === OFFSET_HOURS && (m !== 0 || s !== 0)) { throw new DateTimeError('Zone offset not in valid range: -18:00 to +18:00'); } } // ZoneRegion.ofId: a well-formed name the tz database has. function zoneRegion(id: string): void { if (!/^[A-Za-z][A-Za-z0-9~/._+-]+$/.test(id)) { throw new DateTimeError(`Invalid ID for region-based ZoneId, invalid format: ${id}`); } if (!REGIONS.has(id)) throw new DateTimeError(`Unknown time-zone ID: ${id}`); } // In the order ZoneId.of tries them. const OFFSET_PREFIXES = ['UTC', 'GMT', 'UT']; // ZoneId.of(id)'s message, or undefined when it takes it. export function zoneIdError(id: string): string | undefined { try { const prefix = OFFSET_PREFIXES.find((candidate) => id.startsWith(candidate)); if (id.length <= 1 || id.startsWith('+') || id.startsWith('-')) zoneOffset(id); else if (prefix) offsetWithPrefix(id, prefix.length); else zoneRegion(id); return undefined; } catch (error) { if (error instanceof DateTimeError) return error.message; throw error; } } // ZoneId.ofWithPrefix: UTC, GMT or UT, alone or with an offset. function offsetWithPrefix(id: string, prefixLength: number): void { if (id.length === prefixLength) return; if (id[prefixLength] !== '+' && id[prefixLength] !== '-') { zoneRegion(id); return; } try { zoneOffset(id.slice(prefixLength)); } catch { throw new DateTimeError(`Invalid ID for offset-based ZoneId: ${id}`); } } // --- Joda-Time --------------------------------------------------------------------- interface ZonedTime { year: number; month: number; day: number; hour: number; minute: number; second: number; millis: number; // ISO: Monday 1 ... Sunday 7. weekday: number; offsetMinutes: number; } const MS_PER_SECOND = 1000; const MS_PER_MINUTE = 60_000; const MS_PER_DAY = 86_400_000; const MINUTES_PER_HOUR = 60; const HOURS_PER_DAY = 24; const HALF_DAY = 12; const DAYS_PER_WEEK = 7; const THURSDAY = 4; const YEAR_DIGITS = 4; const MILLIS_DIGITS = 3; const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const SHORT = 3; const FULL = 4; // The wall-clock fields of an instant in a zone. function zoned(epochMillis: number, zone: string): ZonedTime { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', }) .formatToParts(new Date(epochMillis)) .map((part) => [part.type, Number(part.value)]), ); const millis = ((epochMillis % MS_PER_SECOND) + MS_PER_SECOND) % MS_PER_SECOND; const wall = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, ); const weekday = new Date(wall).getUTCDay(); return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second, millis, weekday: weekday === 0 ? DAYS_PER_WEEK : weekday, offsetMinutes: Math.round((wall + millis - epochMillis) / MS_PER_MINUTE), }; } const pad = (value: number, width: number) => `${value < 0 ? '-' : ''}${String(Math.abs(value)).padStart(width, '0')}`; function offsetText(minutes: number, separator: boolean, zeroText?: string): string { if (minutes === 0 && zeroText !== undefined) return zeroText; const sign = minutes < 0 ? '-' : '+'; const absolute = Math.abs(minutes); const hours = pad(Math.floor(absolute / MINUTES_PER_HOUR), 2); const rest = pad(absolute % MINUTES_PER_HOUR, 2); return `${sign}${hours}${separator ? ':' : ''}${rest}`; } const dayOfYear = (time: ZonedTime) => (Date.UTC(time.year, time.month - 1, time.day) - Date.UTC(time.year, 0, 1)) / MS_PER_DAY + 1; // ISO week-based year and week (Joda's x and w). function isoWeek(time: ZonedTime): { weekyear: number; week: number } { const date = new Date(Date.UTC(time.year, time.month - 1, time.day)); date.setUTCDate(date.getUTCDate() + THURSDAY - time.weekday); const weekyear = date.getUTCFullYear(); const week = Math.floor((date.getTime() - Date.UTC(weekyear, 0, 1)) / MS_PER_DAY / DAYS_PER_WEEK) + 1; return { weekyear, week }; } const zoneName = (epochMillis: number, zone: string, long: boolean) => new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeZoneName: long ? 'long' : 'short' }) .formatToParts(new Date(epochMillis)) .find((part) => part.type === 'timeZoneName')?.value ?? zone; // One pattern letter repeated `count` times (DateTimeFormat.parsePatternTo). function field(letter: string, count: number, time: ZonedTime, epochMillis: number, zone: string) { const twoDigits = (value: number) => pad(((value % 100) + 100) % 100, 2); switch (letter) { case 'G': return time.year > 0 ? 'AD' : 'BC'; case 'C': return pad(Math.floor(time.year / 100), count); case 'Y': case 'y': return count === 2 ? twoDigits(time.year) : pad(time.year, count); case 'x': return count === 2 ? twoDigits(isoWeek(time).weekyear) : pad(isoWeek(time).weekyear, count); case 'w': return pad(isoWeek(time).week, count); case 'e': return pad(time.weekday, count); case 'E': return count >= FULL ? WEEKDAYS[time.weekday - 1] : WEEKDAYS[time.weekday - 1].slice(0, SHORT); case 'D': return pad(dayOfYear(time), count); case 'M': if (count >= FULL) return MONTH_NAMES[time.month - 1]; if (count === SHORT) return MONTH_NAMES[time.month - 1].slice(0, SHORT); return pad(time.month, count); case 'd': return pad(time.day, count); case 'a': return time.hour < HALF_DAY ? 'AM' : 'PM'; case 'h': return pad(time.hour % HALF_DAY === 0 ? HALF_DAY : time.hour % HALF_DAY, count); case 'H': return pad(time.hour, count); case 'k': return pad(time.hour === 0 ? HOURS_PER_DAY : time.hour, count); case 'K': return pad(time.hour % HALF_DAY, count); case 'm': return pad(time.minute, count); case 's': return pad(time.second, count); case 'S': return String(time.millis).padStart(MILLIS_DIGITS, '0').padEnd(count, '0').slice(0, count); case 'z': return zoneName(epochMillis, zone, count >= FULL); case 'Z': if (count === 1) return offsetText(time.offsetMinutes, false); if (count === 2) return offsetText(time.offsetMinutes, true); return zone; default: return undefined; } } // DateTimeFormat.forPattern(pattern).print(instant in zone), or undefined // for a pattern Joda refuses (an empty one, an "Illegal pattern component"). // A zone name (z) comes from the host's Intl data, not Java's, so it can // differ ("GMT-5" for "EST"). export function jodaFormat(pattern: string, epochMillis: number, zone: string): string | undefined { if (pattern === '') return undefined; const time = zoned(epochMillis, zone); let out = ''; let i = 0; while (i < pattern.length) { const c = pattern[i]; if (c === "'") { const quoted = quotedText(pattern, i); out += quoted.text; i = quoted.next; } else if (/[A-Za-z]/.test(c)) { let j = i; while (pattern[j] === c) j += 1; const printed = field(c, j - i, time, epochMillis, zone); if (printed === undefined) return undefined; out += printed; i = j; } else { out += c; i += 1; } } return out; } // The text quoted from `start` on, '' a quote inside it, and where the // pattern goes on; '' alone is a quote. function quotedText(pattern: string, start: number): { text: string; next: number } { let j = start + 1; let text = ''; while (j < pattern.length && !(pattern[j] === "'" && pattern[j + 1] !== "'")) { text += pattern[j]; j += pattern[j] === "'" ? 2 : 1; } return { text: j === start + 1 && pattern[j] === "'" ? "'" : text, next: j + 1 }; } // Joda's DateTime.toString(): ISO-8601 with millis and the offset, Z for UTC. export function jodaIso(epochMillis: number, zone: string): string { const time = zoned(epochMillis, zone); return ( `${pad(time.year, YEAR_DIGITS)}-${pad(time.month, 2)}-${pad(time.day, 2)}T` + `${pad(time.hour, 2)}:${pad(time.minute, 2)}:${pad(time.second, 2)}` + `.${pad(time.millis, MILLIS_DIGITS)}` + offsetText(time.offsetMinutes, true, 'Z') ); } // Whether Intl knows the zone. export function knownZone(zone: string): boolean { try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); return true; } catch { return false; } } // The JVM's default zone, which the mock takes to be the host's; GMT, as the // JVM falls back to, when the host's can't be told (TZ="", Etc/Unknown). export function hostZone(): string { const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; return zone && knownZone(zone) ? zone : 'UTC'; }