The workdays-and-weekends guide showed how Tempo.select(interval, Tempo.workdays(:US)) filters out weekends. Holidays are the other half of "when is the office closed?" — they're territory-specific, year-specific, and maintained by people who care about them. Tempo doesn't ship holiday data; instead, it consumes standard iCalendar (.ics) feeds through Tempo.ICal.from_ical/1 and lets set operations do the rest.
This guide walks through fetching a real holiday calendar from officeholidays.com, parsing it into a %Tempo.IntervalSet{}, and using it to answer three scheduling questions: "how many working days are actually in Q3?", "which holidays will hit my project?", and "what's five business days from today if we skip holidays?".
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.SigilsThe import adds only sigil_o/2 and sigil_TEMPO/2 to the caller's namespace; no helper functions leak in.
The data source
officeholidays.com publishes public-holiday calendars for every UN-recognised country and territory, updated weekly, delivered as iCalendar .ics feeds. Two URL patterns to know:
https://www.officeholidays.com/ics/{country}— all public holidays (includes state and regional holidays where relevant).https://www.officeholidays.com/ics-fed/{country}— federal or national-level holidays only.
Country names are lowercase with hyphens: usa, united-kingdom, germany, japan, saudi-arabia. Subscription URLs are the same as download URLs — a calendar program like iCal or Outlook points at the webcal:// equivalent; our code fetches the https:// form as a plain HTTP body.
Fetching and parsing
The end-to-end fetch-and-parse is three lines:
%Req.Response{body: ics} =
Req.get!("https://www.officeholidays.com/ics-fed/usa")
{:ok, holidays} = Tempo.ICal.from_ical(ics)
Tempo.IntervalSet.count(holidays)
#=> 12 (US federal holidays for 2026)(Any HTTP client works — the examples use :req; :httpc from OTP or another library is equally fine. What Tempo needs is the response body as a string.)
Tempo.ICal.from_ical/1 returns a %Tempo.IntervalSet{} where each member is a %Tempo.Interval{} with the iCal event metadata preserved on :metadata — summary, description, location, uid, custom X-* properties. The intervals themselves are half-open [from, to) day-spans in Tempo's standard convention.
holidays
|> Tempo.IntervalSet.to_list()
|> Enum.take(3)
|> Enum.each(fn iv ->
IO.puts "#{Tempo.month(iv)}/#{Tempo.day(iv)}: #{Tempo.Interval.metadata(iv).summary}"
end)
#=> 1/1: New Year's Day
#=> 1/19: Martin Luther King Jr. Day
#=> 2/16: President's Day (Regional Holiday)Caching — don't fetch on every request. The calendar updates weekly at most; store the parsed %Tempo.IntervalSet{} in an Agent, GenServer, :persistent_term, or your application's config cache and refresh on a schedule.
Three planning questions
Assume the calendar has been fetched and parsed into holidays.
1. How many working days are actually in Q3 2026?
Pure set algebra: workdays minus holidays.
q3 = ~o"2026-07-01/2026-10-01"
{:ok, workdays} = Tempo.select(q3, Tempo.workdays(:US))
{:ok, net_workdays} = Tempo.members_outside(workdays, holidays)
Tempo.IntervalSet.count(net_workdays)
#=> 64 (66 workdays − 2 federal holidays in Q3)Read aloud: "Workdays in Q3 are the Monday-through-Friday days inside July-September. Net working days are those workday members that don't overlap any holiday."
Tempo.members_outside/2 is the member-preserving companion to Tempo.difference/2: each workday that survives the filter is kept as a distinct member, with its own day-level endpoints. This is the natural shape for "count the days" and "list the days" queries — no trimming, no fragmentation. (Tempo.difference/2 would produce the same numeric result here, since each workday is either fully a holiday or fully not, but members_outside is the right name for an event-list question.)
Expressing the Q3 window
The ~o"from/to" range sigil above is the most literal form. Three more concise alternatives all compose equally well with Tempo.select/2 and the set operations:
# ISO 8601 interval range:
q3 = ~o"2026-07/2026-10"
# ISO 8601-2 quarter designator:
q3 = ~o"2026Y3Q"
# Range-in-slot:
q3 = ~o"2026Y{7..9}M"All four produce the same 66-workday count when passed through Tempo.select(q3, Tempo.workdays(:US)). Pick whichever reads most naturally for your domain. The quarter designator is the shortest and most direct for calendar-quarter queries; the range form ~o"2026-07/2026-10" is the best fit when your window doesn't align to a standard quarter.
The same composition works for seasons (ISO 8601-2 codes 25–32, astronomical equinox/solstice bounded — e.g. ~o"2026Y26M" for Northern summer), month ranges (~o"2026Y{3..6}M" for H1 minus Q1), and archaeological masks (~o"156X" for the 1560s). Each of these AST shapes materialises to concrete endpoints and flows cleanly through the workday selector and set operations.
2. Which holidays will hit my project?
Tempo.members_overlapping/2 returns the holiday members that fall inside the query window — with their iCal metadata intact:
q3 = ~o"2026-07/2026-10"
{:ok, q3_holidays} = Tempo.members_overlapping(holidays, q3)
q3_holidays
|> Tempo.IntervalSet.to_list()
|> Enum.each(fn iv ->
IO.puts "#{Tempo.month(iv)}/#{Tempo.day(iv)}: #{Tempo.Interval.metadata(iv).summary}"
end)
#=> 7/3: Independence Day (in lieu)
#=> 7/4: Independence Day
#=> 9/7: Labor DayRead aloud: "The Q3 holidays are the holiday-set members that overlap the Q3 window. Each one carries its name from the iCal feed."
Because Tempo.members_overlapping/2 keeps surviving members whole — with their original metadata — you can compose further: filter to a team's availability, difference out someone's PTO, union multiple territories' holidays for an international team. The names travel through.
3. What's five business days from today, skipping holidays?
Four composed set operations. The whole pipeline is set-algebra; no list-level filtering required.
today = ~o"2026-06-30"
window = Tempo.Interval.new!(from: today, to: Tempo.shift(today, week: 3))
{:ok, workdays} = Tempo.select(window, Tempo.workdays(:US))
{:ok, open_days} = Tempo.members_outside(workdays, holidays)
target =
open_days
|> Tempo.IntervalSet.to_list()
|> Enum.at(5)
#=> %Tempo.Interval{from: ~o"2026Y7M8D", ...}Read aloud: "Starting today, build a three-week window. Keep the workdays inside it. Subtract the holidays. The sixth survivor is the answer for 'five business days from today' under the banking convention where today is day zero."
(Enum.at(5) picks the sixth element. If your convention counts today as day one, use Enum.at(n - 1).)
Territory-aware planning
Change the URL, get a different country's holidays. The same code works for every territory officeholidays.com publishes:
defmodule MyApp.HolidayCalendar do
def fetch(territory) do
url = "https://www.officeholidays.com/ics/#{territory_slug(territory)}"
with {:ok, %Req.Response{status: 200, body: ics}} <- Req.get(url),
{:ok, set} <- Tempo.ICal.from_ical(ics) do
{:ok, set}
end
end
defp territory_slug(:US), do: "usa"
defp territory_slug(:GB), do: "united-kingdom"
defp territory_slug(:DE), do: "germany"
defp territory_slug(:JP), do: "japan"
defp territory_slug(:SA), do: "saudi-arabia"
# extend as needed
endPair this with Tempo.select(interval, Tempo.workdays(:SA)) for a fully territory-consistent planning layer: Saudi weekends (Fri/Sat) are excluded by the workday query; Saudi holidays come from the ICS feed; the set operations compose across both.
For teams across multiple territories, union the holiday sets before differencing — "office closed" becomes "any member territory's holiday":
{:ok, us_holidays} = MyApp.HolidayCalendar.fetch(:US)
{:ok, gb_holidays} = MyApp.HolidayCalendar.fetch(:GB)
{:ok, de_holidays} = MyApp.HolidayCalendar.fetch(:DE)
{:ok, all_closed} = Tempo.union(us_holidays, gb_holidays)
{:ok, all_closed} = Tempo.union(all_closed, de_holidays)Then compute "working days for the global team" as Tempo.members_outside(workdays, all_closed). Each member interval still carries the territory/name metadata — so the July 3 entry stays labelled as US in the global union, and you can render conflicts with full attribution.
Scheduling a training week
A concrete example that ties it together: pick the first five-day work week in Q3 2026 with no US federal holidays, suitable for scheduling a training course.
q3 = ~o"2026-07/2026-10"
{:ok, workdays} = Tempo.select(q3, Tempo.workdays(:US))
{:ok, open_days} = Tempo.members_outside(workdays, holidays)
candidate_weeks =
open_days
|> Tempo.IntervalSet.to_list()
|> Enum.chunk_by(&week_key/1)
|> Enum.filter(&(length(&1) == 5))
first_available = hd(candidate_weeks)
#=> five-member list starting the Monday of the first clean weekRead aloud: "Take the open workdays of Q3, group them by week, keep only the weeks that still have all five workdays, and pick the first." week_key/1 is a helper you'd write — the year-week pair derived from each interval's from endpoint. The core logic is four composable set operations on Tempo's primitives.
Related reading
Working with workdays and weekends —
Tempo.workdays/1,Tempo.weekend/1, territory-aware weekend conventions, and the primitive patterns this guide builds on.Set operations — union, intersection, difference, the instant-level vs member-preserving distinction, and companions like
members_overlapping/members_outside.iCalendar integration — full detail on
Tempo.ICal.from_ical/1, metadata preservation, and round-tripping.icsfiles.Cookbook — recipe-format examples for scheduling, availability, and related queries.