guides/calendar_behaviour.md

# User-defined Calendars

This guide explains how to define a new calendar in Calendrical by `use`ing 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

```elixir
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 `use`ing `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`.

```elixir
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.

```elixir
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:

```elixir
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 `t: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 to `Calendar.ISO` but 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).

```elixir
# 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:

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:

```elixir
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.

```elixir
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:

```elixir
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.

```elixir
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](https://hex.pm/packages/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.

```elixir
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.

```elixir
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:

| 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?/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.

## 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:

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.

```elixir
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.