CodeCrafting All articles
Build & Delivery

The Tuesday That Wasn't: Teaching Your Scheduler About the UK Calendar

CodeCrafting
The Tuesday That Wasn't: Teaching Your Scheduler About the UK Calendar

There's a particular kind of incident report that starts with the words "the job ran fine, but…". A billing run fires at 06:00 on the first working day of the month. Only the first working day of the month turns out to be a bank holiday nobody in the sprint planned for, so the invoices go out a day early, the payroll reminder lands in an inbox that stays dark until Thursday, and a support rota that assumed Monday-to-Friday quietly has nobody on it.

The cron expression was correct. The assumption underneath it was not.

A cron line is not a calendar

0 6 * * 1-5 reads like "weekdays, six in the morning". What it actually means is "Monday to Friday, every week, forever", which is a different thing entirely. It knows nothing about bank holidays, nothing about substitute days when Christmas falls on a weekend, and nothing about the fact that a colleague in Edinburgh is having a day off while a colleague in Leeds is not.

That gap between the cron line and the calendar is where a surprising number of small, avoidable bugs live. Not the dramatic ones — nobody's getting paged at 3am — but the ones that erode trust: a direct debit collection that goes out a day early, an SLA clock that keeps ticking through a public holiday, a digest email that arrives when the entire company has switched off.

The UK doesn't have one calendar, it has three (or more)

This is the bit that catches people out. Bank holidays aren't a single national list. They're devolved, and the differences are not trivia.

Then there's the substitute day rule. When a bank holiday falls on a weekend, the following Monday usually becomes the day off instead. Christmas Day on a Saturday means the substitute lands on the Monday, and Boxing Day shifts again. The pattern is learnable, but it is not derivable from day % 7 == 0, and it has been quietly amended by legislation more than once.

If your product has users in more than one of these jurisdictions, the question "is today a working day?" doesn't have a single answer. It has one answer per region, and probably one per user.

What actually breaks

A short, non-exhaustive tour of the things that go wrong when the calendar is hard-coded:

Billing and payments. Runs scheduled for "the next business day" slip into holidays, or worse, land on a substitute day that your finance team treats as closed. Bacs submission windows don't care that your code thought Monday was fine.

SLA timers. A four-hour response target that counts bank holidays as elapsed time will look like a breach when nobody was ever at their desk. Conversely, a timer that pauses for every weekend can hide genuine delays on a Sunday support shift.

Notifications and digests. The weekly summary that goes out on a Monday morning is ignored on a holiday Monday and buried by Tuesday's backlog. Small thing, real annoyance.

On-call rotas. "Weekday cover" that silently skips a bank holiday leaves a gap that nobody notices until something pings.

None of these are exotic. All of them come from the same root cause: treating the calendar as a constant when it's really just another piece of configuration.

Treat the calendar as data, not logic

You have two broad options, and both are better than a hard-coded list buried in a helper function.

Use a maintained library. Most ecosystems have something well-tested for this: holidays in Python, date-holidays in JavaScript, the Holidays package in .NET, or whatever your language of choice provides. Crucially, pick one that lets you ask for a specific subdivision — GB-SCT, GB-NIR, GB-ENG — rather than a single "UK" bucket. A library that only knows about England and Wales is a library that will embarrass you in Aberdeen.

Or keep a data file, versioned and reviewed. A JSON or YAML list per region, checked into the repo, with the source and the date it was last verified in a comment at the top. This is less elegant than a library but has one advantage: someone on the team can read it and spot the error. Treat it like any other reference data — reviewed annually, with a changelog.

Either way, wrap it behind a function with a name that means something:

def is_working_day(date: date, region: str) -> bool:
    ...

Then every scheduler, timer and reminder in the codebase calls that, and nowhere else. When Scotland gets a new date, you change one thing.

Test against real calendars, not made-up dates

This is where most teams stop too early. They write a unit test for "25 December is a holiday" and call it done. That test passes whether the code is right or wrong, because it tests the data you typed in, not the rules you built around it.

Better tests look like this:

If you can, run these against the library or data file itself, not a fixture you've hand-written. The point is to verify that the source of truth is wired up correctly, not that your test file agrees with itself.

The quiet argument for caring

None of this is glamorous work. It's the sort of thing that sits in a backlog for months because it's never quite caused an outage — just a handful of slightly wrong emails and one confused message in the finance channel.

But calendars are product logic. A date is not an implementation detail when it decides whether someone gets billed, whether a timer runs out, or whether a colleague is expected to answer. The teams that get this right tend to be the ones that stopped thinking of scheduling as a cron problem and started thinking of it as a domain problem — with rules, edge cases, and a source of truth that someone owns.

The job will still run at 06:00. The difference is whether anyone's there to care about what it does.

All Articles

Related Articles

Two Tills and a Back Room: Sizing Software for the Team You've Got

Two Tills and a Back Room: Sizing Software for the Team You've Got

The Empty Desk Rule: Shipping Software When the Country Logs Off

The Empty Desk Rule: Shipping Software When the Country Logs Off

Before the Mug Goes Cold: A Team Budget for Local Build Speed

Before the Mug Goes Cold: A Team Budget for Local Build Speed