@js-temporal/polyfill
Version:
Polyfill for Tc39 Stage 3 proposal Temporal (https://github.com/tc39/proposal-temporal)
1,156 lines (1,121 loc) • 107 kB
text/typescript
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 } 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 ObjectEntries = Object.entries;
const ObjectKeys = Object.keys;
/**
* 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;
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<Params['dateFromFields'][1]>,
calendar: Temporal.Calendar
): Temporal.PlainDate;
yearMonthFromFields(
fields: Params['yearMonthFromFields'][0],
options: NonNullable<Params['yearMonthFromFields'][1]>,
calendar: Temporal.Calendar
): Temporal.PlainYearMonth;
monthDayFromFields(
fields: Params['monthDayFromFields'][0],
options: NonNullable<Params['monthDayFromFields'][1]>,
calendar: Temporal.Calendar
): Temporal.PlainMonthDay;
dateAdd(
date: Temporal.PlainDate,
years: number,
months: number,
weeks: number,
days: number,
overflow: Overflow,
calendar: Temporal.Calendar
): 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[];
mergeFields(fields: Record<string, unknown>, additionalFields: Record<string, unknown>): Record<string, unknown>;
}
/**
* Implementations for each calendar. Non-ISO calendars have an extra `helper`
* property that provides additional per-calendar logic.
*/
const impl = {} as {
iso8601: CalendarImpl;
} & {
[id in Exclude<BuiltinCalendarId, 'iso8601'>]: NonIsoImpl;
};
/**
* 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, 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 ES.ToString(this);
}
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);
return impl[GetSlot(this, CALENDAR_ID)].dateFromFields(fields, options, this);
}
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);
return impl[GetSlot(this, CALENDAR_ID)].yearMonthFromFields(fields, options, this);
}
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);
return impl[GetSlot(this, CALENDAR_ID)].monthDayFromFields(fields, options, this);
}
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(fields: Params['mergeFields'][0], additionalFields: Params['mergeFields'][1]): Return['mergeFields'] {
if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver');
return impl[GetSlot(this, CALENDAR_ID)].mergeFields(fields, additionalFields);
}
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'
);
return impl[GetSlot(this, CALENDAR_ID)].dateAdd(
date,
GetSlot(duration, YEARS),
GetSlot(duration, MONTHS),
GetSlot(duration, WEEKS),
days,
overflow,
this
);
}
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);
}
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 ES.ToString(this);
}
static from(item: Params['from'][0]): Return['from'] {
return ES.ToTemporalCalendar(item);
}
[Symbol.toStringTag]!: 'Temporal.Calendar';
}
MakeIntrinsicClass(Calendar, 'Temporal.Calendar');
DefineIntrinsic('Temporal.Calendar.from', Calendar.from);
/**
* 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, calendar) {
const overflow = ES.ToTemporalOverflow(options);
let fields = ES.PrepareTemporalFields(fieldsParam, ['day', 'month', 'monthCode', 'year'], ['year', 'day']);
fields = resolveNonLunisolarMonth(fields);
let { year, month, day } = fields;
({ year, month, day } = ES.RegulateISODate(year, month, day, overflow));
return ES.CreateTemporalDate(year, month, day, calendar);
},
yearMonthFromFields(fieldsParam, options, calendar) {
const overflow = ES.ToTemporalOverflow(options);
let fields = ES.PrepareTemporalFields(fieldsParam, ['month', 'monthCode', 'year'], ['year']);
fields = resolveNonLunisolarMonth(fields);
let { year, month } = fields;
({ year, month } = ES.RegulateISOYearMonth(year, month, overflow));
return ES.CreateTemporalYearMonth(year, month, calendar, /* referenceISODay = */ 1);
},
monthDayFromFields(fieldsParam, options, calendar) {
const overflow = ES.ToTemporalOverflow(options);
let fields = ES.PrepareTemporalFields(fieldsParam, ['day', 'month', 'monthCode', 'year'], ['day']);
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, calendar, referenceISOYear);
},
fields(fields) {
return fields;
},
mergeFields(fields, additionalFields) {
const merged: typeof fields = {};
for (const nextKey of ObjectKeys(fields)) {
if (nextKey === 'month' || nextKey === 'monthCode') continue;
merged[nextKey] = fields[nextKey];
}
const newKeys = ObjectKeys(additionalFields);
for (const nextKey of newKeys) {
merged[nextKey] = additionalFields[nextKey];
}
if (!ArrayIncludes.call(newKeys, 'month') && !ArrayIncludes.call(newKeys, 'monthCode')) {
const { month, monthCode } = fields;
if (month !== undefined) merged.month = month;
if (monthCode !== undefined) merged.monthCode = monthCode;
}
return merged;
},
dateAdd(date, years, months, weeks, days, overflow, calendar) {
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, calendar);
},
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));
},
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 powers both ISO and non-ISO calendars. That interface is extended
// (as `NonIsoImpl`) with a `helper` property that implements logic that varies
// between each non-ISO calendar.
/**
* Interface for non-ISO calendar implementations. The `helper` is an abstract
* base class that's extended for each non-ISO calendar, e.g. `HebrewHelper`.
*/
interface NonIsoImpl extends CalendarImpl {
helper: HelperBase;
}
/**
* 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<Temporal.AssignmentOptions['overflow']>;
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<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 = 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?<T extends Partial<EraAndEraYear>>(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<FullCalendarDate> = {};
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<FullCalendarDate>): asserts calendarDate is FullCalendarDate {
const { era, month, year, day, eraYear, monthCode, monthExtra } = calendarDate as Partial<FullCalendarDate>;
// 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}`);
}
}
}
/**
* 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<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);
// 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<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 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;
let maybeConstrained = false;
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;
// If the calendar day is larger than the minimal length for this
// month, then it might be larger than the actual length of the month.
// So we won't cache it as the correct calendar date for this ISO
// date.
maybeConstrained = date.day > this.minimumMonthLength(date);
} 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);
maybeConstrained = true;
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');
}
if (!maybeConstrained) {
// Also cache the reverse mapping
const keyReverse = JSON.stringify({
func: 'isoToCalendarDate',
isoYear: isoEstimate.year,
isoMonth: isoEstimate.month,
isoDay: isoEstimate.day,
id: this.id
});
cache.set(keyReverse, date);
}
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<CalendarYMD>, date2Param: Partial<CalendarYMD>): 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 diffYears = calendarTwo.year - calendarOne.year;
const diffMonths = calendarTwo.month - calendarOne.month;
const diffDays = calendarTwo.day - calendarOne.day;
const sign = this.compareCalendarDates(calendarTwo, calendarOne);
if (!sign) {
return { years: 0, months: 0, weeks: 0, days: 0 };
}
if (largestUnit === 'year' && diffYears) {
const isOneFurtherInYear = diffMonths * sign < 0 || (diffMonths === 0 && diffDays * 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 th