Working with Workdays and Weekends

Copy Markdown View Source

Business-day queries are the most common reason developers reach for a date library beyond the standard library. "How many working days until the deadline?" "What's five business days from today?" "When does this invoice age out?" Tempo answers these directly with territory-aware, calendar-correct functions — add_working_days/3, next_working_day/2, previous_working_day/2, working_days_in/2, and the workday?/2 / weekend?/2 predicates.

This guide starts with those functions, then shows the lower-level Tempo.select/2 + Tempo.workdays/1 selector machinery they build on — useful when you need to weave workday-awareness into a larger set-operation query — and finishes with how to extend them with a real holiday calendar.

Setup — required for every example

Every code example in this guide uses the ~o sigil from Tempo.Sigils. Before running any of them — in iex, a script, or a module — you must bring the sigil into scope:

import Tempo.Sigils

The import adds only sigil_o/2 and sigil_TEMPO/2 to the caller's namespace; no helper functions leak in.

The built-in functions

For the common questions, reach for the built-ins directly. All are territory-aware (the weekend is read from CLDR) and calendar-correct (they work on any calendar Tempo supports, since the weekday is read off a date in the value's own calendar):

import Tempo.Sigils

# Five business days on from a Monday is the next Monday.
Tempo.add_working_days(~o"2026-06-15", 5, :US)
#=> ~o"2026Y6M22D"

# Walk one working day forward / back, skipping the weekend.
Tempo.next_working_day(~o"2026-06-12", :US)        #=> ~o"2026Y6M15D"  (Fri → Mon)
Tempo.previous_working_day(~o"2026-06-15", :US)    #=> ~o"2026Y6M12D"

# Is this a working day, or a weekend, in this territory?
Tempo.workday?(~o"2026-06-13", :US)                #=> false  (Saturday)
Tempo.weekend?(~o"2026-06-12", :SA)                #=> true   (Friday, in Saudi Arabia)

# How many working days in a window? (half-open [from, to))
{:ok, june} = Tempo.Interval.new(from: ~o"2026-06-01", to: ~o"2026-07-01")
Tempo.working_days_in(june, :US)                   #=> 22

The territory argument resolves through Tempo.Territory.resolve/1 — an atom (:US), a string ("US"), a locale ("en-GB"), or nil to walk the configured/ambient chain. add_working_days/3 preserves the time of day, calendar, and zone, and 0 is a no-op.

These functions know about weekends only. For a holiday-aware calendar, see Extending with holidays at the end of this guide. The rest of the guide shows the Tempo.select/2 selector machinery the built-ins compose with — reach for it when a workday filter is one step inside a larger query.

The three primitives

All business-day queries in Tempo reduce to three capabilities the library already provides:

  • Build a window. Tempo.shift/2 (keyword-list duration arithmetic) and Tempo.Interval.new!/1 construct a bounded span to search over.

  • Narrow to workdays. Tempo.workdays/1 returns a territory-aware day-of-week selector — Tempo.workdays(:US) is Mon-Fri, Tempo.workdays(:SA) is Sun-Thu. Tempo.select(interval, Tempo.workdays(:US)) returns a %Tempo.IntervalSet{} of just the workdays inside the window. The companion Tempo.weekend/1 is the complement — together they partition the seven days of the week.

  • Pick an element. Tempo.IntervalSet.to_list/1 produces a plain list of %Tempo.Interval{} values that Enum.at/2, Enum.count/1, List.last/1, hd/1, and friends operate on directly.

Every workday query below is a one-liner composition of these three.

Core example — N business days from today

"Five business days from today" is the most-asked version of this question. Here it is end-to-end:

today = ~o"2026-06-15"  # a Monday

window_end = Tempo.shift(today, week: 6)                  # generous window
window     = Tempo.Interval.new!(from: today, to: window_end)

{:ok, workdays} = Tempo.select(window, Tempo.workdays(:US))

target =
  workdays
  |> Tempo.IntervalSet.to_list()
  |> Enum.at(5)
#=> %Tempo.Interval{from: ~o"2026Y6M22D", ...}

Read aloud: "Build a window from today that's long enough to contain five workdays. Narrow it to just the US workdays. Take the fifth one." The convention here matches banking and SLA usage — today is day zero, so Enum.at(5) is "the fifth business day after today". If you want to count today as day one, use Enum.at(n - 1).

The window needs to be generous enough to contain n workdays. Six weeks holds 30 workdays in any territory, and Tempo.select/2 is fast enough that the oversize doesn't matter. The idiomatic way to size it is Tempo.shift(today, week: n + 1) — a week per five workdays plus one for good measure.

Why the window matters

You may wonder why we can't just "walk forward from today until we've counted five workdays". The answer is Tempo's thesis: iteration is bounded by design. Every enumeration is over a finite span — there's no infinite stream of days to filter. The window makes the computation finite and the laziness unnecessary; Tempo.select/2 returns the complete result, and Enum.at/2 does the index lookup.

Every scheduling question you'd ask about business days is a small variation on the core pattern.

Is today a business day?

tempo
|> Tempo.day_of_week()
|> Kernel.in(1..5)     # 1 = Mon, 7 = Sun; M-F for the US
#=> true or false

This is the fast path — no interval needed. For territory-aware weekend detection, use the selector:

{:ok, set} = Tempo.select(tempo, Tempo.workdays(:SA))
Tempo.IntervalSet.count(set) > 0
#=> true if `tempo` is a workday under Saudi Arabia's Fri/Sat weekend

Next business day

tomorrow  = Tempo.shift(today, day: 1)
window    = Tempo.Interval.new!(from: tomorrow, to: Tempo.shift(today, week: 2))

{:ok, workdays} = Tempo.select(window, Tempo.workdays(:US))

next_wd =
  workdays
  |> Tempo.IntervalSet.to_list()
  |> hd()
#=> Friday's next_wd is the following Monday.

Read aloud: "Starting tomorrow, find the US workdays in the next two weeks and take the first." A two-week window is generous — the longest weekend in any territory is three days, so the first workday is always within a week.

Business days between two dates

window = ~o"2026-06-15/2026-06-29"

{:ok, workdays} = Tempo.select(window, Tempo.workdays(:US))
Tempo.IntervalSet.count(workdays)
#=> 10   (two full work weeks)

This is a direct Enum.count/1 on the members. For inclusive/exclusive day-counting semantics, adjust the from and to endpoints — Tempo uses half-open [from, to) consistently, so a 14-day span (2026-06-15 through 2026-06-28 inclusive) is from: 15, to: 29.

Nth business day of month

{:ok, workdays} = Tempo.select(~o"2026-06", Tempo.workdays(:US))
members         = Tempo.IntervalSet.to_list(workdays)

first = hd(members).from                       #=> ~o"2026Y6M1D"
last  = List.last(members).from                #=> ~o"2026Y6M30D"
third = Enum.at(members, 2).from               #=> ~o"2026Y6M3D"

Passing a month-resolution Tempo value to Tempo.select/2 is the cleanest form — the selector treats the implicit month-span as the search window. Read aloud: "The US workdays of June 2026 are these; take the first / last / third."

Territory-aware weekends

Weekend conventions vary by territory. Tempo.workdays/1 and Tempo.weekend/1 honour CLDR data:

# United States: Mon–Fri workdays, Sat–Sun weekend.
Tempo.select(~o"2026-06", Tempo.workdays(:US))

# Saudi Arabia: Sun–Thu workdays, Fri–Sat weekend.
Tempo.select(~o"2026-06", Tempo.workdays(:SA))

# Iran: Sat–Wed workdays, Thu–Fri weekend.
Tempo.select(~o"2026-06", Tempo.workdays(:IR))

Tempo.workdays/1 and Tempo.weekend/1 accept a territory atom (:US), a territory string ("US", "sa", "sazzzz"), a locale string ("en-GB", "ar-SA"), or a %Localize.LanguageTag{}. Passing nil (or calling with no arguments) walks the resolution chain: Application.get_env(:ex_tempo, :default_territory), then the ambient Localize.get_locale(). See Tempo.Territory.resolve/1 for the full normalisation rules.

Tempo.select/2 is pure

Tempo.select/2 has no ambient reads. Every input that can affect the result is a value on the selector. Tempo.workdays(:US) is the value that carries the US workday definition — it's constructed once and composed in:

# These produce the same IntervalSet:
Tempo.select(window, Tempo.workdays(:US))
Tempo.select(window, Tempo.workdays("en-US"))

# Hand-rolled day-of-week selector (works but bakes :US-specific knowledge):
Tempo.select(window, ~o"{1..5}K")

The benefit of naming a selector Tempo.workdays(:US) is that the territory indirection lives in the constructor. A hardcoded [1..5] would be wrong in Saudi Arabia. Tempo.workdays(territory) delegates that decision to CLDR, and because the constructor returns a plain %Tempo{} value, it's safe to capture anywhere — including a module attribute, since the territory is explicit and the result isn't locale-sensitive at capture time:

@us_workdays Tempo.workdays(:US)  # safe — :US is explicit

def workdays_in(window), do: Tempo.select(window, @us_workdays)

Because the result is an IntervalSet, set operations compose naturally and preserve member identity. Three common patterns:

# Workdays minus holidays — survivors keep their original member
# identity, each day distinct:
{:ok, net_workdays} = Tempo.members_outside(workdays, holidays)

# Workdays that overlap a specific window (filter, not trim):
{:ok, q2_workdays} = Tempo.members_overlapping(workdays, ~o"2026-04/2026-07")

# All workdays across territories — union preserves both sides'
# members so per-territory metadata survives:
{:ok, global}   = Tempo.union(us_workdays, de_workdays)

See the set operations guide for the distinction between the instant-level defaults (intersection, difference, symmetric_difference, complement) and the member-preserving companions (union, members_overlapping, members_outside, members_in_exactly_one) — the former for covered-time questions, the latter for event-list questions.

Extending with holidays

The built-in functions handle weekends; holidays are a domain concern Tempo deliberately leaves to your app — which territory's holidays, which year's calendar, whether fiscal-quarter-end adjustments apply, are all choices the library can't make for you. The shape is to compose the built-ins (or the selector) with your own holiday set. Skipping weekends and holidays:

defmodule MyApp.BusinessDays do
  @moduledoc "Business-day arithmetic that also skips MyApp's holidays."

  # `holidays` is a MapSet of day-resolution Tempo values, e.g. loaded
  # from an ICS feed (see the Holidays guide).
  def add(from, n, holidays, territory \\ :US) when n > 0 do
    Enum.reduce(1..n, from, fn _, day -> next_business_day(day, holidays, territory) end)
  end

  defp next_business_day(day, holidays, territory) do
    candidate = Tempo.next_working_day(day, territory)
    if MapSet.member?(holidays, candidate), do: next_business_day(candidate, holidays, territory), else: candidate
  end

  def business_day?(tempo, holidays, territory \\ :US) do
    Tempo.workday?(tempo, territory) and not MapSet.member?(holidays, tempo)
  end
end

The weekend logic is Tempo.next_working_day/2 and Tempo.workday?/2; your module adds only the holiday filter. See the Holidays guide for loading a real holiday calendar from an ICS feed.

If for some reason you need the lower-level selector form of the built-ins — for example to fold a workday filter into a larger Tempo.select/2 pipeline — the same patterns expressed over Tempo.workdays/1 are:

defmodule MyApp.BusinessDays do
  @moduledoc """
  Business-day arithmetic for MyApp's booking logic.

  Territory defaults to :US; callers pass a different territory
  for locale-specific behaviour.
  """

  @doc """
  Add `n` business days to `from`.

  Today is day zero; `add(today, 1)` is tomorrow-if-workday else
  the next workday.
  """
  @spec add(Tempo.t(), pos_integer(), Tempo.Territory.input()) ::
          {:ok, Tempo.t()} | {:error, term()}
  def add(from, n, territory \\ :US) when n > 0 do
    window_end = Tempo.shift(from, week: n + 1)
    window = Tempo.Interval.new!(from: from, to: window_end)

    with {:ok, workdays} <- Tempo.select(window, Tempo.workdays(territory)) do
      case workdays |> Tempo.IntervalSet.to_list() |> Enum.at(n) do
        nil -> {:error, :not_enough_workdays_in_window}
        interval -> {:ok, interval.from}
      end
    end
  end

  @doc "Is `tempo` a business day in the given territory?"
  @spec business_day?(Tempo.t(), Tempo.Territory.input()) :: boolean()
  def business_day?(tempo, territory \\ :US) do
    case Tempo.select(tempo, Tempo.workdays(territory)) do
      {:ok, set} -> Tempo.IntervalSet.count(set) > 0
      _ -> false
    end
  end

  @doc "Count business days in `[from, to)`."
  @spec count_between(Tempo.t(), Tempo.t(), Tempo.Territory.input()) :: non_neg_integer()
  def count_between(from, to, territory \\ :US) do
    window = Tempo.Interval.new!(from: from, to: to)
    {:ok, workdays} = Tempo.select(window, Tempo.workdays(territory))
    Tempo.IntervalSet.count(workdays)
  end
end

These three selector-form helpers are exactly what Tempo.add_working_days/3, Tempo.workday?/2, and Tempo.working_days_in/2 now do for you — shown here so the mechanism underneath the built-ins is legible. Reach for the selector form only when a workday filter is one step inside a larger Tempo.select/2 pipeline; otherwise prefer the built-ins. What stays in your app is the part Tempo can't decide for you — which days are holidays, and any fiscal-calendar adjustments — composed on top as shown above.