import Duration, {type DurationLike, toDuration} from './duration.js'; import Calendar from './calendar.js'; import { balanceTimeDuration, differenceInstantWithRounding, differenceZonedDateTimeWithRounding, durationToTimeDuration, getPossibleMilliseconds, isDateUnit, maxTemporalUnit, normalizeDateAdd, parseUnit, roundToIncrement, toTimeDuration, } from './utilities.js'; import {Instant} from './instant.js'; import {RoundingMode, TimeUnit} from './types.js'; import {DateTimeFormatTimeZone, TimeZone} from './time-zone.js'; import {PlainDate} from './plain-date.js'; import {PlainTime} from './plain-time.js'; import {ISO8601Result, Zulu} from './parse.js'; import {DAY, HOUR, MINUTE, PER_UNIT, SECOND} from './units.js'; import {PlainDateTime} from './plain-date-time.js'; export interface ZonedDateTimeComponents { year: number; month: number; week: number; day: number; hour: number; minute: number; second: number; millisecond: number; } export interface ZonedDateTimeFromOptions { disambiguation?: 'compatible' | 'earlier' | 'later'; } export type ZonedDateTimeLike = ZonedDateTime | DateTimeLike | string; export interface DateTimeLike { year: number; month: number; day: number; hour?: number; minute?: number; second?: number; millisecond?: number; timeZone: string | TimeZone; } export function pad(number: number, minLength = 2) { return number.toString().padStart(minLength, '0'); } export function padYear(year: number) { if (year < 0 || year > 9999) { let sign = year < 0 ? '-' : '+'; return `${sign}${pad(Math.abs(year), 6)}`; } else { return pad(year, 4); } } interface ZonedDateAddParameters { overflow?: 'reject' | 'constrain'; } interface ZonedDateTimeRoundParameters { smallestUnit: 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; roundingIncrement?: number; roundingMode?: RoundingMode; } interface ZonedDateTimeDeltaParameters { /// The largest unit of time to round to in the resulting duration, defaults to 'auto'. largestUnit?: 'auto' | 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; /// The smallest unit of time to round to in the resulting duration, defaults to 'millisecond', which means no rounding. smallestUnit?: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; /// The granularity to round to, of the unit given by smallestUnit. Defaults to `1`. roundingIncrement?: number; /// Rounding method to use roundingMode?: RoundingMode; } interface ZonedDateTimeToStringParameters { offset?: 'auto' | 'never'; timeZoneName?: 'auto' | 'never' | 'critical'; calendarName?: 'auto' | 'always' | 'never' | 'critical'; fractionalSecondDigits?: 'auto' | 1 | 2 | 3; smallestUnit?: 'minute' | 'second' | 'millisecond'; roundingMode?: RoundingMode; } export class ZonedDateTime { private raw: Date; private timeZone: TimeZone; private calendar: Calendar; constructor(epochMillisecond: number, timeZoneLike: string | TimeZone) { this.raw = new Date(epochMillisecond); this.calendar = Calendar.from('iso8601'); if (typeof timeZoneLike === 'string') { this.timeZone = DateTimeFormatTimeZone.from(timeZoneLike); } else { this.timeZone = timeZoneLike; } } get hour() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCHours(); } get minute() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCMinutes(); } get second() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCSeconds(); } get millisecond() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCMilliseconds(); } get day() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCDate(); } get month() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCMonth() + 1; } get year() { let date = new Date(this.raw.getTime() + this.offsetMilliseconds); return date.getUTCFullYear(); } get epochSeconds() { return Math.floor(this.raw.valueOf() / SECOND); } get epochMilliseconds() { return this.raw.valueOf(); } get offsetMilliseconds() { return this.timeZone.getOffsetMillisecondsFor(this.epochMilliseconds); } get offset() { let offset = this.offsetMilliseconds; let sign = offset >= 0 ? '+' : '-'; offset = Math.abs(offset); let hour = Math.trunc(offset / (60 * MINUTE)); let minutes = Math.trunc(offset / (60 * SECOND)) % 60; return `${sign}${pad(hour)}:${pad(minutes)}`; } get inLeapYear() { return this.calendar.inLeapYear(this); } get timeZoneId(): string { return this.timeZone.id; } getCalendar() { return this.calendar; } get calendarId(): string { return this.calendar.id; } get daysInWeek() { return this.calendar.daysInWeek(this); } get daysInMonth() { return this.calendar.daysInMonth(this); } get monthsInYear() { return this.calendar.monthsInYear(this); } get daysInYear() { return this.calendar.daysInYear(this); } get weekOfYear() { return this.calendar.weekOfYear(this); } get yearOfWeek() { return this.calendar.yearOfWeek(this); } get hoursInDay() { let initial = this.startOfDay(); let tomorrow = initial.add({days: 1}).startOfDay(); let hours = tomorrow.epochMilliseconds - initial.epochMilliseconds; return hours / HOUR; } /** * The dayOfYear read-only property gives the ordinal day of the year that the * date falls on. For the ISO 8601 calendar, this is a value between 1 and 365, * or 366 in a leap year. */ get dayOfYear() { return this.calendar.dayOfYear(this); } /** * The dayOfWeek read-only property gives the weekday number that the date falls * on. For the ISO 8601 calendar, the weekday number is defined as in the ISO 8601 * standard: a value between 1 and 7, inclusive, with Monday being 1, and Sunday 7 */ get dayOfWeek(): number { return this.calendar.dayOfWeek(this); } /** * Returns the time zone object. */ getTimeZone() { return this.timeZone; } /** * This method adds duration to zonedDateTime. * * Returns: a new ZonedDateTime object representing the sum of zonedDateTime plus duration. */ add(durationLike: DurationLike, options?: ZonedDateAddParameters) { let duration = toDuration(durationLike); let copy: ZonedDateTime; if (duration.years || duration.months) { let {year, month, day} = normalizeDateAdd(this.year, this.month, this.day, duration, options?.overflow); copy = this.with({year, month, day}); } else { copy = new ZonedDateTime(this.epochMilliseconds, this.timeZone); } if (duration.days || duration.weeks) { let days = duration.days + duration.weeks * 7; let originalOffset = copy.offsetMilliseconds; copy.raw.setUTCMilliseconds(copy.raw.getMilliseconds() + DAY * days); copy.adjustOffset(originalOffset); } let timeDuration = durationToTimeDuration(duration); copy.raw.setUTCMilliseconds(copy.raw.getMilliseconds() + timeDuration); return copy; } subtract(duration: DurationLike, options?: ZonedDateAddParameters) { let negated = toDuration(duration).negated(); return this.add(negated, options); } /** * Returns: A new ZonedDateTime instance representing the earliest valid local * clock time during the current calendar day and time zone of zonedDateTime. */ startOfDay() { return this.with({ hour: 0, minute: 0, second: 0, millisecond: 0, }); } round(options: ZonedDateTimeRoundParameters['smallestUnit'] | ZonedDateTimeRoundParameters) { if (typeof options === 'string') { options = { smallestUnit: options, }; } let smallestUnit = parseUnit(options.smallestUnit); let roundingIncrement = options.roundingIncrement ?? 1; let roundingMode = options.roundingMode ?? 'halfExpand'; let originalOffset = this.offsetMilliseconds; let remainder = toTimeDuration(this); let startOfDay = this.epochMilliseconds - remainder; let roundedRemainder = roundToIncrement(remainder, PER_UNIT[smallestUnit] * roundingIncrement, roundingMode); let copy = new ZonedDateTime(startOfDay + roundedRemainder, this.timeZone); copy.adjustOffset(originalOffset); return copy; } until(zonedDateTimeLike: ZonedDateTimeLike, options: ZonedDateTimeDeltaParameters = {}): Duration { let other = toZonedDateTime(zonedDateTimeLike); let smallestUnit = parseUnit(options.smallestUnit, 'millisecond'); let largestUnit = parseUnit(options.largestUnit, 'auto'); let roundingIncrement = options.roundingIncrement ?? 1; let roundingMode = options.roundingMode ?? 'trunc'; if (largestUnit === 'auto') { largestUnit = maxTemporalUnit('hour', smallestUnit); } if (!isDateUnit(largestUnit)) { let diffResult = differenceInstantWithRounding( this.toInstant(), other.toInstant(), smallestUnit as TimeUnit | 'day', roundingIncrement, roundingMode, ); let duration = balanceTimeDuration(diffResult.timeDuration, largestUnit); return Duration.from(duration); } if (this.equals(other)) { return new Duration(); } let resultRecord = differenceZonedDateTimeWithRounding( this, other, largestUnit, smallestUnit, roundingIncrement, roundingMode, ); return resultRecord.duration; } since(other: ZonedDateTimeLike, options?: ZonedDateTimeDeltaParameters): Duration { return this.until(other, options).negated(); } equals(otherLike: ZonedDateTimeLike): boolean { let other = toZonedDateTime(otherLike); return this.epochMilliseconds === other.epochMilliseconds && this.timeZone.id === other.timeZone.id; } with(zonedDateTimeLike: Partial) { let copy = new ZonedDateTime(this.epochMilliseconds, this.timeZone); let raw = copy.raw; let offset = copy.offsetMilliseconds; raw.setUTCMilliseconds(raw.getUTCMilliseconds() + offset); let year = zonedDateTimeLike.year != null ? zonedDateTimeLike.year : raw.getUTCFullYear(); let month = zonedDateTimeLike.month != null ? zonedDateTimeLike.month - 1 : raw.getUTCMonth(); let day = zonedDateTimeLike.day != null ? zonedDateTimeLike.day : raw.getUTCDate(); raw.setUTCFullYear(year, month, day); let hour = zonedDateTimeLike.hour != null ? zonedDateTimeLike.hour : raw.getUTCHours(); let minute = zonedDateTimeLike.minute != null ? zonedDateTimeLike.minute : raw.getUTCMinutes(); let second = zonedDateTimeLike.second != null ? zonedDateTimeLike.second : raw.getUTCSeconds(); let millisecond = zonedDateTimeLike.millisecond != null ? zonedDateTimeLike.millisecond : raw.getUTCMilliseconds(); raw.setUTCHours(hour, minute, second, millisecond); raw.setUTCMilliseconds(raw.getUTCMilliseconds() - offset); copy.adjustOffset(offset); return copy; } toJSON() { return this.toString(); } toString(options: ZonedDateTimeToStringParameters = {}) { let date: ZonedDateTime = this; let smallestUnit = parseUnit(options.smallestUnit); if (smallestUnit) { date = this.round({ smallestUnit, roundingMode: options.roundingMode ?? 'trunc', }); } let timeZone = options.timeZoneName === 'never' ? '' : `[${date.timeZoneId}]`; let offset = options.offset === 'never' ? '' : date.offset; let dateString = `${padYear(date.year)}-${pad(date.month)}-${pad(date.day)}`; let time = `${pad(date.hour)}:${pad(date.minute)}:${pad(date.second)}.${pad(date.millisecond, 3)}`; return `${dateString}T${time}${offset}${timeZone}`; } toLocaleString(locales: string | string[], options: Intl.DateTimeFormatOptions = {}) { let formatter = new Intl.DateTimeFormat(locales, { ...options, timeZone: this.timeZoneId, }); return formatter.format(this.epochMilliseconds); } toInstant() { return Instant.fromEpochMilliseconds(this.epochMilliseconds); } toPlainDate() { return new PlainDate(this.year, this.month, this.day); } toPlainTime() { return new PlainTime(this.hour, this.minute, this.second, this.millisecond); } toPlainDateTime() { return new PlainDateTime(this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond); } withTimeZone(timeZone: string | TimeZone) { return new ZonedDateTime(this.epochMilliseconds, timeZone); } valueOf() { throw new Error('Built-in arithmetic operators isn’t supported on ZonedDateTime'); } private adjustOffset(originalOffsetMilliseconds: number) { let shiftedOffset = this.timeZone.getOffsetMillisecondsFor(this.raw.valueOf()); let delta = originalOffsetMilliseconds - shiftedOffset; if (delta) { this.raw.setUTCMilliseconds(this.raw.getMilliseconds() + delta); // If the adjustment resulted in the time going back, and the UTC offset ended up // as the original offset, revert the adjustment. let adjusted = delta < 0; if (adjusted && this.timeZone.getOffsetMillisecondsFor(this.raw.valueOf()) === originalOffsetMilliseconds) { this.raw.setUTCMilliseconds(this.raw.getUTCMilliseconds() - delta); } } } static from(dateComponents: ZonedDateTimeLike, options: ZonedDateTimeFromOptions = {}): ZonedDateTime { if (dateComponents instanceof ZonedDateTime) { return new ZonedDateTime(dateComponents.epochMilliseconds, dateComponents.timeZone); } else if (dateComponents && typeof dateComponents === 'object' && !Array.isArray(dateComponents)) { let dateTime = new ZonedDateTime(Date.now(), dateComponents.timeZone); let offset = dateTime.offsetMilliseconds; dateTime.raw.setUTCFullYear(dateComponents.year, dateComponents.month - 1, dateComponents.day); dateTime.raw.setUTCHours( dateComponents.hour ?? 0, dateComponents.minute ?? 0, dateComponents.second ?? 0, dateComponents.millisecond ?? 0, ); dateTime.raw.setUTCMilliseconds(dateTime.raw.getUTCMilliseconds() - offset); dateTime.adjustOffset(offset); let possibilities = getPossibleMilliseconds(dateTime.epochMilliseconds, dateTime.timeZone); if (possibilities.length > 1) { let disambiguation = options.disambiguation ?? 'compatible'; switch (disambiguation) { case 'compatible': case 'earlier': return Instant.fromEpochMilliseconds(possibilities[0]).toZonedDateTimeISO(dateTime.timeZone); case 'later': return Instant.fromEpochMilliseconds(possibilities[possibilities.length - 1]).toZonedDateTimeISO( dateTime.timeZone, ); } } return dateTime; } else if (typeof dateComponents === 'string') { let result = ISO8601Result.parseDateTime(dateComponents); if (result && result.date && result.timeZone) { if (result.offset === Zulu) { let epochMilliseconds = Date.UTC( result.date.year, result.date.month - 1, result.date.day, result.time?.hour, result.time?.minute, result.time?.second, result.time?.millisecond, ); return Instant.fromEpochMilliseconds(epochMilliseconds).toZonedDateTimeISO(result.timeZone); } let entry = ZonedDateTime.from({ year: result.date.year, month: result.date.month, day: result.date.day, hour: result.time ? result.time.hour : 0, minute: result.time ? result.time.minute : 0, second: result.time ? result.time.second : 0, millisecond: result.time ? result.time.millisecond : 0, timeZone: result.timeZone, }); if (result.offset != null && typeof result.offset === 'number' && entry.offsetMilliseconds !== result.offset) { let delta = entry.offsetMilliseconds - result.offset; entry = entry.add({milliseconds: delta}); if (entry.offsetMilliseconds !== result.offset) { throw new RangeError('Offset doesn’t match'); } } return entry; } } throw new RangeError('Unsupported string'); } /** * Compares two ZonedDateTime objects * * Returns an integer indicating whether one comes before or after or is equal to two */ static compare(one: ZonedDateTimeLike, two: ZonedDateTimeLike): number { return Math.sign(toZonedDateTime(one).epochMilliseconds - toZonedDateTime(two).epochMilliseconds); } } export function toZonedDateTime(entry: ZonedDateTimeLike): ZonedDateTime { return entry instanceof ZonedDateTime ? entry : ZonedDateTime.from(entry); }