Tempo.Holidays (Tempo.Holidays v0.1.0)

Copy Markdown View Source

Holidays as Tempo recurrences, for any window.

A holiday is a recurrence, not a date: "Christmas is the 25th of December", "Thanksgiving is the fourth Thursday of November". A territory's holidays are a recurrence set (a Tempo.t/0) — one member per holiday, each tagged with the holiday's metadata — and Tempo.to_interval/2 converts it to the set (a Tempo.t/0) of the holidays in a window, each occurrence tagged the same way. Because both are Tempo values, holidays compose with anything else through set algebra.

The data is the date-holidays dataset for every territory, carrying every :type (public holidays through to observances). Fixed dates and weekday-in-month holidays are ISO 8601-2 selections (FL12M25DN, FL6M1K2IN); Easter-relative holidays are windows off the computed (easter)e event; Islamic, Hebrew and other calendar holidays are recurrences in their own calendar, which a set of occurrences brings to the one calendar it is in; a holiday observed on its own date and a substitute day is a nested set of both; and a bridge day or a holiday moved off another is a conditional member (Tempo.RecurrenceSet.keep_when/2, move_when/2), resolved against the others when the set converts.

Each holiday's metadata is :id (its date-holidays rule, unique within the set, where names repeat), :name and :type; a substitute day adds substitute: true, and a holiday whose rule is in another calendar than the Gregorian adds :calendar, that calendar's module. The set's own metadata names its territory, its subdivision and the :dates it gives.

The primary API is holidays/2. add/3 and remove/2 change the holidays it gives with holidays of your own, and the :holidays configuration changes a territory's for every request; compile/2 reads a holiday from a rule in the dataset's grammar, and day_start/2 projects the Islamic and Hebrew holidays whose day begins at sunset onto the evening before.

Example

{:ok, holidays} = Tempo.Holidays.holidays(:AU)
{:ok, this_year} = Tempo.to_interval(holidays, within: ~o"2026")

{:ok, clashes} = Tempo.intersection(my_diary, holidays, metadata: :merge)

"Australia's holidays are a set of recurrences. This year's are the days they fall on in 2026; the diary's clashes are its entries that fall on one, each labelled with the holiday it hit."

Summary

Types

The dates a holiday observed on another day is given on: :substitute the day it is observed on, :gazetted its own date, or :both.

The category a holiday falls under, following date-holidays: a :public holiday is a statutory day off, :bank a banking holiday, :school a school holiday, :optional a day observed at the holder's discretion, and :observance a marked but non-statutory day.

A holiday request's target: a positional CLDR territory code (atom or string, always a territory — never a language), a Localize.LanguageTag.t/0, or a keyword list carrying a :territory or :locale option.

Functions

Adds a holiday of your own to a set of holidays.

Reads a holiday rule into the holiday it describes.

Projects holiday occurrences onto the evening their day begins.

Returns a territory's holidays as a recurrence set.

Removes holidays from a set of holidays by id or name.

Types

dates()

@type dates() :: :substitute | :gazetted | :both

The dates a holiday observed on another day is given on: :substitute the day it is observed on, :gazetted its own date, or :both.

holiday_type()

@type holiday_type() :: :public | :bank | :school | :optional | :observance

The category a holiday falls under, following date-holidays: a :public holiday is a statutory day off, :bank a banking holiday, :school a school holiday, :optional a day observed at the holder's discretion, and :observance a marked but non-statutory day.

target()

@type target() :: atom() | String.t() | Localize.LanguageTag.t() | keyword()

A holiday request's target: a positional CLDR territory code (atom or string, always a territory — never a language), a Localize.LanguageTag.t/0, or a keyword list carrying a :territory or :locale option.

Functions

add(holidays, rule, options \\ [])

@spec add(Tempo.t(), String.t(), keyword()) ::
  {:ok, Tempo.t()} | {:error, {atom(), term()} | Exception.t()}

Adds a holiday of your own to a set of holidays.

The holiday is read from its rule as compile/2 reads it, with the :dates the set was read with, and takes the place of a holiday of the same id. It is added whatever its type: a selection by type in holidays/2 selects the territory's holidays, not yours.

A holiday that depends on others, a bridge day or a holiday moved off another, is decided against the holidays in the set when the set converts: an added holiday that depends on the set's, and a holiday of the set that depends on yours. The exception is a holiday whose dependency holidays/2 left out by type, which carries the holidays it depends on as they were read and does not see yours.

Arguments

  • holidays is a set of holidays as holidays/2 returns it.

  • rule is a holiday rule in the date-holidays grammar. See the grammar.

  • options is a keyword list of options.

Options

  • :name, :type, :id and :metadata are as for compile/2. The holiday's dates are the set's, so :dates is not an option.

Returns

  • {:ok, holidays} — the set with the holiday added, or

  • {:error, exception} — an exception compile/2 returns for a rule it cannot read, or

  • {:error, reason} — {:invalid_holidays, holidays} for a value that is not a set of holidays, {:invalid_rule, rule} for a rule that is not a string, {:invalid_option, option}, or a reason compile/2 returns for an option.

Examples

iex> import Tempo.Sigils
iex> {:ok, holidays} = Tempo.Holidays.holidays(:AU, include: :public)
iex> {:ok, holidays} = Tempo.Holidays.add(holidays, "1st friday in December", name: "Company Day")
iex> {:ok, december} = Tempo.to_interval(holidays, within: ~o"2026-12")
iex> Tempo.IntervalSet.map(december, &Tempo.metadata(&1).name)
["Company Day", "Christmas Day", "Boxing Day"]

compile(rule, options \\ [])

@spec compile(String.t(), keyword()) ::
  {:ok, Tempo.t()} | {:error, {atom(), term()} | Exception.t()}

Reads a holiday rule into the holiday it describes.

The rule is written in the date-holidays grammar the dataset's holidays are written in, which Tempo reads with Tempo.DateRule: "12-25", "4th thursday in November", "01-26 and if saturday, sunday then next monday", "15 Nisan". The holiday is tagged as a member of holidays/2 is, so it sits beside a territory's holidays as one of them.

A rule that depends on other holidays, a bridge day ("08-14 if 08-15 is holiday") or a holiday moved off another, is a conditional member: decided against the other members of a set it is in, and against none when it is converted alone, so alone it falls on no day.

Arguments

  • rule is a holiday rule in the date-holidays grammar. See the grammar.

  • options is a keyword list of options.

Options

  • :name — the holiday's name. The default is the rule.

  • :type — its holiday_type/0. The default is :public.

  • :id — its id, unique within a set of holidays. The default is the rule, as a dataset holiday's id is its rule.

  • :metadata — a map of metadata of your own, kept beside the id, name and type. The default is %{}.

  • :dates — the dates a holiday observed on another day is given on, :substitute, :gazetted or :both, as for holidays/2. The default is :substitute.

Returns

  • {:ok, holiday} — the holiday, a Tempo.t/0: a yearly recurrence, a recurrence set of its date and its observed days, or a conditional member, tagged with :id, :name, :type and your own metadata, and with :calendar for a rule of another calendar than the Gregorian.

  • {:error, exception} — a Tempo.ParseError for a rule that cannot be read, naming where reading stopped, or another exception for a date the rule names that its calendar does not have (Tempo.DateRule.parse/2), or

  • {:error, reason} — {:invalid_option, option}, {:invalid_id, value}, {:invalid_name, value}, {:invalid_holiday_type, value}, {:invalid_metadata, value} or {:invalid_dates, value}.

Examples

iex> import Tempo.Sigils
iex> {:ok, thanksgiving} = Tempo.Holidays.compile("4th thursday in November", name: "Thanksgiving Day")
iex> Tempo.metadata(thanksgiving)
%{id: "4th thursday in November", name: "Thanksgiving Day", type: :public}
iex> {:ok, this_year} = Tempo.to_interval(thanksgiving, within: ~o"2026")
iex> Tempo.IntervalSet.map(this_year, &Tempo.day/1)
[26]

iex> {:error, %Tempo.ParseError{}} = Tempo.Holidays.compile("12-25 on a full moon")

day_start(occurrences, day_start)

@spec day_start(Tempo.t(), Tempo.Holidays.DayStart.t()) ::
  {:ok, Tempo.t()} | {:error, term()}

Projects holiday occurrences onto the evening their day begins.

A day in Tempo is just a day: an Islamic or Hebrew holiday converts to a day in its own calendar, which carries no day-start convention. That calendar's day begins at sunset, so on the Gregorian timeline the holiday begins the evening before. day_start/2 projects each occurrence of a holiday of such a calendar onto the datetime interval from the boundary that begins its first day to the one that begins the day after its last. A set of occurrences is in one calendar, so a holiday is told by the :calendar its metadata carries, the calendar its rule is in, and an occurrence that carries none by the calendar its own day is in.

A set is of one frame, so every other occurrence becomes the civil day it is where the day start is read: in the zone it names, at its location, or, for a bare :evening or :sunset, at the reference place of the set's sunset holidays. Where no holiday is projected, as for a set of Gregorian holidays alone, every occurrence is left as it is.

Arguments

  • occurrences is a set (a Tempo.t/0) of holiday occurrences, as Tempo.to_interval/2 converts the set holidays/2 returns.

  • day_start is how the day begins, a Tempo.Holidays.DayStart.t/0:

    • :midnight — leaves the occurrences as they are.

    • :evening or :sunset — 18:00, or sunset, at the calendar's reference place: Mecca for an Islamic date, Jerusalem for a Hebrew one.

    • {:evening, location} or {:sunset, location} — the same at a location of your own, an IANA zone id ("Asia/Kuala_Lumpur") or a {longitude, latitude} point.

Returns

  • {:ok, interval_set} — the occurrences, each projected one a datetime interval and each other one a day in the day start's zone, keeping their metadata. A lazy set stays lazy.

  • {:error, {:invalid_day_start, day_start}}, {:error, {:invalid_occurrences, occurrences}}, or {:error, {:location_required, day_start}} for a lazy set and a bare :evening or :sunset, since a set read as it is walked cannot be looked through for the reference place of its sunset holidays.

Examples

iex> import Tempo.Sigils
iex> {:ok, holidays} = Tempo.Holidays.holidays(:SA, include: :public)
iex> {:ok, this_year} = Tempo.to_interval(holidays, within: ~o"2026")
iex> {:ok, evenings} = Tempo.Holidays.day_start(this_year, {:evening, "Asia/Riyadh"})
iex> eid = Enum.find(Tempo.IntervalSet.members(evenings), &(Tempo.metadata(&1).name =~ "Eid al-Fitr"))
iex> {:ok, begins} = Tempo.to_elixir(Tempo.Interval.from(eid))
iex> {DateTime.to_date(begins), DateTime.to_time(begins)}
{~D[2026-03-18], ~T[18:00:00]}

holidays(target \\ [], options \\ [])

@spec holidays(target(), keyword()) ::
  {:ok, Tempo.t()} | {:error, {atom(), term()} | Exception.t()}

Returns a territory's holidays as a recurrence set.

Each member is one holiday — its recurrence, a nested set of its own date and its substitute days, or a conditional member for a bridge day or a holiday moved off another — tagged with :id, :name and :type metadata. The set's own metadata names the territory, and the subdivision when one is given. Tempo.to_interval/2 converts it to the holidays in a window.

A period date-holidays splits over the year end, because its data cannot run one past it, is one member: Victoria's summer school holidays run from December to the end of January, 31 December included.

A selection by type never changes a holiday's date: a kept holiday that moves off another (Näfelser Fahrt off Maundy Thursday, an observance) carries the holidays it depends on when their type is left out.

Holidays of your own change a territory's in two ways. add/3 and remove/2 change the set this function returns. config :tempo_holidays, holidays: changes a territory's holidays for every request, keyed by territory, or by territory and subdivision, each with an :add (rules, each a rule or {rule, options} with the options of add/3) and a :remove (ids or names). A territory's apply to its subdivisions too, and a subdivision's come after its territory's, each added holiday in place of one of its id. The configured holidays are the territory's, so a selection by type selects them as it does the dataset's. The configuration is read at each request.

config :tempo_holidays,
  holidays: [
    AU: [add: [{"1st friday in December", name: "Company Day", type: :optional}]],
    BR: [remove: ["Election Day"]],
    "AU-VIC": [add: [{"11-02", name: "Office Closure"}]]
  ]

Arguments

Options

  • :territory — an explicit CLDR territory code, validated (territory: :SA is Saudi Arabia).

  • :locale — a BCP 47 locale identifier or Localize.LanguageTag.t/0 whose territory is derived (locale: "en-US-u-sd-usca" selects California).

  • :subdivision — a subdivision code, the ISO 3166-2 part after the territory's ("ENG", "CA"), or the whole code ("GB-ENG"), in place of a locale's u-sd subdivision. It names a subdivision at whichever level the data holds it: a state, or a region by its own code or by its path from its state ("CA-LA", Los Angeles, where "LA" alone is Louisiana). See Tempo.Holidays.Data.subdivision/2.

  • :include — the holiday_type/0 to keep, a type or a list of them. The default is every type.

  • :exclude — the holiday_type/0 to leave out, a type or a list of them, winning over :include. The default is none.

  • :dates — the dates a holiday observed on another day is given on, when its own date falls on a weekend, say:

    • :substitute — the day it is observed on: its substitute day in place of its own date, so each holiday is counted once. The default.

    • :gazetted — its own date alone, the date its law names.

    • :both — its own date and its substitute day, as date-holidays gives them. A holiday the data moves to another day, rather than observing on both, is on the day it moves to.

Returns

  • {:ok, recurrence_set} — a recurrence set (a Tempo.t/0) of the holidays: the territory's, with a subdivision's own in place of the ones it redefines. Its metadata names the territory, the subdivision when one is given, and the :dates it gives, or

  • {:error, reason} — {:unknown_territory, territory}, {:invalid_locale, target}, {:unknown_subdivision, subdivision}, {:ambiguous_subdivision, subdivision}, {:invalid_holiday_type, value}, {:invalid_dates, value}, {:invalid_config, entry} or {:invalid_option, option}, or

  • an error compile/2 returns for a rule the configuration adds.

Examples

iex> {:ok, holidays} = Tempo.Holidays.holidays(:AU)
iex> holidays |> Tempo.RecurrenceSet.members() |> Enum.map(&Tempo.metadata(&1).name) |> Enum.take(2)
["New Year's Day", "Australia Day"]
iex> Tempo.metadata(holidays)
%{territory: "AU", dates: :substitute}

iex> {:ok, banking} = Tempo.Holidays.holidays(:AT, include: :bank)
iex> banking |> Tempo.RecurrenceSet.members() |> Enum.map(&Tempo.metadata(&1).type) |> Enum.uniq()
[:bank]

iex> import Tempo.Sigils
iex> {:ok, england} = Tempo.Holidays.holidays(:GB, subdivision: "ENG", exclude: :observance)
iex> {:ok, this_year} = Tempo.to_interval(england, within: ~o"2026")
iex> Tempo.IntervalSet.count(this_year)
8

remove(holidays, names)

@spec remove(Tempo.t(), String.t() | [String.t()]) ::
  {:ok, Tempo.t()} | {:error, {atom(), term()} | Exception.t()}

Removes holidays from a set of holidays by id or name.

Names repeat where ids do not, so a name removes every holiday of that name. A holiday that depends on one removed, a bridge day or a holiday moved off another, no longer counts it, unless holidays/2 left out the type it depends on and it carries those holidays as they were read.

Arguments

  • holidays is a set of holidays as holidays/2 returns it.

  • names is a holiday's id or name, or a list of them. A holiday's id is its rule, unless add/3 gave it another.

Returns

  • {:ok, holidays} — the set without the holidays named. A name no holiday has removes nothing, or

  • {:error, reason} — {:invalid_holidays, holidays} for a value that is not a set of holidays, or {:invalid_name, name} for an id or name that is not a string.

Examples

iex> import Tempo.Sigils
iex> {:ok, holidays} = Tempo.Holidays.holidays(:AU, include: :public)
iex> {:ok, holidays} = Tempo.Holidays.remove(holidays, "Boxing Day")
iex> {:ok, december} = Tempo.to_interval(holidays, within: ~o"2026-12")
iex> Tempo.IntervalSet.map(december, &Tempo.metadata(&1).name)
["Christmas Day"]