Building hldays #5: Observed holidays and edge cases
In the second post I said hldays stores rules, not dates: fixed: "06-13", easter: 60, "last Monday of May". A rule gives you the date of a holiday. It doesn't always give you the day off.
This post is about the gap between the two: weekend shifts, holidays that move, and laws that change. Most of the bugs I've hit live here.
Two different ways a holiday "moves"
I ended up needing two separate fields, because countries mean two different things by "moving" a holiday. Both are documented in src/core/types.ts:
/**
* Substitute day when the date falls on the given ISO weekday (keys "1"-"7"), e.g. UK { "6": next, "7": next },
* US federal { "6": previous, "7": next }. The day moves to the nearest day that is neither a weekend day nor
* another holiday of the same jurisdiction; the original date is still listed.
*/
substitute?: Partial<Record<string, SubstituteMove>>;
/**
* The holiday itself moves (no day off on the original date) when it falls on the given ISO weekday: days to add,
* e.g. Acre (BR) { "2": 3, "3": 2, "4": 1 } moves Tuesday–Thursday to Friday; Colombia's Ley Emiliani moves to Monday.
*/
move?: Partial<Record<string, number>>;
- A substitute adds a day. Christmas is still on the 25th, and there is also a day off on the Monday.
- A move replaces the day. The original date becomes a normal working day.
The difference matters for business days. If you treat a move like a substitute, you get two days off where there is one.
Substitute days: the UK and the US
In the data, it is one line per holiday. The US federal New Year's Day:
- id: new-years-day
name: {"en": "New Year's Day"}
type: public
rule: {"fixed": "01-01"}
substitute: {"6": "previous", "7": "next"}
Saturday goes back to Friday, Sunday goes forward to Monday. The UK uses {"6": "next", "7": "next"}: both go forward.
The interesting part is what happens when two holidays need the same day. Christmas 2027 is a Saturday and Boxing Day is a Sunday. Both move forward, but they can't both land on Monday:
curl "https://api.hldays.dev/v1/holidays?jurisdiction=GB-ENG&year=2027&include_sources=false"
{ "date": "2027-12-25", "holiday_id": "christmas-day" },
{ "date": "2027-12-26", "holiday_id": "boxing-day" },
{ "date": "2027-12-27", "holiday_id": "christmas-day", "substitute_for": "2027-12-25" },
{ "date": "2027-12-28", "holiday_id": "boxing-day", "substitute_for": "2027-12-26" }
The original dates stay listed and the substitutes carry substitute_for, so a calendar UI can show "Christmas Day (substitute)". The engine handles the clash by processing holidays in date order and keeping a set of days already taken:
for (const [o, h] of all) {
const move = h.substitute?.[String(isoWeekday(parseDate(o.date)!))];
if (!move) continue;
const step = move === "next" ? 1 : -1;
let d = addDays(parseDate(o.date)!, step);
while (weekend.includes(isoWeekday(d)) || taken.has(formatDate(d))) d = addDays(d, step);
taken.add(formatDate(d));
out.push({ ...o, date: formatDate(d), substitute_for: o.date });
}
The taken.add line is the whole trick: Christmas takes Monday, so Boxing Day walks on to Tuesday.
A bug the git history remembers
The first version only counted a jurisdiction's own holidays as taken. That was wrong for Scotland, which has its own 2 January holiday on top of the UK's New Year's Day. In 2022 both fell on a weekend. The fix (commit "substitutes avoid inherited holidays") changed one comment and the taken set:
- * Only this jurisdiction's own public/local holidays block a day, not inherited ones.
+ * Public/local holidays of this jurisdiction and its ancestors (with their substitutes) block a day, so a regional
+ * holiday moves past a national substitute (Scotland 2022: New Year Sat → Mon 3, 2 January Sun → Tue 4).
- const taken = new Set(all.filter(([o]) => o.type !== "optional").map(([o]) => o.date));
+ const inherited = years.flatMap((y) => this.resolved(j, y, true, false));
+ const taken = new Set([...all.map(([o]) => o), ...inherited].filter((o) => o.type !== "optional").map((o) => o.date));
Now the live API gives Scotland four entries in early January 2022:
{ "date": "2022-01-01", "holiday_id": "new-years-day", "jurisdiction": "GB" },
{ "date": "2022-01-02", "holiday_id": "new-year-holiday", "jurisdiction": "GB-SCT" },
{ "date": "2022-01-03", "holiday_id": "new-years-day", "substitute_for": "2022-01-01", "jurisdiction": "GB" },
{ "date": "2022-01-04", "holiday_id": "new-year-holiday", "substitute_for": "2022-01-02", "jurisdiction": "GB-SCT" }
(Trimmed: jurisdiction is really an object with id, name and level.) Optional holidays don't block a day, since they aren't a day off for everyone.
Crossing New Year
1 January 2022 was a Saturday. Under the US rule, the day off was Friday 31 December 2021. So the substitute for a 2022 holiday lives in 2021:
curl "https://api.hldays.dev/v1/holidays?jurisdiction=US&year=2021&include_sources=false"
{ "date": "2021-12-24", "holiday_id": "christmas-day", "substitute_for": "2021-12-25" },
{ "date": "2021-12-25", "holiday_id": "christmas-day" },
{ "date": "2021-12-31", "holiday_id": "new-years-day", "substitute_for": "2022-01-01" }
That's why withSubstitutes expands three years ([year - 1, year, year + 1]) and filters back down to the one you asked for. A test pins it down:
// 1 Jan 2022 is a Saturday: observed on Fri 31 Dec 2021, in the previous year
expect(subs(2021)).toEqual([["2021-07-05", "2021-07-04"], ["2021-12-31", "2022-01-01"]]);
expect(x.isBusinessDay(x.resolve("XX")!, "2021-12-31").business_day).toBe(false);
Calendar-based rules have the same problem in a different shape: an Islamic date with an offset of -1 can land in the previous Gregorian year, so expandRule looks at neighbouring years there too.
Portugal, for the record, has no substitute days at all. A holiday on a Sunday is just a Sunday. There isn't a single substitute in the Portuguese data.
Moves: Acre's Friday rule
The move field came with the Brazil import. The state of Acre moves some holidays that fall from Tuesday to Thursday onto the Friday, as python-holidays reads state law since 2009:
- id: evangelical-day
name: {"pt": "Dia do Evangélico", "en": "Evangelical Day"}
type: public
rule: {"fixed": "01-23"}
move: {"2": 3, "3": 2, "4": 1}
The engine applies it while expanding the rule, before anything else sees the date:
for (const raw of expandRule(h.rule, year)) {
const shift = h.move?.[String(isoWeekday(parseDate(raw)!))];
const date = shift ? formatDate(addDays(parseDate(raw)!, shift)) : raw;
23 January 2024 was a Tuesday, so the live API lists Evangelical Day in Acre on Friday 26 January, and the 23rd is a normal working day.
The importer has to infer moves from python-holidays' dates, which is risky: almost any odd year looks like a "move" if you squint. So scripts/import-country.py only accepts a move if it looks like a real law:
# A real moving rule sends every moved day to one weekday that never moves itself, and recurs; anything else is
# a few odd years dressed up as a rule (GB May Day 2020).
targets = {(wd - 1 + o) % 7 + 1 for wd, o in moved.items()}
if moved and (len(targets) > 1 or targets & set(moved) or any(seen[wd] < 2 for wd in moved)):
return i, {}
The UK's 2020 Early May bank holiday was moved to Friday 8 May for the VE Day anniversary. Without that guard, the importer would have turned a one-off into a permanent "Monday to Friday" rule.
Laws change: valid_from and valid_to
Holidays get created, abolished and moved. Every rule can have a year range. Brazil is a clean example: Black Awareness Day on 20 November became a national holiday in 2024 (Lei 14.759/2023). Before that, São Paulo city had it locally. In the data, the city's rule ends and the national one starts:
# data/BR/municipalities.yaml (São Paulo city)
- id: black-awareness-day
rule: {"fixed": "11-20"}
valid_to: 2023
# data/BR/country.yaml
- id: national-day-of-zumbi-and-black-awareness
rule: {"fixed": "11-20"}
valid_from: 2024
Greece's Labour Day is messier. It is 1 May, unless that falls in Orthodox Holy Week or on Easter Monday, and the labour ministry decides the replacement each year. The importer split it into segments:
- id: labor-day
rule: {"fixed": "05-01"}
valid_to: 2023
- id: labor-day-dates
rule: {"dates": ["2024-05-07"]}
- id: labor-day-2025
rule: {"fixed": "05-01"}
valid_from: 2025
- id: labor-day-observed
rule: {"dates": ["2021-05-04", "2022-05-02", "2027-05-04", "2032-05-04", ...]}
In 2024 it was moved outright (1 May was a Wednesday in Holy Week), so 1 May 2024 is a business day in Greece and 7 May is not. The other years keep 1 May and add an observed day. docs/data-review-import.md flags this one: 2021 and 2024 match the actual ministerial decisions, but future years are python-holidays' reading of the rule, so they are estimates.
Portugal 2013 to 2015: the holidays that disappeared
During the bailout years, Portugal suspended four national holidays: Corpus Christi, 5 October, 1 November and 1 December. They came back with Lei n.º 8/2016. If you model Portuguese holidays before 2016, you need those gaps.
hldays doesn't model them, because it doesn't go back that far. Supported years start at 2016, set in src/core/calendar.ts:
export const MIN_YEAR = 2016;
export const MAX_YEAR = 2099;
Ask for 2015 and you get a 400 with invalid_year: "Year must be between 2016 and 2099". Every Portuguese national holiday cites the Labour Code "as amended by Lei n.º 8/2016", which is exactly the version that applies from 2016 on. The same cut-off keeps municipal history simple: Alfândega da Fé moved its holiday from 29 June to 8 May in 2016, so its record has valid_from: 2016 and the old date isn't needed.
If I ever extend the range backwards, the suspension is the first thing that has to go in: those four holidays would get a valid_to: 2012 segment and a new one from 2016. I'd rather refuse a year than return dates I know are wrong.
The edge cases that are still open
- West Virginia Day 2026. 20 June is a Saturday, so it moves back to Friday 19 June, which is Juneteenth. The engine skips to Thursday 18 June; python-holidays lets both share the 19th. Only the state's announcement will settle it.
- Nordeste (Azores). Older lists say 18 July; the câmara's programmes show a Monday. The data uses
on_or_before: { date: "07-18", weekday: 1 }, the Monday on or before 18 July, and the conflict is in the notes. excludes. A jurisdiction can drop an inherited holiday by id (a region that replaces a national one). It's tested, but no jurisdiction in the data uses it yet, since US states keep every federal holiday.
What's the strangest holiday rule you've had to deal with? I suspect the comments will turn up a few I haven't handled yet.
Next in the series: Names are harder than dates.