import {PlainDate} from './plain-date.js'; import {PlainTime} from './plain-time.js'; import {RoundingMode} from './types.js'; import { balanceTimeDuration, defaultLargestUnit, maxTemporalUnit, durationToTimeDuration, isCalendarUnit, differenceZonedDateTimeWithRounding, internalRoundTimeDuration, differencePlainDateTimeWithRounding, isDateUnit, RelativeToLike, parseRelativeTo, RelativeTo, parseUnit, } from './utilities.js'; import {ZonedDateTime, pad} from './zoned-date-time.js'; import {DAY} from './units.js'; const numbers = '\\d+(?:[\\.,]\\d+)?'; const datePattern = `(${numbers}Y)?(${numbers}M)?(${numbers}W)?(${numbers}D)?`; const timePattern = `T(${numbers}H)?(${numbers}M)?(${numbers}S)?`; const duration = new RegExp(`^(\-|\\+)?P(?:${datePattern}(?:${timePattern})?)$`); interface DeltaRoundParameters { /// 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; /// Relative to date relativeTo?: RelativeToLike; } interface DurationToStringParameters { fractionalSecondDigits?: 'auto' | 1 | 2 | 3; smallestUnit?: 'minute' | 'second' | 'millisecond'; roundingMode?: RoundingMode; } interface DurationTotalParameters { unit: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; relativeTo?: RelativeToLike; } interface DurationCompareParameters { relativeTo?: RelativeToLike; } export interface DateComponents { years: number; months: number; weeks: number; days: number; hours: number; minutes: number; seconds: number; milliseconds: number; } export type DurationLike = Duration | string | Partial; function parse(string: string): Duration { let match = string.match(duration); if (match) { let [signSymbol, years, months, weeks, days, hours, minutes, seconds] = match.slice(1); let sign = signSymbol === '-' ? -1 : 1; let yearsValue = years ? parseFloat(years.slice(0, -1)) * sign : 0; let monthsValue = months ? parseFloat(months.slice(0, -1)) * sign : 0; let weeksValue = weeks ? parseFloat(weeks.slice(0, -1)) * sign : 0; let daysValue = days ? parseFloat(days.slice(0, -1)) * sign : 0; let hoursValue = hours ? parseFloat(hours.slice(0, -1)) * sign : 0; let minutesValue = minutes ? parseFloat(minutes.slice(0, -1)) * sign : 0; let secondsValue = seconds ? parseFloat(seconds.slice(0, -1)) * sign : 0; let raw = secondsValue * 1000; let s = Math.floor(raw / 1000) % 60; let ms = Math.floor(raw) % 1000; return new Duration(yearsValue, monthsValue, weeksValue, daysValue, hoursValue, minutesValue, s, ms); } throw new RangeError('Invalid ISO8601 string'); } export function toDuration(durationLike: DurationLike): Duration { return durationLike instanceof Duration ? durationLike : Duration.from(durationLike); } export default class Duration { readonly years: number; readonly months: number; readonly weeks: number; readonly days: number; readonly hours: number; readonly minutes: number; readonly seconds: number; readonly milliseconds: number; constructor( years?: number, months?: number, weeks?: number, days?: number, hours?: number, minutes?: number, seconds?: number, milliseconds?: number, ) { this.years = years ?? 0; this.months = months ?? 0; this.weeks = weeks ?? 0; this.days = days ?? 0; this.hours = hours ?? 0; this.minutes = minutes ?? 0; this.seconds = seconds ?? 0; this.milliseconds = milliseconds ?? 0; let expectedSign = this.sign; for (let field of [ this.years, this.months, this.weeks, this.days, this.hours, this.minutes, this.seconds, this.milliseconds, ]) { if (!Number.isInteger(field)) { throw new RangeError('Invalid input'); } if (field !== 0) { if (Math.sign(field) !== expectedSign) { throw new RangeError('mixed-sign values not allowed as duration fields'); } } } } /// The read-only sign property has the value –1, 0, or 1, depending on whether the duration is negative, zero, or positive get sign() { for (let field of [ this.years, this.months, this.weeks, this.days, this.hours, this.minutes, this.seconds, this.milliseconds, ]) { let sign = Math.sign(field); if (sign !== 0) { return sign; } } return 0; } /// The read-only blank property is a convenience property that tells whether duration represents a zero length of time. get blank() { return this.sign === 0; } /// Returns a new duration with the opposite sign negated() { return new Duration( this.years * -1, this.months * -1, this.weeks * -1, this.days * -1, this.hours * -1, this.minutes * -1, this.seconds * -1, this.milliseconds * -1, ); } /// Returns a duration that is always positive abs() { return new Duration( Math.abs(this.years), Math.abs(this.months), Math.abs(this.weeks), Math.abs(this.days), Math.abs(this.hours), Math.abs(this.minutes), Math.abs(this.seconds), Math.abs(this.milliseconds), ); } add(durationLike: DurationLike) { let other = toDuration(durationLike); let thisDefaultLargestUnit = defaultLargestUnit(this); let otherDefaultLargestUnit = defaultLargestUnit(other); let largestUnit = maxTemporalUnit(thisDefaultLargestUnit, otherDefaultLargestUnit); if (isCalendarUnit(largestUnit)) { throw new RangeError('Largest unit can’t be a calendar unit'); } let timeDuration = durationToTimeDuration(this) + durationToTimeDuration(other); let total = timeDuration + (this.days + other.days) * DAY; return Duration.from(balanceTimeDuration(total, largestUnit)); } with(durationLike: Partial) { return new Duration( durationLike.years ?? this.years, durationLike.months ?? this.months, durationLike.weeks ?? this.weeks, durationLike.days ?? this.days, durationLike.hours ?? this.hours, durationLike.minutes ?? this.minutes, durationLike.seconds ?? this.seconds, durationLike.milliseconds ?? this.milliseconds, ); } round(roundTo: Exclude | DeltaRoundParameters) { let smallestUnit: DeltaRoundParameters['smallestUnit']; let largestUnit: DeltaRoundParameters['largestUnit'] = undefined; let roundingMode: RoundingMode; let increment: number = 1; let relativeTo: RelativeTo | undefined = undefined; if (typeof roundTo === 'string') { smallestUnit = parseUnit(roundTo); roundingMode = 'halfExpand'; } else { smallestUnit = parseUnit(roundTo.smallestUnit, 'millisecond'); largestUnit = parseUnit(roundTo.largestUnit); roundingMode = roundTo.roundingMode ?? 'halfExpand'; increment = roundTo.roundingIncrement ?? 1; relativeTo = roundTo.relativeTo ? parseRelativeTo(roundTo.relativeTo) : undefined; } let existingLargestUnit = defaultLargestUnit(this); let defaultLargest = maxTemporalUnit(existingLargestUnit, smallestUnit); if (!largestUnit || largestUnit === 'auto') { largestUnit = defaultLargest; } // Step: 26. let hoursToDaysConversionMayOccur = (this.days !== 0 && relativeTo instanceof ZonedDateTime) || Math.abs(this.hours) >= 24; let roundingGranularityIsNoop = smallestUnit === 'millisecond' && increment === 1; let calendarUnitsPresent = this.years !== 0 || this.months !== 0 || this.weeks !== 0; if ( roundingGranularityIsNoop && largestUnit === existingLargestUnit && !calendarUnitsPresent && !hoursToDaysConversionMayOccur && Math.abs(this.minutes) < 60 && Math.abs(this.seconds) < 60 && Math.abs(this.milliseconds) < 1000 ) { return this.with({}); } let plainDateTimeOrRelativeToWillBeUsed = !roundingGranularityIsNoop || calendarUnitsPresent || this.days !== 0 || isDateUnit(largestUnit); if (relativeTo instanceof ZonedDateTime && plainDateTimeOrRelativeToWillBeUsed) { relativeTo = relativeTo.toPlainDate(); } // Step: 38 if (relativeTo instanceof ZonedDateTime) { let end = relativeTo.add(this); let record = differenceZonedDateTimeWithRounding( relativeTo, end, largestUnit, smallestUnit, increment, roundingMode, ); return record.duration; } else if (relativeTo instanceof PlainDate) { let start = relativeTo.toPlainDateTime(new PlainTime()); let end = start.add(this); let record = differencePlainDateTimeWithRounding(start, end, smallestUnit, largestUnit, increment, roundingMode); return record.duration; } else if (isCalendarUnit(smallestUnit) === false) { let timeDuration = durationToTimeDuration(this); let roundResult = internalRoundTimeDuration(this.days, timeDuration, increment, roundingMode, smallestUnit); let normWithDays = roundResult.timeDuration + roundResult.days * DAY; let balanceResult = balanceTimeDuration(normWithDays, largestUnit); return Duration.from(balanceResult); } else { throw new RangeError('Invalid input data'); } } subtract(durationLike: DurationLike) { return this.add(toDuration(durationLike).negated()); } toString(options: DurationToStringParameters = {}) { let instance: Duration = this; let smallestUnit = parseUnit(options.smallestUnit); if (smallestUnit) { instance = this.round({ smallestUnit, roundingMode: options.roundingMode ?? 'trunc', }); } let fractionalSecondDigits = options.fractionalSecondDigits === 'auto' ? 3 : options.fractionalSecondDigits ?? 3; let values = ['P']; if (this.sign === -1) { values.unshift('-'); instance = this.abs(); } else if (this.sign === 0) { return 'PT0S'; } else { instance = this; } if (instance.years) { values.push(`${instance.years}Y`); } if (instance.months) { values.push(`${instance.months}M`); } if (instance.weeks) { values.push(`${instance.weeks}W`); } if (instance.days) { values.push(`${instance.days}D`); } if (instance.hours || instance.minutes || instance.seconds || instance.milliseconds) { values.push('T'); if (instance.hours) { values.push(`${instance.hours}H`); } if (instance.minutes) { values.push(`${instance.minutes}M`); } if (instance.seconds || instance.milliseconds) { if (instance.milliseconds) { values.push(`${instance.seconds}.${pad(instance.milliseconds, fractionalSecondDigits)}S`); } else { values.push(`${instance.seconds}S`); } } } return values.join(''); } toJSON() { return this.toString(); } total(totalOf: DurationTotalParameters['unit'] | DurationTotalParameters) { let unit: DurationTotalParameters['unit']; let relativeTo: ZonedDateTime | PlainDate | undefined; if (typeof totalOf === 'string') { unit = parseUnit(totalOf); } else { unit = parseUnit(totalOf.unit); relativeTo = totalOf.relativeTo ? parseRelativeTo(totalOf.relativeTo) : undefined; } // Step: 13 & 14 if (relativeTo instanceof ZonedDateTime) { if (isCalendarUnit(unit) || unit === 'day' || this.days || this.years || this.months || this.weeks) { relativeTo = relativeTo.toPlainDate(); } } let timeDuration = durationToTimeDuration(this); // Step 18 if (relativeTo instanceof ZonedDateTime) { let target = relativeTo.add(this); let result = differenceZonedDateTimeWithRounding(relativeTo, target, unit, unit, 1, 'trunc'); return result.total; } else if (relativeTo instanceof PlainDate) { let target = relativeTo.toPlainDateTime(new PlainTime()).add(this); let roundRecord = differencePlainDateTimeWithRounding( relativeTo.toPlainDateTime(new PlainTime()), target, unit, unit, 1, 'trunc', ); return roundRecord.total; } else if (isCalendarUnit(unit) === false) { let normWithDays = timeDuration + this.days * DAY; let duration = internalRoundTimeDuration(0, normWithDays, 1, 'trunc', unit); return duration.total; } else { throw new RangeError('RelativeTo required when using calendar units'); } } valueOf() { throw new Error('Built-in arithmetic operators isn’t supported on Duration'); } static compare(oneLike: DurationLike, twoLike: DurationLike, options?: DurationCompareParameters) { let one = toDuration(oneLike); let two = toDuration(twoLike); let relativeTo = options?.relativeTo; return Math.sign(one.total({relativeTo, unit: 'millisecond'}) - two.total({relativeTo, unit: 'millisecond'})); } static from(other: DurationLike) { if (typeof other === 'string') { return parse(other); } return new Duration( other.years, other.months, other.weeks, other.days, other.hours, other.minutes, other.seconds, other.milliseconds, ); } }