User-defined Calendars

Copy Markdown

This guide explains how to define a new calendar in Calendrical by useing the Calendrical.Behaviour macro. It covers the macro options, the two functions every calendar must define itself, every overridable callback, the conventions for sharing logic across related calendars, and a worked example for each common calendar shape (year-offset, solar, tabular lunar, observational lunar, lunisolar, and composite).

Why a behaviour?

A calendar in Elixir is a module that implements the Calendar behaviour from the standard library. The Calendar behaviour requires ~30 callbacks covering date arithmetic, leap-year detection, month and day counting, parsing and formatting, ISO-day conversion, time-of-day handling, time-zone-aware shift operations, and date-string output. Calendrical adds another ~15 callbacks of its own (Calendrical behaviour) covering localization, period ranges, era handling, and CLDR calendar typing.

Calendrical.Behaviour is a defmacro __using__ template that generates sensible default implementations of every callback from a small number of options. A calendar that uses the behaviour only needs to:

  1. Supply the :epoch option (mandatory) and any other options that differ from the defaults.

  2. Define date_to_iso_days/3 and date_from_iso_days/1, the two functions that map between the calendar's {year, month, day} form and the universal proleptic-Gregorian ISO day number.

  3. Override any callbacks whose default behaviour is wrong for the calendar (e.g. leap_year?/1, days_in_month/2, valid_date?/3).

Every callback generated by the behaviour is defoverridable, so you can replace as many or as few of them as you like. The built-in calendars range from ~80 lines (Calendrical.Buddhist) to ~290 lines (Calendrical.Hebrew), depending on how much customisation they require.

Quick start

defmodule MyApp.MyCalendar do
  use Calendrical.Behaviour,
    epoch: ~D[0001-01-01 Calendar.ISO],
    cldr_calendar_type: :gregorian

  @impl true
  def leap_year?(year), do: rem(year, 4) == 0

  def date_to_iso_days(year, month, day) do
    # ... calendar-specific calculation
  end

  def date_from_iso_days(iso_days) do
    # ... calendar-specific calculation
  end
end

That is the minimum: an epoch, a CLDR calendar type, an overridden leap_year?/1, and the two ISO-day conversion functions. Everything else (parsing, formatting, intervals, day-of-week, day-of-year, period ranges, …) is generated by the behaviour using the defaults.

Required: the two ISO-day conversion functions

Every calendar useing Calendrical.Behaviour must define these two functions itself. They are not generated by the behaviour because the conversion logic is the heart of what makes one calendar different from another.

date_to_iso_days(year, month, day)

Convert a calendar {year, month, day} to an integer ISO day number. The ISO day number is the count of days since the proleptic Gregorian epoch (1 January 0000 = 0). It is the same numbering used by Calendar.ISO.date_to_iso_days/3 and Date.to_gregorian_days/1, and it is the universal "lingua franca" that lets all Calendrical calendars interoperate via Date.convert/2.

def date_to_iso_days(year, month, day) do
  # for example, a year-offset calendar:
  Calendrical.Gregorian.date_to_iso_days(year + offset, month, day)
end

date_from_iso_days(iso_days)

The inverse: given an integer ISO day number, return the calendar {year, month, day} tuple.

def date_from_iso_days(iso_days) do
  {greg_year, month, day} = Calendrical.Gregorian.date_from_iso_days(iso_days)
  {greg_year - offset, month, day}
end

These two functions must be inverses: date_from_iso_days(date_to_iso_days(y, m, d)) == {y, m, d} for every valid {y, m, d} in the calendar. The behaviour uses both internally for date arithmetic, intervals, day-of-week computation, and so on, so any inconsistency between them will surface as test failures across the rest of the API.

Macro options

All options are passed as keyword arguments to the use macro:

use Calendrical.Behaviour,
  epoch: ~D[0622-03-20 Calendrical.Julian],
  cldr_calendar_type: :persian,
  months_in_ordinary_year: 12
OptionRequiredDefaultDescription
:epochyesThe epoch of the calendar as a Date.t/0 sigil literal in any calendar that has already been compiled. Typically Calendar.ISO, Calendrical.Gregorian, or Calendrical.Julian. The epoch is converted to ISO days at compile time and made available via the generated epoch/0 function.
:cldr_calendar_typeno:gregorianThe CLDR calendar type used by Calendrical.localize/3 to look up era, month, day, and day-period names. Must be one of the values listed in the Calendrical.cldr_calendar_type/0 callback type.
:cldr_calendar_baseno:monthWhether the calendar is :month-based or :week-based. Returned by the generated calendar_base/0 function.
:days_in_weekno7The number of days in a week. Returned by the generated days_in_week/0 function and used to compute last_day_of_week/0.
:first_day_of_weekno1 (Monday)The day-of-week ordinal (1..7) on which the week begins, where 1 is Monday and 7 is Sunday. The special value :first causes day_of_week/4 to use the first day of the calendar year as the start of each week (used by some week-based calendars).
:months_in_ordinary_yearno12The number of months in a non-leap year. Used as the default return value of months_in_year/1 for non-leap years.
:months_in_leap_yearnosame as :months_in_ordinary_yearThe number of months in a leap year. Calendars with a leap month (such as the Hebrew or Chinese calendars) should set this to one more than :months_in_ordinary_year.

Choosing the epoch

The epoch must be expressed in another calendar that has already been compiled by the time use Calendrical.Behaviour runs. In practice this means one of:

# Persian (Hijri Shamsi): epoch is 20 March 622 Julian
epoch: ~D[0622-03-20 Calendrical.Julian]

# Coptic: epoch is 29 August 284 Julian (Era of Martyrs)
epoch: ~D[0284-08-29 Calendrical.Julian]

# Buddhist: 1 January 543 BCE Gregorian (proleptic, with year zero)
epoch: ~D[-0542-01-01 Calendrical.Gregorian]

The epoch is evaluated at compile time, converted to an ISO day number via the source calendar's own date_to_iso_days/3, and stored as the @epoch module attribute. The generated epoch/0 function returns this integer at runtime.

The behaviour also computes epoch_day_of_week/0 automatically by converting the epoch to Calendar.ISO and calling Date.day_of_week/1.

Generated and overridable callbacks

After use Calendrical.Behaviour, ..., the following functions are available in the calling module. All of them are defoverridable, so you can replace any of them.

Identity / configuration

CallbackDefault behaviour
epoch/0Returns the ISO-day form of the supplied :epoch option.
epoch_day_of_week/0Returns the ISO day-of-week (1=Mon, 7=Sun) of the epoch, computed at compile time.
first_day_of_week/0Returns the supplied :first_day_of_week option (default 1 = Monday).
last_day_of_week/0Returns (first_day_of_week + days_in_week - 1) mod days_in_week adjusted to 1..7.
days_in_week/0Returns the supplied :days_in_week option (default 7).
cldr_calendar_type/0Returns the supplied :cldr_calendar_type option (default :gregorian).
calendar_base/0Returns :month or :week (default :month).
months_in_ordinary_year/0Returns the supplied option (default 12).
months_in_leap_year/0Returns the supplied option (default same as ordinary).

Validity

CallbackDefault behaviour
valid_date?(year, month, day)Returns month <= months_in_year(year) and day <= days_in_month(year, month). Override for calendars with discontinuous month numbering (e.g. Hebrew month 6 only valid in leap years) or with stricter day rules.
valid_time?(hour, minute, second, microsecond)Delegates to Calendar.ISO.

Year and era

CallbackDefault behaviour
year_of_era/1 and year_of_era/3Computes era and year-of-era using the auto-generated Calendrical.Era.<CalendarType> module from CLDR era data. Override when the calendar's era logic doesn't match the CLDR data (e.g. Coptic and Ethiopic, which use simple year-sign logic).
calendar_year/3Returns the year unchanged. Override for calendars where the displayed year differs from the storage year (e.g. Japanese era years).
extended_year/3Returns the year unchanged.
related_gregorian_year/3Returns the year unchanged. Override to return the actual Gregorian year that contains the given calendar date — used by some localization formats.
cyclic_year/3Returns the year unchanged. Override for calendars with named year cycles (Chinese 60-year sexagenary cycle, etc.).
day_of_era/3Computes day-in-era from the auto-generated era module. Override when era boundaries are not in the CLDR data.

Periods

CallbackDefault behaviour
quarter_of_year/3Returns ceil(month / (months_in_year(year) / 4)). Override with {:error, :not_defined} for calendars that don't define quarters (Coptic, Ethiopic, Hebrew).
month_of_year/3Returns the month unchanged. Override to return {month, :leap} when the date is in a leap month so that Calendrical.localize/3 picks up the CLDR _yeartype_leap variant (e.g. Hebrew Adar II).
week_of_year/3Returns {:error, :not_defined}. Override for calendars that define weeks of the year.
iso_week_of_year/3Returns {:error, :not_defined}.
week_of_month/3Returns {:error, :not_defined}.
day_of_year/3Returns iso_days(year, month, day) - iso_days(year, 1, 1) + 1. Works for any month-based calendar.
day_of_week/4Computes the ISO day-of-week (1=Mon, 7=Sun) using the calendar's date_to_iso_days/3. Override for calendars whose week starts on a non-Monday (Coptic and Ethiopic both use Saturday).

Period counts

CallbackDefault behaviour
periods_in_year/1Delegates to months_in_year/1.
months_in_year/1Returns months_in_leap_year or months_in_ordinary_year based on leap_year?/1.
weeks_in_year/1Returns {:error, :not_defined}.
days_in_year/1Computes date_to_iso_days(year + 1, 1, 1) - date_to_iso_days(year, 1, 1). Override for an explicit constant when known.
days_in_month/1Returns {:error, :undefined}. Override if the month length is independent of the year.
days_in_month/2Computes the difference between the start of the month and the start of the next month. Override for any non-trivial calendar (this is one of the most commonly overridden callbacks).
leap_year?/1Not provided by default. Every calendar must define its own leap_year?/1.

Period ranges

CallbackDefault behaviour
year/1Returns a Date.Range covering 1 January (or the first valid month/day) through the last day of months_in_year(year).
quarter/2Returns {:error, :not_defined}.
month/2Returns a Date.Range covering the first to last day of the given month.
week/2Returns {:error, :not_defined}.

Arithmetic

CallbackDefault behaviour
plus/5 and plus/6Adds an increment of :months to a {year, month, day}. Used internally by shift_date/4. The default handles only :months; calendars that need :years, :weeks, etc. should override.
shift_date/4Delegates to Calendrical.shift_date/5 with the calendar module.
shift_time/5Delegates to Calendar.ISO.shift_time/5.
shift_naive_datetime/8Delegates to Calendrical.shift_naive_datetime/9 with the calendar module.

ISO-day conversion

CallbackDefault behaviour
naive_datetime_to_iso_days/7{date_to_iso_days(year, month, day), time_to_day_fraction(hour, minute, second, microsecond)}.
naive_datetime_from_iso_days/1Inverse of the above.
iso_days_to_beginning_of_day/1Delegates to Calendar.ISO.
iso_days_to_end_of_day/1Delegates to Calendar.ISO.

Parsing and formatting

CallbackDefault behaviour
parse_date/1Delegates to Calendrical.Parse.parse_date/2 with the calendar module.
parse_naive_datetime/1Delegates to Calendrical.Parse.parse_naive_datetime/2.
parse_utc_datetime/1Delegates to Calendrical.Parse.parse_utc_datetime/2.
parse_time/1Delegates to Calendar.ISO.
date_to_string/3Delegates to Calendar.ISO.
naive_datetime_to_string/7Delegates to Calendar.ISO.
datetime_to_string/11Delegates to Calendar.ISO.
time_to_string/4Delegates to Calendar.ISO.
time_from_day_fraction/1Delegates to Calendar.ISO.
time_to_day_fraction/4Delegates to Calendar.ISO.
day_rollover_relative_to_midnight_utc/0Delegates to Calendar.ISO.

Era support

When the using module is compiled, an @after_compile hook automatically calls Calendrical.Era.define_era_module/1. This:

  1. Reads the CLDR era data for the calendar's :cldr_calendar_type.
  2. Generates a Calendrical.Era.<CalendarType> module containing a year_of_era/2 and day_of_era/1 lookup function.
  3. Wires the calendar's default year_of_era/{1, 3} and day_of_era/3 to use this generated module.

For most calendars (Persian, Buddhist, Indian, ROC, …) this is exactly what you want — the CLDR era data has the correct era boundaries and your calendar gets era support for free.

A few calendars (Coptic, Ethiopic, Julian) use a simpler "positive year = era 1, negative year = era 0" convention that does not match the CLDR data. They override year_of_era/1, year_of_era/3, and day_of_era/3 directly:

def year_of_era(year) when year > 0, do: {year, 1}
def year_of_era(year) when year < 0, do: {abs(year), 0}

@impl true
def year_of_era(year, _month, _day), do: year_of_era(year)

If two calendars share the same :cldr_calendar_type (for example Calendrical.Chinese and Calendrical.LunarJapanese both use :chinese), the era module is created exactly once. The Calendrical.Era.define_era_module/1 function uses an ETS-based lock to coordinate creation under parallel compilation.

Worked examples

Year-offset over Gregorian (Buddhist, ROC)

The simplest possible calendar: take the proleptic Gregorian arithmetic and add a fixed year offset. This is how Calendrical.Buddhist, Calendrical.Roc, and Calendrical.Ethiopic.AmeteAlem are implemented.

defmodule MyApp.Buddhist do
  use Calendrical.Behaviour,
    epoch: ~D[-0542-01-01 Calendrical.Gregorian],
    cldr_calendar_type: :buddhist

  @offset 543

  @impl true
  def leap_year?(year), do: Calendrical.Gregorian.leap_year?(year - @offset)

  @impl true
  def days_in_month(year, month) do
    Calendrical.Gregorian.days_in_month(year - @offset, month)
  end

  def date_to_iso_days(year, month, day) do
    Calendrical.Gregorian.date_to_iso_days(year - @offset, month, day)
  end

  def date_from_iso_days(iso_days) do
    {gy, m, d} = Calendrical.Gregorian.date_from_iso_days(iso_days)
    {gy + @offset, m, d}
  end
end

That is the entire calendar — about 25 lines plus moduledoc. Every other callback (parsing, day-of-week, intervals, localization, period ranges, sigils, …) comes for free from the behaviour.

A 13-month tabular calendar (Coptic, Ethiopic)

A 13-month calendar overrides one extra option (:months_in_ordinary_year) plus the callbacks that are 13-month-aware:

defmodule MyApp.MyCoptic do
  use Calendrical.Behaviour,
    epoch: ~D[0284-08-29 Calendrical.Julian],
    cldr_calendar_type: :coptic,
    months_in_ordinary_year: 13,
    months_in_leap_year: 13

  @impl true
  def leap_year?(year), do: Integer.mod(year, 4) == 3

  @impl true
  def days_in_month(year, 13), do: if(leap_year?(year), do: 6, else: 5)
  def days_in_month(_year, month) when month in 1..12, do: 30

  @impl true
  def days_in_year(year), do: if(leap_year?(year), do: 366, else: 365)

  @impl true
  def quarter_of_year(_year, _month, _day), do: {:error, :not_defined}

  @impl true
  def day_of_week(year, month, day, :default) do
    iso = date_to_iso_days(year, month, day)
    {Integer.mod(iso + 5, 7) + 1, 6, 5}  # Saturday = 6, Friday = 5
  end

  def date_to_iso_days(year, month, day) do
    epoch() - 1 + 365 * (year - 1) + div(year, 4) + 30 * (month - 1) + day
  end

  def date_from_iso_days(iso_days) do
    # ... (see lib/calendrical/calendars/coptic.ex for the full formula)
  end
end

A lunisolar calendar with leap months (Hebrew)

The Hebrew calendar adds two complications:

  1. A discontinuous month numbering: month 6 (Adar I) only exists in leap years.
  2. A _yeartype_leap localization variant: month 7 is "Adar" in ordinary years and "Adar II" in leap years.

The first is handled by overriding valid_date?/3 to reject month 6 in non-leap years. The second is handled by overriding month_of_year/3 to return {7, :leap} in leap years; Calendrical.localize/3 then automatically looks up the _yeartype_leap variant from the CLDR data.

defmodule MyApp.MyHebrew do
  use Calendrical.Behaviour,
    epoch: Date.new!(-3761, 10, 7, Calendrical.Julian),
    cldr_calendar_type: :hebrew,
    months_in_ordinary_year: 12,
    months_in_leap_year: 13

  @impl true
  def leap_year?(year), do: Integer.mod(7 * year + 1, 19) < 7

  @impl true
  def valid_date?(year, 6, _day), do: leap_year?(year)
  def valid_date?(year, month, day) when month in 1..13 and day in 1..30 do
    day <= days_in_month(year, month)
  end
  def valid_date?(_year, _month, _day), do: false

  @impl true
  def month_of_year(year, 7, _day) do
    if leap_year?(year), do: {7, :leap}, else: 7
  end
  def month_of_year(_year, month, _day), do: month

  # ... days_in_month/2, date_to_iso_days/3, date_from_iso_days/1
end

See lib/calendrical/calendars/hebrew.ex for the full implementation including the molad of Tishri and dehiyyah postponement rules.

An astronomical calendar (Persian, observational Islamic)

Astronomical calendars use the Astro library to compute month and year boundaries from actual astronomical events. The pattern is the same: override the few callbacks that need astronomical data, and let everything else use the defaults.

defmodule MyApp.MyPersian do
  use Calendrical.Behaviour,
    epoch: ~D[0622-03-20 Calendrical.Julian],
    cldr_calendar_type: :persian

  @impl true
  def leap_year?(year) do
    new_year = date_to_iso_days(year, 1, 1)
    next_year = date_to_iso_days(year + 1, 1, 1)
    next_year - new_year == 366
  end

  def date_to_iso_days(year, _month, _day) do
    # ... use Astro.equinox/2 to find the vernal equinox in Tehran
    #     for the relevant Gregorian year
  end

  def date_from_iso_days(_iso_days) do
    # ... inverse: find the most recent Persian new year on or before
    #     the iso_days
  end
end

See lib/calendrical/calendars/persian.ex for the complete implementation, and lib/calendrical/calendars/islamic/observational.ex and rgsa.ex for crescent-visibility-based calendars.

Composite calendars

A composite calendar is one that uses one base calendar before a specified date and a different calendar after. It is built with the separate Calendrical.Composite macro rather than Calendrical.Behaviour directly. Use this when you need to model a historical calendar reform — the canonical example being the European Julian-to-Gregorian transition.

defmodule MyApp.England do
  use Calendrical.Composite,
    calendars: [
      ~D[1155-03-25 Calendrical.Julian.March25],
      ~D[1751-03-25 Calendrical.Julian.Jan1],
      ~D[1752-09-14 Calendrical.Gregorian]
    ],
    base_calendar: Calendrical.Julian
end

The Calendrical.Composite macro accepts:

OptionRequiredDefaultDescription
:calendarsyesA list of dates representing the first day on which a new calendar takes effect. Each date must be expressed in the calendar that takes effect on that day.
:base_calendarnoCalendrical.JulianThe calendar in use before any of the configured transitions.

The composite calendar:

  • Delegates every callback (leap_year?/1, days_in_month/2, date_to_iso_days/3, day_of_week/4, …) to whichever base calendar is in effect for the date in question.

  • Treats the days "skipped" by a transition (e.g. 3–13 September 1752 in the English calendar) as invalidvalid_date?/3 returns false for them.

  • Round-trips correctly: Date.shift(~D[1752-09-02 MyApp.England], day: 1) returns ~D[1752-09-14 MyApp.England].

You can chain any number of transitions and combine any pair of calendars. See lib/calendrical/calendars/england.ex and lib/calendrical/calendars/russia.ex for two pre-built examples.

When two calendars share most of their algorithms but differ in one or two values (for example: Calendrical.Islamic.Civil and Calendrical.Islamic.Tbla differ only in epoch; Calendrical.Islamic.Observational and Calendrical.Islamic.Rgsa differ only in the observation location), the convention is:

  1. Put the shared algorithm in a private helper module that takes the varying value as a parameter.
  2. Have each public calendar use Calendrical.Behaviour with the appropriate options.
  3. Have the public callbacks delegate to the helper.
defmodule Calendrical.Islamic.Tabular do
  @moduledoc false
  def leap_year?(year), do: Integer.mod(14 + 11 * year, 30) < 11

  def date_to_iso_days(year, month, day, epoch) do
    # ... formula parameterised by epoch
  end

  def date_from_iso_days(iso_days, epoch) do
    # ...
  end
end

defmodule Calendrical.Islamic.Civil do
  use Calendrical.Behaviour,
    epoch: ~D[0622-07-19 Calendrical.Gregorian],
    cldr_calendar_type: :islamic_civil

  alias Calendrical.Islamic.Tabular

  @impl true
  def leap_year?(year), do: Tabular.leap_year?(year)

  def date_to_iso_days(year, month, day) do
    Tabular.date_to_iso_days(year, month, day, epoch())
  end

  def date_from_iso_days(iso_days) do
    Tabular.date_from_iso_days(iso_days, epoch())
  end
end

This pattern keeps each public calendar small (~70-90 lines) while the shared algorithm lives in one place.

When Calendrical.Behaviour is not the right tool

Calendrical.Behaviour is designed for algorithmic calendars — calendars whose date arithmetic can be expressed as a closed-form computation. It is not the right tool when:

  • You need a week-based calendar with non-standard week numbering or quarter-of-year structure. Use Calendrical.Base.Week directly via Calendrical.new/3, which generates a calendar from a weeks_in_month configuration.

  • You need a month-based calendar that varies its first month or its year-anchor month for fiscal/financial purposes. Use Calendrical.Base.Month directly via Calendrical.new/3 or look at the pre-built Calendrical.FiscalYear.<TERR> calendars.

  • You need a composite calendar that uses different base calendars for different date ranges. Use Calendrical.Composite (described above).

In all three cases, the resulting module still implements the standard Calendar and Calendrical behaviours and interoperates with everything else in the library — it just gets its callbacks from a different macro.