A ticket arrives on Friday at 16:00
Your support team promises a reply within 4 business hours. The office works 09:00 to 12:00 and 13:00 to 17:00, Monday to Friday. A ticket comes in on Friday at 16:00. When is it due?
Monday at 12:00. One hour on Friday, then three on Monday morning, skipping the weekend and the lunch break. Most people get there in their head in a few seconds.
Now write the code. Then add the things real teams have: a public holiday on Monday, a Christmas Eve half day, a night shift that runs past midnight, a ticket that waits on the customer for two days, and an office in New York serving customers in London. And the night in March when the clocks change, so one day has only 23 hours.
Each of those is a place where hand-written date code quietly goes wrong. So I built temporal-workdays, a small library for business days and business hours on top of the new JavaScript Temporal API. This post shows the problems it solves, and how I convinced myself the answers are right.
Why Temporal, and why a new library
Date is a moment in time pretending to be a calendar date. Store 2026-12-25 in a Date, read it on a machine west of UTC, and you get December 24. Business-day code is full of this kind of bug.
Temporal fixes it with separate types. Temporal.PlainDate is a date with no time zone, which is what a holiday is. Temporal.ZonedDateTime is a moment in a real time zone, which is what a ticket timestamp is. Temporal ships in Firefox 139+, Chrome 144+ and Node.js 26+, and temporal-polyfill covers everything else.
The popular business-day libraries were built before Temporal existed:
| Library | Built on | Last release | Weekly downloads |
|---|---|---|---|
| moment-business-days | Moment.js | April 2020 | about 270,000 |
| luxon-business-days | Luxon | September 2022 | about 86,000 |
date-fns addBusinessDays
|
Date |
maintained | part of date-fns |
None of them knows office hours like 09:00 to 17:00 with a lunch break, and date-fns only knows a Monday to Friday week with no holidays. I wanted one library that takes Temporal types in, gives the same type back, and covers both days and hours.
Workdays in two minutes
npm install temporal-workdays temporal-polyfill
import 'temporal-polyfill/global';
import { addWorkdays, isWorkday, workdaysBetween } from 'temporal-workdays';
const friday = Temporal.PlainDate.from('2026-11-06');
addWorkdays(friday, 1).toString(); // '2026-11-09'
addWorkdays(friday, -5).toString(); // '2026-10-30'
isWorkday(Temporal.PlainDate.from('2026-11-07')); // false
workdaysBetween(Temporal.PlainDate.from('2026-11-02'), friday.add({ days: 3 })); // 5
Not every country has a Saturday and Sunday weekend, and some countries move working days around holidays. Both are options:
import { createWorkCalendar } from 'temporal-workdays';
const calendar = createWorkCalendar({
weekend: [5, 6], // Friday and Saturday
holidays: ['2026-03-20', '12-25'], // one date, and December 25 every year
extraWorkdays: ['2026-03-28'], // a Saturday that is a working day
});
calendar.isWorkday(Temporal.PlainDate.from('2026-03-28')); // true
calendar.nextWorkday(Temporal.PlainDate.from('2026-03-19')).toString(); // '2026-03-22'
You get back the type you pass in. Give it a PlainDateTime or a ZonedDateTime and the time of day and the time zone are kept.
Holidays that move
A list of dates works for one year. Most holidays follow a rule instead: Thanksgiving is the fourth Thursday of November, Good Friday is two days before Easter. And when a holiday falls on a weekend, many countries give the day off on Friday or Monday instead.
const usFederal = createWorkCalendar({
observed: 'nearest', // Saturday moves to Friday, Sunday to Monday
holidays: [
'01-01',
{ name: 'Memorial Day', month: 5, dayOfWeek: 1, week: -1 }, // last Monday of May
'07-04',
{ name: 'Thanksgiving', month: 11, dayOfWeek: 4, week: 4 },
'12-25',
],
});
// July 4, 2026 is a Saturday, so the day off is Friday the 3rd.
usFederal.isHoliday(Temporal.PlainDate.from('2026-07-03')); // true
const england = createWorkCalendar({
observed: 'next',
holidays: [{ name: 'Good Friday', easter: -2 }, { name: 'Easter Monday', easter: 1 }, '12-25', '12-26'],
});
// Christmas 2021 was a Saturday and Boxing Day a Sunday, so the 27th and 28th were days off.
england.isHoliday(Temporal.PlainDate.from('2021-12-28')); // true
The two "observed" rules are not the same, and that detail is where hand-written versions break. With 'nearest', the Saturday holiday moves back to Friday. With 'next', both holidays move forward, and the second one skips the Monday the first one took.
For real holiday data, the library reads date-holidays, which covers over 200 countries:
import Holidays from 'date-holidays';
import { createWorkCalendar, fromDateHolidays } from 'temporal-workdays';
const bavaria = createWorkCalendar({
...fromDateHolidays(new Holidays('DE', 'BY'), { types: ['public', 'bank'] }),
hours: [['09:00', '17:00']],
});
bavaria.getWorkingHours(Temporal.PlainDate.from('2026-12-24')); // [['09:00', '14:00']]
Christmas Eve is a half day in Bavaria, so the office closes at 14:00. The adapter also fixes a trap in date-holidays itself. Its own isHoliday(new Date(...)) depends on the time zone of the machine it runs on. Run on a server in India, it says US Thanksgiving is not a holiday.
The SLA clock
Back to the Friday ticket. With working hours in the calendar, the answer is one call:
const support = createWorkCalendar({
hours: [
['09:00', '12:00'],
['13:00', '17:00'],
],
});
const ticket = Temporal.ZonedDateTime.from('2026-11-06T16:00[America/New_York]'); // a Friday
support.addWorkingTime(ticket, { hours: 4 }).toString();
// '2026-11-09T12:00:00-05:00[America/New_York]'
support.workingTimeBetween(ticket, Temporal.ZonedDateTime.from('2026-11-09T14:30[America/New_York]')).toString();
// 'PT5H30M'
With a ZonedDateTime, time is measured as it really passes. A working day of 09:00 to 17:00 is still 8 hours when the clocks change overnight. A 24-hour operation is different: on the day the clocks go forward, 00:00 to 24:00 is only 23 hours, and the library counts 23.
A night shift runs past midnight. Write it with an end before the start, and it belongs to the day it starts. Friday's shift is worked even though Saturday is a weekend:
const nights = createWorkCalendar({ hours: [['22:00', '06:00']] });
nights.isWorkingTime(Temporal.PlainDateTime.from('2026-11-07T03:00')); // true, Saturday 03:00
The last piece is pausing. Most support SLAs stop while the ticket waits on the customer. Pass those periods as pauses, then add what is left:
const desk = createWorkCalendar({ hours: [['09:00', '17:00']] });
const opened = Temporal.PlainDateTime.from('2026-11-09T15:00'); // Monday
const now = Temporal.PlainDateTime.from('2026-11-10T15:00');
const waitingOnCustomer = [
[Temporal.PlainDateTime.from('2026-11-10T09:00'), Temporal.PlainDateTime.from('2026-11-10T13:00')],
];
const used = desk.workingTimeBetween(opened, now, { pauses: waitingOnCustomer }); // 'PT4H'
const deadline = desk.addWorkingTime(now, Temporal.Duration.from({ hours: 8 }).subtract(used));
deadline.toString(); // '2026-11-11T11:00:00'
Pauses can overlap each other and the ends of the range. Each working minute is left out at most once.
How I convinced myself it is right
Date code that looks right on a few examples can still be wrong on the day it matters. So the tests compare the library with things I did not write:
- Real holiday data. The US federal and England and Wales rules match date-holidays for every year from 1990 to 2060. The only differences are one-off royal holidays, and Veterans Day substitutes, which date-holidays leaves out. Easter matches for 1900 to 2100.
- The libraries people migrate from. Every translation in the migration guides runs against the real moment-business-days, luxon-business-days and date-fns. luxon-business-days' default holidays match my rules for every day from 1900 to 2200.
- Slow but obvious reference code. Property tests with fast-check generate random weekends, holidays and working hours, and compare each answer with a version that walks minute by minute.
- Mutation testing. Stryker changes the source in about 1,900 small ways and checks that some test fails each time. 92% of the changes are caught. Many of the rest change nothing observable, like removing a fast path.
- Runtimes. CI runs on Node 18 to 26, Deno, Bun, Chromium, Firefox, WebKit and Cloudflare Workers, with polyfills and with native Temporal where the runtime has it.
- The docs themselves. Every code example in the README runs in the test suite, and its commented results are asserted.
It is also fast. Times per call on an Apple M4 with Node 22 and the polyfill, with the same holidays in each library:
| Task | temporal-workdays | moment-business-days | luxon-business-days |
|---|---|---|---|
| Add 20 business days | 1.3 µs | 17 µs | 43 µs |
| Count business days in a year | 6.9 µs | 246 µs | not available |
| Is it a business day | 0.15 µs | 0.34 µs | not available |
| Business days of a month | 19 µs | 32 µs | not available |
The package has no dependencies. The workday functions are about 3.2 kB minified and compressed, and everything is about 7.4 kB. Releases are published from GitHub Actions with npm provenance.
Coming from moment-business-days, luxon-business-days or date-fns
Each of them has a migration guide in the README, checked against the real library. The common calls map one to one:
| You use now | temporal-workdays |
|---|---|
moment(d).businessAdd(3) |
addWorkdays(d, 3) |
moment(d).isBusinessDay() |
isWorkday(d) |
moment(d).monthBusinessDays() |
workdaysInMonth(d) |
dt.plusBusiness({ days: 3 }) (luxon) |
addWorkdays(d, 3) |
addBusinessDays(date, 3) (date-fns) |
addWorkdays(d, 3) |
Two differences are worth knowing before you switch:
-
businessDiffin moment-business-days anddifferenceInBusinessDaysin date-fns count from the earlier date up to, but not including, the later one.workdaysBetweencounts the days after the earlier date up to and including the later one, soworkdaysBetween(d, addWorkdays(d, n))is alwaysn. Both give the same answer when both dates are workdays. The guides show how to get the old numbers exactly when they aren't. - moment-business-days'
nextBusinessDay()stops looking after 7 days and then returns a day that isn't a business day.nextWorkdayalways finds one, and throws if your settings leave no workdays at all.
There is also no global state. moment-business-days and luxon-business-days configure one global locale. Here each calendar is a plain object, so two offices with different holidays can live side by side.
Try it
The fastest way to see it is the playground. Pick a country, set the office hours and an SLA, and it shows the deadline day by day. It runs the same code that is on npm.
- Docs and recipes for SLA deadlines, payroll and delivery estimates: akashgupta1909.github.io/temporal-workdays
- npm: temporal-workdays
- JSR, for Deno: @akashgupta1909/temporal-workdays
- Source: github.com/akashgupta1909/temporal-workdays
I would like to hear where it falls short. Which working-time rule does your team have that this can't express yet? Leave a comment or open an issue.
Top comments (0)