User-defined Calendars
Copy MarkdownThis 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:
Supply the
:epochoption (mandatory) and any other options that differ from the defaults.Define
date_to_iso_days/3anddate_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.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
endThat 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)
enddate_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}
endThese 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| Option | Required | Default | Description |
|---|---|---|---|
:epoch | yes | — | The 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_type | no | :gregorian | The 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_base | no | :month | Whether the calendar is :month-based or :week-based. Returned by the generated calendar_base/0 function. |
:days_in_week | no | 7 | The 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_week | no | 1 (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_year | no | 12 | The 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_year | no | same as :months_in_ordinary_year | The 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:
Calendar.ISO— the standard library proleptic Gregorian.Calendrical.Gregorian— equivalent toCalendar.ISObut with CLDR support and a year offset of 0.Calendrical.Julian— proleptic Julian, useful for ancient calendars whose epoch is naturally a Julian date (Coptic, Ethiopic, Persian, Islamic).
# 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
| Callback | Default behaviour |
|---|---|
epoch/0 | Returns the ISO-day form of the supplied :epoch option. |
epoch_day_of_week/0 | Returns the ISO day-of-week (1=Mon, 7=Sun) of the epoch, computed at compile time. |
first_day_of_week/0 | Returns the supplied :first_day_of_week option (default 1 = Monday). |
last_day_of_week/0 | Returns (first_day_of_week + days_in_week - 1) mod days_in_week adjusted to 1..7. |
days_in_week/0 | Returns the supplied :days_in_week option (default 7). |
cldr_calendar_type/0 | Returns the supplied :cldr_calendar_type option (default :gregorian). |
calendar_base/0 | Returns :month or :week (default :month). |
months_in_ordinary_year/0 | Returns the supplied option (default 12). |
months_in_leap_year/0 | Returns the supplied option (default same as ordinary). |
Validity
| Callback | Default 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
| Callback | Default behaviour |
|---|---|
year_of_era/1 and year_of_era/3 | Computes 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/3 | Returns the year unchanged. Override for calendars where the displayed year differs from the storage year (e.g. Japanese era years). |
extended_year/3 | Returns the year unchanged. |
related_gregorian_year/3 | Returns the year unchanged. Override to return the actual Gregorian year that contains the given calendar date — used by some localization formats. |
cyclic_year/3 | Returns the year unchanged. Override for calendars with named year cycles (Chinese 60-year sexagenary cycle, etc.). |
day_of_era/3 | Computes day-in-era from the auto-generated era module. Override when era boundaries are not in the CLDR data. |
Periods
| Callback | Default behaviour |
|---|---|
quarter_of_year/3 | Returns 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/3 | Returns 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/3 | Returns {:error, :not_defined}. Override for calendars that define weeks of the year. |
iso_week_of_year/3 | Returns {:error, :not_defined}. |
week_of_month/3 | Returns {:error, :not_defined}. |
day_of_year/3 | Returns iso_days(year, month, day) - iso_days(year, 1, 1) + 1. Works for any month-based calendar. |
day_of_week/4 | Computes 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
| Callback | Default behaviour |
|---|---|
periods_in_year/1 | Delegates to months_in_year/1. |
months_in_year/1 | Returns months_in_leap_year or months_in_ordinary_year based on leap_year?/1. |
weeks_in_year/1 | Returns {:error, :not_defined}. |
days_in_year/1 | Computes 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/1 | Returns {:error, :undefined}. Override if the month length is independent of the year. |
days_in_month/2 | Computes 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?/1 | Not provided by default. Every calendar must define its own leap_year?/1. |
Period ranges
| Callback | Default behaviour |
|---|---|
year/1 | Returns a Date.Range covering 1 January (or the first valid month/day) through the last day of months_in_year(year). |
quarter/2 | Returns {:error, :not_defined}. |
month/2 | Returns a Date.Range covering the first to last day of the given month. |
week/2 | Returns {:error, :not_defined}. |
Arithmetic
| Callback | Default behaviour |
|---|---|
plus/5 and plus/6 | Adds 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/4 | Delegates to Calendrical.shift_date/5 with the calendar module. |
shift_time/5 | Delegates to Calendar.ISO.shift_time/5. |
shift_naive_datetime/8 | Delegates to Calendrical.shift_naive_datetime/9 with the calendar module. |
ISO-day conversion
| Callback | Default 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/1 | Inverse of the above. |
iso_days_to_beginning_of_day/1 | Delegates to Calendar.ISO. |
iso_days_to_end_of_day/1 | Delegates to Calendar.ISO. |
Parsing and formatting
| Callback | Default behaviour |
|---|---|
parse_date/1 | Delegates to Calendrical.Parse.parse_date/2 with the calendar module. |
parse_naive_datetime/1 | Delegates to Calendrical.Parse.parse_naive_datetime/2. |
parse_utc_datetime/1 | Delegates to Calendrical.Parse.parse_utc_datetime/2. |
parse_time/1 | Delegates to Calendar.ISO. |
date_to_string/3 | Delegates to Calendar.ISO. |
naive_datetime_to_string/7 | Delegates to Calendar.ISO. |
datetime_to_string/11 | Delegates to Calendar.ISO. |
time_to_string/4 | Delegates to Calendar.ISO. |
time_from_day_fraction/1 | Delegates to Calendar.ISO. |
time_to_day_fraction/4 | Delegates to Calendar.ISO. |
day_rollover_relative_to_midnight_utc/0 | Delegates to Calendar.ISO. |
Era support
When the using module is compiled, an @after_compile hook automatically calls Calendrical.Era.define_era_module/1. This:
- Reads the CLDR era data for the calendar's
:cldr_calendar_type. - Generates a
Calendrical.Era.<CalendarType>module containing ayear_of_era/2andday_of_era/1lookup function. - Wires the calendar's default
year_of_era/{1, 3}andday_of_era/3to 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
endThat 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
endA lunisolar calendar with leap months (Hebrew)
The Hebrew calendar adds two complications:
- A discontinuous month numbering: month 6 (Adar I) only exists in leap years.
- A
_yeartype_leaplocalization 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
endSee 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
endSee 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
endThe Calendrical.Composite macro accepts:
| Option | Required | Default | Description |
|---|---|---|---|
:calendars | yes | — | A 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_calendar | no | Calendrical.Julian | The 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 invalid —
valid_date?/3returnsfalsefor 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.
Sharing logic across related calendars
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:
- Put the shared algorithm in a private helper module that takes the varying value as a parameter.
- Have each public calendar
use Calendrical.Behaviourwith the appropriate options. - 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
endThis 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.Weekdirectly viaCalendrical.new/3, which generates a calendar from aweeks_in_monthconfiguration.You need a month-based calendar that varies its first month or its year-anchor month for fiscal/financial purposes. Use
Calendrical.Base.Monthdirectly viaCalendrical.new/3or look at the pre-builtCalendrical.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.