import { DEBUG } from './debug'; import * as ES from './ecmascript'; import { GetIntrinsic, MakeIntrinsicClass, DefineIntrinsic } from './intrinsicclass'; import { CALENDAR_ID, ISO_YEAR, ISO_MONTH, ISO_DAY, YEARS, MONTHS, WEEKS, DAYS, HOURS, MINUTES, SECONDS, MILLISECONDS, MICROSECONDS, NANOSECONDS, CreateSlots, GetSlot, HasSlot, SetSlot } from './slots'; import type { Temporal } from '..'; import type { BuiltinCalendarId, CalendarParams as Params, CalendarReturn as Return, AnyTemporalKey, CalendarSlot } from './internaltypes'; const ArrayIncludes = Array.prototype.includes; const ArrayPrototypePush = Array.prototype.push; const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat; const ArraySort = Array.prototype.sort; const MathAbs = Math.abs; const MathFloor = Math.floor; const ObjectCreate = Object.create; const ObjectEntries = Object.entries; const OriginalSet = Set; const ReflectOwnKeys = Reflect.ownKeys; const SetPrototypeAdd = Set.prototype.add; const SetPrototypeValues = Set.prototype.values; /** * Shape of internal implementation of each built-in calendar. Note that * parameter types are simpler than CalendarProtocol because the `Calendar` * class performs validation and parameter normalization before handing control * over to CalendarImpl. * * There are two instances of this interface: one for the ISO calendar and * another that handles logic that's the same across all non-ISO calendars. The * latter is cloned for each non-ISO calendar at the end of this file. */ interface CalendarImpl { year(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; month(date: Temporal.PlainDate | Temporal.PlainYearMonth | Temporal.PlainMonthDay): number; monthCode(date: Temporal.PlainDate | Temporal.PlainYearMonth | Temporal.PlainMonthDay): string; day(date: Temporal.PlainDate | Temporal.PlainMonthDay): number; era(date: Temporal.PlainDate | Temporal.PlainYearMonth): string | undefined; eraYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number | undefined; dayOfWeek(date: Temporal.PlainDate): number; dayOfYear(date: Temporal.PlainDate): number; weekOfYear(date: Temporal.PlainDate): number; yearOfWeek(date: Temporal.PlainDate): number; daysInWeek(date: Temporal.PlainDate): number; daysInMonth(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; daysInYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; monthsInYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; inLeapYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): boolean; dateFromFields( fields: Params['dateFromFields'][0], options: NonNullable, calendar: string ): Temporal.PlainDate; yearMonthFromFields( fields: Params['yearMonthFromFields'][0], options: NonNullable, calendar: string ): Temporal.PlainYearMonth; monthDayFromFields( fields: Params['monthDayFromFields'][0], options: NonNullable, calendar: string ): Temporal.PlainMonthDay; dateAdd( date: Temporal.PlainDate, years: number, months: number, weeks: number, days: number, overflow: Overflow, calendar: string ): Temporal.PlainDate; dateUntil( one: Temporal.PlainDate, two: Temporal.PlainDate, largestUnit: 'year' | 'month' | 'week' | 'day' ): { years: number; months: number; weeks: number; days: number }; fields(fields: string[]): string[]; fieldKeysToIgnore(keys: string[]): string[]; } type CalendarImplementations = { [k in BuiltinCalendarId]: CalendarImpl; }; /** * Implementations for each calendar. * Registration for each of these calendars happens throughout this file. The ISO and non-ISO calendars are registered * separately - look for 'iso8601' for the ISO calendar registration, and all non-ISO calendar registrations happens * at the bottom of the file. */ const impl: CalendarImplementations = {} as unknown as CalendarImplementations; /** * Thin wrapper around the implementation of each built-in calendar. This * class's methods follow a similar pattern: * 1. Validate parameters * 2. Fill in default options (for methods where options are present) * 3. Simplify and/or normalize parameters. For example, some methods accept * PlainDate, PlainDateTime, ZonedDateTime, etc. and these are normalized to * PlainDate. * 4. Look up the ID of the built-in calendar * 5. Fetch the implementation object for that ID. * 6. Call the corresponding method in the implementation object. */ export class Calendar implements Temporal.Calendar { constructor(idParam: Params['constructor'][0]) { // Note: if the argument is not passed, IsBuiltinCalendar("undefined") will fail. This check // exists only to improve the error message. if (arguments.length < 1) { throw new RangeError('missing argument: id is required'); } const id = ES.ToString(idParam); if (!ES.IsBuiltinCalendar(id)) throw new RangeError(`invalid calendar identifier ${id}`); CreateSlots(this); SetSlot(this, CALENDAR_ID, ES.ASCIILowercase(id)); if (DEBUG) { Object.defineProperty(this, '_repr_', { value: `${this[Symbol.toStringTag]} <${id}>`, writable: false, enumerable: false, configurable: false }); } } get id(): Return['id'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return GetSlot(this, CALENDAR_ID); } dateFromFields( fields: Params['dateFromFields'][0], optionsParam: Params['dateFromFields'][1] = undefined ): Return['dateFromFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsObject(fields)) throw new TypeError('invalid fields'); const options = ES.GetOptionsObject(optionsParam); const id = GetSlot(this, CALENDAR_ID); return impl[id].dateFromFields(fields, options, id); } yearMonthFromFields( fields: Params['yearMonthFromFields'][0], optionsParam: Params['yearMonthFromFields'][1] = undefined ): Return['yearMonthFromFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsObject(fields)) throw new TypeError('invalid fields'); const options = ES.GetOptionsObject(optionsParam); const id = GetSlot(this, CALENDAR_ID); return impl[id].yearMonthFromFields(fields, options, id); } monthDayFromFields( fields: Params['monthDayFromFields'][0], optionsParam: Params['monthDayFromFields'][1] = undefined ): Return['monthDayFromFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsObject(fields)) throw new TypeError('invalid fields'); const options = ES.GetOptionsObject(optionsParam); const id = GetSlot(this, CALENDAR_ID); return impl[id].monthDayFromFields(fields, options, id); } fields(fields: Params['fields'][0]): Return['fields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const fieldsArray = [] as string[]; const allowed = new Set([ 'year', 'month', 'monthCode', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond' ]); for (const name of fields) { if (typeof name !== 'string') throw new TypeError('invalid fields'); if (!allowed.has(name)) throw new RangeError(`invalid field name ${name}`); allowed.delete(name); ArrayPrototypePush.call(fieldsArray, name); } return impl[GetSlot(this, CALENDAR_ID)].fields(fieldsArray); } mergeFields( fieldsParam: Params['mergeFields'][0], additionalFieldsParam: Params['mergeFields'][1] ): Return['mergeFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const fields = ES.ToObject(fieldsParam); const fieldsCopy = ObjectCreate(null); ES.CopyDataProperties(fieldsCopy, fields, [], [undefined]); const additionalFields = ES.ToObject(additionalFieldsParam); const additionalFieldsCopy = ObjectCreate(null); ES.CopyDataProperties(additionalFieldsCopy, additionalFields, [], [undefined]); const additionalKeys = ReflectOwnKeys(additionalFieldsCopy) as (keyof typeof additionalFields)[]; const overriddenKeys = impl[GetSlot(this, CALENDAR_ID)].fieldKeysToIgnore(additionalKeys); const merged = ObjectCreate(null); const fieldsKeys = ReflectOwnKeys(fieldsCopy); for (const key of fieldsKeys) { let propValue = undefined; if (ES.Call(ArrayIncludes, overriddenKeys, [key])) propValue = additionalFieldsCopy[key]; else propValue = fieldsCopy[key]; if (propValue !== undefined) merged[key] = propValue; } ES.CopyDataProperties(merged, additionalFieldsCopy, []); return merged; } dateAdd( dateParam: Params['dateAdd'][0], durationParam: Params['dateAdd'][1], optionsParam: Params['dateAdd'][2] = undefined ): Return['dateAdd'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); const duration = ES.ToTemporalDuration(durationParam); const options = ES.GetOptionsObject(optionsParam); const overflow = ES.ToTemporalOverflow(options); const { days } = ES.BalanceDuration( GetSlot(duration, DAYS), GetSlot(duration, HOURS), GetSlot(duration, MINUTES), GetSlot(duration, SECONDS), GetSlot(duration, MILLISECONDS), GetSlot(duration, MICROSECONDS), GetSlot(duration, NANOSECONDS), 'day' ); const id = GetSlot(this, CALENDAR_ID); return impl[id].dateAdd( date, GetSlot(duration, YEARS), GetSlot(duration, MONTHS), GetSlot(duration, WEEKS), days, overflow, id ); } dateUntil( oneParam: Params['dateUntil'][0], twoParam: Params['dateUntil'][1], optionsParam: Params['dateUntil'][2] = undefined ): Return['dateUntil'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const one = ES.ToTemporalDate(oneParam); const two = ES.ToTemporalDate(twoParam); const options = ES.GetOptionsObject(optionsParam); let largestUnit = ES.GetTemporalUnit(options, 'largestUnit', 'date', 'auto'); if (largestUnit === 'auto') largestUnit = 'day'; const { years, months, weeks, days } = impl[GetSlot(this, CALENDAR_ID)].dateUntil(one, two, largestUnit); const Duration = GetIntrinsic('%Temporal.Duration%'); return new Duration(years, months, weeks, days, 0, 0, 0, 0, 0, 0); } year(dateParam: Params['year'][0]): Return['year'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].year(date as Temporal.PlainDate | Temporal.PlainYearMonth); } month(dateParam: Params['month'][0]): Return['month'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (ES.IsTemporalMonthDay(date)) throw new TypeError('use monthCode on PlainMonthDay instead'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].month(date as Temporal.PlainDate | Temporal.PlainYearMonth); } monthCode(dateParam: Params['monthCode'][0]): Return['monthCode'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date) && !ES.IsTemporalMonthDay(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].monthCode( date as Temporal.PlainDate | Temporal.PlainMonthDay | Temporal.PlainYearMonth ); } day(dateParam: Params['day'][0]): Return['day'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalMonthDay(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].day(date as Temporal.PlainDate | Temporal.PlainMonthDay); } era(dateParam: Params['era'][0]): Return['era'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].era(date as Temporal.PlainDate | Temporal.PlainYearMonth); } eraYear(dateParam: Params['eraYear'][0]): Return['eraYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].eraYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } dayOfWeek(dateParam: Params['dayOfWeek'][0]): Return['dayOfWeek'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].dayOfWeek(date); } dayOfYear(dateParam: Params['dayOfYear'][0]): Return['dayOfYear'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].dayOfYear(date); } weekOfYear(dateParam: Params['weekOfYear'][0]): Return['weekOfYear'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].weekOfYear(date); } yearOfWeek(dateParam: Params['yearOfWeek'][0]): Return['yearOfWeek'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].yearOfWeek(date); } daysInWeek(dateParam: Params['daysInWeek'][0]): Return['daysInWeek'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].daysInWeek(date); } daysInMonth(dateParam: Params['daysInMonth'][0]): Return['daysInMonth'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].daysInMonth(date as Temporal.PlainDate | Temporal.PlainYearMonth); } daysInYear(dateParam: Params['daysInYear'][0]): Return['daysInYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].daysInYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } monthsInYear(dateParam: Params['monthsInYear'][0]): Return['monthsInYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].monthsInYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } inLeapYear(dateParam: Params['inLeapYear'][0]): Return['inLeapYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].inLeapYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } toString(): string { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return GetSlot(this, CALENDAR_ID); } toJSON(): Return['toJSON'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return GetSlot(this, CALENDAR_ID); } static from(item: Params['from'][0]): Return['from'] { const calendarSlotValue = ES.ToTemporalCalendarSlotValue(item); return ES.ToTemporalCalendarObject(calendarSlotValue); } [Symbol.toStringTag]!: 'Temporal.Calendar'; } MakeIntrinsicClass(Calendar, 'Temporal.Calendar'); DefineIntrinsic('Temporal.Calendar.from', Calendar.from); DefineIntrinsic('Temporal.Calendar.prototype.dateAdd', Calendar.prototype.dateAdd); DefineIntrinsic('Temporal.Calendar.prototype.dateFromFields', Calendar.prototype.dateFromFields); DefineIntrinsic('Temporal.Calendar.prototype.dateUntil', Calendar.prototype.dateUntil); DefineIntrinsic('Temporal.Calendar.prototype.day', Calendar.prototype.day); DefineIntrinsic('Temporal.Calendar.prototype.dayOfWeek', Calendar.prototype.dayOfWeek); DefineIntrinsic('Temporal.Calendar.prototype.dayOfYear', Calendar.prototype.dayOfYear); DefineIntrinsic('Temporal.Calendar.prototype.daysInMonth', Calendar.prototype.daysInMonth); DefineIntrinsic('Temporal.Calendar.prototype.daysInWeek', Calendar.prototype.daysInWeek); DefineIntrinsic('Temporal.Calendar.prototype.daysInYear', Calendar.prototype.daysInYear); DefineIntrinsic('Temporal.Calendar.prototype.era', Calendar.prototype.era); DefineIntrinsic('Temporal.Calendar.prototype.eraYear', Calendar.prototype.eraYear); DefineIntrinsic('Temporal.Calendar.prototype.fields', Calendar.prototype.fields); DefineIntrinsic('Temporal.Calendar.prototype.inLeapYear', Calendar.prototype.inLeapYear); DefineIntrinsic('Temporal.Calendar.prototype.mergeFields', Calendar.prototype.mergeFields); DefineIntrinsic('Temporal.Calendar.prototype.month', Calendar.prototype.month); DefineIntrinsic('Temporal.Calendar.prototype.monthCode', Calendar.prototype.monthCode); DefineIntrinsic('Temporal.Calendar.prototype.monthDayFromFields', Calendar.prototype.monthDayFromFields); DefineIntrinsic('Temporal.Calendar.prototype.monthsInYear', Calendar.prototype.monthsInYear); DefineIntrinsic('Temporal.Calendar.prototype.weekOfYear', Calendar.prototype.weekOfYear); DefineIntrinsic('Temporal.Calendar.prototype.year', Calendar.prototype.year); DefineIntrinsic('Temporal.Calendar.prototype.yearMonthFromFields', Calendar.prototype.yearMonthFromFields); DefineIntrinsic('Temporal.Calendar.prototype.yearOfWeek', Calendar.prototype.yearOfWeek); /** * Implementation for the ISO 8601 calendar. This is the only calendar that's * guaranteed to be supported by all ECMAScript implementations, including those * without Intl (ECMA-402) support. */ impl['iso8601'] = { dateFromFields(fieldsParam, options, calendarSlotValue) { let fields = ES.PrepareTemporalFields(fieldsParam, ['day', 'month', 'monthCode', 'year'], ['year', 'day']); const overflow = ES.ToTemporalOverflow(options); fields = resolveNonLunisolarMonth(fields); let { year, month, day } = fields; ({ year, month, day } = ES.RegulateISODate(year, month, day, overflow)); return ES.CreateTemporalDate(year, month, day, calendarSlotValue); }, yearMonthFromFields(fieldsParam, options, calendarSlotValue) { let fields = ES.PrepareTemporalFields(fieldsParam, ['month', 'monthCode', 'year'], ['year']); const overflow = ES.ToTemporalOverflow(options); fields = resolveNonLunisolarMonth(fields); let { year, month } = fields; ({ year, month } = ES.RegulateISOYearMonth(year, month, overflow)); return ES.CreateTemporalYearMonth(year, month, calendarSlotValue, /* referenceISODay = */ 1); }, monthDayFromFields(fieldsParam, options, calendarSlotValue) { let fields = ES.PrepareTemporalFields(fieldsParam, ['day', 'month', 'monthCode', 'year'], ['day']); const overflow = ES.ToTemporalOverflow(options); if (fields.month !== undefined && fields.year === undefined && fields.monthCode === undefined) { throw new TypeError('either year or monthCode required with month'); } const useYear = fields.monthCode === undefined; const referenceISOYear = 1972; fields = resolveNonLunisolarMonth(fields); let { month, day, year } = fields; ({ month, day } = ES.RegulateISODate(useYear ? year : referenceISOYear, month, day, overflow)); return ES.CreateTemporalMonthDay(month, day, calendarSlotValue, referenceISOYear); }, fields(fields) { return fields; }, fieldKeysToIgnore(keys) { const result = new OriginalSet(); for (let ix = 0; ix < keys.length; ix++) { const key = keys[ix]; ES.Call(SetPrototypeAdd, result, [key]); if (key === 'month') { ES.Call(SetPrototypeAdd, result, ['monthCode']); } else if (key === 'monthCode') { ES.Call(SetPrototypeAdd, result, ['month']); } } return [...ES.Call(SetPrototypeValues, result, [])]; }, dateAdd(date, years, months, weeks, days, overflow, calendarSlotValue) { let year = GetSlot(date, ISO_YEAR); let month = GetSlot(date, ISO_MONTH); let day = GetSlot(date, ISO_DAY); ({ year, month, day } = ES.AddISODate(year, month, day, years, months, weeks, days, overflow)); return ES.CreateTemporalDate(year, month, day, calendarSlotValue); }, dateUntil(one, two, largestUnit) { return ES.DifferenceISODate( GetSlot(one, ISO_YEAR), GetSlot(one, ISO_MONTH), GetSlot(one, ISO_DAY), GetSlot(two, ISO_YEAR), GetSlot(two, ISO_MONTH), GetSlot(two, ISO_DAY), largestUnit ); }, year(date) { return GetSlot(date, ISO_YEAR); }, era() { return undefined; }, eraYear() { return undefined; }, month(date) { return GetSlot(date, ISO_MONTH); }, monthCode(date) { return buildMonthCode(GetSlot(date, ISO_MONTH)); }, day(date) { return GetSlot(date, ISO_DAY); }, dayOfWeek(date) { return ES.DayOfWeek(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)); }, dayOfYear(date) { return ES.DayOfYear(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)); }, weekOfYear(date) { return ES.WeekOfYear(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)).week; }, yearOfWeek(date) { return ES.WeekOfYear(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)).year; }, daysInWeek() { return 7; }, daysInMonth(date) { return ES.ISODaysInMonth(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH)); }, daysInYear(dateParam) { let date = dateParam; if (!HasSlot(date, ISO_YEAR)) date = ES.ToTemporalDate(date); return ES.LeapYear(GetSlot(date, ISO_YEAR)) ? 366 : 365; }, monthsInYear() { return 12; }, inLeapYear(dateParam) { let date = dateParam; if (!HasSlot(date, ISO_YEAR)) date = ES.ToTemporalDate(date); return ES.LeapYear(GetSlot(date, ISO_YEAR)); } }; // Note: Built-in calendars other than iso8601 are not part of the Temporal // proposal for ECMA-262. These calendars will be standardized as part of // ECMA-402. Code below here includes an implementation of these calendars to // validate the Temporal API and to get feedback. However, native non-ISO // calendar behavior is at least somewhat implementation-defined, so may not // match this polyfill's output exactly. // // Some ES implementations don't include ECMA-402. For this reason, it's helpful // to ensure a clean separation between the ISO calendar implementation which is // a part of ECMA-262 and the non-ISO calendar implementation which requires // ECMA-402. // // To ensure this separation, the implementation is split. A `CalendarImpl` // interface defines the common operations between both ISO and non-ISO // calendars. /** * This type is passed through from Calendar#dateFromFields(). * `monthExtra` is additional information used internally to identify lunisolar leap months. */ type CalendarDateFields = Params['dateFromFields'][0] & { monthExtra?: string }; /** * This is a "fully populated" calendar date record. It's only lacking * `era`/`eraYear` (which may not be present in all calendars) and `monthExtra` * which is only used in some cases. */ type FullCalendarDate = { era?: string; eraYear?: number; year: number; month: number; monthCode: string; day: number; monthExtra?: string; }; // The types below are various subsets of calendar dates type CalendarYMD = { year: number; month: number; day: number }; type CalendarYM = { year: number; month: number }; type CalendarYearOnly = { year: number }; type EraAndEraYear = { era: string; eraYear: number }; /** Record representing YMD of an ISO calendar date */ type IsoYMD = { year: number; month: number; day: number }; type Overflow = NonNullable; function monthCodeNumberPart(monthCode: string) { if (!monthCode.startsWith('M')) { throw new RangeError(`Invalid month code: ${monthCode}. Month codes must start with M.`); } const month = +monthCode.slice(1); if (isNaN(month)) throw new RangeError(`Invalid month code: ${monthCode}`); return month; } function buildMonthCode(month: number | string, leap = false) { return `M${month.toString().padStart(2, '0')}${leap ? 'L' : ''}`; } /** * Safely merge a month, monthCode pair into an integer month. * If both are present, make sure they match. * This logic doesn't work for lunisolar calendars! * */ function resolveNonLunisolarMonth( calendarDate: T, overflow: Overflow | undefined = undefined, monthsPerYear = 12 ) { let { month, monthCode } = calendarDate; if (monthCode === undefined) { if (month === undefined) throw new TypeError('Either month or monthCode are required'); // The ISO calendar uses the default (undefined) value because it does // constrain/reject after this method returns. Non-ISO calendars, however, // rely on this function to constrain/reject out-of-range `month` values. if (overflow === 'reject') ES.RejectToRange(month, 1, monthsPerYear); if (overflow === 'constrain') month = ES.ConstrainToRange(month, 1, monthsPerYear); monthCode = buildMonthCode(month); } else { const numberPart = monthCodeNumberPart(monthCode); if (month !== undefined && month !== numberPart) { throw new RangeError(`monthCode ${monthCode} and month ${month} must match if both are present`); } if (monthCode !== buildMonthCode(numberPart)) { throw new RangeError(`Invalid month code: ${monthCode}`); } month = numberPart; if (month < 1 || month > monthsPerYear) throw new RangeError(`Invalid monthCode: ${monthCode}`); } return { ...calendarDate, month, monthCode }; } type CachedTypes = Temporal.PlainYearMonth | Temporal.PlainDate | Temporal.PlainMonthDay; /** * This prototype implementation of non-ISO calendars makes many repeated calls * to Intl APIs which may be slow (e.g. >0.2ms). This trivial cache will speed * up these repeat accesses. Each cache instance is associated (via a WeakMap) * to a specific Temporal object, which speeds up multiple calendar calls on the * same Temporal object instance. No invalidation or pruning is necessary * because each object's cache is thrown away when the object is GC-ed. */ class OneObjectCache { map = new Map(); calls = 0; now: number; hits = 0; misses = 0; constructor(cacheToClone?: OneObjectCache) { this.now = globalThis.performance ? globalThis.performance.now() : Date.now(); if (cacheToClone !== undefined) { let i = 0; for (const entry of cacheToClone.map.entries()) { if (++i > OneObjectCache.MAX_CACHE_ENTRIES) break; this.map.set(...entry); } } } get(key: string) { const result = this.map.get(key); if (result) { this.hits++; this.report(); } this.calls++; return result; } set(key: string, value: unknown) { this.map.set(key, value); this.misses++; this.report(); } report() { /* if (this.calls === 0) return; const ms = (globalThis.performance ? globalThis.performance.now() : Date.now()) - this.now; const hitRate = ((100 * this.hits) / this.calls).toFixed(0); console.log(`${this.calls} calls in ${ms.toFixed(2)}ms. Hits: ${this.hits} (${hitRate}%). Misses: ${this.misses}.`); */ } setObject(obj: CachedTypes) { if (OneObjectCache.objectMap.get(obj)) throw new RangeError('object already cached'); OneObjectCache.objectMap.set(obj, this); this.report(); } static objectMap = new WeakMap(); static MAX_CACHE_ENTRIES = 1000; /** * Returns a WeakMap-backed cache that's used to store expensive results * that are associated with a particular Temporal object instance. * * @param obj - object to associate with the cache */ static getCacheForObject(obj: CachedTypes) { let cache = OneObjectCache.objectMap.get(obj); if (!cache) { cache = new OneObjectCache(); OneObjectCache.objectMap.set(obj, cache); } return cache; } } function toUtcIsoDateString({ isoYear, isoMonth, isoDay }: { isoYear: number; isoMonth: number; isoDay: number }) { const yearString = ES.ISOYearString(isoYear); const monthString = ES.ISODateTimePartString(isoMonth); const dayString = ES.ISODateTimePartString(isoDay); return `${yearString}-${monthString}-${dayString}T00:00Z`; } function simpleDateDiff(one: CalendarYMD, two: CalendarYMD) { return { years: one.year - two.year, months: one.month - two.month, days: one.day - two.day }; } /** * Implementation helper that's common to all non-ISO calendars */ abstract class HelperBase { abstract id: BuiltinCalendarId; abstract monthsInYear(calendarDate: CalendarYearOnly, cache?: OneObjectCache): number; abstract maximumMonthLength(calendarDate?: CalendarYM): number; abstract minimumMonthLength(calendarDate?: CalendarYM): number; abstract estimateIsoDate(calendarDate: CalendarYMD): IsoYMD; abstract inLeapYear(calendarDate: CalendarYearOnly, cache?: OneObjectCache): boolean; abstract calendarType: 'solar' | 'lunar' | 'lunisolar'; reviseIntlEra?>(calendarDate: T, isoDate: IsoYMD): T; constantEra?: string; checkIcuBugs?(isoDate: IsoYMD): void; private formatter?: globalThis.Intl.DateTimeFormat; getFormatter() { // `new Intl.DateTimeFormat()` is amazingly slow and chews up RAM. Per // https://bugs.chromium.org/p/v8/issues/detail?id=6528#c4, we cache one // DateTimeFormat instance per calendar. Caching is lazy so we only pay for // calendars that are used. Note that the nonIsoHelperBase object is spread // into each each calendar's implementation before any cache is created, so // each calendar gets its own separate cached formatter. if (typeof this.formatter === 'undefined') { this.formatter = new IntlDateTimeFormat(`en-US-u-ca-${this.id}`, { day: 'numeric', month: 'numeric', year: 'numeric', era: this.eraLength, timeZone: 'UTC' }); } return this.formatter; } isoToCalendarDate(isoDate: IsoYMD, cache: OneObjectCache): FullCalendarDate { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; const key = JSON.stringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); const cached = cache.get(key); if (cached) return cached; const dateTimeFormat = this.getFormatter(); let parts, isoString; try { isoString = toUtcIsoDateString({ isoYear, isoMonth, isoDay }); parts = dateTimeFormat.formatToParts(new Date(isoString)); } catch (e: unknown) { throw new RangeError(`Invalid ISO date: ${JSON.stringify({ isoYear, isoMonth, isoDay })}`); } const result: Partial = {}; for (let { type, value } of parts) { if (type === 'year') result.eraYear = +value; // TODO: remove this type annotation when `relatedYear` gets into TS lib types if (type === ('relatedYear' as Intl.DateTimeFormatPartTypes)) result.eraYear = +value; if (type === 'month') { const matches = /^([0-9]*)(.*?)$/.exec(value); if (!matches || matches.length != 3 || (!matches[1] && !matches[2])) { throw new RangeError(`Unexpected month: ${value}`); } // If the month has no numeric part (should only see this for the Hebrew // calendar with newer FF / Chromium versions; see // https://bugzilla.mozilla.org/show_bug.cgi?id=1751833) then set a // placeholder month index of `1` and rely on the derived class to // calculate the correct month index from the month name stored in // `monthExtra`. result.month = matches[1] ? +matches[1] : 1; if (result.month < 1) { throw new RangeError( `Invalid month ${value} from ${isoString}[u-ca-${this.id}]` + ' (probably due to https://bugs.chromium.org/p/v8/issues/detail?id=10527)' ); } if (result.month > 13) { throw new RangeError( `Invalid month ${value} from ${isoString}[u-ca-${this.id}]` + ' (probably due to https://bugs.chromium.org/p/v8/issues/detail?id=10529)' ); } // The ICU formats for the Hebrew calendar no longer support a numeric // month format. So we'll rely on the derived class to interpret it. // `monthExtra` is also used on the Chinese calendar to handle a suffix // "bis" indicating a leap month. if (matches[2]) result.monthExtra = matches[2]; } if (type === 'day') result.day = +value; if (this.hasEra && type === 'era' && value != null && value !== '') { // The convention for Temporal era values is lowercase, so following // that convention in this prototype. Punctuation is removed, accented // letters are normalized, and spaces are replaced with dashes. // E.g.: "ERA0" => "era0", "Before R.O.C." => "before-roc", "En’ō" => "eno" // The call to normalize() and the replacement regex deals with era // names that contain non-ASCII characters like Japanese eras. Also // ignore extra content in parentheses like JPN era date ranges. value = value.split(' (')[0]; result.era = value .normalize('NFD') .replace(/[^-0-9 \p{L}]/gu, '') .replace(' ', '-') .toLowerCase(); } } if (result.eraYear === undefined) { // Node 12 has outdated ICU data that lacks the `relatedYear` field in the // output of Intl.DateTimeFormat.formatToParts. throw new RangeError( `Intl.DateTimeFormat.formatToParts lacks relatedYear in ${this.id} calendar. Try Node 14+ or modern browsers.` ); } // Translate eras that may be handled differently by Temporal vs. by Intl // (e.g. Japanese pre-Meiji eras). See https://github.com/tc39/proposal-temporal/issues/526. if (this.reviseIntlEra) { const { era, eraYear } = this.reviseIntlEra(result, isoDate); result.era = era; result.eraYear = eraYear; } if (this.checkIcuBugs) this.checkIcuBugs(isoDate); const calendarDate = this.adjustCalendarDate(result, cache, 'constrain', true); if (calendarDate.year === undefined) throw new RangeError(`Missing year converting ${JSON.stringify(isoDate)}`); if (calendarDate.month === undefined) throw new RangeError(`Missing month converting ${JSON.stringify(isoDate)}`); if (calendarDate.day === undefined) throw new RangeError(`Missing day converting ${JSON.stringify(isoDate)}`); cache.set(key, calendarDate); // Also cache the reverse mapping ['constrain', 'reject'].forEach((overflow) => { const keyReverse = JSON.stringify({ func: 'calendarToIsoDate', year: calendarDate.year, month: calendarDate.month, day: calendarDate.day, overflow, id: this.id }); cache.set(keyReverse, isoDate); }); return calendarDate; } validateCalendarDate(calendarDate: Partial): asserts calendarDate is FullCalendarDate { const { era, month, year, day, eraYear, monthCode, monthExtra } = calendarDate; // When there's a suffix (e.g. "5bis" for a leap month in Chinese calendar) // the derived class must deal with it. if (monthExtra !== undefined) throw new RangeError('Unexpected `monthExtra` value'); if (year === undefined && eraYear === undefined) throw new TypeError('year or eraYear is required'); if (month === undefined && monthCode === undefined) throw new TypeError('month or monthCode is required'); if (day === undefined) throw new RangeError('Missing day'); if (monthCode !== undefined) { if (typeof monthCode !== 'string') { throw new RangeError(`monthCode must be a string, not ${typeof monthCode}`); } if (!/^M([01]?\d)(L?)$/.test(monthCode)) throw new RangeError(`Invalid monthCode: ${monthCode}`); } if (this.constantEra) { if (era !== undefined && era !== this.constantEra) { throw new RangeError(`era must be ${this.constantEra}, not ${era}`); } if (eraYear !== undefined && year !== undefined && eraYear !== year) { throw new RangeError(`eraYear ${eraYear} does not match year ${year}`); } } if (this.hasEra) { if ((calendarDate['era'] === undefined) !== (calendarDate['eraYear'] === undefined)) { throw new RangeError("properties 'era' and 'eraYear' must be provided together"); } } } /** * Allows derived calendars to add additional fields and/or to make * adjustments e.g. to set the era based on the date or to revise the month * number in lunisolar calendars per * https://github.com/tc39/proposal-temporal/issues/1203. * * The base implementation fills in missing values by assuming the simplest * possible calendar: * - no eras or a constant era defined in `.constantEra` * - non-lunisolar calendar (no leap months) * */ adjustCalendarDate( calendarDateParam: Partial, cache: OneObjectCache | undefined = undefined, overflow: Overflow = 'constrain', // This param is only used by derived classes // eslint-disable-next-line @typescript-eslint/no-unused-vars fromLegacyDate = false ): FullCalendarDate { if (this.calendarType === 'lunisolar') throw new RangeError('Override required for lunisolar calendars'); let calendarDate = calendarDateParam; this.validateCalendarDate(calendarDate); // For calendars that always use the same era, set it here so that derived // calendars won't need to implement this method simply to set the era. if (this.constantEra) { // year and eraYear always match when there's only one possible era const { year, eraYear } = calendarDate; calendarDate = { ...calendarDate, era: this.constantEra, year: year !== undefined ? year : eraYear, eraYear: eraYear !== undefined ? eraYear : year }; } const largestMonth = this.monthsInYear(calendarDate as CalendarYearOnly, cache); let { month, monthCode } = calendarDate; ({ month, monthCode } = resolveNonLunisolarMonth(calendarDate, overflow, largestMonth)); return { ...(calendarDate as typeof calendarDate & CalendarYMD), month, monthCode }; } regulateMonthDayNaive(calendarDate: FullCalendarDate, overflow: Overflow, cache: OneObjectCache): FullCalendarDate { const largestMonth = this.monthsInYear(calendarDate, cache); let { month, day } = calendarDate; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day, 1, this.maximumMonthLength(calendarDate)); } else { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength({ ...calendarDate, month })); } return { ...calendarDate, month, day }; } calendarToIsoDate(dateParam: CalendarDateFields, overflow: Overflow = 'constrain', cache: OneObjectCache): IsoYMD { const originalDate = dateParam as Partial; // First, normalize the calendar date to ensure that (year, month, day) // are all present, converting monthCode and eraYear if needed. let date = this.adjustCalendarDate(dateParam, cache, overflow, false); // Fix obviously out-of-bounds values. Values that are valid generally, but // not in this particular year, may not be caught here for some calendars. // If so, these will be handled lower below. date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; const key = JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow, id: this.id }); let cached = cache.get(key); if (cached) return cached; // If YMD are present in the input but the input has been constrained // already, then cache both the original value and the constrained value. let keyOriginal; if ( originalDate.year !== undefined && originalDate.month !== undefined && originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { keyOriginal = JSON.stringify({ func: 'calendarToIsoDate', year: originalDate.year, month: originalDate.month, day: originalDate.day, overflow, id: this.id }); cached = cache.get(keyOriginal); if (cached) return cached; } // First, try to roughly guess the result let isoEstimate = this.estimateIsoDate({ year, month, day }); const calculateSameMonthResult = (diffDays: number) => { // If the estimate is in the same year & month as the target, then we can // calculate the result exactly and short-circuit any additional logic. // This optimization assumes that months are continuous. It would break if // a calendar skipped days, like the Julian->Gregorian switchover. But the // only ICU calendars that currently skip days (japanese/roc/buddhist) is // a bug (https://bugs.chromium.org/p/chromium/issues/detail?id=1173158) // that's currently detected by `checkIcuBugs()` which will throw. So // this optimization should be safe for all ICU calendars. let testIsoEstimate = this.addDaysIso(isoEstimate, diffDays); if (date.day > this.minimumMonthLength(date)) { // There's a chance that the calendar date is out of range. Throw or // constrain if so. let testCalendarDate = this.isoToCalendarDate(testIsoEstimate, cache); while (testCalendarDate.month !== month || testCalendarDate.year !== year) { if (overflow === 'reject') { throw new RangeError(`day ${day} does not exist in month ${month} of year ${year}`); } // Back up a day at a time until we're not hanging over the month end testIsoEstimate = this.addDaysIso(testIsoEstimate, -1); testCalendarDate = this.isoToCalendarDate(testIsoEstimate, cache); } } return testIsoEstimate; }; let sign = 0; let roundtripEstimate = this.isoToCalendarDate(isoEstimate, cache); let diff = simpleDateDiff(date, roundtripEstimate); if (diff.years !== 0 || diff.months !== 0 || diff.days !== 0) { const diffTotalDaysEstimate = diff.years * 365 + diff.months * 30 + diff.days; isoEstimate = this.addDaysIso(isoEstimate, diffTotalDaysEstimate); roundtripEstimate = this.isoToCalendarDate(isoEstimate, cache); diff = simpleDateDiff(date, roundtripEstimate); if (diff.years === 0 && diff.months === 0) { isoEstimate = calculateSameMonthResult(diff.days); } else { sign = this.compareCalendarDates(date, roundtripEstimate); } } // If the initial guess is not in the same month, then then bisect the // distance to the target, starting with 8 days per step. let increment = 8; while (sign) { isoEstimate = this.addDaysIso(isoEstimate, sign * increment); const oldRoundtripEstimate = roundtripEstimate; roundtripEstimate = this.isoToCalendarDate(isoEstimate, cache); const oldSign = sign; sign = this.compareCalendarDates(date, roundtripEstimate); if (sign) { diff = simpleDateDiff(date, roundtripEstimate); if (diff.years === 0 && diff.months === 0) { isoEstimate = calculateSameMonthResult(diff.days); // Signal the loop condition that there's a match. sign = 0; } else if (oldSign && sign !== oldSign) { if (increment > 1) { // If the estimate overshot the target, try again with a smaller increment // in the reverse direction. increment /= 2; } else { // Increment is 1, and neither the previous estimate nor the new // estimate is correct. The only way that can happen is if the // original date was an invalid value that will be constrained or // rejected here. if (overflow === 'reject') { throw new RangeError(`Can't find ISO date from calendar date: ${JSON.stringify({ ...originalDate })}`); } else { // To constrain, pick the earliest value const order = this.compareCalendarDates(roundtripEstimate, oldRoundtripEstimate); // If current value is larger, then back up to the previous value. if (order > 0) isoEstimate = this.addDaysIso(isoEstimate, -1); sign = 0; } } } } } cache.set(key, isoEstimate); if (keyOriginal) cache.set(keyOriginal, isoEstimate); if ( date.year === undefined || date.month === undefined || date.day === undefined || date.monthCode === undefined || (this.hasEra && (date.era === undefined || date.eraYear === undefined)) ) { throw new RangeError('Unexpected missing property'); } return isoEstimate; } temporalToCalendarDate( date: Temporal.PlainDate | Temporal.PlainMonthDay | Temporal.PlainYearMonth, cache: OneObjectCache ): FullCalendarDate { const isoDate = { year: GetSlot(date, ISO_YEAR), month: GetSlot(date, ISO_MONTH), day: GetSlot(date, ISO_DAY) }; const result = this.isoToCalendarDate(isoDate, cache); return result; } compareCalendarDates(date1Param: Partial, date2Param: Partial): 0 | 1 | -1 { // `date1` and `date2` are already records. The calls below simply validate // that all three required fields are present. const date1 = ES.PrepareTemporalFields(date1Param, ['day', 'month', 'year'], ['day', 'month', 'year']); const date2 = ES.PrepareTemporalFields(date2Param, ['day', 'month', 'year'], ['day', 'month', 'year']); if (date1.year !== date2.year) return ES.ComparisonResult(date1.year - date2.year); if (date1.month !== date2.month) return ES.ComparisonResult(date1.month - date2.month); if (date1.day !== date2.day) return ES.ComparisonResult(date1.day - date2.day); return 0; } /** Ensure that a calendar date actually exists. If not, return the closest earlier date. */ regulateDate(calendarDate: CalendarYMD, overflow: Overflow = 'constrain', cache: OneObjectCache): FullCalendarDate { const isoDate = this.calendarToIsoDate(calendarDate, overflow, cache); return this.isoToCalendarDate(isoDate, cache); } addDaysIso(isoDate: IsoYMD, days: number): IsoYMD { const added = ES.AddISODate(isoDate.year, isoDate.month, isoDate.day, 0, 0, 0, days, 'constrain'); return added; } addDaysCalendar(calendarDate: CalendarYMD, days: number, cache: OneObjectCache): FullCalendarDate { const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); const addedIso = this.addDaysIso(isoDate, days); const addedCalendar = this.isoToCalendarDate(addedIso, cache); return addedCalendar; } addMonthsCalendar( calendarDateParam: CalendarYMD, months: number, overflow: Overflow, cache: OneObjectCache ): CalendarYMD { let calendarDate = calendarDateParam; const { day } = calendarDate; for (let i = 0, absMonths = MathAbs(months); i < absMonths; i++) { const { month } = calendarDate; const oldCalendarDate = calendarDate; const days = months < 0 ? -Math.max(day, this.daysInPreviousMonth(calendarDate, cache)) : this.daysInMonth(calendarDate, cache); const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); let addedIso = this.addDaysIso(isoDate, days); calendarDate = this.isoToCalendarDate(addedIso, cache); // Normally, we can advance one month by adding the number of days in the // current month. However, if we're at the end of the current month and // the next month has fewer days, then we rolled over to the after-next // month. Below we detect this condition and back up until we're back in // the desired month. if (months > 0) { const monthsInOldYear = this.monthsInYear(oldCalendarDate, cache); while (calendarDate.month - 1 !== month % monthsInOldYear) { addedIso = this.addDaysIso(addedIso, -1); calendarDate = this.isoToCalendarDate(addedIso, cache); } } if (calendarDate.day !== day) { // try to retain the original day-of-month, if possible calendarDate = this.regulateDate({ ...calendarDate, day }, 'constrain', cache); } } if (overflow === 'reject' && calendarDate.day !== day) { throw new RangeError(`Day ${day} does not exist in resulting calendar month`); } return calendarDate; } addCalendar( calendarDate: CalendarYMD & { monthCode: string }, { years = 0, months = 0, weeks = 0, days = 0 }, overflow: Overflow, cache: OneObjectCache ): FullCalendarDate { const { year, day, monthCode } = calendarDate; const addedYears = this.adjustCalendarDate({ year: year + years, monthCode, day }, cache); const addedMonths = this.addMonthsCalendar(addedYears, months, overflow, cache); const initialDays = days + weeks * 7; const addedDays = this.addDaysCalendar(addedMonths, initialDays, cache); return addedDays; } untilCalendar( calendarOne: FullCalendarDate, calendarTwo: FullCalendarDate, largestUnit: Temporal.DateUnit, cache: OneObjectCache ): { years: number; months: number; weeks: number; days: number } { let days = 0; let weeks = 0; let months = 0; let years = 0; switch (largestUnit) { case 'day': days = this.calendarDaysUntil(calendarOne, calendarTwo, cache); break; case 'week': { const totalDays = this.calendarDaysUntil(calendarOne, calendarTwo, cache); days = totalDays % 7; weeks = (totalDays - days) / 7; break; } case 'month': case 'year': { const sign = this.compareCalendarDates(calendarTwo, calendarOne); if (!sign) { return { years: 0, months: 0, weeks: 0, days: 0 }; } const diffYears = calendarTwo.year - calendarOne.year; const diffDays = calendarTwo.day - calendarOne.day; if (largestUnit === 'year' && diffYears) { let diffInYearSign = 0; if (calendarTwo.monthCode > calendarOne.monthCode) diffInYearSign = 1; if (calendarTwo.monthCode < calendarOne.monthCode) diffInYearSign = -1; if (!diffInYearSign) diffInYearSign = Math.sign(diffDays); const isOneFurtherInYear = diffInYearSign * sign < 0; years = isOneFurtherInYear ? diffYears - sign : diffYears; } const yearsAdded = years ? this.addCalendar(calendarOne, { years }, 'constrain', cache) : calendarOne; // Now we have less than one year remaining. Add one month at a time // until we go over the target, then back up one month and calculate // remaining days and weeks. let current; let next: CalendarYMD = yearsAdded; do { months += sign; current = next; next = this.addMonthsCalendar(current, sign, 'constrain', cache); if (next.day !== calendarOne.day) { // In case the day was constrained down, try to un-constrain it next = this.regulateDate({ ...next, day: calendarOne.day }, 'constrain', cache); } } while (this.compareCalendarDates(calendarTwo, next) * sign >= 0); months -= sign; // correct for loop above which overshoots by 1 const remainingDays = this.calendarDaysUntil(current, calendarTwo, cache); days = remainingDays; break; } } return { years, months, weeks, days }; } daysInMonth(calendarDate: CalendarYMD, cache: OneObjectCache): number { // Add enough days to roll over to the next month. One we're in the next // month, we can calculate the length of the current month. NOTE: This // algorithm assumes that months are continuous. It would break if a // calendar skipped days, like the Julian->Gregorian switchover. But the // only ICU calendars that currently skip days (japanese/roc/buddhist) is a // bug (https://bugs.chromium.org/p/chromium/issues/detail?id=1173158) // that's currently detected by `checkIcuBugs()` which will throw. So this // code should be safe for all ICU calendars. const { day } = calendarDate; const max = this.maximumMonthLength(calendarDate); const min = this.minimumMonthLength(calendarDate); // easiest case: we already know the month length if min and max are the same. if (min === max) return min; // Add enough days to get into the next month, without skipping it const increment = day <= max - min ? max : min; const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); const addedIsoDate = this.addDaysIso(isoDate, increment); const addedCalendarDate = this.isoToCalendarDate(addedIsoDate, cache); // Now back up to the last day of the original month const endOfMonthIso = this.addDaysIso(addedIsoDate, -addedCalendarDate.day); const endOfMonthCalendar = this.isoToCalendarDate(endOfMonthIso, cache); return endOfMonthCalendar.day; } daysInPreviousMonth(calendarDate: CalendarYMD, cache: OneObjectCache): number { const { day, month, year } = calendarDate; // Check to see if we already know the month length, and return it if so const previousMonthYear = month > 1 ? year : year - 1; let previousMonthDate = { year: previousMonthYear, month, day: 1 }; const previousMonth = month > 1 ? month - 1 : this.monthsInYear(previousMonthDate, cache); previousMonthDate = { ...previousMonthDate, month: previousMonth }; const min = this.minimumMonthLength(previousMonthDate); const max = this.maximumMonthLength(previousMonthDate); if (min === max) return max; const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); const lastDayOfPreviousMonthIso = this.addDaysIso(isoDate, -day); const lastDayOfPreviousMonthCalendar = this.isoToCalendarDate(lastDayOfPreviousMonthIso, cache); return lastDayOfPreviousMonthCalendar.day; } startOfCalendarYear(calendarDate: CalendarYearOnly): CalendarYMD & { monthCode: string } { return { year: calendarDate.year, month: 1, monthCode: 'M01', day: 1 }; } startOfCalendarMonth(calendarDate: CalendarYM): CalendarYMD { return { year: calendarDate.year, month: calendarDate.month, day: 1 }; } calendarDaysUntil(calendarOne: CalendarYMD, calendarTwo: CalendarYMD, cache: OneObjectCache): number { const oneIso = this.calendarToIsoDate(calendarOne, 'constrain', cache); const twoIso = this.calendarToIsoDate(calendarTwo, 'constrain', cache); return this.isoDaysUntil(oneIso, twoIso); } isoDaysUntil(oneIso: IsoYMD, twoIso: IsoYMD): number { const duration = ES.DifferenceISODate( oneIso.year, oneIso.month, oneIso.day, twoIso.year, twoIso.month, twoIso.day, 'day' ); return duration.days; } // The short era format works for all calendars except Japanese, which will // override. eraLength: Intl.DateTimeFormatOptions['era'] = 'short'; // All built-in calendars except Chinese/Dangi and Hebrew use an era hasEra = true; // See https://github.com/tc39/proposal-temporal/issues/1784 erasBeginMidYear = false; monthDayFromFields(fields: FullCalendarDate, overflow: Overflow, cache: OneObjectCache): IsoYMD { let { monthCode, day } = fields; if (monthCode === undefined) { let { year, era, eraYear } = fields; if (year === undefined && (era === undefined || eraYear === undefined)) { throw new TypeError('when `monthCode` is omitted, `year` (or `era` and `eraYear`) and `month` are required'); } // Apply overflow behaviour to year/month/day, to get correct monthCode/day ({ monthCode, day } = this.isoToCalendarDate(this.calendarToIsoDate(fields, overflow, cache), cache)); } let isoYear, isoMonth, isoDay; let closestCalendar, closestIso; // Look backwards starting from one of the calendar years spanning ISO year // 1972, up to 100 calendar years prior, to find a year that has this month // and day. Normal months and days will match immediately, but for leap days // and leap months we may have to look for a while. const startDateIso = { year: 1972, month: 12, day: 31 }; const calendarOfStartDateIso = this.isoToCalendarDate(startDateIso, cache); // Note: relies on lexicographical ordering of monthCodes const calendarYear = calendarOfStartDateIso.monthCode > monthCode || (calendarOfStartDateIso.monthCode === monthCode && calendarOfStartDateIso.day >= day) ? calendarOfStartDateIso.year : calendarOfStartDateIso.year - 1; for (let i = 0; i < 100; i++) { const testCalendarDate: FullCalendarDate = this.adjustCalendarDate( { day, monthCode, year: calendarYear - i }, cache ); const isoDate = this.calendarToIsoDate(testCalendarDate, 'constrain', cache); const roundTripCalendarDate = this.isoToCalendarDate(isoDate, cache); ({ year: isoYear, month: isoMonth, day: isoDay } = isoDate); if (roundTripCalendarDate.monthCode === monthCode && roundTripCalendarDate.day === day) { return { month: isoMonth, day: isoDay, year: isoYear }; } else if (overflow === 'constrain') { // non-ISO constrain algorithm tries to find the closest date in a matching month if ( closestCalendar === undefined || (roundTripCalendarDate.monthCode === closestCalendar.monthCode && roundTripCalendarDate.day > closestCalendar.day) ) { closestCalendar = roundTripCalendarDate; closestIso = isoDate; } } } if (overflow === 'constrain' && closestIso !== undefined) return closestIso; throw new RangeError(`No recent ${this.id} year with monthCode ${monthCode} and day ${day}`); } } interface HebrewMonthInfo { [m: string]: ( | { leap: undefined; regular: number; } | { leap: number; regular: undefined; } | { leap: number; regular: number; } ) & { monthCode: string; days: | number | { min: number; max: number; }; }; } class HebrewHelper extends HelperBase { id = 'hebrew' as const; calendarType = 'lunisolar' as const; inLeapYear(calendarDate: CalendarYearOnly) { const { year } = calendarDate; // FYI: In addition to adding a month in leap years, the Hebrew calendar // also has per-year changes to the number of days of Heshvan and Kislev. // Given that these can be calculated by counting the number of days in // those months, I assume that these DO NOT need to be exposed as // Hebrew-only prototype fields or methods. return (7 * year + 1) % 19 < 7; } monthsInYear(calendarDate: CalendarYearOnly) { return this.inLeapYear(calendarDate) ? 13 : 12; } minimumMonthLength(calendarDate: CalendarYM) { return this.minMaxMonthLength(calendarDate, 'min'); } maximumMonthLength(calendarDate: CalendarYM) { return this.minMaxMonthLength(calendarDate, 'max'); } minMaxMonthLength(calendarDate: CalendarYM, minOrMax: 'min' | 'max') { const { month, year } = calendarDate; const monthCode = this.getMonthCode(year, month); const monthInfo = ObjectEntries(this.months).find((m) => m[1].monthCode === monthCode); if (monthInfo === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); const daysInMonth = monthInfo[1].days; return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; } /** Take a guess at what ISO date a particular calendar date corresponds to */ estimateIsoDate(calendarDate: CalendarYMD) { const { year } = calendarDate; return { year: year - 3760, month: 1, day: 1 }; } months: HebrewMonthInfo = { Tishri: { leap: 1, regular: 1, monthCode: 'M01', days: 30 }, Heshvan: { leap: 2, regular: 2, monthCode: 'M02', days: { min: 29, max: 30 } }, Kislev: { leap: 3, regular: 3, monthCode: 'M03', days: { min: 29, max: 30 } }, Tevet: { leap: 4, regular: 4, monthCode: 'M04', days: 29 }, Shevat: { leap: 5, regular: 5, monthCode: 'M05', days: 30 }, Adar: { leap: undefined, regular: 6, monthCode: 'M06', days: 29 }, 'Adar I': { leap: 6, regular: undefined, monthCode: 'M05L', days: 30 }, 'Adar II': { leap: 7, regular: undefined, monthCode: 'M06', days: 29 }, Nisan: { leap: 8, regular: 7, monthCode: 'M07', days: 30 }, Iyar: { leap: 9, regular: 8, monthCode: 'M08', days: 29 }, Sivan: { leap: 10, regular: 9, monthCode: 'M09', days: 30 }, Tamuz: { leap: 11, regular: 10, monthCode: 'M10', days: 29 }, Av: { leap: 12, regular: 11, monthCode: 'M11', days: 30 }, Elul: { leap: 13, regular: 12, monthCode: 'M12', days: 29 } }; getMonthCode(year: number, month: number) { if (this.inLeapYear({ year })) { return month === 6 ? buildMonthCode(5, true) : buildMonthCode(month < 6 ? month : month - 1); } else { return buildMonthCode(month); } } override adjustCalendarDate( calendarDate: Partial, cache?: OneObjectCache, overflow: Overflow = 'constrain', fromLegacyDate = false ): FullCalendarDate { // The incoming type is actually CalendarDate (same as args to // Calendar.dateFromParams) but TS isn't smart enough to follow all the // reassignments below, so as an alternative to 10+ type casts, we'll lie // here and claim that the type has `day` and `year` filled in already. let { year, eraYear, month, monthCode, day, monthExtra } = calendarDate as Omit< typeof calendarDate, 'year' | 'day' > & { year: number; day: number }; if (year === undefined && eraYear !== undefined) year = eraYear; if (eraYear === undefined && year !== undefined) eraYear = year; if (fromLegacyDate) { // In Pre Node-14 V8, DateTimeFormat.formatToParts `month: 'numeric'` // output returns the numeric equivalent of `month` as a string, meaning // that `'6'` in a leap year is Adar I, while `'6'` in a non-leap year // means Adar. In this case, `month` will already be correct and no action // is needed. However, in Node 14 and later formatToParts returns the name // of the Hebrew month (e.g. "Tevet"), so we'll need to look up the // correct `month` using the string name as a key. if (monthExtra) { const monthInfo = this.months[monthExtra]; if (!monthInfo) throw new RangeError(`Unrecognized month from formatToParts: ${monthExtra}`); month = this.inLeapYear({ year }) ? monthInfo.leap : monthInfo.regular; } // Because we're getting data from legacy Date, then `month` will always be present monthCode = this.getMonthCode(year, month as number); const result = { year, month: month as number, day, era: undefined as string | undefined, eraYear, monthCode }; return result; } else { // When called without input coming from legacy Date output, simply ensure // that all fields are present. this.validateCalendarDate(calendarDate); if (month === undefined) { if ((monthCode as string).endsWith('L')) { if (monthCode !== 'M05L') { throw new RangeError(`Hebrew leap month must have monthCode M05L, not ${monthCode}`); } month = 6; if (!this.inLeapYear({ year })) { if (overflow === 'reject') { throw new RangeError(`Hebrew monthCode M05L is invalid in year ${year} which is not a leap year`); } else { // constrain to same day of next month (Adar) month = 6; monthCode = 'M06'; } } } else { month = monthCodeNumberPart(monthCode as string); // if leap month is before this one, the month index is one more than the month code if (this.inLeapYear({ year }) && month >= 6) month++; const largestMonth = this.monthsInYear({ year }); if (month < 1 || month > largestMonth) throw new RangeError(`Invalid monthCode: ${monthCode}`); } } else { if (overflow === 'reject') { ES.RejectToRange(month, 1, this.monthsInYear({ year })); ES.RejectToRange(day, 1, this.maximumMonthLength({ year, month })); } else { month = ES.ConstrainToRange(month, 1, this.monthsInYear({ year })); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength({ year, month })); } if (monthCode === undefined) { monthCode = this.getMonthCode(year, month); } else { const calculatedMonthCode = this.getMonthCode(year, month); if (calculatedMonthCode !== monthCode) { throw new RangeError(`monthCode ${monthCode} doesn't correspond to month ${month} in Hebrew year ${year}`); } } } return { ...calendarDate, day, month, monthCode: monthCode as string, year, eraYear }; } } // All built-in calendars except Chinese/Dangi and Hebrew use an era override hasEra = false; } /** * For Temporal purposes, the Islamic calendar is simple because it's always the * same 12 months in the same order. */ abstract class IslamicBaseHelper extends HelperBase { abstract override id: BuiltinCalendarId; calendarType = 'lunar' as const; inLeapYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { // In leap years, the 12th month has 30 days. In non-leap years: 29. const days = this.daysInMonth({ year: calendarDate.year, month: 12, day: 1 }, cache); return days === 30; } monthsInYear(/* calendarYear, cache */) { return 12; } minimumMonthLength(/* calendarDate */) { return 29; } maximumMonthLength(/* calendarDate */) { return 30; } DAYS_PER_ISLAMIC_YEAR = 354 + 11 / 30; DAYS_PER_ISO_YEAR = 365.2425; override constantEra = 'ah'; estimateIsoDate(calendarDate: CalendarYMD) { const { year } = this.adjustCalendarDate(calendarDate); return { year: MathFloor((year * this.DAYS_PER_ISLAMIC_YEAR) / this.DAYS_PER_ISO_YEAR) + 622, month: 1, day: 1 }; } } // There are 6 Islamic calendars with the same implementation in this polyfill. // They vary only in their ID. They do emit different output from the underlying // Intl implementation, but our code for each of them is identical. class IslamicHelper extends IslamicBaseHelper { id = 'islamic' as const; } class IslamicUmalquraHelper extends IslamicBaseHelper { id = 'islamic-umalqura' as const; } class IslamicTblaHelper extends IslamicBaseHelper { id = 'islamic-tbla' as const; } class IslamicCivilHelper extends IslamicBaseHelper { id = 'islamic-civil' as const; } class IslamicRgsaHelper extends IslamicBaseHelper { id = 'islamic-rgsa' as const; } class IslamicCcHelper extends IslamicBaseHelper { id = 'islamicc' as const; } class PersianHelper extends HelperBase { id = 'persian' as const; calendarType = 'solar' as const; inLeapYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { // Same logic (count days in the last month) for Persian as for Islamic, // even though Persian is solar and Islamic is lunar. return IslamicHelper.prototype.inLeapYear.call(this, calendarDate, cache); } monthsInYear(/* calendarYear, cache */) { return 12; } minimumMonthLength(calendarDate: CalendarYM) { const { month } = calendarDate; if (month === 12) return 29; return month <= 6 ? 31 : 30; } maximumMonthLength(calendarDate: CalendarYM) { const { month } = calendarDate; if (month === 12) return 30; return month <= 6 ? 31 : 30; } override constantEra = 'ap'; estimateIsoDate(calendarDate: CalendarYMD) { const { year } = this.adjustCalendarDate(calendarDate); return { year: year + 621, month: 1, day: 1 }; } } interface IndianMonthInfo { [month: number]: { length: number; month: number; day: number; leap?: { length: number; month: number; day: number; }; nextYear?: true | undefined; }; } class IndianHelper extends HelperBase { id = 'indian' as const; calendarType = 'solar' as const; inLeapYear(calendarDate: CalendarYearOnly) { // From https://en.wikipedia.org/wiki/Indian_national_calendar: // Years are counted in the Saka era, which starts its year 0 in the year 78 // of the Common Era. To determine leap years, add 78 to the Saka year – if // the result is a leap year in the Gregorian calendar, then the Saka year // is a leap year as well. return isGregorianLeapYear(calendarDate.year + 78); } monthsInYear(/* calendarYear, cache */) { return 12; } minimumMonthLength(calendarDate: CalendarYM) { return this.getMonthInfo(calendarDate).length; } maximumMonthLength(calendarDate: CalendarYM) { return this.getMonthInfo(calendarDate).length; } override constantEra = 'saka'; // Indian months always start at the same well-known Gregorian month and // day. So this conversion is easy and fast. See // https://en.wikipedia.org/wiki/Indian_national_calendar months: IndianMonthInfo = { 1: { length: 30, month: 3, day: 22, leap: { length: 31, month: 3, day: 21 } }, 2: { length: 31, month: 4, day: 21 }, 3: { length: 31, month: 5, day: 22 }, 4: { length: 31, month: 6, day: 22 }, 5: { length: 31, month: 7, day: 23 }, 6: { length: 31, month: 8, day: 23 }, 7: { length: 30, month: 9, day: 23 }, 8: { length: 30, month: 10, day: 23 }, 9: { length: 30, month: 11, day: 22 }, 10: { length: 30, month: 12, day: 22 }, 11: { length: 30, month: 1, nextYear: true, day: 21 }, 12: { length: 30, month: 2, nextYear: true, day: 20 } }; getMonthInfo(calendarDate: CalendarYM) { const { month } = calendarDate; let monthInfo = this.months[month]; if (monthInfo === undefined) throw new RangeError(`Invalid month: ${month}`); if (this.inLeapYear(calendarDate) && monthInfo.leap) monthInfo = monthInfo.leap; return monthInfo; } estimateIsoDate(calendarDateParam: CalendarYMD) { // FYI, this "estimate" is always the exact ISO date, which makes the Indian // calendar fast! const calendarDate = this.adjustCalendarDate(calendarDateParam); const monthInfo = this.getMonthInfo(calendarDate); const isoYear = calendarDate.year + 78 + (monthInfo.nextYear ? 1 : 0); const isoMonth = monthInfo.month; const isoDay = monthInfo.day; const isoDate = ES.AddISODate(isoYear, isoMonth, isoDay, 0, 0, 0, calendarDate.day - 1, 'constrain'); return isoDate; } // https://bugs.chromium.org/p/v8/issues/detail?id=10529 causes Intl's Indian // calendar output to fail for all dates before 0001-01-01 ISO. For example, // in Node 12 0000-01-01 is calculated as 6146/12/-583 instead of 10/11/-79 as // expected. vulnerableToBceBug = new Date('0000-01-01T00:00Z').toLocaleDateString('en-US-u-ca-indian', { timeZone: 'UTC' }) !== '10/11/-79 Saka'; override checkIcuBugs(isoDate: IsoYMD) { if (this.vulnerableToBceBug && isoDate.year < 1) { throw new RangeError( `calendar '${this.id}' is broken for ISO dates before 0001-01-01` + ' (see https://bugs.chromium.org/p/v8/issues/detail?id=10529)' ); } } } /** * Era metadata defined for each calendar. * TODO: instead of optional properties, this should really have rules * encoded in the type, e.g. isoEpoch is required unless reverseOf is present. * */ interface InputEra { /** name of the era */ name: string; /** * Signed calendar year where this era begins.Will be * 1 (or 0 for zero-based eras) for the anchor era assuming that `year` * numbering starts at the beginning of the anchor era, which is true * for all ICU calendars except Japanese. If an era starts mid-year * then a calendar month and day are included. Otherwise * `{ month: 1, day: 1 }` is assumed. */ anchorEpoch?: CalendarYearOnly | CalendarYMD; /** ISO date of the first day of this era */ isoEpoch?: { year: number; month: number; day: number }; /** * If present, then this era counts years backwards like BC * and this property points to the forward era. This must be * the last (oldest) era in the array. * */ reverseOf?: string; /** * If true, the era's years are 0-based. If omitted or false, * then the era's years are 1-based. * */ hasYearZero?: boolean; /** * Override if this era is the anchor. Not normally used because * anchor eras are inferred. * */ isAnchor?: boolean; } /** * Transformation of the `InputEra` type with all fields filled in by * `adjustEras()` * */ interface Era { /** name of the era */ name: string; /** * alternate name of the era used in old versions of ICU data * format is `era{n}` where n is the zero-based index of the era * with the oldest era being 0. * */ genericName: string; /** * Signed calendar year where this era begins. Will be 1 (or 0 for zero-based * eras) for the anchor era assuming that `year` numbering starts at the * beginning of the anchor era, which is true for all ICU calendars except * Japanese. For input, the month and day are optional. If an era starts * mid-year then a calendar month and day are included. * Otherwise `{ month: 1, day: 1 }` is assumed. */ anchorEpoch: CalendarYMD; /** ISO date of the first day of this era */ isoEpoch: IsoYMD; /** * If present, then this era counts years backwards like BC * and this property points to the forward era. This must be * the last (oldest) era in the array. * */ reverseOf?: Era; /** * If true, the era's years are 0-based. If omitted or false, * then the era's years are 1-based. * */ hasYearZero?: boolean; /** * Override if this era is the anchor. Not normally used because * anchor eras are inferred. * */ isAnchor?: boolean; } /** * This function adds additional metadata that makes it easier to work with * eras. Note that it mutates and normalizes the original era objects, which is * OK because this is non-observable, internal-only metadata. * * The result is an array of eras with the shape defined above. * */ function adjustEras(erasParam: InputEra[]): { eras: Era[]; anchorEra: Era } { let eras: (InputEra | Era)[] = erasParam; if (eras.length === 0) { throw new RangeError('Invalid era data: eras are required'); } if (eras.length === 1 && eras[0].reverseOf) { throw new RangeError('Invalid era data: anchor era cannot count years backwards'); } if (eras.length === 1 && !eras[0].name) { throw new RangeError('Invalid era data: at least one named era is required'); } if (eras.filter((e) => e.reverseOf != null).length > 1) { throw new RangeError('Invalid era data: only one era can count years backwards'); } // Find the "anchor era" which is the era used for (era-less) `year`. Reversed // eras can never be anchors. The era without an `anchorEpoch` property is the // anchor. let anchorEra: Era | InputEra | undefined; eras.forEach((e) => { if (e.isAnchor || (!e.anchorEpoch && !e.reverseOf)) { if (anchorEra) throw new RangeError('Invalid era data: cannot have multiple anchor eras'); anchorEra = e; e.anchorEpoch = { year: e.hasYearZero ? 0 : 1 }; } else if (!e.name) { throw new RangeError('If era name is blank, it must be the anchor era'); } }); // If the era name is undefined, then it's an anchor that doesn't interact // with eras at all. For example, Japanese `year` is always the same as ISO // `year`. So this "era" is the anchor era but isn't used for era matching. // Strip it from the list that's returned. eras = eras.filter((e) => e.name); eras.forEach((e) => { // Some eras are mirror images of another era e.g. B.C. is the reverse of A.D. // Replace the string-valued "reverseOf" property with the actual era object // that's reversed. const { reverseOf } = e; if (reverseOf) { const reversedEra = eras.find((era) => era.name === reverseOf); if (reversedEra === undefined) throw new RangeError(`Invalid era data: unmatched reverseOf era: ${reverseOf}`); e.reverseOf = reversedEra as Era; e.anchorEpoch = reversedEra.anchorEpoch; e.isoEpoch = reversedEra.isoEpoch; } type YMD = { year: number; month: number; day: number; }; if ((e.anchorEpoch as YMD).month === undefined) (e.anchorEpoch as YMD).month = 1; if ((e.anchorEpoch as YMD).day === undefined) (e.anchorEpoch as YMD).day = 1; }); // Ensure that the latest epoch is first in the array. This lets us try to // match eras in index order, with the last era getting the remaining older // years. Any reverse-signed era must be at the end. ArraySort.call(eras, (e1, e2) => { if (e1.reverseOf) return 1; if (e2.reverseOf) return -1; if (!e1.isoEpoch || !e2.isoEpoch) throw new RangeError('Invalid era data: missing ISO epoch'); return e2.isoEpoch.year - e1.isoEpoch.year; }); // If there's a reversed era, then the one before it must be the era that's // being reversed. const lastEraReversed = eras[eras.length - 1].reverseOf; if (lastEraReversed) { if (lastEraReversed !== eras[eras.length - 2]) throw new RangeError('Invalid era data: invalid reverse-sign era'); } // Finally, add a "genericName" property in the format "era{n} where `n` is // zero-based index, with the oldest era being zero. This format is used by // older versions of ICU data. eras.forEach((e, i) => { (e as Era).genericName = `era${eras.length - 1 - i}`; }); return { eras: eras as Era[], anchorEra: (anchorEra || eras[0]) as Era }; } function isGregorianLeapYear(year: number) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } /** Base for all Gregorian-like calendars. */ abstract class GregorianBaseHelper extends HelperBase { id: BuiltinCalendarId; eras: Era[]; anchorEra: Era; constructor(id: BuiltinCalendarId, originalEras: InputEra[]) { super(); this.id = id; const { eras, anchorEra } = adjustEras(originalEras); this.anchorEra = anchorEra; this.eras = eras; } calendarType = 'solar' as const; inLeapYear(calendarDate: CalendarYearOnly) { // Calendars that don't override this method use the same months and leap // years as Gregorian. Once we know the ISO year corresponding to the // calendar year, we'll know if it's a leap year or not. const { year } = this.estimateIsoDate({ month: 1, day: 1, year: calendarDate.year }); return isGregorianLeapYear(year); } monthsInYear(/* calendarDate */) { return 12; } minimumMonthLength(calendarDate: CalendarYM): number { const { month } = calendarDate; if (month === 2) return this.inLeapYear(calendarDate) ? 29 : 28; return [4, 6, 9, 11].indexOf(month) >= 0 ? 30 : 31; } maximumMonthLength(calendarDate: CalendarYM): number { return this.minimumMonthLength(calendarDate); } /** Fill in missing parts of the (year, era, eraYear) tuple */ completeEraYear(calendarDate: Partial) { const checkField = (name: keyof FullCalendarDate, value: string | number | undefined) => { const currentValue = calendarDate[name]; if (currentValue != null && currentValue != value) { throw new RangeError(`Input ${name} ${currentValue} doesn't match calculated value ${value}`); } }; const eraFromYear = (year: number) => { let eraYear; const adjustedCalendarDate = { ...calendarDate, year }; const matchingEra = this.eras.find((e, i) => { if (i === this.eras.length - 1) { if (e.reverseOf) { // This is a reverse-sign era (like BCE) which must be the oldest // era. Count years backwards. if (year > 0) throw new RangeError(`Signed year ${year} is invalid for era ${e.name}`); eraYear = e.anchorEpoch.year - year; return true; } // last era always gets all "leftover" (older than epoch) years, // so no need for a comparison like below. eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); return true; } const comparison = this.compareCalendarDates(adjustedCalendarDate, e.anchorEpoch); if (comparison >= 0) { eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); return true; } return false; }); if (!matchingEra) throw new RangeError(`Year ${year} was not matched by any era`); return { eraYear: eraYear as unknown as number, era: matchingEra.name }; }; let { year, eraYear, era } = calendarDate; if (year != null) { ({ eraYear, era } = eraFromYear(year)); checkField('era', era); checkField('eraYear', eraYear); } else if (eraYear != null) { const matchingEra = era === undefined ? undefined : this.eras.find((e) => e.name === era || e.genericName === era); if (!matchingEra) throw new RangeError(`Era ${era} (ISO year ${eraYear}) was not matched by any era`); if (eraYear < 1 && matchingEra.reverseOf) { throw new RangeError(`Years in ${era} era must be positive, not ${year}`); } if (matchingEra.reverseOf) { year = matchingEra.anchorEpoch.year - eraYear; } else { year = eraYear + matchingEra.anchorEpoch.year - (matchingEra.hasYearZero ? 0 : 1); } checkField('year', year); // We'll accept dates where the month/day is earlier than the start of // the era or after its end as long as it's in the same year. If that // happens, we'll adjust the era/eraYear pair to be the correct era for // the `year`. ({ eraYear, era } = eraFromYear(year)); } else { throw new RangeError('Either `year` or `eraYear` and `era` are required'); } return { ...calendarDate, year, eraYear, era }; } override adjustCalendarDate( calendarDateParam: Partial, cache?: OneObjectCache, overflow: Overflow = 'constrain' ): FullCalendarDate { let calendarDate = calendarDateParam; // Because this is not a lunisolar calendar, it's safe to convert monthCode to a number const { month, monthCode } = calendarDate; if (month === undefined) calendarDate = { ...calendarDate, month: monthCodeNumberPart(monthCode as string) }; this.validateCalendarDate(calendarDate); calendarDate = this.completeEraYear(calendarDate); return super.adjustCalendarDate(calendarDate, cache, overflow); } estimateIsoDate(calendarDateParam: CalendarYMD) { const calendarDate = this.adjustCalendarDate(calendarDateParam); const { year, month, day } = calendarDate; const { anchorEra } = this; const isoYearEstimate = year + anchorEra.isoEpoch.year - (anchorEra.hasYearZero ? 0 : 1); return ES.RegulateISODate(isoYearEstimate, month, day, 'constrain'); } // Several calendars based on the Gregorian calendar use Julian dates (not // proleptic Gregorian dates) before the Julian switchover in Oct 1582. See // https://bugs.chromium.org/p/chromium/issues/detail?id=1173158. v8IsVulnerableToJulianBug = new Date('+001001-01-01T00:00Z') .toLocaleDateString('en-US-u-ca-japanese', { timeZone: 'UTC' }) .startsWith('12'); calendarIsVulnerableToJulianBug = false; override checkIcuBugs(isoDate: IsoYMD) { if (this.calendarIsVulnerableToJulianBug && this.v8IsVulnerableToJulianBug) { const beforeJulianSwitch = ES.CompareISODate(isoDate.year, isoDate.month, isoDate.day, 1582, 10, 15) < 0; if (beforeJulianSwitch) { throw new RangeError( `calendar '${this.id}' is broken for ISO dates before 1582-10-15` + ' (see https://bugs.chromium.org/p/chromium/issues/detail?id=1173158)' ); } } } } abstract class OrthodoxBaseHelper extends GregorianBaseHelper { constructor(id: BuiltinCalendarId, originalEras: InputEra[]) { super(id, originalEras); } override inLeapYear(calendarDate: CalendarYearOnly) { // Leap years happen one year before the Julian leap year. Note that this // calendar is based on the Julian calendar which has a leap year every 4 // years, unlike the Gregorian calendar which doesn't have leap years on // years divisible by 100 except years divisible by 400. // // Note that we're assuming that leap years in before-epoch times match // how leap years are defined now. This is probably not accurate but I'm // not sure how better to do it. const { year } = calendarDate; return (year + 1) % 4 === 0; } override monthsInYear(/* calendarDate */) { return 13; } override minimumMonthLength(calendarDate: CalendarYM) { const { month } = calendarDate; // Ethiopian/Coptic calendars have 12 30-day months and an extra 5-6 day 13th month. if (month === 13) return this.inLeapYear(calendarDate) ? 6 : 5; return 30; } override maximumMonthLength(calendarDate: CalendarYM) { return this.minimumMonthLength(calendarDate); } } // `coptic` and `ethiopic` calendars are very similar to `ethioaa` calendar, // with the following differences: // - Coptic uses BCE-like positive numbers for years before its epoch (the other // two use negative year numbers before epoch) // - Coptic has a different epoch date // - Ethiopic has an additional second era that starts at the same date as the // zero era of ethioaa. class EthioaaHelper extends OrthodoxBaseHelper { constructor() { super('ethioaa', [{ name: 'era0', isoEpoch: { year: -5492, month: 7, day: 17 } }]); } } class CopticHelper extends OrthodoxBaseHelper { constructor() { super('coptic', [ { name: 'era1', isoEpoch: { year: 284, month: 8, day: 29 } }, { name: 'era0', reverseOf: 'era1' } ]); } } // Anchor is currently the older era to match ethioaa, but should it be the newer era? // See https://github.com/tc39/ecma402/issues/534 for discussion. class EthiopicHelper extends OrthodoxBaseHelper { constructor() { super('ethiopic', [ { name: 'era0', isoEpoch: { year: -5492, month: 7, day: 17 } }, { name: 'era1', isoEpoch: { year: 8, month: 8, day: 27 }, anchorEpoch: { year: 5501 } } ]); } } class RocHelper extends GregorianBaseHelper { constructor() { super('roc', [ { name: 'minguo', isoEpoch: { year: 1912, month: 1, day: 1 } }, { name: 'before-roc', reverseOf: 'minguo' } ]); } override calendarIsVulnerableToJulianBug = true; } class BuddhistHelper extends GregorianBaseHelper { constructor() { super('buddhist', [{ name: 'be', hasYearZero: true, isoEpoch: { year: -543, month: 1, day: 1 } }]); } override calendarIsVulnerableToJulianBug = true; } class GregoryHelper extends GregorianBaseHelper { constructor() { super('gregory', [ { name: 'ce', isoEpoch: { year: 1, month: 1, day: 1 } }, { name: 'bce', reverseOf: 'ce' } ]); } override reviseIntlEra>(calendarDate: T /*, isoDate: IsoDate*/): T { let { era, eraYear } = calendarDate; // Firefox 96 introduced a bug where the `'short'` format of the era // option mistakenly returns the one-letter (narrow) format instead. The // code below handles either the correct or Firefox-buggy format. See // https://bugzilla.mozilla.org/show_bug.cgi?id=1752253 if (era === 'bc' || era === 'b') era = 'bce'; if (era === 'ad' || era === 'a') era = 'ce'; return { era, eraYear } as T; } } // NOTE: Only the 5 modern eras (Meiji and later) are included. For dates // before Meiji 1, the `ce` and `bce` eras are used. Challenges with pre-Meiji // eras include: // - Start/end dates of older eras are not precisely defined, which is // challenging given Temporal's need for precision // - Some era dates and/or names are disputed by historians // - As historical research proceeds, new eras are discovered and existing era // dates are modified, leading to considerable churn which is not good for // Temporal use. // - The earliest era (in 645 CE) may not end up being the earliest depending // on future historical scholarship // - Before Meiji, Japan used a lunar (or lunisolar?) calendar but AFAIK // that's not reflected in the ICU implementation. // // For more discussion: https://github.com/tc39/proposal-temporal/issues/526. // // Here's a full list of CLDR/ICU eras: // https://github.com/unicode-org/icu/blob/master/icu4c/source/data/locales/root.txt#L1582-L1818 // https://github.com/unicode-org/cldr/blob/master/common/supplemental/supplementalData.xml#L4310-L4546 // // NOTE: Japan started using the Gregorian calendar in 6 Meiji, replacing a // lunisolar calendar. So the day before January 1 of 6 Meiji (1873) was not // December 31, but December 2, of 5 Meiji (1872). The existing Ecma-402 // Japanese calendar doesn't seem to take this into account, so neither do we: // > args = ['en-ca-u-ca-japanese', { era: 'short' }] // > new Date('1873-01-01T12:00').toLocaleString(...args) // '1 1, 6 Meiji, 12:00:00 PM' // > new Date('1872-12-31T12:00').toLocaleString(...args) // '12 31, 5 Meiji, 12:00:00 PM' class JapaneseHelper extends GregorianBaseHelper { constructor() { super('japanese', [ // The Japanese calendar `year` is just the ISO year, because (unlike other // ICU calendars) there's no obvious "default era", we use the ISO year. { name: 'reiwa', isoEpoch: { year: 2019, month: 5, day: 1 }, anchorEpoch: { year: 2019, month: 5, day: 1 } }, { name: 'heisei', isoEpoch: { year: 1989, month: 1, day: 8 }, anchorEpoch: { year: 1989, month: 1, day: 8 } }, { name: 'showa', isoEpoch: { year: 1926, month: 12, day: 25 }, anchorEpoch: { year: 1926, month: 12, day: 25 } }, { name: 'taisho', isoEpoch: { year: 1912, month: 7, day: 30 }, anchorEpoch: { year: 1912, month: 7, day: 30 } }, { name: 'meiji', isoEpoch: { year: 1868, month: 9, day: 8 }, anchorEpoch: { year: 1868, month: 9, day: 8 } }, { name: 'ce', isoEpoch: { year: 1, month: 1, day: 1 } }, { name: 'bce', reverseOf: 'ce' } ]); } override calendarIsVulnerableToJulianBug = true; // The last 3 Japanese eras confusingly return only one character in the // default "short" era, so need to use the long format. override eraLength = 'long' as const; override erasBeginMidYear = true; override reviseIntlEra>(calendarDate: T, isoDate: IsoYMD): T { const { era, eraYear } = calendarDate; const { year: isoYear } = isoDate; if (this.eras.find((e) => e.name === era)) return { era, eraYear } as T; return (isoYear < 1 ? { era: 'bce', eraYear: 1 - isoYear } : { era: 'ce', eraYear: isoYear }) as T; } } interface ChineseMonthInfo { [key: string]: { monthIndex: number; daysInMonth: number }; } interface ChineseDraftMonthInfo { [key: string]: { monthIndex: number; daysInMonth?: number }; } abstract class ChineseBaseHelper extends HelperBase { abstract override id: BuiltinCalendarId; calendarType = 'lunisolar' as const; inLeapYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { const months = this.getMonthList(calendarDate.year, cache); return ObjectEntries(months).length === 13; } monthsInYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { return this.inLeapYear(calendarDate, cache) ? 13 : 12; } minimumMonthLength(/* calendarDate */) { return 29; } maximumMonthLength(/* calendarDate */) { return 30; } getMonthList(calendarYear: number, cache: OneObjectCache): ChineseMonthInfo { if (calendarYear === undefined) { throw new TypeError('Missing year'); } const key = JSON.stringify({ func: 'getMonthList', calendarYear, id: this.id }); const cached = cache.get(key); if (cached) return cached; const dateTimeFormat = this.getFormatter(); const getCalendarDate = (isoYear: number, daysPastFeb1: number) => { const isoStringFeb1 = toUtcIsoDateString({ isoYear, isoMonth: 2, isoDay: 1 }); const legacyDate = new Date(isoStringFeb1); // Now add the requested number of days, which may wrap to the next month. legacyDate.setUTCDate(daysPastFeb1 + 1); const newYearGuess = dateTimeFormat.formatToParts(legacyDate); const calendarMonthString = (newYearGuess.find((tv) => tv.type === 'month') as Intl.DateTimeFormatPart).value; const calendarDay = +(newYearGuess.find((tv) => tv.type === 'day') as Intl.DateTimeFormatPart).value; let calendarYearToVerify: globalThis.Intl.DateTimeFormatPart | number | undefined = newYearGuess.find( (tv) => (tv.type as string) === 'relatedYear' ); if (calendarYearToVerify !== undefined) { calendarYearToVerify = +calendarYearToVerify.value; } else { // Node 12 has outdated ICU data that lacks the `relatedYear` field in the // output of Intl.DateTimeFormat.formatToParts. throw new RangeError( `Intl.DateTimeFormat.formatToParts lacks relatedYear in ${this.id} calendar. Try Node 14+ or modern browsers.` ); } return { calendarMonthString, calendarDay, calendarYearToVerify }; }; // First, find a date close to Chinese New Year. Feb 17 will either be in // the first month or near the end of the last month of the previous year. let isoDaysDelta = 17; let { calendarMonthString, calendarDay, calendarYearToVerify } = getCalendarDate(calendarYear, isoDaysDelta); // If we didn't guess the first month correctly, add (almost in some months) // a lunar month if (calendarMonthString !== '1') { isoDaysDelta += 29; ({ calendarMonthString, calendarDay } = getCalendarDate(calendarYear, isoDaysDelta)); } // Now back up to near the start of the first month, but not too near that // off-by-one issues matter. isoDaysDelta -= calendarDay - 5; const result = {} as ChineseDraftMonthInfo; let monthIndex = 1; let oldCalendarDay: number | undefined; let oldMonthString: string | undefined; let done = false; do { ({ calendarMonthString, calendarDay, calendarYearToVerify } = getCalendarDate(calendarYear, isoDaysDelta)); if (oldCalendarDay) { result[oldMonthString as string].daysInMonth = oldCalendarDay + 30 - calendarDay; } if (calendarYearToVerify !== calendarYear) { done = true; } else { result[calendarMonthString] = { monthIndex: monthIndex++ }; // Move to the next month. Because months are sometimes 29 days, the day of the // calendar month will move forward slowly but not enough to flip over to a new // month before the loop ends at 12-13 months. isoDaysDelta += 30; } oldCalendarDay = calendarDay; oldMonthString = calendarMonthString; } while (!done); result[oldMonthString].daysInMonth = oldCalendarDay + 30 - calendarDay; cache.set(key, result); return result as ChineseMonthInfo; } estimateIsoDate(calendarDate: CalendarYMD) { const { year, month } = calendarDate; return { year, month: month >= 12 ? 12 : month + 1, day: 1 }; } override adjustCalendarDate( calendarDate: Partial, cache: OneObjectCache, overflow: Overflow = 'constrain', fromLegacyDate = false ): FullCalendarDate { let { year, month, monthExtra, day, monthCode, eraYear } = calendarDate; if (fromLegacyDate) { // Legacy Date output returns a string that's an integer with an optional // "bis" suffix used only by the Chinese/Dangi calendar to indicate a leap // month. Below we'll normalize the output. year = eraYear; if (monthExtra && monthExtra !== 'bis') throw new RangeError(`Unexpected leap month suffix: ${monthExtra}`); const monthCode = buildMonthCode(month as number, monthExtra !== undefined); const monthString = `${month}${monthExtra || ''}`; const months = this.getMonthList(year as number, cache); const monthInfo = months[monthString]; if (monthInfo === undefined) throw new RangeError(`Unmatched month ${monthString} in Chinese year ${year}`); month = monthInfo.monthIndex; return { year: year as number, month, day: day as number, era: undefined, eraYear, monthCode }; } else { // When called without input coming from legacy Date output, // simply ensure that all fields are present. this.validateCalendarDate(calendarDate); if (year === undefined) year = eraYear; if (eraYear === undefined) eraYear = year; if (month === undefined) { ES.assertExists(monthCode); const months = this.getMonthList(year as number, cache); let numberPart = monthCode.replace('L', 'bis').slice(1); if (numberPart[0] === '0') numberPart = numberPart.slice(1); let monthInfo = months[numberPart]; month = monthInfo && monthInfo.monthIndex; // If this leap month isn't present in this year, constrain to the same // day of the previous month. if (month === undefined && monthCode.endsWith('L') && monthCode != 'M13L' && overflow === 'constrain') { let withoutML = monthCode.slice(1, -1); if (withoutML[0] === '0') withoutML = withoutML.slice(1); monthInfo = months[withoutML]; if (monthInfo) { month = monthInfo.monthIndex; monthCode = buildMonthCode(withoutML); } } if (month === undefined) { throw new RangeError(`Unmatched month ${monthCode} in Chinese year ${year}`); } } else if (monthCode === undefined) { const months = this.getMonthList(year as number, cache); const monthEntries = ObjectEntries(months); const largestMonth = monthEntries.length; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day as number, 1, this.maximumMonthLength()); } else { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength()); } const matchingMonthEntry = monthEntries.find(([, v]) => v.monthIndex === month); if (matchingMonthEntry === undefined) { throw new RangeError(`Invalid month ${month} in Chinese year ${year}`); } monthCode = buildMonthCode( matchingMonthEntry[0].replace('bis', ''), matchingMonthEntry[0].indexOf('bis') !== -1 ); } else { // Both month and monthCode are present. Make sure they don't conflict. const months = this.getMonthList(year as number, cache); let numberPart = monthCode.replace('L', 'bis').slice(1); if (numberPart[0] === '0') numberPart = numberPart.slice(1); const monthInfo = months[numberPart]; if (!monthInfo) throw new RangeError(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); if (month !== monthInfo.monthIndex) { throw new RangeError(`monthCode ${monthCode} doesn't correspond to month ${month} in Chinese year ${year}`); } } return { ...calendarDate, year: year as number, eraYear, month, monthCode: monthCode, day: day as number }; } } // All built-in calendars except Chinese/Dangi and Hebrew use an era override hasEra = false; } class ChineseHelper extends ChineseBaseHelper { id = 'chinese' as const; } // Dangi (Korean) calendar has same implementation as Chinese class DangiHelper extends ChineseBaseHelper { id = 'dangi' as const; } /** * Common implementation of all non-ISO calendars. * Per-calendar id and logic live in `id` and `helper` properties attached later. * This split allowed an easy separation between code that was similar between * ISO and non-ISO implementations vs. code that was very different. */ class NonIsoCalendar implements CalendarImpl { constructor(private readonly helper: HelperBase) {} dateFromFields( fieldsParam: Params['dateFromFields'][0], options: NonNullable, calendarSlotValue: string ): Temporal.PlainDate { const cache = new OneObjectCache(); const fieldNames = this.fields(['day', 'month', 'monthCode', 'year']) as AnyTemporalKey[]; const fields = ES.PrepareTemporalFields(fieldsParam, fieldNames, []); const overflow = ES.ToTemporalOverflow(options); const { year, month, day } = this.helper.calendarToIsoDate(fields, overflow, cache); const result = ES.CreateTemporalDate(year, month, day, calendarSlotValue); cache.setObject(result); return result; } yearMonthFromFields( fieldsParam: Params['yearMonthFromFields'][0], options: NonNullable, calendarSlotValue: CalendarSlot ): Temporal.PlainYearMonth { const cache = new OneObjectCache(); const fieldNames = this.fields(['month', 'monthCode', 'year']) as AnyTemporalKey[]; const fields = ES.PrepareTemporalFields(fieldsParam, fieldNames, []); const overflow = ES.ToTemporalOverflow(options); const { year, month, day } = this.helper.calendarToIsoDate({ ...fields, day: 1 }, overflow, cache); const result = ES.CreateTemporalYearMonth(year, month, calendarSlotValue, /* referenceISODay = */ day); cache.setObject(result); return result; } monthDayFromFields( fieldsParam: Params['monthDayFromFields'][0], options: NonNullable, calendarSlotValue: CalendarSlot ): Temporal.PlainMonthDay { const cache = new OneObjectCache(); // For lunisolar calendars, either `monthCode` or `year` must be provided // because `month` is ambiguous without a year or a code. const fieldNames = this.fields(['day', 'month', 'monthCode', 'year']) as AnyTemporalKey[]; const fields = ES.PrepareTemporalFields(fieldsParam, fieldNames, []); const overflow = ES.ToTemporalOverflow(options); const { year, month, day } = this.helper.monthDayFromFields(fields, overflow, cache); // `year` is a reference year where this month/day exists in this calendar const result = ES.CreateTemporalMonthDay(month, day, calendarSlotValue, /* referenceISOYear = */ year); cache.setObject(result); return result; } fields(fieldsParam: string[]): string[] { let fields = fieldsParam; if (ArrayIncludes.call(fields, 'year')) fields = [...fields, 'era', 'eraYear']; return fields; } fieldKeysToIgnore( keys: Exclude[] ): Exclude[] { const result = new OriginalSet(); for (let ix = 0; ix < keys.length; ix++) { const key = keys[ix]; ES.Call(SetPrototypeAdd, result, [key]); switch (key) { case 'era': ES.Call(SetPrototypeAdd, result, ['eraYear']); ES.Call(SetPrototypeAdd, result, ['year']); break; case 'eraYear': ES.Call(SetPrototypeAdd, result, ['era']); ES.Call(SetPrototypeAdd, result, ['year']); break; case 'year': ES.Call(SetPrototypeAdd, result, ['era']); ES.Call(SetPrototypeAdd, result, ['eraYear']); break; case 'month': ES.Call(SetPrototypeAdd, result, ['monthCode']); // See https://github.com/tc39/proposal-temporal/issues/1784 if (this.helper.erasBeginMidYear) { ES.Call(SetPrototypeAdd, result, ['era']); ES.Call(SetPrototypeAdd, result, ['eraYear']); } break; case 'monthCode': ES.Call(SetPrototypeAdd, result, ['month']); if (this.helper.erasBeginMidYear) { ES.Call(SetPrototypeAdd, result, ['era']); ES.Call(SetPrototypeAdd, result, ['eraYear']); } break; case 'day': if (this.helper.erasBeginMidYear) { ES.Call(SetPrototypeAdd, result, ['era']); ES.Call(SetPrototypeAdd, result, ['eraYear']); } break; } } return [...ES.Call(SetPrototypeValues, result, [])]; } dateAdd( date: Temporal.PlainDate, years: number, months: number, weeks: number, days: number, overflow: Overflow, calendarSlotValue: CalendarSlot ): Temporal.PlainDate { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); const added = this.helper.addCalendar(calendarDate, { years, months, weeks, days }, overflow, cache); const isoAdded = this.helper.calendarToIsoDate(added, 'constrain', cache); const { year, month, day } = isoAdded; const newTemporalObject = ES.CreateTemporalDate(year, month, day, calendarSlotValue); // The new object's cache starts with the cache of the old object const newCache = new OneObjectCache(cache); newCache.setObject(newTemporalObject); return newTemporalObject; } dateUntil(one: Temporal.PlainDate, two: Temporal.PlainDate, largestUnit: Temporal.DateUnit) { const cacheOne = OneObjectCache.getCacheForObject(one); const cacheTwo = OneObjectCache.getCacheForObject(two); const calendarOne = this.helper.temporalToCalendarDate(one, cacheOne); const calendarTwo = this.helper.temporalToCalendarDate(two, cacheTwo); const result = this.helper.untilCalendar(calendarOne, calendarTwo, largestUnit, cacheOne); return result; } year(date: Temporal.PlainDate | Temporal.PlainYearMonth): number { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); return calendarDate.year; } month(date: Temporal.PlainDate | Temporal.PlainYearMonth | Temporal.PlainMonthDay): number { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); return calendarDate.month; } day(date: Temporal.PlainDate | Temporal.PlainMonthDay): number { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); return calendarDate.day; } era(date: Temporal.PlainDate | Temporal.PlainYearMonth): string | undefined { if (!this.helper.hasEra) return undefined; const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); return calendarDate.era; } eraYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number | undefined { if (!this.helper.hasEra) return undefined; const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); return calendarDate.eraYear; } monthCode(date: Temporal.PlainDate | Temporal.PlainYearMonth | Temporal.PlainMonthDay): string { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); return calendarDate.monthCode; } dayOfWeek(date: Temporal.PlainDate): number { return impl['iso8601'].dayOfWeek(date); } dayOfYear(date: Temporal.PlainDate): number { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.isoToCalendarDate(date, cache); const startOfYear = this.helper.startOfCalendarYear(calendarDate); const diffDays = this.helper.calendarDaysUntil(startOfYear, calendarDate, cache); return diffDays + 1; } weekOfYear(date: Temporal.PlainDate): number { return impl['iso8601'].weekOfYear(date); } yearOfWeek(date: Temporal.PlainDate): number { return impl['iso8601'].yearOfWeek(date); } daysInWeek(date: Temporal.PlainDate): number { return impl['iso8601'].daysInWeek(date); } daysInMonth(date: Temporal.PlainDate | Temporal.PlainYearMonth): number { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); // Easy case: if the helper knows the length without any heavy calculation. const max = this.helper.maximumMonthLength(calendarDate); const min = this.helper.minimumMonthLength(calendarDate); if (max === min) return max; // The harder case is where months vary every year, e.g. islamic calendars. // Find the answer by calculating the difference in days between the first // day of the current month and the first day of the next month. const startOfMonthCalendar = this.helper.startOfCalendarMonth(calendarDate); const startOfNextMonthCalendar = this.helper.addMonthsCalendar(startOfMonthCalendar, 1, 'constrain', cache); const result = this.helper.calendarDaysUntil(startOfMonthCalendar, startOfNextMonthCalendar, cache); return result; } daysInYear(dateParam: Temporal.PlainDate | Temporal.PlainYearMonth): number { let date = dateParam; if (!HasSlot(date, ISO_YEAR)) date = ES.ToTemporalDate(date); const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); const startOfYearCalendar = this.helper.startOfCalendarYear(calendarDate); const startOfNextYearCalendar = this.helper.addCalendar(startOfYearCalendar, { years: 1 }, 'constrain', cache); const result = this.helper.calendarDaysUntil(startOfYearCalendar, startOfNextYearCalendar, cache); return result; } monthsInYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number { const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); const result = this.helper.monthsInYear(calendarDate, cache); return result; } inLeapYear(dateParam: Temporal.PlainDate | Temporal.PlainYearMonth): boolean { let date = dateParam; if (!HasSlot(date, ISO_YEAR)) date = ES.ToTemporalDate(date); const cache = OneObjectCache.getCacheForObject(date); const calendarDate = this.helper.temporalToCalendarDate(date, cache); const result = this.helper.inLeapYear(calendarDate, cache); return result; } } for (const Helper of [ HebrewHelper, PersianHelper, EthiopicHelper, EthioaaHelper, CopticHelper, ChineseHelper, DangiHelper, RocHelper, IndianHelper, BuddhistHelper, GregoryHelper, JapaneseHelper, IslamicHelper, IslamicUmalquraHelper, IslamicTblaHelper, IslamicCivilHelper, IslamicRgsaHelper, IslamicCcHelper ]) { const helper = new Helper(); // Construct a new NonIsoCalendar instance with the given Helper implementation that contains // per-calendar logic. impl[helper.id] = new NonIsoCalendar(helper); }