Migrating from ex_cldr_calendars

Copy Markdown

This guide covers the changes needed when migrating from ex_cldr_calendars (and its companion calendar libraries) to Calendrical.

Migrating to Calendrical 2.0

Calendrical 2.0 rebuilds every calendar on a kernel of eight callbacks and derives the rest. What a consumer of 1.x changes:

  • use Calendrical.Behaviour is use Calendrical.Calendar. months_in_ordinary_year: 12, months_in_leap_year: 13 is months_in_year: {12, 13}, and first_day_of_week: 7 is weeks: [first_day: 7]; :cldr_calendar_base and :days_in_week are gone. An option the maker does not read is refused.

  • use Calendrical.Base.Month, month_of_year: 7 is use Calendrical.Calendar.Shifted, year_begins: {7, 1}, with :year as :year_number and the week options as :weeks. use Calendrical.Julian, new_year_starting_month_and_day: {3, 25}, year: :beginning is use Calendrical.Calendar.Shifted, base: Calendrical.Julian, year_begins: {3, 25}, year_number: :beginning, written_year: :label.

  • use Calendrical.Base.Week, day_of_week: 1, month_of_year: 1, min_days_in_first_week: 4 is use Calendrical.Calendar.Weeks, year_begins: {:monday, :on_or_before, {1, 4}}; begins_or_ends: :ends, first_or_last: :last is year_ends: {day_of_week, :on_or_after, {month_of_year, last_day - min_days_in_first_week + 1}}, so a year that ends on the Saturday nearest 31 January (min_days_in_first_week: 4) is year_ends: {:saturday, :nearest, {1, 31}}, and :weeks_in_month is :pattern. Calendrical.new/3 takes the same options.

  • plus/6 is shift/6 and :coerce is :clamp, true by default in every calendar.

  • A calendar implementing the behaviour by hand implements 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, and the six primitive Calendar callbacks. Every other function a calendar module answered in 1.x it still answers, derived from those, but it is no longer a callback and cannot be overridden.

  • month_of_year/3 is the CLDR month a date's month is named by, which cardinal_month/1 used to add. Every calendar numbers whole weeks from its own first day of the week, and a Hebrew date is written with its month's place in the year while its name stays CLDR's.

  • calendar_year/3, __config__/0, Calendrical.start_end_gregorian_years/2, the identity functions first_day_of_week/0, last_day_of_week/0 and epoch_day_of_week/0, and the 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 that took a Date or a tuple are gone. A Julian year-start variant's date_from_julian_date/3 is date_from_base_date/3, beside a new base_date/3.

  • shift/6 returns Calendrical.InvalidDateError for a {year, month, day} that is no date, where it shifted the fields, and Date.shift/2 raises it. diff/3 returns the same error, and Calendrical.MissingFieldsError for a date missing a field, 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, and the lunisolar new!/3 raises Calendrical.InvalidDateError.

  • weeks_in_year/1 answers an integer in every calendar, numbering whole weeks by TR35's rule from the calendar's own first day of the week.

  • The Julian year-start variants carry counted months, so Calendrical.Julian.March25 and Dec25 have thirteen; Dec25 and Sept1 number a year by the Julian year it ends in, and a variant takes a date's era from its label year.

  • 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, and keeps Gregorian era names. 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 than in 1.3.

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

  • Calendrical.TimeZone.resolve/3 delegates to Localize.DateTime.Timezone.resolve/3: its errors are exceptions, an abbreviation resolves only in a locale whose CLDR data writes it, and a repeated hour is read in standard time.

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

  • The parse functions delegate to Localize's parsers and return its errors: Calendrical.DateParseError, DateRangeParseError, DateTimeParseError, TimeParseError and ParseError are removed in favour of Localize.DateParseError, Localize.DateRangeParseError, Localize.DateTimeParseError and Localize.TimeParseError. week N of YYYY counts the calendar's own weeks and an ISO 8601 offset is kept. The :calendar option is a calendar module and the date is returned in it; :return_calendar is removed and returns Calendrical.RemovedOptionError, and 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, as Calendrical.Hebrew's are, where Calendrical.Hebrew.valid_date?/3 rejected them; Calendrical.Ethiopic gives them to Amete Alem, so 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, and the Julian days 1 January to 24 March 1155 have no dates of their own.

  • Calendrical.Composite.new/2 returns Calendrical.InvalidCompositeError where it returned atoms such as :no_calendars_configured, Calendrical.UnknownOptionError for an option other than :calendars and :base_calendar, and {:ok, module} for a module that exists already, where it returned {:module_already_exists, module}. Calendrical.FiscalYear.calendar_for/1 returns Calendrical.UnknownFiscalYearError for a territory with no fiscal calendar, and Calendrical.new/3 returns Calendrical.InvalidOptionValueError for a kind other than :month and :week.

Overview

Calendrical consolidates the following ex_cldr packages into a single library:

Old PackageNew Module
ex_cldr_calendars (core)Calendrical
ex_cldr_calendars_persianCalendrical.Persian
ex_cldr_calendars_copticCalendrical.Coptic
ex_cldr_calendars_ethiopicCalendrical.Ethiopic
ex_cldr_calendars_japaneseCalendrical.Japanese
ex_cldr_calendars_lunisolarCalendrical.Chinese, Calendrical.Korean, Calendrical.LunarJapanese
ex_cldr_calendars_formatCalendrical.Format, Calendrical.Formatter

All calendar functionality, localization data, formatting, and date arithmetic are available from a single dependency.

Dependency changes

Remove all ex_cldr calendar dependencies and replace with calendrical:

# Old
defp deps do
  [
    {:ex_cldr_calendars, "~> 2.0"},
    {:ex_cldr_calendars_persian, "~> 1.0"},
    {:ex_cldr_calendars_coptic, "~> 1.0"},
    {:ex_cldr_calendars_format, "~> 1.0"},
    # ... other calendar packages
  ]
end

# New
defp deps do
  [
    {:calendrical, "~> 2.1"},
  ]
end

Calendrical depends on Localize for CLDR locale data and Astro for astronomical calculations used by the Persian and lunisolar calendars.

Localize replaces ex_cldr

Calendrical uses Localize instead of ex_cldr for all locale data access. Localize provides the same CLDR data but without the backend module architecture.

No backend modules

The most significant architectural change is the removal of the backend pattern. There is no equivalent of MyApp.Cldr in Calendrical.

# Old — required a backend module
defmodule MyApp.Cldr do
  use Cldr,
    providers: [Cldr.Calendar, Cldr.Number, Cldr.Unit, Cldr.List],
    locales: ["en", "fr", "ar", "he"],
    default_locale: "en"
end

MyApp.Cldr.Calendar.localize(date, :month)
MyApp.Cldr.Calendar.strftime_options!(locale: "fr")

# New — call Calendrical directly
Calendrical.localize(date, :month)
Calendrical.strftime_options!(locale: "fr")

Locale management

Replace Cldr locale functions with their Localize equivalents:

OldNew
Cldr.get_locale()Localize.get_locale()
Cldr.put_locale(locale)Localize.put_locale(locale)
Cldr.validate_locale(locale, backend)Localize.validate_locale(locale)
Cldr.validate_territory(territory)Localize.validate_territory(territory)
Cldr.LanguageTagLocalize.LanguageTag
Cldr.Locale.territory_from_locale(locale)Localize.Territory.territory_from_locale(locale)

All functions that previously accepted a :backend option no longer do. The :locale option defaults to Localize.get_locale().

Module namespace changes

All modules move from the Cldr.Calendar namespace to Calendrical:

OldNew
Cldr.CalendarCalendrical
Cldr.Calendar.GregorianCalendrical.Gregorian
Cldr.Calendar.JulianCalendrical.Julian
Cldr.Calendar.ISOCalendrical.ISO
Cldr.Calendar.ISOWeekCalendrical.ISOWeek
Cldr.Calendar.NRFCalendrical.NRF
Cldr.Calendar.PersianCalendrical.Persian
Cldr.Calendar.CopticCalendrical.Coptic
Cldr.Calendar.EthiopicCalendrical.Ethiopic
Cldr.Calendar.JapaneseCalendrical.Japanese
Cldr.Calendar.ChineseCalendrical.Chinese
Cldr.Calendar.KoreanCalendrical.Korean
Cldr.Calendar.LunarJapaneseCalendrical.LunarJapanese
Cldr.Calendar.FiscalYearCalendrical.FiscalYear
Cldr.Calendar.FiscalYear.USCalendrical.FiscalYear.US
Cldr.Calendar.Configremoved — a calendar's options are its maker's. See Configuration changes.
Cldr.Calendar.IntervalCalendrical.Interval
Cldr.Calendar.KdayCalendrical.Kday
Cldr.Calendar.Sigilsremoved — use Elixir's native ~D sigil. See Sigils below.
Cldr.Calendar.PreferenceCalendrical.Preference

Territory-derived calendars also change namespace. For example, Cldr.Calendar.US becomes Calendrical.US and Cldr.Calendar.GB becomes Calendrical.GB.

Exception modules

Calendrical's exceptions have been completely restructured:

  • One file per exception in lib/calendrical/exception/, mirroring the layout used by Localize.
  • Semantic struct fields instead of a single opaque :message string. Callers can now pattern-match on the exception's data fields.
  • gettext-based messages so error text can be translated. The backend is Calendrical.Gettext, and the messages are written in MessageFormat 2 ({$name} placeholders, interpolated by Localize.Gettext.Interpolation) in the "calendrical" domain, with the contexts "calendar", "date", "format", "option" and "style". The template is priv/gettext/calendrical.pot.
  • All names end with Error for consistency with the Localize convention.
OldNewFields
Cldr.IncompatibleCalendarErrorCalendrical.IncompatibleCalendarError:from, :to
Cldr.InvalidCalendarModuleCalendrical.InvalidCalendarModuleError:module
Cldr.InvalidDateOrderCalendrical.InvalidDateOrderError:from, :to
Cldr.IncompatibleTimeZoneCalendrical.IncompatibleTimeZoneError:from, :to
Cldr.MissingFieldsCalendrical.MissingFieldsError:function, :fields

New exceptions introduced by this refactor (replacing inline {ArgumentError, "..."} tuples):

ModuleFieldsUsed by
Calendrical.InvalidPartError:part, :valid_partsCalendrical.localize/3
Calendrical.InvalidTypeError:type, :valid_typesCalendrical.localize/3
Calendrical.InvalidStyleError:style, :valid_stylesCalendrical.localize/3
Calendrical.UnknownOptionError:maker, :options, :known_optionsthe calendar makers, Calendrical.new/3, Calendrical.Composite.new/2
Calendrical.InvalidOptionValueError:option, :value, :makerthe calendar makers, Calendrical.new/3, date_at/2, Calendrical.strftime/3
Calendrical.InvalidCompositeError:reason, :valueuse Calendrical.Composite, Calendrical.Composite.new/2
Calendrical.RemovedOptionError:optionthe parse functions, for :return_calendar
Calendrical.NoDuskError:day_start, :location, :datedate_at/2
Calendrical.InvalidSolarTermError:indexsolar_term/2, Calendrical.Lunisolar.solar_term_name/1
Calendrical.InvalidDateError:calendar, :valueevery function given a date, a week or a day of the year the calendar does not have
Calendrical.NotDefinedError:calendar, :question, :valuedays_in_month/1, months_in_year/0, Calendrical.Info.years_in_cycle/1
Calendrical.UnknownReformError:territoryCalendrical.Reform.reform_date/1, Calendrical.Reform.calendar_for/1
Calendrical.UnknownFiscalYearError:territoryCalendrical.FiscalYear.calendar_for/1
Calendrical.AstronomyError:calendar, :value, :reasonthe astronomical calendars, for an Astro failure other than its range

The 1.x atoms each became an exception:

1.x2.0
{:error, :invalid_date}{:error, %Calendrical.InvalidDateError{}}
{:error, :invalid_month}, {:error, :invalid_leap_month}{:error, %Calendrical.InvalidMonthError{}}
{:error, :undefined}, {:error, :not_defined}{:error, %Calendrical.NotDefinedError{}}
{:error, :year_out_of_range}, {:error, :not_found}{:error, %Calendrical.UnsupportedDateRangeError{}}
{:error, :unknown_territory}{:error, %Calendrical.UnknownReformError{}} from Calendrical.Reform, {:error, %Calendrical.UnknownFiscalYearError{}} from Calendrical.FiscalYear
{:error, :no_calendars_configured} and the other atoms of Calendrical.Composite.new/2{:error, %Calendrical.InvalidCompositeError{reason: atom}}
{:error, :incompatible_calendars}{:error, %Calendrical.IncompatibleCalendarError{reason: :day_rollover}}

days_in_month/2 returns {:error, %Calendrical.InvalidMonthError{}} in every calendar for a month its year does not have, where it raised, answered 0 or gave another month's length in 1.x.

Two kinds of callback still return an atom, because code outside Calendrical reads it. A calendar's parse_date/1, parse_naive_datetime/1 and parse_utc_datetime/1 return {:error, atom} as Elixir's Calendar behaviour requires, for Date.from_iso8601/2 and the others. date_from_written_date/3 returns {:error, :invalid_date} and written_month_from_cldr_month/2 returns {:error, :invalid_month}, which Localize's date parser reads.

Error return convention

All {:error, _} returns from Calendrical now use the modern Elixir convention {:error, %Exception{}} instead of the legacy two-tuple form {:error, {ExceptionModule, "message"}}. This applies to functions returning Localize exceptions (Localize.UnknownTerritoryError, Localize.UnknownCalendarError, Localize.InvalidLocaleError) as well as Calendrical exceptions.

# Old
case Calendrical.calendar_from_territory(:YY) do
  {:ok, calendar} -> calendar
  {:error, {Localize.UnknownTerritoryError, message}} -> handle(message)
end

# New
case Calendrical.calendar_from_territory(:YY) do
  {:ok, calendar} -> calendar
  {:error, %Localize.UnknownTerritoryError{territory: territory}} -> handle(territory)
end

The new pattern lets callers extract structured data from the exception (e.g. :territory, :calendar, :module, :fields) instead of parsing message strings.

Behaviour module

Calendars that use the behaviour macro change from use Cldr.Calendar.Behaviour to use Calendrical.Calendar, from use Cldr.Calendar.Base.Month to use Calendrical.Calendar.Shifted, and from use Cldr.Calendar.Base.Week to use Calendrical.Calendar.Weeks. Their options change as described in Migrating to Calendrical 2.0.

Removed: Cldr.Calendar.Duration

The Cldr.Calendar.Duration module has been removed entirely. Use Elixir's built-in %Duration{} struct (available since Elixir 1.17) and Date.diff/2 instead.

Computing differences

# Old
{:ok, duration} = Cldr.Calendar.Duration.new(~D[2020-01-01], ~D[2021-03-15])
# => {:ok, %Cldr.Calendar.Duration{year: 1, month: 2, day: 14, ...}}

# New — use Date.diff for day counts
Date.diff(~D[2021-03-15], ~D[2020-01-01])
# => 439

# Or construct a Duration directly
%Duration{year: 1, month: 2, day: 14}

Formatting durations

The localized Duration.to_string/2 function which used Cldr.Unit and Cldr.List for formatting has been removed. Use Localize.Unit and Localize.List directly if localized duration formatting is needed.

Shifting dates with durations

# Old
duration = Cldr.Calendar.Duration.new!(~D[2020-01-01], ~D[2020-03-15])
Cldr.Calendar.shift(~D[2025-01-01], duration)

# New — use Date.shift with a Duration
Date.shift(~D[2025-01-01], %Duration{month: 2, day: 14})

Removed: Calendrical.plus and Calendrical.minus

The public Calendrical.plus/2,3,4 and Calendrical.minus/3,4 functions have been removed. Use Elixir's Date.shift/2, DateTime.shift/2, and NaiveDateTime.shift/2 instead.

Date arithmetic

# Old
Cldr.Calendar.shift(date, :years, 1)
Cldr.Calendar.shift(date, :months, 3)
Cldr.Calendar.shift(date, :quarters, 1)
Cldr.Calendar.shift(date, :weeks, 2)
Cldr.Calendar.shift(date, :days, 10)
Cldr.Calendar.minus(date, :months, 1)

# New
Date.shift(date, year: 1)
Date.shift(date, month: 3)
Date.shift(date, month: 3)    # quarters: multiply by 3
Date.shift(date, week: 2)
Date.shift(date, day: 10)
Date.shift(date, month: -1)

Date.shift/2 works correctly with all Calendrical calendar types including week-based fiscal calendars, lunisolar calendars, and the Julian calendar. Each calendar implements the Calendar.shift_date/4 callback with the appropriate semantics for its calendar system.

Coercion

The old plus/4 accepted a :clamp option that controlled whether invalid dates (such as February 30) would be clamped to valid dates or return {:error, :invalid_date}. Date.shift/2 always clamps to valid dates. A calendar's own shift/6 keeps :clamp, true by default; clamp: false keeps the day, which can name a day the month lacks.

# Old — could disable clamping
Cldr.Calendar.shift(~D[2024-01-31], :months, 1, clamp: false)
# => {:error, :invalid_date}

# New — always clamps
Date.shift(~D[2024-01-31], month: 1)
# => ~D[2024-02-29]

No quarter unit

Date.shift/2 does not support a :quarter unit. Multiply by 3 months instead:

# Old
Cldr.Calendar.shift(date, :quarters, 2)

# New
Date.shift(date, month: 6)

Date.Range shifting

The old Cldr.Calendar.plus/4 could shift a Date.Range and return a new range for the resulting period. This is no longer supported as a single operation. Use Calendrical.next/2 or Calendrical.previous/2 for period navigation, which return Date.Range values for range inputs.

Retained public API

The following functions continue to work as before (with namespace changes):

Calendrical.next/2,3 and Calendrical.previous/2,3 work as before but now use Date.shift internally. They accept dates and date ranges and return the next or previous period:

Calendrical.next(~D[2024-03-15], :month)
# => ~D[2024-04-15]

Calendrical.previous(~D[2024-03-15], :year)
# => ~D[2023-03-15]

# With Date.Range inputs, returns the next/previous period as a range
year_range = Calendrical.Gregorian.year(2024)
Calendrical.next(year_range, :year)
# => Date.range(~D[2025-01-01 Calendrical.Gregorian], ~D[2025-12-31 Calendrical.Gregorian])

Localization

Calendrical.localize/2,3 works the same way but is called directly on the Calendrical module instead of through a backend:

Calendrical.localize(~D[2024-06-15], :month)
# => "Jun"

Calendrical.localize(~D[2024-06-15], :month, style: :wide, locale: "fr")
# => "juin"

Calendrical.localize(~D[2024-06-15], :day_of_week)
# => "Sat"

Calendrical.localize(%{hour: 14}, :day_period)
# => "PM"

Locale data access

The locale data functions that were on the backend (MyApp.Cldr.Calendar.eras/2, .months/2, etc.) are now on the Calendrical module:

Calendrical.eras(:en, :gregorian)
Calendrical.months(:en, :gregorian)
Calendrical.days(:fr, :gregorian)
Calendrical.quarters(:en, :gregorian)
Calendrical.day_periods(:en, :gregorian)
Calendrical.cyclic_years(:en, :chinese)
Calendrical.month_patterns(:en, :chinese)

These return {:ok, data} tuples.

Calendar creation

# Creating custom calendars
{:ok, MyFiscal} = Calendrical.new(MyFiscal, :month, year_begins: {7, 1}, year_number: :ending)

# Or using a maker directly in a module
defmodule MyApp.FiscalYear do
  use Calendrical.Calendar.Shifted,
    year_begins: {7, 1},
    year_number: :ending
end

# Week-based calendar: the year ends on the first Saturday on or after 31 January
defmodule MyApp.Retail do
  use Calendrical.Calendar.Weeks,
    year_ends: {:saturday, :on_or_after, {1, 31}},
    pattern: [4, 4, 5]
end

Week-based calendars hold a month and a day

A date of a week-based calendar is a year, a month and a day of the month, as a date of any calendar is. In ex_cldr_calendars, and in Calendrical before 2.0, its :month field held the week of the year and its :day field the day of the week.

# Old — the second day of week 25
Date.new!(2026, 25, 2, Cldr.Calendar.NRF)

# New — the same day: the sixteenth of the sixth month of the 4-5-4 pattern
Date.new!(2026, 6, 16, Calendrical.NRF)
Calendrical.NRF.date_from_week_date(2026, 25, 2)
# => ~D[2026-06-16 Calendrical.NRF]

Calendrical.NRF.week_date_from_date(2026, 6, 16)
# => {2026, 25, 2}

A date read from date.month as a week, or built from a week and a day of it, goes through week_date_from_date/3 and date_from_week_date/3. A stored "2026-W25-2" still parses; a date is now written "2026-06-16".

Localize writes such a date in the locale's formats of CLDR's generic calendar, "M06 16, 2026 AD", and reads that back as itself. Text that names a Gregorian month, "Feb 1, 2024", is none of the calendar's own formats', so it is still read as the Gregorian date and converted, as the calendar's parsing_calendar/0 says.

Intervals and streams

Calendrical.interval(~D[2024-01-01], 6, :months)
# => [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01], ...]

Calendrical.interval_stream(~D[2024-01-01], ~D[2024-12-31], :quarters)
|> Enum.to_list()

Sigils

The Calendrical.Sigils module — which provided the ~d sigil — has been removed. Use Elixir's native ~D sigil with a fully-qualified calendar suffix instead.

# Old (ex_cldr_calendars / Calendrical 0.0.x)
import Calendrical.Sigils

~d[2024-06-15]                    # Gregorian (default)
~d[2024-06-15 Gregorian]          # Explicit Gregorian
~d[2024-W24-6]                    # ISO Week
~d[2024-06-15 Persian]            # Persian calendar
~d[1446-06-15 C.E. Julian]        # Julian calendar

# New (Calendrical 0.1.0+) — use Elixir's native ~D, ~U, ~N
~D[2024-06-15]                                      # Calendar.ISO (default)
~D[2024-06-15 Calendrical.Gregorian]                # Explicit Gregorian
~D[2024-06-15 Calendrical.Persian]                  # Persian calendar
~D[1446-06-15 Calendrical.Julian]                   # Julian calendar
~D[-1446-06-15 Calendrical.Julian]                  # B.C.E. Julian (negative year)
~D[1446-09-01 Calendrical.Islamic.UmmAlQura]        # Umm al-Qura
~D[5784-08-15 Calendrical.Hebrew]                   # Hebrew (15 Nisan, month 8 of a leap year)

The native ~D sigil has supported the trailing calendar form since Elixir 1.10. It works for any module implementing the Calendar behaviour, so every Calendrical calendar, and any calendar built with Calendrical.Calendar or one of its makers, is valid.

Features the old ~d sigil supported that the native ~D does not:

  • ISO week date format (~d[2024-W24-6]) — the native sigil reads a week date for a calendar of weeks, as the date it names, and any calendar gives the date of one:

    ~D[2024-W24-6 Calendrical.ISOWeek]
    # => ~D[2024-06-13 Calendrical.ISOWeek]
    
    Calendrical.ISOWeek.date_from_week_date(2024, 24, 6)
    # => ~D[2024-06-13 Calendrical.ISOWeek]
  • Short calendar names (~d[2024-06-15 Persian] → Calendrical.Persian) — write the full module name with the native sigil.

  • B.C.E./C.E. era markers (~d[2024-01-01 B.C.E. Julian]) — use a negative year with the native sigil: ~D[-2024-01-01 Calendrical.Julian].

  • Default to Calendrical.Gregorian — bare ~d[2024-01-01] defaulted to Calendrical.Gregorian. Bare ~D[2024-01-01] defaults to Calendar.ISO. The two are arithmetically equivalent (Calendrical.Gregorian wraps Calendar.ISO) but the :calendar field is different. Use ~D[2024-01-01 Calendrical.Gregorian] if you specifically want the Calendrical.Gregorian calendar struct.

Calendar formatting (ex_cldr_calendars_format)

The ex_cldr_calendars_format library is now part of Calendrical. It provides a behaviour-based plugin system for rendering calendars as HTML, Markdown, or custom formats.

Module renames

OldNew
Cldr.Calendar.FormatCalendrical.Format
Cldr.Calendar.FormatterCalendrical.Formatter
Cldr.Calendar.Formatter.OptionsCalendrical.Formatter.Options
Cldr.Calendar.Formatter.HTML.BasicCalendrical.Formatter.HTML.Basic
Cldr.Calendar.Formatter.HTML.WeekCalendrical.Formatter.HTML.Week
Cldr.Calendar.Formatter.MarkdownCalendrical.Formatter.Markdown
Cldr.Calendar.Formatter.UnknownFormatterErrorCalendrical.Formatter.UnknownFormatterError
Cldr.Calendar.Formatter.InvalidDateErrorCalendrical.Formatter.InvalidDateError
Cldr.Calendar.Formatter.InvalidOptionCalendrical.Formatter.InvalidOptionError

Removed :backend option

The :backend option has been removed from Calendrical.Formatter.Options. If passed, it is silently ignored for backward compatibility.

# Old
Cldr.Calendar.Format.year(2024, backend: MyApp.Cldr, locale: "fr")
Cldr.Calendar.Format.month(2024, 6, backend: MyApp.Cldr, formatter: Cldr.Calendar.Formatter.Markdown)

# New
Calendrical.Format.year(2024, locale: "fr")
Calendrical.Format.month(2024, 6, formatter: Calendrical.Formatter.Markdown)

The :locale option defaults to Localize.get_locale() instead of backend.get_locale().

Number formatting changes

The formatter options validation for :number_system now uses the Localize API directly. The old three-argument Cldr.Number.validate_number_system(locale, system, backend) is replaced by Localize.validate_number_system(system).

Number formatting inside formatters uses Localize.Number.to_string!/2 instead of Cldr.Number.to_string!/3 (no backend parameter):

# Old (inside a custom formatter)
Cldr.Number.to_string!(day, backend, locale: locale, number_system: number_system)

# New
Localize.Number.to_string!(day, locale: locale, number_system: number_system)

Custom formatter behaviour

The Calendrical.Formatter behaviour callbacks are unchanged. Custom formatters that implement the four callbacks (format_year/3, format_month/4, format_week/5, format_day/4) work the same way. The only change is the module name in the @behaviour declaration and the Options struct no longer containing a :backend field:

# Old
defmodule MyApp.CustomFormatter do
  @behaviour Cldr.Calendar.Formatter

  @impl true
  def format_day(date, year, month, options) do
    # options.backend was available here
    ...
  end
end

# New
defmodule MyApp.CustomFormatter do
  @behaviour Calendrical.Formatter

  @impl true
  def format_day(date, year, month, options) do
    # options.backend no longer exists
    # use Localize directly for any locale data needs
    ...
  end
end

Options struct changes

The Calendrical.Formatter.Options struct fields:

FieldStatusNotes
:calendarUnchangedCalendar module, defaults to Calendrical.Gregorian
:formatterUnchangedFormatter module, defaults to Calendrical.Formatter.HTML.Basic
:localeChangedDefaults to Localize.get_locale() instead of backend.get_locale()
:number_systemChangedValidated via Localize.validate_number_system/1
:territoryChangedDerived via Localize.Territory.territory_from_locale/1
:backendRemovedNo longer present in the struct
:captionUnchanged
:classUnchanged
:idUnchanged
:todayUnchanged
:day_namesUnchanged
:privateUnchanged

Configuration changes

There is no configuration struct: a calendar's options are those of its maker, and :cldr_backend is gone. Calendrical.calendar_for_territory/2 makes a month calendar with a territory's first day of the week and minimum days in the first week.

# Old
Cldr.Calendar.new(MyCalendar, :week, [
  cldr_backend: MyApp.Cldr,
  day_of_week: 1,
  month_of_year: 1
])

# New — the year begins on the Monday on or before 1 January
Calendrical.new(MyCalendar, :week, year_begins: {:monday, :on_or_before, {1, 1}})
# => {:ok, MyCalendar}

Unit application order

A subtle but important semantic difference: Date.shift/2 applies duration units in order from largest to smallest (years → months → weeks → days), matching the Elixir stdlib convention. The old Cldr.Calendar.shift(date, %Duration{}) applied units in the opposite order (days → months → years). In most cases the results are identical, but they can differ when date clamping occurs at intermediate steps.