UNPKG

@js-temporal/polyfill

Version:

Polyfill for Temporal (https://github.com/tc39/proposal-temporal), an ECMA TC39 Stage 3 proposal

1,178 lines (1,122 loc) 102 kB
import * as ES from './ecmascript'; import { DefineIntrinsic } from './intrinsicclass'; import type { Temporal } from '..'; import type { BuiltinCalendarId, CalendarDateRecord, CalendarFieldsRecord, CalendarYMD, DateDuration, FieldKey, ISODate, ISODateToFieldsType, MonthDayFromFieldsObject, Overflow, Resolve } from './internaltypes'; function arrayFromSet<T>(src: Set<T>): T[] { return [...src]; } function calendarDateWeekOfYear(id: BuiltinCalendarId, isoDate: ISODate): { week: number; year: number } | undefined { // Supports only Gregorian and ISO8601 calendar; can be updated to add support for other calendars. // Returns undefined for calendars without a well-defined week calendar system. // eslint-disable-next-line max-len // Also see: https://github.com/unicode-org/icu/blob/ab72ab1d4a3c3f9beeb7d92b0c7817ca93dfdb04/icu4c/source/i18n/calendar.cpp#L1606 if (id !== 'gregory' && id !== 'iso8601') return undefined; const calendar = impl[id]; let yow = isoDate.year; const { dayOfWeek, dayOfYear, daysInYear } = calendar.isoToDate(isoDate, { dayOfWeek: true, dayOfYear: true, daysInYear: true }); const fdow = calendar.getFirstDayOfWeek(); const mdow = calendar.getMinimalDaysInFirstWeek(); ES.uncheckedAssertNarrowedType<number>(fdow, 'guaranteed to exist for iso8601/gregory'); ES.uncheckedAssertNarrowedType<number>(mdow, 'guaranteed to exist for iso8601/gregory'); // For both the input date and the first day of its calendar year, calculate the day of week // relative to first day of week in the relevant calendar (e.g., in iso8601, relative to Monday). let relDow = (dayOfWeek + 7 - fdow) % 7; // Assuming the year length is less than 7000 days. let relDowJan1 = (dayOfWeek - dayOfYear + 7001 - fdow) % 7; let woy = Math.floor((dayOfYear - 1 + relDowJan1) / 7); if (7 - relDowJan1 >= mdow) { ++woy; } // Adjust for weeks at the year end that overlap into the previous or next calendar year. if (woy == 0) { // Check for last week of previous year; if true, handle the case for // first week of next year const prevYearCalendar = calendar.isoToDate(calendar.dateAdd(isoDate, { years: -1 }, 'constrain'), { daysInYear: true }); let prevDoy = dayOfYear + prevYearCalendar.daysInYear; woy = weekNumber(fdow, mdow, prevDoy, dayOfWeek); yow--; } else { // For it to be week 1 of the next year, dayOfYear must be >= lastDoy - 5 // L-5 L // doy: 359 360 361 362 363 364 365 001 // dow: 1 2 3 4 5 6 7 let lastDoy = daysInYear; if (dayOfYear >= lastDoy - 5) { let lastRelDow = (relDow + lastDoy - dayOfYear) % 7; if (lastRelDow < 0) { lastRelDow += 7; } if (6 - lastRelDow >= mdow && dayOfYear + 7 - relDow > lastDoy) { woy = 1; yow++; } } } return { week: woy, year: yow }; } function ISODateSurpasses(sign: -1 | 0 | 1, y1: number, m1: number, d1: number, isoDate2: ISODate) { if (y1 !== isoDate2.year) { if (sign * (y1 - isoDate2.year) > 0) return true; } else if (m1 !== isoDate2.month) { if (sign * (m1 - isoDate2.month) > 0) return true; } else if (d1 !== isoDate2.day) { if (sign * (d1 - isoDate2.day) > 0) return true; } return false; } type ResolveFieldsReturn<Type extends ISODateToFieldsType> = Resolve< CalendarFieldsRecord & { year: Type extends 'date' ? number : never; month: number; monthCode: string; day: number; } >; /** * 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. */ export interface CalendarImpl { isoToDate< Request extends Partial<Record<keyof CalendarDateRecord, true>>, T extends { [Field in keyof CalendarDateRecord]: Request extends { [K in Field]: true } ? CalendarDateRecord[Field] : never; } >( isoDate: ISODate, requestedFields: Request ): T; getFirstDayOfWeek(): number | undefined; getMinimalDaysInFirstWeek(): number | undefined; resolveFields<Type extends ISODateToFieldsType>( fields: CalendarFieldsRecord, type: Type ): asserts fields is ResolveFieldsReturn<Type>; dateToISO(fields: ResolveFieldsReturn<'date'>, overflow: Overflow): ISODate; monthDayToISOReferenceDate(fields: ResolveFieldsReturn<'month-day'>, overflow: Overflow): ISODate; dateAdd(date: ISODate, duration: Partial<DateDuration>, overflow: Overflow): ISODate; dateUntil(one: ISODate, two: ISODate, largestUnit: 'year' | 'month' | 'week' | 'day'): DateDuration; extraFields(fields: FieldKey[]): FieldKey[]; fieldKeysToIgnore(keys: FieldKey[]): FieldKey[]; } 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; /** * 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'] = { resolveFields(fields, type) { if ((type === 'date' || type === 'year-month') && fields.year === undefined) { throw new TypeError('year is required'); } if ((type === 'date' || type === 'month-day') && fields.day === undefined) { throw new TypeError('day is required'); } Object.assign(fields, resolveNonLunisolarMonth(fields)); }, dateToISO(fields, overflow) { return ES.RegulateISODate(fields.year, fields.month, fields.day, overflow); }, monthDayToISOReferenceDate(fields, overflow) { const referenceISOYear = 1972; const { month, day } = ES.RegulateISODate(fields.year ?? referenceISOYear, fields.month, fields.day, overflow); return { month, day, year: referenceISOYear }; }, extraFields() { return []; }, fieldKeysToIgnore(keys) { const result = new Set<FieldKey>(); for (let ix = 0; ix < keys.length; ix++) { const key = keys[ix]; result.add(key); if (key === 'month') { result.add('monthCode'); } else if (key === 'monthCode') { result.add('month'); } } return arrayFromSet(result); }, dateAdd(isoDate, { years = 0, months = 0, weeks = 0, days = 0 }, overflow) { let { year, month, day } = isoDate; year += years; month += months; ({ year, month } = ES.BalanceISOYearMonth(year, month)); ({ year, month, day } = ES.RegulateISODate(year, month, day, overflow)); day += days + 7 * weeks; return ES.BalanceISODate(year, month, day); }, dateUntil(one, two, largestUnit) { const sign = -ES.CompareISODate(one, two); if (sign === 0) return { years: 0, months: 0, weeks: 0, days: 0 }; ES.uncheckedAssertNarrowedType<-1 | 1>(sign, "the - operator's return type is number"); let years = 0; let months = 0; let intermediate; if (largestUnit === 'year' || largestUnit === 'month') { // We can skip right to the neighbourhood of the correct number of years, // it'll be at least one less than two.year - one.year (unless it's zero) let candidateYears = two.year - one.year; if (candidateYears !== 0) candidateYears -= sign; // loops at most twice while (!ISODateSurpasses(sign, one.year + candidateYears, one.month, one.day, two)) { years = candidateYears; candidateYears += sign; } let candidateMonths = sign; intermediate = ES.BalanceISOYearMonth(one.year + years, one.month + candidateMonths); // loops at most 12 times while (!ISODateSurpasses(sign, intermediate.year, intermediate.month, one.day, two)) { months = candidateMonths; candidateMonths += sign; intermediate = ES.BalanceISOYearMonth(intermediate.year, intermediate.month + sign); } if (largestUnit === 'month') { months += years * 12; years = 0; } } intermediate = ES.BalanceISOYearMonth(one.year + years, one.month + months); const constrained = ES.ConstrainISODate(intermediate.year, intermediate.month, one.day); let weeks = 0; let days = ES.ISODateToEpochDays(two.year, two.month - 1, two.day) - ES.ISODateToEpochDays(constrained.year, constrained.month - 1, constrained.day); if (largestUnit === 'week') { weeks = Math.trunc(days / 7); days %= 7; } return { years, months, weeks, days }; }, isoToDate< Request extends Partial<Record<keyof CalendarDateRecord, true>>, T extends { [Field in keyof CalendarDateRecord]: Request extends { [K in Field]: true } ? CalendarDateRecord[Field] : never; } >({ year, month, day }: ISODate, requestedFields: Request): T { // requestedFields parameter is not part of the spec text. It's an // illustration of one way implementations may choose to optimize this // operation. const date: Partial<CalendarDateRecord> = { era: undefined, eraYear: undefined, year, month, day, daysInWeek: 7, monthsInYear: 12 }; if (requestedFields.monthCode) date.monthCode = buildMonthCode(month); if (requestedFields.dayOfWeek) { // https://en.wikipedia.org/wiki/Determination_of_the_day_of_the_week#Disparate_variation const shiftedMonth = month + (month < 3 ? 10 : -2); const shiftedYear = year - (month < 3 ? 1 : 0); const century = Math.floor(shiftedYear / 100); const yearInCentury = shiftedYear - century * 100; const monthTerm = Math.floor(2.6 * shiftedMonth - 0.2); const yearTerm = yearInCentury + Math.floor(yearInCentury / 4); const centuryTerm = Math.floor(century / 4) - 2 * century; const dow = (day + monthTerm + yearTerm + centuryTerm) % 7; date.dayOfWeek = dow + (dow <= 0 ? 7 : 0); } if (requestedFields.dayOfYear) { let days = day; for (let m = month - 1; m > 0; m--) { days += ES.ISODaysInMonth(year, m); } date.dayOfYear = days; } if (requestedFields.weekOfYear) date.weekOfYear = calendarDateWeekOfYear('iso8601', { year, month, day }); if (requestedFields.daysInMonth) date.daysInMonth = ES.ISODaysInMonth(year, month); if (requestedFields.daysInYear || requestedFields.inLeapYear) { date.inLeapYear = ES.LeapYear(year); date.daysInYear = date.inLeapYear ? 366 : 365; } return date as T; }, getFirstDayOfWeek() { return 1; }, getMinimalDaysInFirstWeek() { return 4; } }; // 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 CalendarImpl#dateFromFields(). * `monthExtra` is additional information used internally to identify lunisolar leap months. */ type CalendarDateFields = CalendarFieldsRecord & { 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 CalendarYM = { year: number; month: number }; type CalendarYearOnly = { year: number }; type EraAndEraYear = { era: string; eraYear: number }; function nonLeapMonthCodeNumberPart(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 (Number.isNaN(month)) throw new RangeError(`Invalid month code: ${monthCode}`); return month; } function buildMonthCode(month: number, leap = false) { const digitPart = `${month}`.padStart(2, '0'); const leapMarker = leap ? 'L' : ''; return `M${digitPart}${leapMarker}`; } /** * 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<T extends { monthCode?: string; month?: number }>( 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 = nonLeapMonthCodeNumberPart(monthCode); if (monthCode !== buildMonthCode(numberPart)) { throw new RangeError(`Invalid month code: ${monthCode}`); } if (month !== undefined && month !== numberPart) { throw new RangeError(`monthCode ${monthCode} and month ${month} must match if both are present`); } month = numberPart; if (month < 1 || month > monthsPerYear) throw new RangeError(`Invalid monthCode: ${monthCode}`); } return { ...calendarDate, month, monthCode }; } function weekNumber(firstDayOfWeek: number, minimalDaysInFirstWeek: number, desiredDay: number, dayOfWeek: number) { let periodStartDayOfWeek = (dayOfWeek - firstDayOfWeek - desiredDay + 1) % 7; if (periodStartDayOfWeek < 0) periodStartDayOfWeek += 7; let weekNo = Math.floor((desiredDay + periodStartDayOfWeek - 1) / 7); if (7 - periodStartDayOfWeek >= minimalDaysInFirstWeek) { ++weekNo; } return weekNo; } /** * 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 = OneObjectCache.monotonicTimestamp(); hits = 0; misses = 0; // static monotonicTimestamp() { // return performance?.now() ?? Date.now(); // } constructor(cacheToClone?: OneObjectCache) { 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 = OneObjectCache.monotonicTimestamp() - this.now; // const hitRate = ((100 * this.hits) / this.calls).toFixed(0); // const t = `${ms.toFixed(2)}ms`; // // eslint-disable-next-line no-console // console.log(`${this.calls} calls in ${t}. Hits: ${this.hits} (${hitRate}%). Misses: ${this.misses}.`); } setObject(obj: ISODate) { 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: ISODate) { 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 maxLengthOfMonthCodeInAnyYear(monthCode: string): number; abstract estimateIsoDate(calendarDate: CalendarYMD): ISODate; abstract inLeapYear(calendarDate: CalendarYearOnly, cache?: OneObjectCache): boolean; abstract calendarType: 'solar' | 'lunar' | 'lunisolar'; reviseIntlEra?<T extends Partial<EraAndEraYear>>(calendarDate: T, isoDate: ISODate): T; eras: Era[] = []; checkIcuBugs?(isoDate: ISODate): 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 HelperBase class is extended to // create 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 Intl.DateTimeFormat(`en-US-u-ca-${this.id}`, { day: 'numeric', month: 'numeric', year: 'numeric', era: 'short', timeZone: 'UTC' }); } return this.formatter; } getCalendarParts(isoString: string) { let dateTimeFormat = this.getFormatter(); let legacyDate = new Date(isoString); // PlainDate's minimum date -271821-04-19 is one day beyond legacy Date's // minimum -271821-04-20, because of accommodating all Instants in all time // zones. If we have -271821-04-19, instead format -271821-04-20 in a time // zone that pushes the result into the previous day. This is a slow path // because we create a new Intl.DateTimeFormat. if (isoString === '-271821-04-19T00:00Z') { const options = dateTimeFormat.resolvedOptions(); dateTimeFormat = new Intl.DateTimeFormat(options.locale, { ...(options as Intl.DateTimeFormatOptions), timeZone: 'Etc/GMT+1' }); legacyDate = new Date('-271821-04-20T00:00Z'); } try { return dateTimeFormat.formatToParts(legacyDate); } catch (e) { throw new RangeError(`Invalid ISO date: ${isoString}`); } } isoToCalendarDate(isoDate: ISODate, 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 isoString = toUtcIsoDateString({ isoYear, isoMonth, isoDay }); const parts = this.getCalendarParts(isoString); const result: Partial<FullCalendarDate> = {}; for (let i = 0; i < parts.length; i++) { const { type, value } = parts[i]; // TODO: remove this type annotation when `relatedYear` gets into TS lib types if (type === 'year' || type === ('relatedYear' as Intl.DateTimeFormatPartTypes)) { if (this.hasEra) { result.eraYear = +value; } else { result.year = +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. result.era = value .split(' (')[0] .normalize('NFD') .replace(/[^-0-9 \p{L}]/gu, '') .replace(/ /g, '-') .toLowerCase(); } } if (this.hasEra && 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 old ICU era codes "ERA0" etc. into canonical era names. if (this.hasEra) { const replacement = this.eras.find((e) => result.era === e.genericName); if (replacement) result.era = replacement.code; } // 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 const cacheReverse = (overflow: Overflow) => { const keyReverse = JSON.stringify({ func: 'calendarToIsoDate', year: calendarDate.year, month: calendarDate.month, day: calendarDate.day, overflow, id: this.id }); cache.set(keyReverse, isoDate); }; (['constrain', 'reject'] as const).forEach(cacheReverse); return calendarDate; } validateCalendarDate(calendarDate: Partial<FullCalendarDate>): asserts calendarDate is FullCalendarDate { const { 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.hasEra) { if ((calendarDate['era'] === undefined) !== (calendarDate['eraYear'] === undefined)) { throw new TypeError('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 * - non-lunisolar calendar (no leap months) * */ adjustCalendarDate( calendarDateParam: Partial<FullCalendarDate>, 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); const largestMonth = this.monthsInYear(calendarDate, 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): ISODate { const originalDate = dateParam as Partial<FullCalendarDate>; // 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 // current ICU calendars only skip days (japanese/roc/buddhist) because of // a bug (https://bugs.chromium.org/p/chromium/issues/detail?id=1173158) // that's currently worked around by a custom calendarToIsoDate // implementation in those calendars. 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 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; } compareCalendarDates(date1: CalendarYMD, date2: CalendarYMD) { 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: ISODate, days: number): ISODate { const added = ES.BalanceISODate(isoDate.year, isoDate.month, isoDate.day + days); 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 = Math.abs(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 current // ICU calendars only skip days (japanese/roc/buddhist) because of a bug // (https://bugs.chromium.org/p/chromium/issues/detail?id=1173158) that's // currently worked around by a custom calendarToIsoDate implementation in // those calendars. 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 ( ES.ISODateToEpochDays(twoIso.year, twoIso.month - 1, twoIso.day) - ES.ISODateToEpochDays(oneIso.year, oneIso.month - 1, oneIso.day) ); } // Override if calendar uses eras hasEra = false; // See https://github.com/tc39/proposal-temporal/issues/1784 erasBeginMidYear = false; // Override this to shortcut the search space if certain month codes only // occur long in the past monthDaySearchStartYear(monthCode: string, day: number) { void monthCode, day; return 1972; } monthDayFromFields(fields: MonthDayFromFieldsObject, overflow: Overflow, cache: OneObjectCache): ISODate { let { era, eraYear, year, month, monthCode, day } = fields; if (month !== undefined && year === undefined && (!this.hasEra || era === undefined || eraYear === undefined)) { throw new TypeError('when month is present, year (or era and eraYear) are required'); } if (monthCode === undefined || year !== undefined || (this.hasEra && eraYear !== undefined)) { // 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 20 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. For searches longer than // 20 years, override the start date in monthDaySearchStartYear. const startDateIso = { year: this.monthDaySearchStartYear(monthCode, day), 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 < 20; 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') { // If the requested day is never present in any instance of this month // code, and the round trip date is an instance of this month code with // the most possible days, we are as close as we can get. const maxDayForMonthCode = this.maxLengthOfMonthCodeInAnyYear(roundTripCalendarDate.monthCode); if ( roundTripCalendarDate.monthCode === monthCode && roundTripCalendarDate.day === maxDayForMonthCode && day > maxDayForMonthCode ) { return { month: isoMonth, day: isoDay, year: isoYear }; } // 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}`); } getFirstDayOfWeek(): number | undefined { return undefined; } getMinimalDaysInFirstWeek(): number | undefined { return undefined; } } 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 = Object.entries(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 : day