A support desk in London works 09:00 to 17:00 on weekdays, with a lunch hour, and is closed on 25 and 28 December 2026. A ticket comes in at 16:30 on Christmas Eve with an 8-hour SLA. When is it due?
The answer is 09:30 on Wednesday 30 December. Getting there means skipping lunch hours, a weekend and two holidays, all in the desk's own time zone. That's business-time arithmetic, and it turns up everywhere: support SLAs, payroll and overtime, delivery ETAs that only count operating hours, staff rosters.
I built workclock, an open-source library for it: one Rust core, published for Rust, Python and JavaScript. This post walks through the cases that make the problem harder than it looks, and how workclock handles each one.
Why another library
I'd worked on this kind of problem before, with shifts that run past midnight. In Python, the usual library for it is businesstimedelta. Its last release, 1.0.1, came out in September 2019, and several of its bugs are still open. I wanted a maintained replacement that gets the edge cases right, and that existing code could move to without a rewrite.
The two questions
A business-time library answers two questions:
- How much working time lies between two instants?
- Which instant do you reach after adding or subtracting an amount of working time?
In workclock, a schedule is built from rules. Working-hour rules produce blocks of working time; breaks and holidays produce time off. The schedule's working time is the union of the working blocks minus the union of the time off. Every operation is defined on that set, so the order you list the rules in never matters, and overlapping rules never count time twice.
That definition sounds obvious. Most of the bugs below come from code that doesn't quite follow it.
Hard case 1: shifts that cross midnight
Take a 22:00 to 06:00 shift, Monday to Friday. Between Monday 22:00 and Tuesday 06:00 there are clearly 8 working hours.
businesstimedelta 1.0.1 counts 0. Adding 4 hours at Monday 22:00 lands on Wednesday 02:00 instead of Tuesday 02:00, and subtracting 4 hours from Tuesday 06:00 raises OverflowError.
workclock's rule: if a shift's end is at or before its start, the shift is overnight and the end falls on the next date. A shift belongs to the weekday it starts on, so a Friday 22:00 to 06:00 shift runs into Saturday morning, and no shift ends on Monday morning unless one starts on Sunday. A start equal to the end is a 24-hour shift. With that, the three answers above become 8 hours, Tuesday 02:00 and Tuesday 02:00.
Hard case 2: holidays
Holidays look like the easy part. Two open businesstimedelta issues say otherwise:
- Once a query passes the last date in the holiday list, it raises
TypeError(#8, #10). - When the holidays and the working hours use different time zones, it can loop forever (#9).
The second one is subtle. A holiday defined in UTC under a Los Angeles workday doesn't remove the Los Angeles day: it removes 16:00 the day before to 16:00 on the holiday. In workclock, each rule has its own time zone, and holidays default to the schedule's zone rather than UTC, which avoids that mismatch. Dates after the last holiday are simply ordinary days.
There's also a design question: what happens when an overnight shift runs into a holiday? workclock's answer is that a holiday removes calendar time, midnight to midnight, not whole shifts. A shift that crosses into a holiday loses only the part after midnight. Keeping it that simple keeps the rules independent of each other.
Hard case 3: daylight saving time
Working hours are wall-clock times ("09:00 to 17:00 in Europe/London"), but durations are elapsed real time. So a shift that spans a DST change is an hour shorter or longer than it looks. In America/New_York, a 22:00 to 06:00 shift is 7 hours on the night of Saturday 7 March 2026, when clocks jump forward, and 9 hours on the night of Saturday 31 October 2026, when they fall back.
DST also creates local times that don't exist and local times that happen twice. workclock resolves them with the same "compatible" strategy as jiff, RFC 5545 and JavaScript Temporal:
- A time in the spring-forward gap moves forward by the length of the gap: 02:30, on a day when clocks jump from 02:00 to 03:00, becomes 03:30.
- A time in the fall-back overlap resolves to the earlier occurrence.
Following the same rule as those ecosystems means the three bindings agree with the date libraries people already use in each language. (businesstimedelta, through pytz's localize(), picks the later occurrence. That's one of the differences listed in the migration guide.)
A holiday on a DST transition day is still midnight to midnight, so it's shorter or longer than 24 hours by the size of the change.
Hard case 4: searches that never end
Ask for "8 working hours from now" on a schedule with no working time, and a naive implementation loops forever. In businesstimedelta, a rule with no working time hangs on addition.
In workclock every search is bounded. A schedule with no working time fails when it's built. add, sub and the period lookups stop with an error if they cross 366 days, plus seven days per holiday date, without finding working time. Library code doesn't panic: failures are errors, never hangs.
One smaller fix: between(a, b) is signed. If b is before a, the result is negative, the same as subtracting two datetimes (#12).
What it looks like
Here's the support desk from the top, in Python:
from datetime import date, datetime, time from zoneinfo import ZoneInfo from workclock import Break, Holidays, Schedule, WorkDay london = ZoneInfo("Europe/London") support = Schedule( [ WorkDay(time(9), time(17)), # Monday to Friday by default Break(time(12), time(13)), Holidays([date(2026, 12, 25), date(2026, 12, 28)]), ], time_zone=london, ) opened = datetime(2026, 12, 24, 16, 30, tzinfo=london) support.add(opened, hours=8) # 2026-12-30 09:30, London time support.between(opened, datetime(2026, 12, 29, 11, tzinfo=london)) # 2:30:00 The JavaScript package takes Dates or ISO 8601 strings, and durations in milliseconds:
import { Schedule } from "workclock"; const support = new Schedule({ timeZone: "Europe/London", rules: [ { type: "workday", start: "09:00", end: "17:00" }, { type: "break", start: "12:00", end: "13:00" }, { type: "holidays", dates: ["2026-12-25", "2026-12-28"] }, ], }); support.add("2026-12-24T16:30:00Z", 8 * 3_600_000); // Date 2026-12-30T09:30:00.000Z support.between("2026-12-24T16:30:00Z", new Date("2026-12-29T11:00:00Z")); // 9000000 ms If you have existing businesstimedelta code, most of it runs unchanged with one import change:
import workclock.compat as businesstimedelta The businesstimedelta test suite runs against workclock.compat in CI, and MIGRATING.md lists where it intentionally differs.
One core, three languages
The core is written in Rust on top of jiff. Python gets it through PyO3 and JavaScript through WebAssembly, so all three languages share one implementation instead of three that drift apart. The Python wheels and the npm package bundle the IANA time zone database, so they don't depend on the system's tzdata.
Every rule is specified with examples in a design document. Each example has an ID and becomes a test in every binding it applies to, and each binding is checked against a brute-force reference implementation with property-based tests.
Speed
Correctness was the point, but the engine's design also makes it fast. It computes working time a week at a time, and a week with no DST change and no holiday near it is the same as the week before, so a long span adds the previous week's total instead of rebuilding it. The cost of a span depends on how many of its weeks hold holidays or DST changes, not on its length.
Medians from Python, in a GitHub Actions run on a GitHub-hosted Ubuntu runner (AMD EPYC 7763, 4 cores), against businesstimedelta 1.0.1:
| Case | businesstimedelta 1.0.1 | workclock | Speed-up |
|---|---|---|---|
| One call, a 5½-hour span | 169 µs | 3.96 µs | 43× |
| One call, a year with bank holidays | 53 ms | 85.4 µs | 620× |
| 1,000,000 pairs | 9.9 min | 2.13 s (between_many on NumPy arrays) | 280× |
To be fair to the standard library: for one short span, a loop written by hand with datetime and zoneinfo keeps up (4.01 µs), because most of workclock's call goes on converting datetimes into and out of Rust. workclock pulls ahead as spans get longer or calls are batched, and that hand-written loop handles only one fixed schedule, without overnight shifts or per-rule time zones.
Try it
cargo add workclock jiff pip install workclock npm i workclock workclock is pre-1.0, so the API may still change in a minor release.
- Docs: workclock.karthik.nz/docs
- Playground: workclock.karthik.nz/playground
- Code: github.com/Karthik-beta/workclock
If you find a case it gets wrong, an issue on GitHub is the best place to tell me.
I'm Karthik Parameshwara, a software engineer in Bengaluru. More of my work is at karthik.nz.