import { InvalidDateTimeError } from './invalid-date-time.error'; const DEFAULT_TZ_FALLBACK = 'GMT+7'; /** ECMAScript Date time value limits (±100_000_000 days from epoch). */ const MAX_UNIX_MS = 8_640_000_000_000_000; /** ISO datetime with optional fractional seconds and optional Z / offset. */ const ISO_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i; /** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */ const TZ_OFFSET = /^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i; function pad2(n: number): string { return n.toString().padStart(2, '0'); } function pad3(n: number): string { return n.toString().padStart(3, '0'); } function parseFractionalMs(raw: string | undefined): number { if (raw === undefined) { return 0; } // Truncate beyond millisecond precision (do not round — avoids second carry). return Number.parseInt(raw.slice(0, 3).padEnd(3, '0'), 10); } function assertValidOffset(hours: number, minutes: number, sign: number): void { if (minutes > 59) { throw new InvalidDateTimeError(); } // Real fixed-offset range is -12:00 to +14:00. const total = sign * (hours * 60 + minutes); if (total < -12 * 60 || total > 14 * 60) { throw new InvalidDateTimeError(); } } /** * Parses a fixed-offset timezone string into minutes east of UTC. * Accepts: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700, Z, UTC, GMT (and negatives). */ export function parseTimezoneOffsetMinutes(raw: string): number { const trimmed = raw.trim(); const match = TZ_OFFSET.exec(trimmed); if (!match) { throw new InvalidDateTimeError(); } // Z / UTC / GMT alone leave the sign capture unset. if (match[1] === undefined) { return 0; } const sign = match[1] === '-' ? -1 : 1; const hours = Number.parseInt(match[2], 10); const minutes = match[3] !== undefined ? Number.parseInt(match[3], 10) : 0; assertValidOffset(hours, minutes, sign); return sign * (hours * 60 + minutes); } export function resolveDefaultTimezone(): string { const fromEnv = process.env.DEFAULT_TIMEZONE; if (fromEnv === undefined || fromEnv.trim() === '') { return DEFAULT_TZ_FALLBACK; } return fromEnv.trim(); } function resolveDefaultOffsetMinutes(): number { return parseTimezoneOffsetMinutes(resolveDefaultTimezone()); } function formatOffset(offsetMinutes: number): string { const sign = offsetMinutes < 0 ? '-' : '+'; const abs = Math.abs(offsetMinutes); const hours = Math.floor(abs / 60); const minutes = abs % 60; return `${sign}${pad2(hours)}:${pad2(minutes)}`; } function utcMsFromParts( year: number, month: number, day: number, hour: number, minute: number, second: number, ms: number, offsetMinutes: number, ): number { const asUtc = Date.UTC(year, month - 1, day, hour, minute, second, ms); const d = new Date(asUtc); // Reject overflow (e.g. Feb 30) via round-trip on calendar components. if ( d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day || d.getUTCHours() !== hour || d.getUTCMinutes() !== minute || d.getUTCSeconds() !== second || d.getUTCMilliseconds() !== ms ) { throw new InvalidDateTimeError(); } return asUtc - offsetMinutes * 60_000; } function formatInOffset(utcMs: number, offsetMinutes: number): string { const shifted = new Date(utcMs + offsetMinutes * 60_000); if (Number.isNaN(shifted.getTime())) { throw new InvalidDateTimeError(); } const y = shifted.getUTCFullYear(); const m = pad2(shifted.getUTCMonth() + 1); const d = pad2(shifted.getUTCDate()); const h = pad2(shifted.getUTCHours()); const min = pad2(shifted.getUTCMinutes()); const s = pad2(shifted.getUTCSeconds()); const ms = pad3(shifted.getUTCMilliseconds()); return `${y}-${m}-${d}T${h}:${min}:${s}.${ms}${formatOffset(offsetMinutes)}`; } export class DateTime { private static readonly createToken = Symbol('DateTime.create'); private constructor( private readonly unixMs: number, token: symbol, ) { if (token !== DateTime.createToken) { throw new TypeError( 'DateTime can only be created via DateTime.create() or DateTime.fromUnixMs()', ); } Object.freeze(this); } static create(raw: string): DateTime { if (typeof raw !== 'string') { throw new InvalidDateTimeError(); } const trimmed = raw.trim(); const match = ISO_DATETIME.exec(trimmed); if (!match) { throw new InvalidDateTimeError(); } const year = Number.parseInt(match[1], 10); const month = Number.parseInt(match[2], 10); const day = Number.parseInt(match[3], 10); const hour = Number.parseInt(match[4], 10); const minute = Number.parseInt(match[5], 10); const second = Number.parseInt(match[6], 10); const ms = parseFractionalMs(match[7]); let offsetMinutes: number; if (match[8] !== undefined && match[8].toUpperCase() === 'Z') { offsetMinutes = 0; } else if (match[9] !== undefined) { const sign = match[9] === '-' ? -1 : 1; const oh = Number.parseInt(match[10], 10); const om = match[11] !== undefined ? Number.parseInt(match[11], 10) : 0; assertValidOffset(oh, om, sign); offsetMinutes = sign * (oh * 60 + om); } else { offsetMinutes = resolveDefaultOffsetMinutes(); } const utcMs = utcMsFromParts( year, month, day, hour, minute, second, ms, offsetMinutes, ); return new DateTime(utcMs, DateTime.createToken); } static fromUnixMs(ms: number): DateTime { if ( typeof ms !== 'number' || !Number.isSafeInteger(ms) || Math.abs(ms) > MAX_UNIX_MS ) { throw new InvalidDateTimeError(); } return new DateTime(ms, DateTime.createToken); } get value(): number { return this.unixMs; } equals(other: DateTime): boolean { return other instanceof DateTime && this.unixMs === other.unixMs; } format(tz?: string): string { const offsetMinutes = tz === undefined ? resolveDefaultOffsetMinutes() : parseTimezoneOffsetMinutes(tz); return formatInOffset(this.unixMs, offsetMinutes); } toString(): string { return this.format(); } toJSON(): number { return this.unixMs; } }