diff --git a/lib/calendar.ts b/lib/calendar.ts index 49aae5c4..0e8743b1 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1106,13 +1106,14 @@ abstract class HelperBase { return calendarDate; } addCalendar( - calendarDate: CalendarYMD, + calendarDate: CalendarYMD & { monthCode: string }, { years = 0, months = 0, weeks = 0, days = 0 }, overflow: Overflow, cache: OneObjectCache ): FullCalendarDate { - const { year, month, day } = calendarDate; - const addedMonths = this.addMonthsCalendar({ year: year + years, month, day }, months, overflow, cache); + 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; @@ -1213,8 +1214,8 @@ abstract class HelperBase { const lastDayOfPreviousMonthCalendar = this.isoToCalendarDate(lastDayOfPreviousMonthIso, cache); return lastDayOfPreviousMonthCalendar.day; } - startOfCalendarYear(calendarDate: CalendarYearOnly): CalendarYMD { - return { year: calendarDate.year, month: 1, day: 1 }; + 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 }; diff --git a/test/intl.mjs b/test/intl.mjs index fbea001e..4ee26c78 100644 --- a/test/intl.mjs +++ b/test/intl.mjs @@ -745,7 +745,7 @@ describe('Intl', () => { years: { duration: { years: 3, months: 6, days: 17 }, results: addYearsMonthsDaysCases, - startDate: { year: 1997, month: 12, day: 1 } + startDate: { year: 1997, monthCode: 'M12', day: 1 } } }; const calendars = Object.keys(addMonthsCases); @@ -768,7 +768,33 @@ describe('Intl', () => { equal(`add ${unit} ${id} month: ${end.month}`, `add ${unit} ${id} month: ${values.month}`); equal(`add ${unit} ${id} monthCode: ${end.monthCode}`, `add ${unit} ${id} monthCode: ${values.monthCode}`); const calculatedStart = end.subtract(duration); - equal(`start ${calculatedStart.toString()}`, `start ${start.toString()}`); + // For lunisolar calendars, adding/subtracting years and months in the + // same duration may not be reversible because the number of months in + // a year can vary. To see why this is the case, let's use Chinese + // year 2001, which is a leap year with a leap month after the 4th + // normal month. Adding P1Y6M to the first day of Chinese year 2000 + // will first add one year and then add six months, yielding a date + // that's the first day of the 7th ordinal month of 2001. But because + // that year is a leap year, the result's month code will be M06. + // + // Now let's look at subtracting the same duration from the result. + // First subtract one year, yielding the M06 month of the non-leap + // year 2000. Because M06 (unlike in the following leap year) is the + // 6th month ordinally, subtracting 6 months yields the M12 month of + // the Chinese year 1999. One month earlier than the original date! + // + // This is not a bug; it's an expected consequence of Temporal's + // largest-units-first order of operations that is aligned to the + // behavior standardized by RFC 5545. + // + // Note that this behavior is similar to all calendars' behavior when + // adding or subtracting months and days together. In those cases, + // addition and subtraction may not be reversible because months are + // different lengths. + const isLunisolar = ['chinese', 'dangi', 'hebrew'].includes(id); + const expectedCalculatedStart = + isLunisolar && duration.years !== 0 && !end.monthCode.endsWith('L') ? start.subtract({ months: 1 }) : start; + equal(`start ${calculatedStart.toString()}`, `start ${expectedCalculatedStart.toString()}`); const diff = start.until(end, { largestUnit: unit }); equal(`diff ${unit} ${id}: ${diff}`, `diff ${unit} ${id}: ${duration}`); @@ -1197,6 +1223,45 @@ describe('Intl', () => { }); }); + describe('Addition across lunisolar leap months', () => { + it('Adding years across Hebrew leap month', () => { + const date = Temporal.PlainDate.from({ year: 5783, monthCode: 'M08', day: 2, calendar: 'hebrew' }); + const added = date.add({ years: 1 }); + equal(added.monthCode, date.monthCode); + equal(added.year, date.year + 1); + }); + it('Adding months across Hebrew leap month', () => { + const date = Temporal.PlainDate.from({ year: 5783, monthCode: 'M08', day: 2, calendar: 'hebrew' }); + const added = date.add({ months: 13 }); + equal(added.monthCode, date.monthCode); + equal(added.year, date.year + 1); + }); + it('Adding months and years across Hebrew leap month', () => { + const date = Temporal.PlainDate.from({ year: 5783, monthCode: 'M08', day: 2, calendar: 'hebrew' }); + const added = date.add({ years: 1, months: 12 }); + equal(added.monthCode, date.monthCode); + equal(added.year, date.year + 2); + }); + it('Adding years across Chinese leap month', () => { + const date = Temporal.PlainDate.from({ year: 2000, monthCode: 'M08', day: 2, calendar: 'chinese' }); + const added = date.add({ years: 1 }); + equal(added.monthCode, date.monthCode); + equal(added.year, date.year + 1); + }); + it('Adding months across Chinese leap month', () => { + const date = Temporal.PlainDate.from({ year: 2000, monthCode: 'M08', day: 2, calendar: 'chinese' }); + const added = date.add({ months: 13 }); + equal(added.monthCode, date.monthCode); + equal(added.year, date.year + 1); + }); + it('Adding months and years across Chinese leap month', () => { + const date = Temporal.PlainDate.from({ year: 2001, monthCode: 'M08', day: 2, calendar: 'chinese' }); + const added = date.add({ years: 1, months: 12 }); + equal(added.monthCode, date.monthCode); + equal(added.year, date.year + 2); + }); + }); + describe('DateTimeFormat', () => { describe('supportedLocalesOf', () => { it('should return an Array', () => assert(Array.isArray(Intl.DateTimeFormat.supportedLocalesOf())));