
Guides
Part of How to count working hours between two timestamps
Working hours API: the four operations and their responses
A working hours API answers four questions: duration, add, is_open and next_open. Edge rules, a response with segments, and test vectors for each.
A working-hours endpoint is judged at its edges: a moment outside the window, a moment during a break, a span that crosses a weekend. Four operations cover every question a caller asks, and each one needs its edge cases decided and written down.
The arithmetic is short. Getting two callers to the same answer is the design problem, and it is solved by the contract, not by the code.
What to take away
- Four operations cover the questions
duration,add,is_openandnext_open. durationandaddare inverses only where the endpoints are inside the window, so the service must pick an answer for the rest and document it.- Return the segments for every call, not behind a flag, and echo the schedule version that produced the answer.
- A break is time outside every interval, so
is_openreturns closed during one.
The four operations
| Operation | Question it answers |
|---|---|
duration | How many business hours lie between two moments |
add | What moment is N business hours after this one |
is_open | Is the schedule open at this moment |
next_open | When does the schedule next open after this moment |
duration and add are inverses only where the endpoints are inside the window. Adding zero business hours to a moment outside the window has two defensible answers, being the moment itself or the next opening, and the service has to pick one and document it. The same ambiguity appears in day-level arithmetic and is set out under adding business days.
is_open needs the same discipline during a break. A break is time outside every interval, so the answer is closed. Treating it as open makes duration and is_open disagree, which is worse than either answer alone.
What the operations read
Every operation reads one schedule object. Model the window as a per-weekday list of intervals rather than an open time and a close time, and a lunch break, a split shift and a short Friday become the same feature.
<figure class="inline-figure">
<img src="/inline/working-hours-api-fig1-1280.webp" alt="Table comparing weekly interval pattern with date-keyed exceptions in a schedule object (Modeling working hours as an API: a schedule object, not a raw count)" loading="lazy" decoding="async" />
<figcaption>Exceptions replace the weekly pattern entirely rather than merging with it, so a short day is stated as its own intervals. <a href="https://workdaydatecalculator.com/image-licence">Image: Workday Date Calculator</a></figcaption>
</figure>
schedule:
id, version
zone: "America/Chicago"
weekly:
mon: [ ["09:00","12:30"], ["13:30","17:00"] ]
tue: [ ["09:00","12:30"], ["13:30","17:00"] ]
...
sat: []
sun: []
exceptions:
"2027-03-10": [ ["09:00","12:00"] ] a short day
"2027-03-11": [] closed
A break is a gap between two intervals, so the arithmetic never has to know what a break is. A closed weekday is an empty list, which means the code path for Sunday and the code path for a closure are the same path.
Exceptions are keyed by plain date and replace the weekly pattern for that date entirely. A half day is easier to state as its own intervals than as an instruction to subtract part of the usual ones. Where those dates come from, and how they are versioned, is under how closure calendars are made.
The zone belongs to the schedule, not to the caller. A window described as 09:00 to 17:00 is meaningless without saying whose 09:00. Callers send moments with offsets, and the service converts them into the schedule's zone before clipping. A local time with no offset should be rejected, because the service cannot tell which moment it means.
Response shape
result: 4
unit: business_hours
applied: { schedule_id, schedule_version, zone, from, to }
segments: [ { date, open, close, hours }, ... ]
Return the segments for every call, not behind a flag. For a four-hour answer, that is two rows. Those rows are the only way a caller can tell a wrong window from a wrong span without reimplementing the service, and they turn a support conversation into a five-second check.
Accept a schedule inline or by identifier, and echo whichever was used. Inline suits a caller with one schedule. An identifier with a version suits everyone else, and the version is what makes an old answer reproducible. The reasoning for treating an hour figure and a day figure as different quantities is set out under working hours.
Test vectors
An 09:00 to 17:00 window, Monday to Friday, no exceptions, March 2027. Start with duration.
| From | To | Expect |
|---|---|---|
| Friday 5th 15:00 | Monday 8th 11:00 | 4 |
| Friday 5th 17:00 | Monday 8th 09:00 | 0 |
| Saturday 6th 10:00 | Sunday 7th 16:00 | 0 |
| Thursday 4th 08:00 | Thursday 4th 18:00 | 8 |
| Friday 5th 12:15 | Tuesday 9th 10:45 | 14.5 |
| 1st 09:00 | 31st 17:00 | 184 |
An implementation returning a nonzero figure for row two is not clipping the ends. One returning 8.5 for row four is including a break that the schedule does not have, or clamping the wrong way. Row six is 23 open days times eight, which ties the hour arithmetic to the day arithmetic under counting a month.
The other three operations get one vector each from the same window.
| Operation | Input | Expect |
|---|---|---|
add | 4 hours from Friday 5th 16:30 | Monday 8th 12:30 |
is_open | Thursday 4th 10:00 | open |
is_open | Saturday 6th 10:00 | closed |
next_open | Friday 5th 18:00 | Monday 8th 09:00 |
A sheet gives a second opinion on the duration rows; its setup and timezone trap are under working hours in Google Sheets.
Days that are not 24 hours long
One local day a year has 23 hours and one has 25. For a daytime window this changes nothing, because the transitions happen in the small hours. For an overnight or early-morning window it changes the answer by an hour on two days a year.
The test is worth writing even if your window is currently 09:00 to 17:00, because windows get widened and nobody re-derives the tests when they do. The transition rules are published per region. In the United States the rule sits in 15 U.S.C. 260a, and NIST explains the daylight saving time rules on its own page.
Store the zone as an identifier from the standard database rather than as an offset. An offset is right until the region's rules change, and a schedule pinned to an offset silently drifts by an hour twice a year.
Common questions
Should the service accept a schedule inline or only by identifier?
Both, and echo whichever it used. The echoed version is what lets a caller reproduce an answer given last month.
How should an overnight shift be modeled?
As intervals that cross midnight, stated on the day the shift starts. Splitting them across two dates works too, but only if every consumer splits them the same way, which in practice they do not.
What should add return for zero hours outside the window?
Either the moment itself or the next opening. Both are defensible, so pick one, write it in the contract, and cover it with a test.
What about a day-level equivalent?
The same design question, with direction in its own field, is under the subtract business days API.







