Skip to content

Calendrical version 2.0.0

Choose a tag to compare

@kipcole9 kipcole9 released this 28 Oct 05:24
· 6 commits to main since this release

[2.0.0] — 2026-10-24

Calendrical 2.0 rebuilds every calendar on a kernel of eight callbacks and derives the rest. The migration guide describes each change in full.

Breaking changes

  • The Calendrical behaviour has eight callbacks: info/0, date_to_iso_days/3, date_from_iso_days/1, leap_month/1, written_date/3, date_from_written_date/3, shift/6 and diff/3. The other callbacks of 1.3 are derived functions every calendar still answers but cannot override.

  • use Calendrical.Behaviour is use Calendrical.Calendar, with months_in_year: and weeks: in place of the old month and week options. A maker refuses an option it does not read at compile time.

  • Every error is {:error, exception}: :invalid_date, :invalid_month, :undefined, :not_defined, :year_out_of_range and :unknown_territory are exceptions, mapped in the migration guide. Only the Calendar parse callbacks, date_from_written_date/3 and written_month_from_cldr_month/2 keep their atoms.

  • days_in_month/2 returns {:error, %Calendrical.InvalidMonthError{}} in every calendar for a month its year does not have, where it raised FunctionClauseError or answered 0.

  • Calendrical.Reform returns Calendrical.UnknownReformError for a territory it has no reform for, and the lunisolar new!/3 raises Calendrical.InvalidDateError.

  • shift/6 returns Calendrical.InvalidDateError for a {year, month, day} that is no date, where it shifted the fields to another non-date; Date.shift/2 raises it. diff/3 returns the same error, and Calendrical.MissingFieldsError for a date missing a field, where it counted or raised by calendar, and both return Calendrical.InvalidPartError for a part other than :years, :quarters, :months, :weeks and :days.

  • months_in_year/1 returns Calendrical.MissingFieldsError for a nil year in every calendar, where some raised and others answered a count.

  • use Calendrical.Base.Month, use Calendrical.Base.Week and use Calendrical.Julian are use Calendrical.Calendar.Shifted and use Calendrical.Calendar.Weeks, and Calendrical.new/3 takes their options (year_begins:, year_ends:, year_number:, pattern:). The guide maps each old option.

  • plus/6 is shift/6 and :coerce is :clamp, true by default in every calendar, including the Julian calendars where :coerce defaulted to false.

  • Every calendar numbers whole weeks by TR35's rule from its own first day of the week, and weeks_in_year/1 answers an integer. The hand-written, Julian and composite calendars cut their weeks at the year's ends in 1.3.

  • A Hebrew date's month is its position in the year (Adar is 6 and Nisan 7 in an ordinary year), as Date.new/4 and ICU expect, while its name stays CLDR's. The calendar has a year 0, as its info/0 states and the other calendars of one era do, so the years before 1 are 0, -1 and so on.

  • month_of_year/3 is the CLDR month a date's month is named by, which cardinal_month/1 used to add: a fiscal year begun in July answers 7 for its month 1.

  • calendar_year/3, epoch_day_of_week/0, first_day_of_week/0, last_day_of_week/0, __config__/0, Calendrical.start_end_gregorian_years/2 and the Date and tuple forms of leap_year?/1, leap_month/1, leap_month?/2, cyclic_year/3, lunar_month_of_year/2 and date_to_iso_days/3 are removed.

  • The Julian year-start variants' date_from_julian_date/3 is date_from_base_date/3, beside a new base_date/3. They carry counted months, so a variant whose year begins within a month (March25, Dec25) has thirteen.

  • Calendrical.Julian.Dec25 and Calendrical.Julian.Sept1 number a year by the Julian year it ends in, as the Nativity and Byzantine years were reckoned. The Julian year-start variants take a date's era from its label year.

  • A date of a calendar of weeks (Calendrical.ISOWeek, Calendrical.NRF) is a year, a month of its pattern and a day of that month, so the second day of week 25 is ~D[2026-06-16 Calendrical.NRF]. week_of_year/3 and date_from_week_date/3 convert, and ~D still reads 2026-W25-2.

  • A calendar of weeks names its months "M01" to "M12" from CLDR's generic calendar (cldr_calendar_type/0 is :generic), from the Gregorian month its year begins in, so NRF's first month, which begins in February, is "M02"; it keeps Gregorian era names.

  • days_in_month/1 answers a composite with the range its members span, and month lengths that are not consecutive are a list, [28, 35] for a week calendar's last month.

  • A month or week calendar's day_of_era/3 counts era 1 from the first day of calendar year 1, so NRF's days count 34 fewer.

  • Calendrical.TimeZone.resolve/3 is Localize.DateTime.Timezone.resolve/3: errors are exceptions, abbreviations resolve only in a locale whose CLDR data writes them, and a repeated hour is read in standard time.

  • Calendrical.strftime/3 returns {:ok, string} or {:error, exception} where it raised; Calendrical.strftime!/3 returns the string. It passes Calendar.strftime/3's own options through.

  • The parse functions delegate to Localize's parsers and return its errors, so Calendrical.DateParseError, DateRangeParseError, DateTimeParseError, TimeParseError and ParseError are removed. week N of YYYY counts the calendar's own weeks and an ISO 8601 offset is kept.

  • The parse functions' :calendar option is a calendar module and the date is returned in it; :return_calendar is removed and returns a Calendrical.RemovedOptionError. A CLDR calendar name such as :hebrew is Localize.UnknownCalendarError.

  • Calendrical.Coptic has CLDR 49's single era, so its years before 1 are 0, -1 and so on. Calendrical.Ethiopic gives them to Amete Alem: year -50 is {5450, 0}.

  • A composite's member counts its own months, so England's 1155 is the Lady Day year from 25 March 1155 to 24 March 1156. The Julian days 1 January to 24 March 1155, whose dates it takes, have no dates of their own.

  • Calendrical.Composite.new/2 and use Calendrical.Composite refuse a configuration a composite cannot keep with a Calendrical.InvalidCompositeError, where new/2 returned atoms such as :no_calendars_configured, and an option other than :calendars and :base_calendar with a Calendrical.UnknownOptionError. new/2 returns {:ok, module} for a module that exists already, as Calendrical.new/3 does, where it returned {:module_already_exists, module}.

  • Every calendar maker, and Calendrical.new/3, refuses an option it does not read with a Calendrical.UnknownOptionError and a value it cannot take with a Calendrical.InvalidOptionValueError, which Calendrical.new/3 also returns for a kind other than :month and :week. Calendrical.FiscalYear.calendar_for/1 returns Calendrical.UnknownFiscalYearError for a territory with no fiscal calendar.

  • date_at/2, solar_term/2 and Calendrical.Lunisolar.solar_term_name/1 return exceptions for errors: Calendrical.InvalidOptionValueError, Calendrical.UnsupportedDateRangeError, Calendrical.NoDuskError and Calendrical.InvalidSolarTermError.

Added

  • Calendrical.Info, the facts of a calendar as data, with in_effect/4 for composites; info().month_lengths states a calendar's month lengths where sampling them is slow.

  • Calendrical.Month, Calendrical.Week, Calendrical.Period, Calendrical.Era and Calendrical.Shift, the derived API every calendar module delegates to.

  • Calendrical.Calendar.Shifted, Calendrical.Calendar.Weeks and Calendrical.Calendar.Lunisolar, the makers of a year begun elsewhere, a year of whole weeks and a calendar reckoned from the sky.

  • written_date/3, date_from_written_date/3 and leap_month/1 on every calendar.

  • Calendrical.diff/3 and a diff/3 callback on every calendar count whole years, quarters, months, weeks or days as shift/6 adds them.

  • add/9, the callback CalendarInterval steps an interval of a year or month with, on every calendar, where only month and week calendars had it.

  • Quarters on every calendar, and quadrimester/2 and semester/2 with Calendrical.Interval.quadrimester/3 and semester/3.

  • solar_term/2 on every calendar and Calendrical.Lunisolar.solar_term_name/1 give the date and name of each of the 24 solar terms.

  • Calendrical.Vietnamese, the Chinese lunisolar calendar observed from Hanoi's meridian, so Tết differs from the Chinese New Year in 1968, 1969, 1985, 2007, 2030 and 2053.

  • Calendrical.Reform.England, England's 1155, 1751 and 1752 calendar reforms as a composite calendar.

  • date_at/2 on the Islamic and Hebrew calendars maps an instant to a date under a :day_start of :midnight (the default), :evening, :sunset or (Hebrew) :nightfall.

  • dates_in_gregorian_year/3 on every calendar gives the dates of a calendar month and day within a Gregorian year.

  • calendar_from_cldr_calendar_type/1 and additional_calendars/0 give every calendar an IXDTF [u-ca=…] identifier, and Calendrical registers itself as Localize's calendar provider.

  • Calendrical.named_month/3, the days of a named month in the order of time.

  • A composite calendar's parsing_calendars/0 names its members, so a date written in a member's formats reads back.

  • Calendrical.Hebrew converts between a month's position and its RFC 7529 traditional month, as the lunisolar calendars do.

  • mix calendrical.umm_al_qura.verify audits the embedded Umm al-Qura tables, and with --kacst compares them with KACST's data.

Changed

  • Requires localize ~> 1.4 and astro ~> 2.8.

  • The optional tz_world requirement is ~> 2.3, where it was ~> 1.0.

  • The package's licenses are Apache-2.0 and Unicode-3.0: the Umm al-Qura months for 1501 to 1600 AH are derived from ICU.

  • Calendrical.Islamic.UmmAlQura uses KACST's official month lengths for 1 to 1500 AH and ICU's for 1501 to 1600 AH, and the civil calendar outside them.

  • Calendrical.Persian computes its new year from the bundled ephemeris for Persian years 1279 to 1479 and uses ICU's arithmetic cycle outside them. new_year_gregorian/1 and year_end_gregorian/1 cover Gregorian years 1900 to 2100.

  • Japanese era boundaries before Meiji are the proleptic Gregorian dates Localize publishes, and Calendrical.LunarJapanese counts an era's years from the lunar year of its proclamation.

  • Calendrical.Preference resolves -u-ca-iso8601 to Calendrical.ISO, the Gregorian calendar with ISO 8601's week rules, instead of Calendrical.ISOWeek.

  • quarter_of_year/3 answers for the Hebrew, Coptic and Ethiopic calendars, where it returned {:error, :not_defined}.

  • next/3 and previous/3 step a week range to the week holding the day after or before it, so a week cut short at a year's end is never skipped.

  • The lunisolar calendars compute each new moon once per question, a quarter as many across a holiday corpus.

  • The lunisolar calendars answer for the 40,001 years counted from their epoch as Gregorian years -20,000 to 20,000; beyond them valid_date?/3 is false and conversions raise Calendrical.UnsupportedDateRangeError, where a far year took minutes.

Fixed

  • Calendrical.Islamic.Observational and Calendrical.Islamic.Rgsa begin a month on the day after the evening the crescent is first seen, where they began a day early.

  • Every calendar answers a question about a date only for a date it has: a Calendrical.InvalidDateError for a date valid_date?/3 rejects, and a Calendrical.MissingFieldsError for a missing field, as quarter/2, quadrimester/2, semester/2 and week/2 now do too.

  • month_of_year/3 and quarter_of_year/3 answer a partial date from its year and month, checking the month without a year, so Calendrical.strftime/3 names a partial date's month as it names a whole date's.

  • Calendrical.Lunisolar.day_name/1 and day_name_on_or_before/2 count from the right epoch, so 1 October 1949 is 甲子; days were named five places on, and day_name_on_or_before/2 did not return a day of the name.

  • The observational Islamic calendars refuse a date far outside the ephemeris at once, where it took seconds or more.

  • leap_year?/1 raises FunctionClauseError for anything but an integer year in every calendar, as Calendar.ISO does, and leap_month/1 answers nil for one in every calendar.

  • year_of_era/1 returns a Calendrical.MissingFieldsError for a missing year in the Coptic, Ethiopic and Julian calendars, as every other calendar does.

  • A Julian year-start variant's or fiscal calendar's written and base dates answer a partial date with nil fields, where they raised ArithmeticError.

  • gregorian_date_for_lunar/3 raises Calendrical.InvalidDateError for a lunar month or day the year lacks, where it returned another day's date.

  • valid_date?/3 is false rather than raising for a month or day below 1, for non-integer parts and for a date outside a calendar's supported range.

  • days_in_month/1 answers a month with no year in every calendar: its length, or {:ambiguous, range} where it varies, and a Calendrical.NotDefinedError for a month the calendar lacks.

  • days_in_month/2 no longer gives a length for a month the year does not have: the lunisolar calendars answered 30 for a 13th month of a 12-month year, and Persian and Reform.Sweden.Transitional 30 or 31 for months 0 and 13.

  • Calendrical.localize/3 returns Calendrical.InvalidDateError for :era, :day_of_week and :days_of_week of a date its calendar does not have, where it answered nil or raised.

  • The astronomical Ecclesiastical functions return Calendrical.UnsupportedDateRangeError for a year outside 1000 to 3000, where they raised FunctionClauseError.

  • Calendrical.Persian, Calendrical.Ecclesiastical and Calendrical.Islamic.UmmAlQura.Astronomical return an exception for an Astro error, where they passed its atom through.

  • The lunisolar new/3 checks a leap month's day against that month's own length, and a lunisolar calendar reads a day's year as the year its new year begins.

  • Calendrical.LunarJapanese converts each lunar year once, and a :lunar_japanese_epoch in late July or August no longer duplicates or skips years.

  • Date.shift/2 adds years and months together and clamps the day once, as Calendar.ISO does, and lunisolar and Hebrew shifts walk each year's own months.

  • A week-based calendar's shift/6 places the day of the month once, and its day_of_year/3 numbers the first day 1.

  • The composite calendars add years, months, weeks and days across a transition, count diff/3 and day_of_era/3 through a change of calendar, and read each date in the calendar that has its year.

  • A composite calendar names a date's months, eras and cycles from the member in effect, a partial date's included where one member holds all of it, so Calendrical.Reform.Japan names its lunisolar months and Meiji era.

  • A composite calendar writes a day with no date of its own as the first later day that has one, and names a month a change began part of the way through from its year and month, so England's March 1751 is "Mar 1751".

  • A Julian year-start variant's dates agree with the Julian calendar's month lengths, weeks and leap days, it has no year 0, and its months and years shift in the Julian months that name them.

  • extended_year/3 counts a Julian year BC from 0 down, as TR35 defines it.

  • Calendrical.Korean and Calendrical.LunarJapanese place a year in the sexagenary cycle by its related Gregorian year, as ICU does, and every lunisolar cyclic_year/3 returns that place.

  • A date before a calendar's first era counts back from it in year_of_era/3 and day_of_era/3, where it raised, and a before era counts back from its last day.

  • Calendrical.strftime/3 names the month and day of the week from the date, so Hebrew, Chinese leap and fiscal months are named correctly.

  • A month calendar's week_of_month/3 follows TR35, and its iso_week_of_year/3 gives the ISO 8601 week of the day its date names.

  • Calendrical.Interval.day/3 answers for every calendar, every constructor accepts Calendar.ISO, and the date forms return an error for a date the calendar lacks where month/1 and day/1 raised.

  • Calendrical.first_day_of_year/2, last_day_of_year/2 and the Gregorian-day functions answer for every calendar, where most raised UndefinedFunctionError.

  • Calendrical.interval/3 and interval_stream/3 order their dates by day, which the Julian year-start calendars need.

  • Calendrical.calendar_from_locale/1 maps -u-ca-<name> through calendar_from_cldr_calendar_type/1, and a value that is no locale is an error where it raised.

  • Calendrical.Islamic.UmmAlQura.first_day_of_month/2 returns a Calendrical.InvalidMonthError for a month outside 1 to 12, where it reported an out-of-range year.

  • Calendrical.Composite.new/2 and Calendrical.Reform.calendar_for/1 create a calendar once when several processes ask at once, where all but one raised CompileError.

  • Every calendar's parse_date/1, parse_naive_datetime/1 and parse_utc_datetime/1 read a date as Calendar.ISO does: {:error, :invalid_format} for text that is no date, and a + before a four-digit year.

  • A calendar of weeks writes its year with four digits at least, so a date before year 1000 reads back: "0004-02-35".

  • The parse functions keep a month name that is also a weekday name, accept format and stand-alone month names, parse dates with eras, resolve flexible day periods and Hebrew month names, and return errors where they raised.

  • as: :map parsing gives a fixed offset such as "GMT+5" the DateTime zone fields, where the map kept the text with no offset.

  • Calendrical.TimeZone.resolve/3 keeps the offset of a name specific to standard or daylight time: "14:00 EST" in May is 14:00 at -05:00.

  • Calendrical.localize/3 names the month of a composite or Julian year-start date, and Calendrical.Format lays out calendars without weeks and leaves out months a year lacks.

  • The tabular Islamic calendars convert dates before the Hijra with floored division.

  • Calendrical.Reform.Sweden.Transitional has no year 0, as the Julian calendar has none, and round-trips every day from 1700 to 1712.

  • Calendrical.Era.year_of_era/4, day_of_era/4 and every calendar's year_of_era/1 answer as the calendar's own year_of_era/3 and day_of_era/3 do, where they reckoned from info/0 and disagreed with a calendar that defines its own: Calendrical.Reform.Japan.year_of_era(1000) is {21, 198}, as year_of_era(1000, 1, 1) is.

  • Calendrical.Reform.TR changes calendar on 1 January 1926, where its data said 1927.

  • A composite calendar's leap_year?/1 is false for a year a change of calendar took the leap day from, so Sweden's 1700, with no 29 February, is no leap year, where it answered the Julian calendar's true.

  • numeric_month/3, cardinal_day/3 and cardinal_year/3 return Calendrical.InvalidDateError for a date the calendar does not have, where they echoed its figures.

  • Calendrical.Persian and Calendrical.LunarJapanese state the first day of their year 1 as info().epoch, where the Persian fact was two days after it and the Lunar Japanese fact the Taika proclamation day, five months after it; the default :lunar_japanese_epoch is ~D[0645-02-05].

  • Calendrical.Hebrew.date_from_iso_days/1 reads the days before year 1, where its estimate of the year was one too high.

  • Calendrical.Era.era_data/1 raises Localize.UnknownCalendarError for a type CLDR does not define, and day_of_week/4 raises Calendrical.InvalidOptionValueError for a starting_on that is no weekday, where both raised ArgumentError.