temporal-workdays 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,7 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Added
6
+
7
+ - **Working hours**: `isWorkingTime`, `addWorkingTime`, `subtractWorkingTime`, `workingTimeBetween` (returns a
8
+ `Temporal.Duration`), `rollForwardToWorkingTime`, `rollBackwardToWorkingTime`, `getWorkingHours`.
9
+ Hours per day of week with breaks, a default, and `specialHours` per date (table or function).
10
+ `ZonedDateTime` measures exact elapsed time across DST changes.
11
+ - `createWorkCalendar({ hours })` returns a `WorkTimeCalendar` with the working-time methods.
12
+ - **`fromDateHolidays(holidays, { types })`**: holidays and partial-day closures from the `date-holidays` package,
13
+ independent of the machine's time zone.
14
+ - One options object can now be shared by every function (day-level functions accept `hours`/`specialHours`).
15
+
16
+ ### Changed
17
+
18
+ - Invalid input is rejected with clearer errors: unknown option names, non-object options, a bare string where an
19
+ array is expected, 5-digit years (use the ISO `+YYYYYY` form), and unsafe integer amounts.
20
+
21
+ ### Fixed
22
+
23
+ - `workdaysBetween` returned `-0` instead of `0` for empty backward ranges.
24
+ - `WorkCalendar#subtractWorkdays` accepted numeric strings such as `'2'`.
25
+ - The README's `date-holidays` example gave wrong answers outside the US time zones; replaced by `fromDateHolidays`.
26
+
3
27
  ## 0.1.0
4
28
 
5
- - Initial release: `isWorkday`, `isWeekend`, `isHoliday`, `addWorkdays`, `subtractWorkdays`,
6
- `nextWorkday`, `previousWorkday`, `rollForward`, `rollBackward`, `workdaysBetween`,
7
- `eachWorkday`, `createWorkCalendar`.
29
+ - Initial release: `isWorkday`, `isWeekend`, `isHoliday`, `addWorkdays`, `subtractWorkdays`, `nextWorkday`,
30
+ `previousWorkday`, `rollForward`, `rollBackward`, `workdaysBetween`, `eachWorkday`, `createWorkCalendar`.
package/README.md CHANGED
@@ -1,142 +1,204 @@
1
1
  # temporal-workdays
2
2
 
3
- Business-day math for the JavaScript [Temporal API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal).
4
- Add and subtract workdays, count workdays between dates, and handle custom weekends, holidays and make-up working days.
3
+ Business days and business hours for the JavaScript [Temporal API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal).
5
4
 
6
- - **Built for Temporal** — works with `PlainDate`, `PlainDateTime` and `ZonedDateTime`, and returns the same type you pass in (time of day and time zone are kept, DST-safe).
7
- - **Any Temporal implementation** — native, [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill) or `@js-temporal/polyfill`. No global `Temporal` required, at runtime or in your types.
8
- - **Zero dependencies**, ~2 KB min+gzip, tree-shakeable, ESM + CommonJS, TypeScript types included.
9
- - **Fast** — date math runs on integers, not on a chain of `Temporal` objects.
5
+ - **Workdays** — add/subtract workdays, count them, list them; custom weekends, holidays and make-up days.
6
+ - **Working hours** — "add 4 business hours", working time between two moments, breaks, half days, per-date hours. Exact across DST changes.
7
+ - **Holiday data** — plug in [`date-holidays`](https://www.npmjs.com/package/date-holidays) (200+ countries), time-zone safe.
8
+ - **Built for Temporal** — `PlainDate`, `PlainDateTime` and `ZonedDateTime` in, the same type out. Works with native Temporal, `temporal-polyfill` or `@js-temporal/polyfill`; no global `Temporal` needed, at runtime or in your types.
9
+ - **Small and strict** — zero dependencies, tree-shakeable (workdays alone ≈ 1.8 kB, everything ≈ 5.5 kB min+brotli), ESM + CommonJS, strict TypeScript types, clear errors instead of silently wrong dates.
10
10
 
11
11
  ```sh
12
12
  npm install temporal-workdays
13
13
  ```
14
14
 
15
- ## Quick start
15
+ ## Workdays
16
16
 
17
17
  ```ts
18
- import { addWorkdays, workdaysBetween, isWorkday } from 'temporal-workdays';
18
+ import { addWorkdays, isWorkday, workdaysBetween } from 'temporal-workdays';
19
19
 
20
20
  const friday = Temporal.PlainDate.from('2026-11-06');
21
21
 
22
- addWorkdays(friday, 1).toString(); // '2026-11-09' (Monday)
23
- addWorkdays(friday, -5).toString(); // '2026-10-30'
24
- isWorkday(Temporal.PlainDate.from('2026-11-07')); // false (Saturday)
22
+ addWorkdays(friday, 1).toString(); // → '2026-11-09'
23
+ addWorkdays(friday, -5).toString(); // → '2026-10-30'
24
+ isWorkday(Temporal.PlainDate.from('2026-11-07')); // → false
25
+ workdaysBetween(Temporal.PlainDate.from('2026-11-02'), Temporal.PlainDate.from('2026-11-09')); // → 5
26
+ ```
27
+
28
+ Holidays, other weekends and make-up working days:
29
+
30
+ ```ts
31
+ import { createWorkCalendar } from 'temporal-workdays';
32
+
33
+ const calendar = createWorkCalendar({
34
+ weekend: [5, 6], // Friday + Saturday (ISO: 1 = Monday … 7 = Sunday)
35
+ holidays: ['2026-03-20', '12-25'], // one date, and every Dec 25
36
+ extraWorkdays: ['2026-03-28'], // a Saturday that is a working day
37
+ });
25
38
 
26
- workdaysBetween(
27
- Temporal.PlainDate.from('2026-11-02'),
28
- Temporal.PlainDate.from('2026-11-09'),
29
- ); // 5
39
+ calendar.isWorkday(Temporal.PlainDate.from('2026-03-20')); // → false
40
+ calendar.isWorkday(Temporal.PlainDate.from('2026-03-28')); // → true
41
+ calendar.nextWorkday(Temporal.PlainDate.from('2026-03-19')).toString(); // → '2026-03-22'
30
42
  ```
31
43
 
32
- ### Holidays, custom weekends and make-up days
44
+ `createWorkCalendar` validates and parses the options once. Every function also takes the options as its last
45
+ argument, e.g. `addWorkdays(date, 3, { holidays })`.
46
+
47
+ ## Working hours
33
48
 
34
49
  ```ts
35
50
  import { createWorkCalendar } from 'temporal-workdays';
36
51
 
37
- const cal = createWorkCalendar({
38
- weekend: [5, 6], // Friday + Saturday (ISO: 1 = Mon … 7 = Sun)
39
- holidays: [
40
- '2026-03-20', // one specific date
41
- '12-25', // every year on Dec 25
42
- Temporal.PlainMonthDay.from('01-01'),
52
+ const support = createWorkCalendar({
53
+ hours: [
54
+ ['09:00', '12:00'],
55
+ ['13:00', '17:00'],
43
56
  ],
44
- extraWorkdays: ['2026-03-28'], // a Saturday that is a working day
45
57
  });
46
58
 
47
- cal.addWorkdays(Temporal.PlainDate.from('2026-03-19'), 1);
48
- cal.workdaysBetween(start, end);
49
- ```
59
+ const ticket = Temporal.ZonedDateTime.from('2026-11-06T16:00[America/New_York]'); // Friday
60
+ const due = support.addWorkingTime(ticket, { hours: 4 }); // SLA: 4 business hours
61
+ due.toString(); // → '2026-11-09T12:00:00-05:00[America/New_York]'
50
62
 
51
- `createWorkCalendar` parses the configuration once and reuses it. Every function also accepts the
52
- same options as its last argument, as in `addWorkdays(date, 3, { holidays })`.
63
+ const spent = support.workingTimeBetween(ticket, Temporal.ZonedDateTime.from('2026-11-09T14:30[America/New_York]'));
64
+ spent.toString(); // → 'PT5H30M'
65
+ spent.total('hours'); // → 5.5
53
66
 
54
- ### Rule-based holidays
67
+ support.isWorkingTime(Temporal.ZonedDateTime.from('2026-11-09T12:30[America/New_York]')); // → false
68
+ support.rollForwardToWorkingTime(Temporal.PlainDateTime.from('2026-11-07T10:00')).toString(); // → '2026-11-09T09:00:00'
69
+ ```
55
70
 
56
- Pass a function to decide holidays yourself, for example to plug in a holiday data source:
71
+ Hours per day of week, and special hours for specific dates:
57
72
 
58
73
  ```ts
59
- const cal = createWorkCalendar({
60
- // Receives an ISO-calendar Temporal.PlainDate. Not called on weekend days.
61
- holidays: (date) => date.month === 7 && date.day === 4,
74
+ import { createWorkCalendar } from 'temporal-workdays';
75
+
76
+ const shop = createWorkCalendar({
77
+ hours: { default: [['09:00', '18:00']], 5: [['09:00', '14:00']] }, // Fridays until 14:00
78
+ specialHours: {
79
+ '2026-12-24': [['09:00', '12:00']], // half day
80
+ '2026-12-31': [], // closed
81
+ },
62
82
  });
83
+
84
+ shop.getWorkingHours(Temporal.PlainDate.from('2026-12-04')); // → [['09:00', '14:00']]
85
+ shop.getWorkingHours(Temporal.PlainDate.from('2026-12-24')); // → [['09:00', '12:00']]
63
86
  ```
64
87
 
65
- For example, with [`date-holidays`](https://www.npmjs.com/package/date-holidays):
88
+ `specialHours` can also be a function `(date, regularHours) => hours | undefined`.
89
+
90
+ How the edges work:
91
+
92
+ - Periods are `[start, end)`: in `09:00–17:00`, 17:00 is not working time. Adding time that ends exactly at the end
93
+ of a period returns that end (`16:00 + 1h` → `17:00`), and subtracting to a start returns that start.
94
+ - Starting outside working hours, counting begins at the next working period.
95
+ - `ZonedDateTime` measures real elapsed time: a 09:00–17:00 day is still 8 hours, but `00:00–24:00` on a
96
+ spring-forward day is 23. Wall times skipped by DST move forward, like Temporal's `'compatible'`.
97
+ - Weekends and holidays have no working hours. Special hours override everything for their date.
98
+
99
+ ## Holidays from `date-holidays`
66
100
 
67
101
  ```ts
68
102
  import Holidays from 'date-holidays';
103
+ import { createWorkCalendar, fromDateHolidays } from 'temporal-workdays';
69
104
 
70
- const hd = new Holidays('US');
71
- const cal = createWorkCalendar({
72
- holidays: (d) => (hd.isHoliday(new Date(d.year, d.month - 1, d.day)) || []).some((h) => h.type === 'public'),
105
+ const bavaria = createWorkCalendar({
106
+ ...fromDateHolidays(new Holidays('DE', 'BY'), { types: ['public', 'bank'] }),
107
+ hours: [['09:00', '17:00']],
73
108
  });
109
+
110
+ bavaria.isWorkday(Temporal.PlainDate.from('2026-12-25')); // → false
111
+ bavaria.getWorkingHours(Temporal.PlainDate.from('2026-12-24')); // → [['09:00', '14:00']]
74
112
  ```
75
113
 
76
- ### Time zones and times of day
114
+ - `types` picks which holiday types close the business (default `['public']`).
115
+ - Days a holiday covers completely are holidays; partly covered days keep reduced hours (Christmas Eve from 14:00).
116
+ - Answers don't depend on the machine's time zone. (Calling `date-holidays`' own `isHoliday(new Date(…))` does:
117
+ run in India, it says US Thanksgiving is not a holiday.)
118
+ - `temporal-workdays` doesn't depend on `date-holidays`; install it yourself if you use this.
77
119
 
78
- Only the **calendar date** decides whether a day is a workday. A `ZonedDateTime` keeps its local wall-clock time:
120
+ To combine it with your own holidays, wrap the predicate:
79
121
 
80
122
  ```ts
81
- const t = Temporal.ZonedDateTime.from('2026-11-06T17:00[America/New_York]');
82
- addWorkdays(t, 1).toString(); // '2026-11-09T17:00:00-05:00[America/New_York]'
123
+ import Holidays from 'date-holidays';
124
+ import { fromDateHolidays, isWorkday } from 'temporal-workdays';
125
+
126
+ const us = fromDateHolidays(new Holidays('US'));
127
+ const companyDays = ['2026-12-24'];
128
+ const options = { ...us, holidays: (date) => us.holidays(date) || companyDays.includes(date.toString()) };
129
+
130
+ isWorkday(Temporal.PlainDate.from('2026-12-24'), options); // → false
131
+ isWorkday(Temporal.PlainDate.from('2026-11-26'), options); // → false
83
132
  ```
84
133
 
85
134
  ## API
86
135
 
87
- All functions take an optional `options` object as the last argument (see [Options](#options)).
88
- `T` is the type you pass in: `Temporal.PlainDate`, `PlainDateTime` or `ZonedDateTime`.
89
-
90
- | Function | Returns |
91
- | --- | --- |
92
- | `isWorkday(date)` | `true` unless the date is a weekend day or holiday. `extraWorkdays` always count as workdays. |
93
- | `isWeekend(date)` | `true` if the date falls on a configured weekend day. |
94
- | `isHoliday(date)` | `true` if the date is a holiday and not in `extraWorkdays`. |
95
- | `addWorkdays(date, n)` | `T`, `n` workdays later (earlier if `n` is negative). `n = 0` returns `date` unchanged. From a non-workday, `+1` gives the next workday. |
96
- | `subtractWorkdays(date, n)` | Same as `addWorkdays(date, -n)`. |
97
- | `nextWorkday(date)` / `previousWorkday(date)` | The nearest workday strictly after / before `date`. |
98
- | `rollForward(date)` / `rollBackward(date)` | `date` if it is a workday, otherwise the next / previous workday. |
99
- | `workdaysBetween(start, end)` | Number of workdays in `(start, end]`, negative if `end < start`. For `n >= 0`, `workdaysBetween(d, addWorkdays(d, n)) === n`. |
100
- | `eachWorkday(start, end)` | A generator of every workday in `[start, end]` (both inclusive), as `T`. |
101
- | `createWorkCalendar(options)` | An object with all of the above, bound to `options`. |
136
+ `T` is the Temporal type you pass in, and is also what you get back.
137
+
138
+ ### Workdays — accept `PlainDate`, `PlainDateTime` or `ZonedDateTime`
139
+
140
+ | Function | Returns |
141
+ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
142
+ | `isWorkday(date)` / `isWeekend(date)` / `isHoliday(date)` | `boolean`. `extraWorkdays` always count as workdays. |
143
+ | `addWorkdays(date, n)` / `subtractWorkdays(date, n)` | `T`, `n` workdays away. `n = 0` returns `date`; from a non-workday, `+1` is the next workday. |
144
+ | `nextWorkday(date)` / `previousWorkday(date)` | `T`, the nearest workday strictly after / before. |
145
+ | `rollForward(date)` / `rollBackward(date)` | `T`: `date` if it is a workday, else the next / previous one. |
146
+ | `workdaysBetween(start, end)` | `number` of workdays in `(start, end]`; negative if `end < start`. `workdaysBetween(d, addWorkdays(d, n)) === n` |
147
+ | `eachWorkday(start, end)` | A generator of every workday in `[start, end]`, as `T`. |
148
+
149
+ ### Working time — accept `PlainDateTime` or `ZonedDateTime`, need `hours`
150
+
151
+ | Function | Returns |
152
+ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
153
+ | `isWorkingTime(dateTime)` | `boolean` |
154
+ | `addWorkingTime(dateTime, duration)` / `subtractWorkingTime(dateTime, duration)` | `T`. `duration` is a `Temporal.Duration` or `{ hours, minutes, … }` (no days/weeks/months). |
155
+ | `workingTimeBetween(start, end)` | `Temporal.Duration` balanced up to hours (`PT27H30M`); negative if `end < start`. |
156
+ | `rollForwardToWorkingTime(dateTime)` / `rollBackwardToWorkingTime(dateTime)` | `T`: `dateTime` if it is working time, else the next period start / previous period end. |
157
+ | `getWorkingHours(date)` | The periods of that date, e.g. `[['09:00', '17:00']]`. |
158
+
159
+ ### Calendars and adapters
160
+
161
+ | Function | Returns |
162
+ | --------------------------------------- | --------------------------------------------------------------------------------------------- |
163
+ | `createWorkCalendar(options)` | All of the above bound to `options`. Working-time methods are included when `hours` is given. |
164
+ | `fromDateHolidays(holidays, { types })` | `{ holidays, specialHours }` to spread into options. |
102
165
 
103
166
  ### Options
104
167
 
105
- | Option | Type | Default |
106
- | --- | --- | --- |
107
- | `weekend` | `DayOfWeek[]`, ISO numbers `1` (Mon) … `7` (Sun) | `[6, 7]` |
108
- | `holidays` | Iterable of `'YYYY-MM-DD'`, `'MM-DD'` (every year), `Temporal.PlainDate`, `Temporal.PlainMonthDay`, **or** `(date: Temporal.PlainDate) => boolean` | none |
109
- | `extraWorkdays` | Iterable of `'YYYY-MM-DD'` or `Temporal.PlainDate`. These dates are workdays even on a weekend or holiday. | none |
168
+ | Option | Type | Default |
169
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
170
+ | `weekend` | ISO days of week, `1` (Mon) … `7` (Sun) | `[6, 7]` |
171
+ | `holidays` | `'YYYY-MM-DD'`, `'MM-DD'` (yearly), `Temporal.PlainDate`, `Temporal.PlainMonthDay`, or `(date) => boolean` | none |
172
+ | `extraWorkdays` | `'YYYY-MM-DD'` or `Temporal.PlainDate`: workdays even on a weekend or holiday | none |
173
+ | `hours` | `[[start, end], …]` for every workday, or `{ 1: […], …, 7: […], default: […] }`. Times are `'HH:MM[:SS[.f]]'` or `Temporal.PlainTime`; `'24:00'` may end a period | required for working time |
174
+ | `specialHours` | `{ 'YYYY-MM-DD': [[start, end], …] }` (`[]` = closed), or `(date, regularHours) => hours \| undefined` | none |
110
175
 
111
- Invalid input (an unknown weekend day, `'2026-02-30'`, a non-integer `n`) throws a `RangeError` or `TypeError` instead of silently producing a wrong date.
112
- If a configuration has no workdays at all (every day is a weekend day, or a predicate that always returns `true`),
113
- the functions throw a `RangeError` instead of looping forever.
176
+ Invalid input throws a `RangeError` or `TypeError` that says what is wrong, including unknown option names.
177
+ A configuration without any workdays or working time throws instead of looping forever.
114
178
 
115
179
  ## Requirements
116
180
 
117
- You need a Temporal implementation:
181
+ A Temporal implementation: native in Firefox 139+, Chrome 144+ and Node.js 26+; elsewhere (Safari/iOS, older Node) use
182
+ a polyfill:
118
183
 
119
- - **Native** — Firefox 139+, Chrome 144+, Node.js 26+.
120
- - **Elsewhere** (Safari/iOS, older Node) — use a polyfill:
121
- ```ts
122
- import 'temporal-polyfill/global'; // installs a global Temporal
123
- // or, without touching globals:
124
- import { Temporal } from 'temporal-polyfill';
125
- ```
184
+ ```js
185
+ import 'temporal-polyfill/global'; // installs a global Temporal
186
+ // or, without touching globals:
187
+ import { Temporal } from 'temporal-polyfill';
188
+ ```
126
189
 
127
- Calendar dates in non-ISO calendars (`hebrew`, `japanese`, …) are supported. Weekends and holidays are always matched on the ISO date, and results keep the input's calendar.
190
+ Non-ISO calendars (`hebrew`, `japanese`, …) work; weekends and holidays are matched on the ISO date and results keep
191
+ the input's calendar. Node.js 18+.
128
192
 
129
193
  ## Migrating from moment-business-days / luxon-business-days
130
194
 
131
- | moment-business-days | temporal-workdays |
132
- | --- | --- |
133
- | `moment(d).businessAdd(3)` | `addWorkdays(d, 3)` |
134
- | `moment(d).businessSubtract(3)` | `subtractWorkdays(d, 3)` |
135
- | `moment(d).isBusinessDay()` | `isWorkday(d)` |
136
- | `moment(d).nextBusinessDay()` | `nextWorkday(d)` |
137
- | `moment(d).prevBusinessDay()` | `previousWorkday(d)` |
138
- | `moment(a).businessDiff(moment(b))` | `workdaysBetween(b, a)` (check the endpoint convention for your case) |
139
- | `moment.updateLocale('us', { holidays, holidayFormat, workingWeekdays })` | `createWorkCalendar({ holidays, weekend })` |
195
+ | moment-business-days | temporal-workdays |
196
+ | ---------------------------------------------------------- | ------------------------------------------------------- |
197
+ | `moment(d).businessAdd(3)` / `.businessSubtract(3)` | `addWorkdays(d, 3)` / `subtractWorkdays(d, 3)` |
198
+ | `moment(d).isBusinessDay()` | `isWorkday(d)` |
199
+ | `moment(d).nextBusinessDay()` / `.prevBusinessDay()` | `nextWorkday(d)` / `previousWorkday(d)` |
200
+ | `moment(a).businessDiff(moment(b))` | `workdaysBetween(b, a)` (check the endpoint convention) |
201
+ | `moment.updateLocale('us', { holidays, workingWeekdays })` | `createWorkCalendar({ holidays, weekend })` |
140
202
 
141
203
  ## License
142
204