Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Handling Moving Holidays in a Business-Day Calculator: Data Sources and Edge Cases

Fixed-date holidays are easy. You store 12-25 and you're done. The holidays that cause real bugs are the ones that move every year: Easter, Lunar New Year and Eid. On top of that, the substitute-day rules move holidays a

Fixed-date holidays are easy. You store 12-25 and you're done. The holidays that cause real bugs are the ones that move every year: Easter, Lunar New Year and Eid. On top of that, the substitute-day rules move holidays a second time when they land on a weekend.

This post covers where I get the dates, which ones I compute and which I never compute, and what broke my tests. All the code below is plain Python (stdlib only), and I ran it against the live official feeds before writing this up.

Rule 1: Compute only what is actually defined by math

Western Easter is pure arithmetic. The Anonymous Gregorian algorithm (Meeus/Jones/Butcher) works fine:

def western_easter(y):
    # Anonymous Gregorian algorithm (Meeus/Jones/Butcher)
    a, b, c = y % 19, y // 100, y % 100
    d, e = b // 4, b % 4
    f = (b + 8) // 25
    g = (b - f + 1) // 3
    h = (19 * a + b - d - g + 15) % 30
    i, k = c // 4, c % 4
    l = (32 + 2 * e + 2 * i - h - k) % 7
    m = (a + 11 * h + 22 * l) // 451
    month = (h + l - 7 * m + 114) // 31
    day = (h + l - 7 * m + 114) % 31 + 1
    return date(y, month, day)

Even so, I don't trust my own math until I've checked it against a government list. The UK publishes its bank holidays as JSON, so the check is cheap:

official_good_fridays = {e["date"] for e in uk["england-and-wales"]["events"] if e["title"] == "Good Friday"}
for y in (2026, 2027):
    gf = western_easter(y) - timedelta(days=2)
    print(y, "computed Good Friday", gf, "| in gov.uk list:", gf.isoformat() in official_good_fridays)

Lunar New Year and Eid are a different story. Both depend on lunar calendars and, for Eid, on official announcements. As of late October 2026, I couldn't find official Gulf dates for Eid in 2027. So the rule in my code is simple: no published date, no holiday. A provisional date is flagged as provisional and never silently treated as final.

Rule 2: "The country" is the wrong key

Here's the UK feed split by division:

# 1) UK: one country, three holiday lists
uk = fetch_json("https://www.gov.uk/bank-holidays.json")
for division in ("england-and-wales", "scotland", "northern-ireland"):
    easter = [e["date"] for e in uk[division]["events"]
              if e["title"] in ("Good Friday", "Easter Monday") and e["date"].startswith("2027")]
    print(f"{division:18} 2027 Easter closures: {easter}")

England & Wales and Northern Ireland close on Easter Monday 2027. Scotland doesn't. If your holiday table is keyed by GB, one of those groups will get the wrong deadline. Key it by the jurisdiction that actually publishes the list.

Lunar New Year has the same problem across countries. Hong Kong's 2027 list starts on February 6:

# 5) Lunar New Year: same festival, different first day
print("HK 2027 holidays in Feb:", [(d, s) for d, s in events.items() if d.startswith("202702")])

Korea's official 2027 calendar, published by the Korea AeroSpace Administration (KASA) with data from the Korea Astronomy and Space Science Institute (KASI), puts Seollal (lunar 1/1) on February 7. Same festival, different first day. Don't share one "Lunar New Year" date between countries.

Rule 3: Read official feeds defensively

My first attempt at parsing Hong Kong's official JSON feed crashed:

json.decoder.JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig)

The file starts with a byte-order mark. The fix is one argument:

def fetch_json(url):
    with urllib.request.urlopen(url, timeout=30) as r:
        raw = r.read()
    # The Hong Kong feed starts with a UTF-8 BOM; plain json.loads(raw.decode()) fails on it.
    return json.loads(raw.decode("utf-8-sig"))

Also, the feeds aren't all shaped alike. The UK feed is {division: {events: [...]}}. Hong Kong's is a jCal-style vcalendar[0].vevent[] with dtstart as ["20260403", {"value": "DATE"}]. Write a small adapter per source and test it against real files.

Rule 4: Substitute-day rules collide

This one bit me. In Hong Kong in 2026, Easter Monday is April 6 and Ching Ming Festival falls on Sunday, April 5. Here's what the official feed lists:

# 3) Hong Kong: substitute days collide
hk = fetch_json("https://www.1823.gov.hk/common/ical/en.json")
events = {e["dtstart"][0]: e["summary"] for e in hk["vcalendar"][0]["vevent"]}
for d in ("20260403", "20260404", "20260405", "20260406", "20260407"):
    print(d, events.get(d, "-"))

The government didn't just shift Ching Ming to Monday. Monday was already Easter Monday, so the extra day landed on Tuesday, April 7. A naive "Sunday → Monday" rule loses a day:

# 4) A naive "Sunday -> next Monday" rule gets that week wrong
def naive_observed(holidays):
    out = set()
    for h in holidays:
        out.add(h + timedelta(days=1) if h.weekday() == 6 else h)
    return out

def observed_next_free_day(holidays):
    """Sunday holiday -> next day that is not already a holiday (or a Sunday)."""
    out = set(h for h in holidays if h.weekday() != 6)
    for h in sorted(holidays):
        if h.weekday() == 6:
            d = h + timedelta(days=1)
            while d in out or d.weekday() == 6:
                d += timedelta(days=1)
            out.add(d)
    return out

easter = western_easter(2026)
base = {easter - timedelta(days=2), easter - timedelta(days=1), easter + timedelta(days=1),
        date(2026, 4, 5)}  # Ching Ming 2026, per the HK gazette
print("naive   :", sorted(d.isoformat() for d in naive_observed(base)))
print("next-free:", sorted(d.isoformat() for d in observed_next_free_day(base)))

The naive version shifts Ching Ming onto April 6, which is already a holiday, and the set quietly merges the two. That's three days off where the official calendar has four.

observed_next_free_day gets this case right, but I don't ship it as the source of truth. Substitute rules differ by country, and they're written in law, not in code:

  • US federal (OPM): for most federal employees, a Saturday holiday is observed the Friday before and a Sunday holiday the Monday after.
  • UK: if a bank holiday falls on a weekend, a substitute weekday becomes a bank holiday, normally the following Monday. The 2027 list has December 27 and 28 as substitutes for Christmas and Boxing Day.
  • Korea: substitute days apply to listed holidays. The 2027 calendar has substitutes on Feb 9, May 3, Jul 19, Aug 16, Oct 4, Oct 11 and Dec 27.
  • UAE: the government portal says that under Cabinet Resolution No. 27 of 2024, a public holiday can't be moved to another day if it falls on a weekend or on another holiday. So a weekend holiday is simply lost.

So my rule ended up being: use the published list, including its substitute days, and use rule-based code only as a test oracle. If my rule and the government list disagree, the list wins and I go read the law.

The output

Here's the full run from the script, unedited:

england-and-wales  2027 Easter closures: ['2027-03-26', '2027-03-29']
scotland           2027 Easter closures: ['2027-03-26']
northern-ireland   2027 Easter closures: ['2027-03-26', '2027-03-29']
2026 computed Good Friday 2026-04-03 | in gov.uk list: True
2027 computed Good Friday 2027-03-26 | in gov.uk list: True
20260403 Good Friday
20260404 The day following Good Friday
20260405 -
20260406 The day following Ching Ming Festival
20260407 The day following Easter Monday
naive   : ['2026-04-03', '2026-04-04', '2026-04-06']
next-free: ['2026-04-03', '2026-04-04', '2026-04-06', '2026-04-07']
HK 2027 holidays in Feb: [('20270206', "Lunar New Year's Day"), ('20270208', 'The third day of Lunar New Year'), ('20270209', 'The fourth day of Lunar New Year')]

Edge cases worth a test each

  1. A computed holiday (Easter) matches the official list for every year you support.
  2. Regional splits: Scotland vs England & Wales on Easter Monday.
  3. The same festival starts on different dates in different countries (Lunar New Year 2027: HK Feb 6, Korea Feb 7).
  4. Two holidays compete for the same substitute day (HK, April 2026).
  5. A holiday that falls on a weekend gets no substitute at all in some countries.
  6. An announced-later holiday (Eid) is missing or provisional, and the UI says so.
  7. Feed parsing: BOMs, different JSON shapes, date-only strings with no time zone.

What's the weirdest holiday edge case you've hit? I'm collecting them.

Sources (checked October 30, 2026): GOV.UK bank holidays JSON, Hong Kong 1823 holiday feed, GovHK 2026 holidays, KASI 2027 calendar data, OPM federal holidays, UAE public holidays (u.ae), KASA 2027 calendar announcement.

Note: I drafted this with help from an AI assistant. I ran the code and checked the dates against the sources above myself.

Disclosure: I build WorkdayMap, a free business-day calculator where you can enter these closures and see every counted and skipped day.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.