bizdays-toolkit 0.17.0 → 0.17.1

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +54 -703
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.17.1] - 2026-10-25
8
+
9
+ ### Changed
10
+
11
+ - README(英語・日本語)を入口に絞り、詳しい説明を `docs/guide.md`・`docs/guide.ja.md`(同梱の提供元・営業日カレンダー・繰り返しの規則・合成・よくある設定の例・月の期日・日ごとの値・スケジュールからのカレンダー・入力・対応年の外・未確定の祝日・エラー・設定の診断)と `docs/providers.md`・`docs/providers.ja.md`(他の国・地域の祝日の提供元の書き方)に移した。README には機能の箇条書き(7 項目)・インストール・クイックスタート・できることの一覧(提供元の表・規則の表・診断の例)・ドキュメントの索引を残した。本文の内容は変えていない(`docs/decisions.md` の 45 番)
12
+
13
+ ### 開発
14
+
15
+ - `pnpm check`: 英語と日本語のドキュメントの対(README・`docs/guide`・`docs/providers`)の見出しの数とコードブロックの数の一致、互いへのリンク、`docs/*.md` の見出しへのリンク(`#…`)の実在を確かめる(`scripts/check-package.ts`)。コード例の実行(`scripts/check-readme-examples.ts`)も docs のガイドを対象に含めた
16
+ - `RELEASING.md`・`docs/decisions.md` の 22・27 番に、ドキュメントの対が増えたことを書いた
17
+
7
18
  ## [0.17.0] - 2026-10-25
8
19
 
9
20
  ### 移行
@@ -458,7 +469,8 @@
458
469
 
459
470
  - 日本: 2026 年 2 月の官報(2027 年の春分・秋分の日)まで反映(`dataVersion: "2026-02"`、`confirmedThrough: 2027`)
460
471
 
461
- [Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.17.0...HEAD
472
+ [Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.17.1...HEAD
473
+ [0.17.1]: https://github.com/inohan77/bizdays-toolkit/compare/v0.17.0...v0.17.1
462
474
  [0.17.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.16.0...v0.17.0
463
475
  [0.16.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.15.0...v0.16.0
464
476
  [0.15.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.14.1...v0.15.0
package/README.md CHANGED
@@ -19,19 +19,13 @@
19
19
 
20
20
  A business-day calendar that combines public holidays with company or store closures.
21
21
 
22
- - **Japanese holidays verified day by day against the Cabinet Office data**: from 1955 through the latest year announced in the Official Gazette, every date in the Cabinet Office "National Holidays" CSV is checked in the tests (substitute holidays, citizens' holidays, holidays for imperial ceremonies and the Olympic moves included)
23
- - **UK bank holidays verified day by day against GOV.UK**: choose England, Wales, Scotland or Northern Ireland. From 2012 through the last year GOV.UK has published, every date and name is checked against the GOV.UK data in the tests, and differences are watched daily
24
- - **Singapore public holidays verified day by day against data.gov.sg**: the dates gazetted by the Ministry of Manpower (2020 through the last year gazetted), with the days in lieu for holidays on a Sunday, are checked against the MOM collection on data.gov.sg in the tests, and differences are watched daily
25
- - **Recurring rules**: rules such as "closed on the 3rd Wednesday", "closed every other Wednesday", "stocktaking on the last day of the month", "closed on Mondays (on the next weekday if Monday is a holiday)" or "closed on the day after a holiday", together with what happens when they fall on a holiday (follow the holiday, apply anyway, or move to the next day). Rules can have an effective period, so "closed on Tuesdays instead of Mondays from April 2027" is one calendar
26
- - **Day-by-day values (e.g. store hours)**: values that change by weekday, holiday and specific date, such as "closed on Tuesdays, Sunday hours on holidays, closed on New Year's Day, short hours on New Year's Eve", are looked up with the same rules
27
- - **Option diagnostics**: `diagnoseCalendar` and `diagnoseSchedule` from `bizdays-toolkit/diagnose` list rules that can never apply, rules whose removal changes nothing, typos in option keys, calendars with no business day and more, without throwing. One line in your tests keeps a change of settings from silently killing a rule
28
- - **Month-based due dates**: due dates such as "closing on the 20th, paid on the 10th of the next month (next business day if closed)" or "the last business day of the month", with the four adjustment conventions used in finance (following, preceding, modified-following, modified-preceding)
29
- - **Business-day arithmetic**: closed weekdays, annual closures (including ranges across the new year), specific closed and open dates, and holidays combine to compute "N business days later", "next business day after the cut-off time" and more
30
- - **A calendar from a schedule**: `createCalendarFromSchedule` turns a schedule such as store hours into a business-day calendar, so the hours are the only configuration and business-day arithmetic works on them
31
- - **Combining calendars**: `combineCalendars` builds a calendar of the days on which all of several calendars are open (your company and the bank, for a remittance date) or any of them is open (any of several stores), and business-day arithmetic works on the result
32
- - **Time-zone safe**: instants such as `Date` are converted to a civil date in the calendar's time zone (`Asia/Tokyo` for Japan, `Europe/London` for the UK, `Asia/Singapore` for Singapore) before evaluation. A server running in UTC does not treat 00:00–09:00 in Japan as the previous day
33
- - **Lightweight, zero dependencies**: about 10.0 KB min+gzip (about 11.8 KB with Japanese holidays, about 11.7 KB with UK bank holidays, about 10.9 KB with Singapore holidays; measured for 0.17.0). ESM, bundled types and `sideEffects: false`. Written in standard JavaScript and `Intl` only (no Node.js APIs); CI tests it on Node.js, Chrome, Firefox, Edge, Safari, Cloudflare Workers, Bun and Deno
34
- - **Extensible to other countries and regions**: holiday providers are pluggable through the `HolidayProvider` interface. Japan, the UK and Singapore are bundled
22
+ - **Holidays verified day by day against official data**: Japan (Cabinet Office, 1955 to the latest year in the Official Gazette), the UK (GOV.UK; England, Wales, Scotland and Northern Ireland, 2012 onwards) and Singapore (Ministry of Manpower on data.gov.sg, 2020 onwards). Every date is checked in the tests, differences are watched daily and shipped as patch versions
23
+ - **Business-day arithmetic**: closed weekdays, annual closures (across the new year too), specific closed and open dates and holidays combine into "N business days later", "next business day after the cutoff" and month-based due dates with the four adjustment conventions used in finance
24
+ - **Recurring rules**: "closed on the 3rd Wednesday", "every other Wednesday", "stocktaking on the last day of the month", "closed on Mondays, or the next weekday when Monday is a holiday", "the day after (or before) a holiday", each with its own behaviour on holidays and an optional effective period
25
+ - **Day-by-day values and combined calendars**: store hours that change by weekday, holiday and date; a business-day calendar made from such a schedule; and the days on which all (or any) of several calendars are open
26
+ - **Option diagnostics**: `bizdays-toolkit/diagnose` lists rules that never apply, rules that are shadowed or redundant, rules that have ended, typos in option names and options with no business day at all, without throwing. One line in a test keeps options honest
27
+ - **Lightweight, zero dependencies, time-zone safe**: about 10.0 KB min+gzip (about 11.8 KB with Japanese holidays, about 11.7 KB with UK bank holidays, about 10.9 KB with Singapore holidays; measured for 0.17.0). Standard JavaScript and `Intl` only, ESM with bundled types; tested on Node.js, Chrome, Firefox, Edge, Safari, Cloudflare Workers, Bun and Deno. Instants such as `Date` are converted to a civil date in the calendar's time zone before evaluation
28
+ - **Extensible to other countries and regions**: holiday providers are pluggable through the `HolidayProvider` interface, and `bizdays-toolkit/provider` exposes the calendar arithmetic (nth weekday of a month, Easter) that most countries' holidays are built from
35
29
 
36
30
  ## Installation
37
31
 
@@ -43,658 +37,64 @@ Node.js 22.12 or later. CI tests the package on Node.js 22.12, 22, 24 and 26, on
43
37
 
44
38
  Types are bundled. CI type-checks a consumer project against the published type definitions with TypeScript 5.0, 5.5, 5.8, 6.0 and 7.0 (the native compiler), each with `moduleResolution` set to `node16`, `nodenext` and `bundler`, and confirms that an intentional type error is reported by each.
45
39
 
46
- ## Usage
47
-
48
- ### Looking up holidays
49
-
50
- ```ts
51
- import { getHoliday, holidaysInYear, isHoliday } from "bizdays-toolkit/jp";
52
-
53
- isHoliday("2026-11-23"); // true
54
- getHoliday("2026-09-22");
55
- // { date: "2026-09-22", name: "国民の休日", nameEn: "Citizens' Holiday", kind: "citizens", confirmed: true }
56
- holidaysInYear(2027);
57
-
58
- // Instants (Date) are evaluated in Japan time
59
- isHoliday(new Date("2026-11-22T15:30:00Z")); // true (Nov 23 in Japan time)
60
- ```
61
-
62
- `kind` is one of `national` (national holiday), `substitute` (substitute holiday), `citizens` (citizens' holiday, a day between two holidays) and `special` (holidays for imperial ceremonies and similar events).
63
-
64
- `confirmed` tells whether the date is officially fixed. The vernal and autumnal equinox days are fixed in the Official Gazette in February of the previous year, so later years are predictions from an approximation formula (`confirmed: false`).
65
-
66
- ### UK bank holidays
40
+ ## Quick start
67
41
 
68
42
  ```ts
69
43
  import { createCalendar } from "bizdays-toolkit";
70
- import { england, northernIreland, scotland, wales } from "bizdays-toolkit/uk";
71
-
72
- const edinburgh = createCalendar({ holidays: scotland, closedWeekdays: [0, 6] });
73
- edinburgh.isBusinessDay("2026-01-02"); // false (2nd January)
74
- edinburgh.addBusinessDays("2026-06-12", 1); // "2026-06-16" (June 15 is the World Cup bank holiday)
75
-
76
- scotland.holidaysInYear(2026)[0];
77
- // { date: "2026-01-01", name: "New Year’s Day", nameEn: "New Year’s Day", kind: "regular", confirmed: true }
78
- wales.holidaysInYear(2026)[0]?.names; // { cy: "Dydd Calan" }
79
-
80
- // Across several regions, use the union (a holiday in any of them is a holiday)
81
- const ukWide = createCalendar({ holidays: [england, scotland, northernIreland] });
82
- ```
83
-
84
- | Provider | ID | GOV.UK division | Notes |
85
- | ----------------- | -------- | ----------------- | ------------------------------------------------ |
86
- | `england` | `GB-ENG` | England and Wales | |
87
- | `wales` | `GB-WLS` | England and Wales | Welsh names (`names.cy`) included |
88
- | `scotland` | `GB-SCT` | Scotland | 2nd January, St Andrew’s Day and others |
89
- | `northernIreland` | `GB-NIR` | Northern Ireland | St Patrick’s Day, Battle of the Boyne and others |
90
-
91
- - `kind` is one of `regular` (an annual bank holiday, including years when a proclamation moved it), `substitute` (a substitute day for a bank holiday on a Saturday or Sunday) and `special` (a one-off bank holiday by proclamation, such as a jubilee or a state funeral). A substitute day has the name of the original holiday, as on GOV.UK
92
- - England and Wales are one division on GOV.UK and currently have the same dates, but they are separate providers, so your code keeps working if they ever differ
93
- - Supported years are 2012–2099. Years after the last year GOV.UK has published (`confirmedThrough`, currently 2028) are predictions from the rules (`confirmed: false`). One-off bank holidays are unknown until announced, so predicted years never contain them
94
- - Local holidays set by Scottish councils are not included. Whether a bank holiday is a day off depends on the employment contract and similar arrangements
95
- - One-off bank holidays may be announced only about ten days ahead. Differences from GOV.UK are watched daily and released as patch versions
96
-
97
- ### Singapore public holidays
98
-
99
- ```ts
100
- import { createCalendar } from "bizdays-toolkit";
101
- import { sg } from "bizdays-toolkit/sg";
102
-
103
- const singapore = createCalendar({ holidays: sg, closedWeekdays: [0, 6] });
104
- singapore.isBusinessDay("2026-08-10"); // false (National Day falls on a Sunday; Monday is the holiday in lieu)
105
- singapore.addBusinessDays("2026-02-16", 1); // "2026-02-19" (Chinese New Year on February 17 and 18)
106
- sg.holidaysInYear(2026).find((h) => h.kind === "substitute");
107
- // { date: "2026-06-01", name: "Vesak Day", nameEn: "Vesak Day", kind: "substitute", confirmed: true }
108
- ```
109
-
110
- - `sg` (ID `SG`, time zone `Asia/Singapore`) is one provider; Singapore has no regional holidays. `kind` is `regular` (an annual public holiday), `substitute` (the day after a public holiday that falls on a Sunday, which the Holidays Act makes a public holiday; it has the name of the original holiday) or `special` (a one-off holiday declared by the Government, such as Polling Day). A public holiday on a Saturday has no substitute day
111
- - Chinese New Year, Hari Raya Puasa, Hari Raya Haji, Vesak Day and Deepavali follow the lunar, Islamic and Hindu calendars, so they cannot be computed from rules. The dates are the ones gazetted by the Ministry of Manpower (MOM), year by year, and the substitute days follow the rule of the Holidays Act. Supported years are the years MOM has gazetted (currently 2020–2027; `confirmedThrough` is the same as the last supported year). Later years are not predicted and are outside the supported years (`out-of-range`), so `sg` never returns `confirmed: false`
112
- - The dates are verified against the "Singapore Public Holidays" collection on data.gov.sg (MOM). Where that data differs from the gazetted dates (2022: the revised Hari Raya dates and two missing substitute days), the gazetted dates are used. Differences from data.gov.sg are watched daily and released as patch versions
113
-
114
- ### Business-day calendars
115
-
116
- ```ts
117
- import { createCalendar } from "bizdays-toolkit";
118
- import { jp } from "bizdays-toolkit/jp";
119
-
120
- const shipping = createCalendar({
121
- holidays: jp,
122
- closedWeekdays: [0], // Sunday (0 = Sunday … 6 = Saturday)
123
- closedAnnual: [{ from: "12-31", to: "01-03", name: "New Year closure" }],
124
- closedDates: [{ from: "2027-08-12", to: "2027-08-16", name: "Summer closure" }],
125
- openDates: ["2026-12-27"], // a specific open day
126
- holidayPolicy: "closed", // closed on holidays ("open": open / "ignore": not used)
127
- });
128
-
129
- shipping.isBusinessDay("2026-11-23"); // false (Labor Thanksgiving Day)
130
- shipping.addBusinessDays("2026-12-30", 1); // "2027-01-04"
131
- shipping.businessDaysBetween("2026-09-01", "2026-10-01"); // business days from (inclusive) to (exclusive)
132
-
133
- // Orders before 14:00 ship today, later orders on the next business day
134
- shipping.businessDayOnOrAfter(new Date(), { cutoff: "14:00" }); // the input must carry a time of day
135
-
136
- shipping.dayInfo("2027-01-01");
137
- // { date: "2027-01-01", weekday: 5, businessDay: false, reason: "closed-annual", name: "New Year closure", holiday: {…}, rulePath: "closedAnnual[0]" }
138
- shipping.daysBetween("2026-12-30", "2027-01-04").map((d) => d.businessDay); // [true, false, false, false, false, true]
139
- ```
140
-
141
- `daysBetween(from, to)` returns `dayInfo` for every day in the period (both ends included). `listBusinessDays` and `holidaysBetween` include both ends too; only `businessDaysBetween(from, to)` counts from `from` (inclusive) to `to` (exclusive), so that `businessDaysBetween(a, b) + businessDaysBetween(b, c)` equals `businessDaysBetween(a, c)` and, when `a` is a business day, `businessDaysBetween(a, addBusinessDays(a, n))` is `n` (the inverse of `addBusinessDays`). From a closed day, `addBusinessDays` counts the next business day as 1 (so `+0` and `+1` give the same day, as `n = 0` is `businessDayOnOrAfter`), while `businessDaysBetween` does not count the closed start, so the two differ by one there.
142
-
143
- `createCalendar` and `createSchedule` validate the options when they are created, so the usual pattern is to create them once and reuse them. Creating is also cheap (about 0.02 ms for a calendar and 0.04 ms for a schedule with Japanese holidays on a development machine), so creating one per request is not a problem in practice.
144
-
145
- Each day is decided in this order of priority (top first).
146
-
147
- 1. `openDates` (specific open days)
148
- 2. `closedDates` (specific closed days)
149
- 3. `closedAnnual` (annual closures)
150
- 4. `openRecurring` and `closedRecurring` rules with `onHolidayRule: "over-holiday"` (applied even on holidays), and holiday rules (`{ holiday: true }`)
151
- 5. Holidays (according to `holidayPolicy`; `"open"` makes them business days even on closed weekdays)
152
- 6. `openRecurring` and `closedRecurring` (the default `"under-holiday"`, and the days moved to by `"after-holiday"`)
153
- 7. `closedWeekdays` (closed weekdays)
154
- 8. Otherwise a business day
155
-
156
- This order is fixed. Only the relation between a recurring rule and holidays can be chosen per rule with `onHolidayRule` (next section).
157
-
158
- ### Recurring rules (weekly, every other week, nth weekday, day of month, holidays, day after or before a holiday)
159
-
160
- Recurring closed and open days such as "closed on the 3rd Wednesday", "closed on the 2nd and 4th Saturdays", "closed every other Wednesday", "stocktaking on the last day of the month", "closed on Mondays (on the next weekday if Monday is a holiday)", "open on substitute holidays", "closed on the day after a holiday" or "late opening on the day before a holiday" go in `closedRecurring` and `openRecurring`.
161
-
162
- ```ts
163
- const museum = createCalendar({
164
- holidays: jp,
165
- holidayPolicy: "open", // open on holidays
166
- closedRecurring: [
167
- { weekday: 1, name: "Closed on Mondays", onHolidayRule: "after-holiday" }, // closed on the next weekday instead
168
- {
169
- weekday: 3,
170
- nth: 3,
171
- months: [2, 8],
172
- name: "Closed on the 3rd Wednesday in February and August",
173
- },
174
- { day: -1, name: "Stocktaking", onHolidayRule: "over-holiday" }, // closed even on holidays
175
- { dayAfterHoliday: true, name: "Closed on the day after a holiday" },
176
- ],
177
- });
178
-
179
- museum.isBusinessDay("2026-10-12"); // true (open on Monday, Sports Day)
180
- museum.dayInfo("2026-10-13");
181
- // { date: "2026-10-13", weekday: 2, businessDay: false, reason: "closed-recurring",
182
- // name: "Closed on Mondays", shiftedFrom: "2026-10-12", rulePath: "closedRecurring[0]" }
183
- museum.dayInfo("2026-02-12").name; // "Closed on the day after a holiday" (National Foundation Day is February 11)
184
- museum.isBusinessDay("2026-05-07"); // false (the day after the May 3–6 run of holidays)
185
- ```
186
-
187
- | Pattern | Meaning |
188
- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
189
- | `{ weekday: 1 }` | Every Monday |
190
- | `{ weekday: 3, nth: 3 }` | The 3rd Wednesday. `nth` is 1–5 or -1 (last); an array such as `[2, 4]` gives several |
191
- | `{ weekday: 3, interval: 2, anchor: "2026-01-07" }` | Every other Wednesday: the Wednesdays an even number of weeks from `anchor` (2 or more weeks; see below) |
192
- | `{ day: 20 }` | The 20th of every month. `day` is 1–31 or -1 (last day); an array gives several |
193
- | `{ holiday: true, kinds: ["substitute"] }` | Holidays, optionally narrowed to the given kinds (`Holiday.kind`); judged before `holidayPolicy` (see below) |
194
- | `{ dayAfterHoliday: true }` | The day after a holiday: the first non-holiday after a holiday or a run of holidays (holidays of the provider only) |
195
- | `{ dayBeforeHoliday: true }` | The day before a holiday: the non-holiday just before a holiday or a run of holidays (holidays of the provider only) |
196
- | `months: [8, 12]` | Narrows any pattern to these months |
197
- | `effectiveFrom: "2027-04-01"`, `effectiveTo: "…"` | The period the rule is in effect (both ends included). Outside it, the rule is as if it were not written (see below) |
198
-
199
- - **The nth weekday** is the nth occurrence of that weekday in the month (days 1–7 hold the 1st). It is not the weekday in the nth row of a printed calendar
200
- - **Days that do not exist in a month** (`nth: 5` or `day: 31` in a month without one) do not match. Use `day: -1` for the last day
201
- - **Every other week** (`interval: 2`) is not the same as the 1st and 3rd weekdays (`nth: [1, 3]`): a month with five Wednesdays shifts the latter by a week. `interval` is the number of weeks (an integer of 2 or more) and `anchor` is a date on the same weekday that the cycle passes through; the rule matches that date and every `interval` weeks before and after it. Stores announce "every other Wednesday from January 7", so copy that date. To start at the anchor rather than also matching earlier dates, add `effectiveFrom`. `interval` cannot be combined with `nth`
202
- - **Holiday rules** (`{ holiday: true }`) write exceptions to the default holiday handling: "open on holidays from April 2027" is `openRecurring: [{ holiday: true, effectiveFrom: "2027-04-01" }]` with `holidayPolicy: "closed"` kept for the past, "open on substitute holidays and citizens' holidays" is `openRecurring: [{ holiday: true, kinds: ["substitute", "citizens"] }]`, and "special hours on the May holidays" in a schedule is `recurring: [{ holiday: true, months: [5], value: … }]`. `kinds` narrows to those `Holiday.kind` values (see the provider's list; with `holidays: jp` the editor completes them), `weekdays` to those weekdays ("morning hours on a Saturday holiday" is `{ holiday: true, weekdays: [6], value: … }`), `months` to those months; omit them for all holidays. The rule is judged at the "over the holiday" step, before `holidayPolicy` or a schedule's `holiday`, and also with `holidayPolicy: "ignore"`. Its days are holidays, so `onHolidayRule` cannot be set on it (`invalid-option`); `holiday` still shows the holiday
203
- - **The day after a holiday** is never itself a holiday, so `onHolidayRule` cannot be set on such a rule (it is an `invalid-option`). "Holidays" are those of the holiday provider, not annual closures. A rule of this kind matches no day without a holiday provider. `weekdays` narrows it to those weekdays: a museum "closed on the day after a holiday, except when that day is a Saturday or Sunday" is `{ dayAfterHoliday: true, weekdays: [1, 2, 3, 4, 5] }` (opening the weekend with `openRecurring` instead would override every other weekend closure)
204
- - **The day before a holiday** (`{ dayBeforeHoliday: true }`) is the mirror image: the day whose next day is a holiday and which is not a holiday itself, so before a run of holidays it is only the day before the run (in May 2026 it is Saturday May 2; May 4 and 5 are holidays themselves). The same rules apply (`weekdays`, `months`, effective periods, no `onHolidayRule`, no match without a provider). It writes "eve-of-holiday rates" in a schedule, "open until 2 a.m. on the day before a holiday" (`weekdays: [1, 2, 3, 4, 5]` keeps weekends as usual), or an earlier shipping cutoff on the day before a holiday (put the cutoff time in a schedule and pass `cutoffs.valueOn(date)` as `cutoff`)
205
- - Among recurring rules, the one written first in the array wins, and `openRecurring` wins over `closedRecurring`. The day after Labor Thanksgiving Day on a Monday (2026-11-23) is both the day the Monday rule moves to and the day after a holiday; the rule written first decides, and `dayInfo` shows which
206
-
207
- **Effective periods** (`effectiveFrom`, `effectiveTo`) let one calendar represent a change of rules and still reproduce past dates. They go on recurring rules and on annual closures (`closedAnnual` in object form).
208
-
209
- ```ts
210
- const shop = createCalendar({
211
- holidays: jp,
212
- closedRecurring: [
213
- { weekday: 1, name: "Closed on Mondays", effectiveTo: "2027-03-31" },
214
- { weekday: 2, name: "Closed on Tuesdays", effectiveFrom: "2027-04-01" },
215
- { weekday: 3, interval: 2, anchor: "2026-01-07", name: "Closed every other Wednesday" },
216
- ],
217
- closedAnnual: [{ from: "08-13", to: "08-16", name: "Summer closure", effectiveTo: "2026-12-31" }],
218
- });
219
-
220
- shop.isBusinessDay("2027-03-29"); // false (Monday, before the change)
221
- shop.isBusinessDay("2027-04-06"); // false (Tuesday, after the change)
222
- shop.addBusinessDays("2027-03-30", 3); // "2027-04-03" (counts across the change; March 31 is an every-other-week Wednesday)
223
- shop.isBusinessDay("2027-08-13"); // true (the summer closure ended in 2026)
224
- ```
225
-
226
- - Outside its period a rule is simply not applied; the order of priority does not change. A rule with only `effectiveFrom` or only `effectiveTo` is open-ended on the other side
227
- - An `"after-holiday"` rule is judged by the date it would originally apply to: if that date is within the period, the rule is moved to the next non-holiday even when that day is outside the period
228
- - Changing the closed weekday is written as two weekly rules with periods, as above, rather than with `closedWeekdays` (which has no period)
229
-
230
- **What happens on a holiday** (`onHolidayRule`) is chosen per rule.
231
-
232
- | Value | Meaning | How a store would announce it |
233
- | --------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------- |
234
- | `"under-holiday"` (default) | Under the holiday: follows the holiday handling (`holidayPolicy`) | "Closed on the 3rd Wednesday (open if it is a holiday)" |
235
- | `"over-holiday"` | Over the holiday: applies even on holidays | "Closed on the 3rd Wednesday (holidays included)" |
236
- | `"after-holiday"` | After the holiday: not applied on holidays, moved to the next non-holiday | "Closed on Mondays (on the next weekday if a holiday)" |
237
-
238
- - With `holidayPolicy: "ignore"` (holidays not used), `"under-holiday"` rules also apply on holidays
239
- - The day an `"after-holiday"` rule moves to is the first non-holiday on or after that day. After a run of holidays (such as Golden Week) it is the day after the run. Specific open or closed days and annual closures on that day take priority. `dayInfo()` for a moved day includes the original date as `shiftedFrom`
240
- - The "holidays" used for moving are only those of the holiday provider (such as `jp`), not annual closures
241
- - `dayInfo()` for a day decided by `openDates`, `closedDates`, `closedAnnual`, `openRecurring` or `closedRecurring` (including the day a rule moved to) includes `rulePath`, the position of that rule in the options in the same form as the diagnostics' `path` (such as `"closedRecurring[0]"`), so rules with the same `name` can be told apart. Days decided by holidays or `closedWeekdays` have no `rulePath`
242
-
243
- In a schedule, the same patterns with a `value` go in `recurring`.
244
-
245
- ```ts
246
- const hours = createSchedule<Hours>({
247
- holidays: jp,
248
- weekly: [sunday, weekday, weekday, weekday, weekday, weekday, weekday],
249
- recurring: [
250
- { weekday: 1, value: null, name: "Closed on Mondays", onHolidayRule: "after-holiday" },
251
- { day: -1, value: { open: "10:00", close: "17:00" }, name: "Short hours at month end" },
252
- {
253
- dayBeforeHoliday: true,
254
- value: { open: "10:00", close: "23:00" },
255
- name: "Late on the eve of a holiday",
256
- },
257
- ],
258
- });
259
- hours.valueOn("2026-05-02"); // { open: "10:00", close: "23:00" } (the day before the May 3–6 holidays)
260
- hours.dayInfo("2026-05-04").reason; // "weekday" (a holiday in the run is not the day before a holiday)
261
- ```
262
-
263
- ### Combining calendars
264
-
265
- `combineCalendars` makes one calendar out of several: the days on which **all** of them are open (`businessDay: "all"`), or on which **any** of them is open (`"any"`). The result is an ordinary calendar, so business-day arithmetic, `adjust`, `dueDate` and the rest work on the combination.
266
-
267
- ```ts
268
- import { combineCalendars, createCalendar } from "bizdays-toolkit";
269
- import { jp } from "bizdays-toolkit/jp";
270
-
271
- const company = createCalendar({
272
- holidays: jp,
273
- closedWeekdays: [0, 6],
274
- closedAnnual: [{ from: "12-29", to: "01-03", name: "New Year closure" }],
275
- });
276
- const bank = createCalendar({
277
- holidays: jp,
278
- closedWeekdays: [0, 6],
279
- closedAnnual: [{ from: "12-31", to: "01-03", name: "Bank holiday period" }],
280
- });
281
-
282
- // A remittance is processed only on days when both the company and the bank are open
283
- const remittance = combineCalendars([company, bank], { businessDay: "all" });
284
- remittance.addBusinessDays("2026-12-28", 2); // "2027-01-05"
285
- remittance.dayInfo("2026-12-29");
286
- // { date: "2026-12-29", weekday: 2, businessDay: false, reason: "closed-annual", name: "New Year closure", rulePath: "closedAnnual[0]", decidedBy: 0, decidedByPath: [0] }
287
-
288
- // Any of several stores is open
289
- const anyStore = combineCalendars([company, bank], { businessDay: "any" });
290
- anyStore.isBusinessDay("2026-12-29"); // true
291
- ```
292
-
293
- - `dayInfo` reports `reason`, `name`, `shiftedFrom` and `rulePath` from the first calendar whose result equals the combined result, and the index of that calendar in `decidedBy` (`rulePath` is a position in that calendar's options). With `"all"`, a closed day is explained by the first calendar that is closed, and an open day by calendar 0 (all are open). With `"any"` it is the other way round
294
- - `decidedByPath` is the list of indexes down to the calendar that decided, outermost first, so that `rulePath` can be traced from the result alone when combined calendars are combined again: `[0]` for a plain combination (the same as `decidedBy`), `[0, 1]` for calendar 1 inside calendar 0. `decidedBy` stays the outer index
295
- - `holiday`, `isHoliday`, `holidaysInYear` and so on use the union of the calendars' holiday providers: the same provider object counts once, a wrapped provider (such as `jp` with some kinds filtered out) counts as a different one, and when two providers have a holiday on the same date, the provider of the earlier calendar decides the holiday shown. Combining a calendar with a wrapped `jp` and one with `jp` itself therefore keeps the business-day results but still shows the filtered-out holidays in `holiday`. `outOfRange` is `"throw"` if any calendar throws outside its supported years
296
- - All calendars must share a time zone (`time-zone-conflict` otherwise). Passing fewer than two calendars, or the same calendar twice, is an `invalid-argument`. `maxSearchDays` (default 3660) can be given in the options
297
- - A combined calendar can be combined again. Option diagnostics work on options, not on calendars, so diagnose each calendar's options separately
298
-
299
- Combining options instead would not work: a specific open day (`openDates`) in one set of options would override closures in the other, and wrapping one calendar as a holiday provider of the other would turn its closures into "holidays" (`isHoliday` would be true, and `holidayPolicy: "open"` would open on them).
300
-
301
- ### Common recipes
302
-
303
- **Treat citizens' holidays (days between two holidays) as business days**: wrap the holiday provider and filter by kind.
304
-
305
- ```ts
306
- import { jp } from "bizdays-toolkit/jp";
307
-
308
- const jpWithoutCitizensHoliday = {
309
- ...jp,
310
- holidaysInYear: (year: number) => jp.holidaysInYear(year).filter((h) => h.kind !== "citizens"),
311
- };
312
- const calendar = createCalendar({ holidays: jpWithoutCitizensHoliday, closedWeekdays: [0, 6] });
313
- ```
314
-
315
- **Split special days kept in one list**: dates (`"YYYY-MM-DD"`) go in `dates` and annual dates (`"MM-DD"`) in `annual`.
316
-
317
- ```ts
318
- type Hours = { open: string; close: string } | null;
319
- const regular: Hours = { open: "10:00", close: "19:00" };
320
- const specialDays: { date: string; value: Hours }[] = [
321
- { date: "2026-12-31", value: { open: "10:00", close: "17:00" } },
322
- { date: "01-01", value: null },
323
- ];
324
- const special = createSchedule<Hours>({
325
- weekly: [regular, regular, regular, regular, regular, regular, regular],
326
- dates: specialDays.filter((d) => d.date.length === 10),
327
- annual: specialDays.filter((d) => d.date.length === 5),
328
- });
329
- special.valueOn("2027-01-01"); // null
330
- ```
331
-
332
- **Which business day of the month a date is**: count the business days from the first of the month up to (and excluding) the date, then add one. `businessDaysBetween` excludes `to`, so the date itself is the `+ 1`. This is the inverse of `nthBusinessDayOfMonth`.
333
-
334
- ```ts
335
- import { monthOf } from "bizdays-toolkit";
336
-
337
- const calendar = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
338
- const businessDayIndex = (date: string): number =>
339
- calendar.isBusinessDay(date) ? calendar.businessDaysBetween(`${monthOf(date)}-01`, date) + 1 : 0;
340
- businessDayIndex("2026-10-07"); // 5 (the 5th business day of October)
341
- businessDayIndex("2026-10-03"); // 0 (Saturday: not a business day)
342
- calendar.nthBusinessDayOfMonth("2026-10", 5); // "2026-10-07"
343
- ```
344
-
345
- **The next closed day and why**: there is no `nextClosedDay`, but `daysBetween` returns `dayInfo` for every day, so take the first one that is not a business day.
346
-
347
- ```ts
348
- import { addDays } from "bizdays-toolkit";
349
-
350
- const calendar = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
351
- const nextClosedDay = (date: string) =>
352
- calendar.daysBetween(addDays(date, 1), addDays(date, 60)).find((d) => !d.businessDay);
353
- nextClosedDay("2026-10-05")?.date; // "2026-10-10"
354
- nextClosedDay("2026-10-05")?.reason; // "closed-weekday"
355
- nextClosedDay("2026-10-11")?.reason; // "holiday" (Sports Day, October 12)
356
- ```
357
-
358
- **Business days of Japanese banks**: banks are closed on weekends, holidays and December 31 to January 3, so a bank calendar is three lines. Combine it with your own calendar for a remittance date (see "Combining calendars").
359
-
360
- ```ts
361
- const bank = createCalendar({
362
- holidays: jp,
363
- closedWeekdays: [0, 6],
364
- closedAnnual: [{ from: "12-31", to: "01-03", name: "Bank holiday period" }],
365
- });
366
- bank.isBusinessDay("2026-12-30"); // true
367
- bank.nextBusinessDay("2026-12-30"); // "2027-01-04"
368
- ```
369
-
370
- **Build per-store calendars from shared options**: options are plain objects, so spread and extend them.
371
-
372
- ```ts
373
- const base = {
374
- holidays: jp,
375
- closedWeekdays: [0],
376
- closedAnnual: [{ from: "12-31", to: "01-03" }],
377
- } as const;
378
- const shibuya = createCalendar({ ...base, openDates: ["2026-12-27"] });
379
- const yokohama = createCalendar({ ...base, closedRecurring: [{ weekday: 3, nth: 3 }] });
380
- ```
381
-
382
- ### Month-based due dates and date adjustment
44
+ import { isHoliday, jp } from "bizdays-toolkit/jp";
383
45
 
384
- Dates that fall on closed days are adjusted to business days with the convention you choose. The nth business day of a month and due dates such as "the nth of every month (next business day if closed)" are also available.
385
-
386
- ```ts
387
- import { closingMonth, createCalendar, monthOf } from "bizdays-toolkit";
388
- import { jp } from "bizdays-toolkit/jp";
46
+ isHoliday("2026-11-23"); // true (Labour Thanksgiving Day)
389
47
 
390
48
  const calendar = createCalendar({
391
49
  holidays: jp,
392
50
  closedWeekdays: [0, 6],
393
- closedAnnual: [{ from: "12-31", to: "01-03", name: "New Year closure" }],
394
- });
395
-
396
- calendar.adjust("2026-10-31", { convention: "following" }); // "2026-11-02" (Oct 31 is a Saturday)
397
- calendar.adjust("2026-10-31", { convention: "modified-following" }); // "2026-10-30" (the month would change, so backwards)
398
-
399
- calendar.firstBusinessDayOfMonth("2027-01"); // "2027-01-04"
400
- calendar.lastBusinessDayOfMonth("2026-12"); // "2026-12-30"
401
- calendar.nthBusinessDayOfMonth("2026-10", 5); // "2026-10-07" (-1 is the last, -2 the second to last)
402
-
403
- // Paid on the 10th of every month (next business day if closed)
404
- calendar.dueDate("2026-10", { day: 10, convention: "following" }); // "2026-10-13" (Oct 12 is Sports Day)
405
-
406
- // Closing on the 20th, paid at the end of the next month (previous business day if closed)
407
- const billing = closingMonth("2026-10-21", 20); // "2026-11" (Oct 21 to Nov 20 belongs to November)
408
- calendar.dueDate(monthOf(billing, 1), { day: -1, convention: "preceding" }); // "2026-12-30"
409
- ```
410
-
411
- **Adjustment conventions** (`convention`, required)
412
-
413
- | Convention | On a closed day |
414
- | ---------------------- | -------------------------------------------------------------------- |
415
- | `"following"` | The next business day |
416
- | `"preceding"` | The previous business day |
417
- | `"modified-following"` | The next business day, or the previous one if the month would change |
418
- | `"modified-preceding"` | The previous business day, or the next one if the month would change |
419
-
420
- - On a business day, every convention returns the same day
421
- - Whether the month changes is judged against **the month of the date before adjustment**. For `dueDate` it is the date after mapping to the last day of the month (for example, payment on the 31st in February uses the last day of February, and `"modified-following"` adjusts within February)
422
- - With `modified-…`, if the month has no business day in the opposite direction either (the whole month is closed), the date is adjusted in the original direction even though the month changes
423
- - There is no "nearest business day" (nearest), because the tie-breaking rule for equal distances differs by industry
424
-
425
- **Month helpers** (root functions; business days are not considered)
426
-
427
- - `monthOf(dateOrMonth, offset?)`: the month `offset` months after (before, if negative) a date or a month `"YYYY-MM"`
428
- - `closingMonth(date, closingDay)`: the month a date belongs to when months are closed on `closingDay` (1–31, `-1` for the last day). The day after a closing day up to the next closing day belongs to the month of the next closing day. If the closing day does not exist in a month (closing on the 31st in February), the last day is used
429
- - `closingPeriod(monthOrDate, closingDay)`: the inverse: the period `{ from, to }` (both ends included) that belongs to the month, from the day after the previous closing day to the closing day. Given a date, the period that date belongs to. The result has the shape of a date range, so it can go straight into `closedDates` or `listBusinessDays`
430
-
431
- ```ts
432
- closingPeriod("2026-11", 20); // { from: "2026-10-21", to: "2026-11-20" }
433
- closingPeriod("2026-03", 31); // { from: "2026-03-01", to: "2026-03-31" } (closing on the 31st in February means the 28th)
434
- const { from, to } = closingPeriod("2026-11-05", 20); // the period "2026-11-05" belongs to
435
- const office = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
436
- office.listBusinessDays(from, to).length; // 22 (business days in the November period)
437
- ```
438
-
439
- **Closing days that move to a business day**: when the contract says "closed on the 20th, or the previous business day if the 20th is not one", use the calendar's `closingMonth(date, closingDay, { convention })` and `closingPeriod(monthOrDate, closingDay, { convention })`. Each month's closing day (the last day when it does not exist) is adjusted with the `convention` of `adjust` (required, no default), and a date belongs to the first month whose adjusted closing day is on or after it. The root functions keep closing on calendar days.
440
-
441
- ```ts
442
- const office = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
443
- // September 20, 2026 is a Sunday: with "preceding" the September closing day is Friday the 18th
444
- office.closingMonth("2026-09-19", 20, { convention: "preceding" }); // "2026-10" (the 19th is after the September closing)
445
- closingMonth("2026-09-19", 20); // "2026-09" (closing on calendar days)
446
- office.closingPeriod("2026-10", 20, { convention: "preceding" }); // { from: "2026-09-19", to: "2026-10-20" }
447
- ```
448
-
449
- A month whose closing day is not preceded by any business day since the previous closing day (a long closure for renovation, say) has no period of its own: its days belong to the next month, `closingMonth` never returns that month, and `closingPeriod` for that month throws `no-business-day`. With `"following"` a closing day can move past a long closure into a later month; the period is then longer, not empty.
450
-
451
- The `day` of `dueDate` also maps days that do not exist in a month (such as February 30) to the last day. Recurring rules (`{ day: 31 }`) do not match such days, so the two behave differently: a due date must always fall somewhere in its month.
452
-
453
- ### Day-by-day values (e.g. store hours)
454
-
455
- `createSchedule` returns any value per day from weekday, holiday and specific-date rules. The value type is up to you, so it also fits booking slots, delivery fee classes and more.
456
-
457
- ```ts
458
- import { createSchedule } from "bizdays-toolkit";
459
- import { jp } from "bizdays-toolkit/jp";
460
-
461
- type Hours = { open: string; close: string } | null; // null means closed
462
- const weekday = { open: "10:00", close: "21:00" };
463
- const sunday = { open: "10:00", close: "19:00" };
464
-
465
- const storeHours = createSchedule<Hours>({
466
- holidays: jp,
467
- weekly: [sunday, weekday, null, weekday, weekday, weekday, weekday], // [Sun, Mon, …, Sat]; closed on Tuesdays
468
- holiday: { sameAs: 0 }, // Sunday hours on holidays ("weekday": by weekday / { value: … }: a fixed value)
469
- annual: [{ date: "01-01", value: null, name: "Closed on New Year's Day" }], // annual dates (ranges too)
470
- dates: [{ date: "2026-12-31", value: { open: "10:00", close: "17:00" }, name: "New Year's Eve" }],
471
- });
472
-
473
- storeHours.valueOn("2026-11-03"); // { open: "10:00", close: "19:00" } (Culture Day uses Sunday hours)
474
- storeHours.dayInfo("2027-01-01");
475
- // { date: "2027-01-01", weekday: 5, value: null, reason: "annual", name: "Closed on New Year's Day", holiday: {…}, rulePath: "annual[0]" }
476
-
477
- // Holidays in a period with their hours: filter daysBetween by holiday
478
- storeHours
479
- .daysBetween("2026-11-01", "2026-11-30")
480
- .filter((d) => d.holiday)
481
- .map((d) => d.date); // ["2026-11-03", "2026-11-23"]
482
-
483
- // Holidays not overridden by specific or annual dates (New Year's Day is closed by the annual rule)
484
- storeHours
485
- .daysBetween("2026-12-28", "2027-01-11")
486
- .filter((d) => d.holiday && d.reason !== "date" && d.reason !== "annual")
487
- .map((d) => d.date); // ["2027-01-11"]
488
- ```
489
-
490
- `reason` is the rule that decided the value, and `rulePath` (for `dates`, `annual` and `recurring`) its position in the options. With `holiday: "weekday"` (holidays follow the weekday rule), a holiday has `reason: "weekday"`, so check `holiday` to know whether a day is a holiday. Filtering by `holiday` alone also keeps holidays whose value is set by `dates` or `annual` (such as special hours on New Year's Day). To list only holidays that follow the holiday handling, also exclude `reason` `"date"` and `"annual"`, as in the last example: `holiday` tells whether the day is a holiday, and `reason` tells which rule decided the value.
491
-
492
- Values are decided in the order `dates` (specific dates and ranges) → `annual` (annual dates and ranges) → `recurring` (`"over-holiday"`) → holidays → `recurring` (default and moved days) → `weekly` (weekday). When rules of the same type overlap, the one written first in the array wins.
493
-
494
- Checking a time range such as "is the store open now" is left to your app, because industries differ (opening past midnight, breaks and so on). With `civilOf` you get the civil date and time in the time zone, and the check is short.
495
-
496
- ```ts
497
- const { date, time } = storeHours.civilOf(new Date()); // { date: "2026-10-14", time: "16:30" } (Japan time)
498
- const hours = storeHours.valueOn(date);
499
- const isOpen = hours !== null && hours.open <= time && time < hours.close;
500
- ```
501
-
502
- For inputs that always carry a time (a `Date`, Temporal `Instant`, `ZonedDateTime` or `PlainDateTime`, or a `"YYYY-MM-DDTHH:mm"` string), `time` is typed as `string`, so no `null` check is needed. `civilOf` is also on business-day calendars, and the same conversion is available as `toDateTimeParts(input, timeZone)` in the root. (`toJapanDate` and `toJapanDateTime` in `bizdays-toolkit/jp` are deprecated since 0.10.0: use `toDateString(input, "Asia/Tokyo")`, `toDateTimeParts(input, "Asia/Tokyo")` or `dateOf`/`civilOf` on a calendar instead. They will be removed in 1.0.0.)
503
-
504
- ### A business-day calendar from a schedule
505
-
506
- When the schedule (store hours, say) is the real configuration, `createCalendarFromSchedule(schedule, isOpen)` makes a business-day calendar from it, so you do not write a second set of rules to keep in sync. `isOpen` decides from the value alone whether the day is a business day; the result is an ordinary calendar with business-day arithmetic, `adjust`, `dueDate` and the rest.
507
-
508
- ```ts
509
- import { createCalendarFromSchedule } from "bizdays-toolkit";
510
-
511
- const hours = createSchedule<Hours>({
512
- holidays: jp,
513
- weekly: [null, weekday, weekday, weekday, weekday, weekday, { open: "10:00", close: "17:00" }],
514
- holiday: { sameAs: 0 },
515
- annual: [
516
- { from: "12-31", to: "01-03", value: null, name: "New Year closure" },
517
- { date: "01-04", value: { open: "11:00", close: "15:00" }, name: "First day of the year" },
518
- ],
51
+ closedAnnual: [{ from: "12-29", to: "01-03", name: "New Year closure" }],
519
52
  });
520
- const calendar = createCalendarFromSchedule(hours, (v) => v !== null);
521
- calendar.addBusinessDays("2026-12-29", 2); // "2027-01-04"
522
- calendar.dayInfo("2027-01-04");
523
- // { date: "2027-01-04", weekday: 1, businessDay: true, reason: "open-annual", name: "First day of the year", rulePath: "annual[1]" }
53
+ calendar.isBusinessDay("2026-12-30"); // false
54
+ calendar.addBusinessDays("2026-12-28", 1); // "2027-01-04"
55
+ calendar.businessDayOnOrAfter("2026-12-28T15:30", { cutoff: "14:00" }); // "2027-01-04" (after the cutoff, so the next business day)
56
+ calendar.dueDate("2026-11", { day: 23, convention: "following" }); // "2026-11-24" (the 23rd is a holiday)
524
57
  ```
525
58
 
526
- - `isOpen` must be a pure function of the value: it receives only the value (not the date), and the calendar calls it repeatedly while searching for business days, so a function that looks at the date, the time or outside state would make the result change from call to call. Conditions that depend on the date belong in the schedule's rules (`dates`, `recurring`)
527
- - `dayInfo` maps the schedule's `reason` to the calendar's: `date` → `"open-date"`/`"closed-date"`, `annual` → `"open-annual"`/`"closed-annual"`, `recurring` → `"open-recurring"`/`"closed-recurring"`, `holiday` → `"holiday"` (whether or not the holiday is open, as with `holidayPolicy: "open"`), `weekday` → `"regular"`/`"closed-weekday"`. `"open-annual"` exists only for calendars made this way, since `closedAnnual` of `createCalendar` only closes. `name`, `holiday`, `shiftedFrom` and `rulePath` are the schedule's
528
- - Holidays, the time zone and `outOfRange` are the schedule's. Diagnose the schedule's options with `diagnoseSchedule`; the calendar itself is not diagnosed. `maxSearchDays` (default 3660) can be given in the options
529
- - Use `createCalendar` when you only need open or closed; use this when you have a schedule anyway
530
-
531
- ### Date and date-time inputs
532
-
533
- Every method accepts the following inputs. Returned dates are always `"YYYY-MM-DD"` strings.
534
-
535
- | Input | Treated as |
536
- | ---------------------------------------------------- | ----------------------------------------------------------------- |
537
- | `"2026-11-23"` | A civil date |
538
- | `"2026-11-23T10:00"` | A date and time in the calendar's time zone (wall clock) |
539
- | `"2026-11-22T15:30:00Z"`, `"2026-11-23T00:30+09:00"` | An instant, converted to a civil date in the calendar's time zone |
540
- | `Date` | An instant |
541
- | Temporal `PlainDate`, `PlainDateTime` | A civil date or date and time (no dependency on Temporal) |
542
- | Temporal `Instant`, `ZonedDateTime` | An instant |
543
-
544
- The separator between the date and the time is `T` (upper or lower case), as in the `DateTimeInput` type; a space (`"2026-11-23 10:00"`) is an `invalid-time` error. Temporal objects are recognized by their shape, so the package does not depend on Temporal. CI also checks real Temporal objects in the runtimes that provide Temporal (the CI log shows whether each runtime had it).
59
+ The [guide](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md) explains everything below in full, with examples that are run in CI. This README is the short version.
545
60
 
546
- The calendar's time zone is the `timeZone` option, then the holiday provider's time zone, then `"UTC"`.
61
+ ## What you can do
547
62
 
548
- **From a civil date and time to an instant**: `toInstant(date, time, timeZone)` and `calendar.instantOf(date, time)` (also on schedules) go the other way from `civilOf`: a civil date `"YYYY-MM-DD"` and a time `"HH:mm"` or `"HH:mm:ss"` become a `Date` for that instant in the time zone. Only a date string is accepted (a `Date` would be ambiguous about which day it means), and the result is a `Date`, which is one line from an ISO string (`.toISOString()`) or a Temporal `Instant` (`Temporal.Instant.fromEpochMilliseconds(d.getTime())`).
63
+ ### Holidays of Japan, the UK and Singapore
549
64
 
550
- ```ts
551
- import { toInstant } from "bizdays-toolkit";
552
-
553
- toInstant("2026-11-23", "10:00", "Asia/Tokyo").toISOString(); // "2026-11-23T01:00:00.000Z"
554
- const store = createCalendar({ holidays: jp, closedWeekdays: [0] });
555
- const opensAt = store.instantOf(store.nextBusinessDay(store.today()), "10:00"); // the next opening, as a Date
556
- ```
557
-
558
- Around daylight-saving changes the result follows Temporal's default (`disambiguation: "compatible"`): a time that does not exist (in the UK, 01:00–01:59 on the last Sunday of March) is moved forward by the length of the gap, and a time that occurs twice (01:00–01:59 on the last Sunday of October) is the earlier one.
65
+ | Provider | Subpath | Time zone | Supported years | Verified against |
66
+ | ------------------------------------------------- | -------------------- | ---------------- | --------------- | -------------------------------------- |
67
+ | `jp` | `bizdays-toolkit/jp` | `Asia/Tokyo` | 1955–2099 | Cabinet Office "National Holidays" CSV |
68
+ | `england`, `wales`, `scotland`, `northernIreland` | `bizdays-toolkit/uk` | `Europe/London` | 2012–2099 | GOV.UK bank holidays |
69
+ | `sg` | `bizdays-toolkit/sg` | `Asia/Singapore` | 2020–2027 | data.gov.sg (Ministry of Manpower) |
559
70
 
560
- ```ts
561
- import { england } from "bizdays-toolkit/uk";
562
-
563
- const uk = createCalendar({ holidays: england });
564
- uk.instantOf("2026-03-29", "01:30").toISOString(); // "2026-03-29T01:30:00.000Z" (02:30 BST; 01:30 does not exist)
565
- uk.instantOf("2026-10-25", "01:30").toISOString(); // "2026-10-25T00:30:00.000Z" (the first 01:30, still BST)
566
- ```
71
+ Each holiday carries its date, name (and English name), `kind` (national, substitute, citizens' holiday, bank holiday and so on, by country) and `confirmed` (whether the date is officially fixed; the Japanese equinox days and UK bank holidays after the last published year are predictions). Years after the supported range are an error unless `outOfRange: "ignore"` is set, and `unconfirmedHolidays: "throw"` stops any result that would depend on a predicted holiday. Details: [Bundled holiday providers](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#bundled-holiday-providers), [Unconfirmed holidays](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#unconfirmed-holidays).
567
72
 
568
- ### Holidays of other countries and regions
73
+ ### Business-day calendars and recurring rules
569
74
 
570
- Implement `HolidayProvider` and pass it as `holidays`. Several providers give their union (if their time zones differ, `timeZone` is required).
571
-
572
- ```ts
573
- import { createCalendar, type HolidayProvider } from "bizdays-toolkit";
574
-
575
- const myCountry: HolidayProvider = {
576
- id: "XX",
577
- timeZone: "Europe/Paris",
578
- supportedYears: { from: 2000, to: 2100 },
579
- dataVersion: "2026-10",
580
- holidaysInYear: (year) => [
581
- {
582
- date: `${year}-12-25`,
583
- name: "Noël",
584
- nameEn: "Christmas Day",
585
- kind: "public",
586
- confirmed: true,
587
- },
588
- ],
589
- };
590
- const calendar = createCalendar({ holidays: myCountry, closedWeekdays: [0, 6] });
591
- ```
592
-
593
- Most holidays of other countries are an nth weekday of a month (the US federal holidays) or a fixed number of days from Easter Sunday (much of Europe). `bizdays-toolkit/provider` exposes the calendar arithmetic the bundled providers use for these, so a provider does not have to reimplement and verify it: `nthWeekdayOfMonth(year, month, weekday, n)` (`n` is 1–5 or -1 for the last), `easterSunday(year)` (Gregorian; verified against GOV.UK for the UK providers), `daysInMonth(year, month)` and `isLeapYear(year)`. Combine them with `addDays` and `weekdayOf` from the root. These functions are `@beta` (they may change in a minor version while their use settles).
594
-
595
- ```ts
596
- import { addDays, createCalendar, type HolidayProvider } from "bizdays-toolkit";
597
- import { easterSunday, nthWeekdayOfMonth } from "bizdays-toolkit/provider";
598
-
599
- const bavaria: HolidayProvider<"public"> = {
600
- id: "DE-BY",
601
- timeZone: "Europe/Berlin",
602
- supportedYears: { from: 2000, to: 2100 },
603
- dataVersion: "example",
604
- holidaysInYear: (year) => {
605
- const easter = easterSunday(year);
606
- const day = (date: string, name: string, nameEn: string) => ({
607
- date,
608
- name,
609
- nameEn,
610
- kind: "public" as const,
611
- confirmed: true,
612
- });
613
- return [
614
- day(addDays(easter, -2), "Karfreitag", "Good Friday"),
615
- day(addDays(easter, 1), "Ostermontag", "Easter Monday"),
616
- day(addDays(easter, 60), "Fronleichnam", "Corpus Christi"),
617
- ];
618
- },
619
- };
620
- createCalendar({ holidays: bavaria }).isHoliday("2026-06-04"); // true (Corpus Christi)
621
- nthWeekdayOfMonth(2026, 11, 4, 4); // "2026-11-26" (the 4th Thursday of November: Thanksgiving Day)
622
- nthWeekdayOfMonth(2026, 5, 1, -1); // "2026-05-25" (the last Monday of May: Memorial Day)
623
- ```
624
-
625
- Holidays can carry names in other languages in `names`. The keys are canonical BCP 47 language tags (`"zh-Hant"`, `"ko"` and so on). The bundled Japanese holidays have no `names` (only the officially sourced Japanese and English names are bundled); among the UK providers, only Wales has Welsh names. If you need more, wrap the provider.
626
-
627
- ```ts
628
- import { jp } from "bizdays-toolkit/jp";
629
-
630
- const zhHant: Record<string, string> = { "2026-01-01": "元日" /* … your own translations */ };
631
- const jpWithNames = {
632
- ...jp,
633
- holidaysInYear: (year: number) =>
634
- jp.holidaysInYear(year).map((h) => {
635
- const name = zhHant[h.date];
636
- return name === undefined ? h : { ...h, names: { "zh-Hant": name } };
637
- }),
638
- };
639
- createCalendar({ holidays: jpWithNames }).getHoliday("2026-01-01")?.names; // { "zh-Hant": "元日" }
640
- ```
75
+ A calendar is built from the holiday provider and your closures: `closedWeekdays`, `closedAnnual` (month-day ranges, across the new year too), `closedDates` and `openDates`, and recurring rules in `closedRecurring` / `openRecurring`:
641
76
 
642
- ### Outside the supported years
77
+ | Rule | Meaning |
78
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
79
+ | `{ weekday: 3, nth: 3 }` | The 3rd Wednesday (`nth` is 1–5 or -1 for the last; an array for several) |
80
+ | `{ weekday: 3, interval: 2, anchor: "2026-01-07" }` | Every other Wednesday, counted from the anchor |
81
+ | `{ day: 20 }`, `{ day: -1 }` | The 20th, the last day of the month |
82
+ | `{ holiday: true, kinds: ["substitute"] }` | Holidays, optionally narrowed by kind |
83
+ | `{ dayAfterHoliday: true }`, `{ dayBeforeHoliday: true }` | The day after (or before) a holiday or a run of holidays |
84
+ | `onHolidayRule: "after-holiday"` | "Closed on Mondays, or the next weekday when Monday is a holiday" (museum rule) |
85
+ | `effectiveFrom`, `effectiveTo` | The period the rule is in effect |
643
86
 
644
- Evaluating a date outside the holiday provider's supported years (1955–2099 for Japan, 2012–2099 for the UK, 2020–2027 for Singapore) throws a `RangeError`. To treat such dates as having no holidays, set `outOfRange: "ignore"`.
87
+ `dayInfo` tells you why a day is open or closed (`reason`, `name`, `rulePath`). Details: [Business-day calendars](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#business-day-calendars), [Recurring rules](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#recurring-rules), [Common recipes](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#common-recipes).
645
88
 
646
- ### Unconfirmed holidays
89
+ ### Due dates, schedules and combined calendars
647
90
 
648
- Years after the provider's `confirmedThrough` may contain predicted holidays (`confirmed: false`): the Japanese equinox days, whose dates are published in the Official Gazette every February for the next year, and UK bank holidays after the last year GOV.UK has published. By default the predictions are used. With `unconfirmedHolidays: "throw"` (next to `outOfRange`, on calendars and schedules), any evaluation that looks up an unconfirmed holiday throws a `RangeError` with `code: "unconfirmed-holiday"`, so that a result which would depend on a prediction, such as a due date in a five-year repayment schedule or a date written into a contract, is never produced silently. "Any evaluation" includes the days a search passes through (`addBusinessDays`, `adjust`, `dueDate`, `closingMonth` and so on) and the neighbouring day a `dayAfterHoliday` or `dayBeforeHoliday` rule looks at, not only the day asked about. `holidaysInYear` and `holidaysBetween` never throw, so they are the way out: after catching the error, list the days that are affected.
649
-
650
- ```ts
651
- import { createCalendar, isBizdaysError } from "bizdays-toolkit";
652
- import { jp } from "bizdays-toolkit/jp";
653
-
654
- const strict = createCalendar({
655
- holidays: jp,
656
- closedWeekdays: [0, 6],
657
- unconfirmedHolidays: "throw",
658
- });
659
- try {
660
- strict.dueDate("2028-03", { day: 20, convention: "following" });
661
- } catch (e) {
662
- if (isBizdaysError(e) && e.code === "unconfirmed-holiday") {
663
- // Recompute after the Official Gazette of February 2028; these are the days involved
664
- strict
665
- .holidaysBetween("2028-01-01", "2028-12-31")
666
- .filter((h) => !h.confirmed)
667
- .map((h) => h.date); // ["2028-03-20", "2028-09-22"]
668
- }
669
- }
670
- ```
671
-
672
- - It matters only when the results depend on holidays. With `holidayPolicy: "ignore"` (or `holiday: "weekday"` in a schedule) and no holiday-based rule (`holiday`, `dayAfterHoliday`, `dayBeforeHoliday`, `"after-holiday"`), the results do not change, but `dayInfo` still looks up the holiday it reports and throws. Option diagnostics flag such options as `redundant`
673
- - Combined calendars throw if any of their calendars throws; a calendar made from a schedule follows the schedule
674
- - The option catches holidays that are predicted, not holidays that are unknown: a one-off UK bank holiday announced later is absent from the predictions, and Singapore has no predictions at all (later years are `out-of-range`)
675
-
676
- ### Errors
677
-
678
- Invalid options and inputs throw ordinary exceptions such as `RangeError` and `TypeError`, with a `code`. Messages may change, so branch on `code`.
679
-
680
- ```ts
681
- import { createCalendar, isBizdaysError } from "bizdays-toolkit";
682
-
683
- try {
684
- createCalendar({ closedDates: ["2026-12-32"] });
685
- } catch (error) {
686
- if (isBizdaysError(error) && error.code === "invalid-date") {
687
- // An invalid date (the message includes the location in the options, "closedDates[0].date")
688
- }
689
- }
690
- ```
691
-
692
- Main codes: `invalid-date`, `invalid-input`, `invalid-month`, `invalid-month-day`, `invalid-time`, `invalid-range`, `invalid-weekday`, `invalid-time-zone`, `invalid-option`, `out-of-range`, `invalid-provider`, `invalid-provider-data`, `no-business-day`. Future versions may add codes.
91
+ - `dueDate("2026-11", { day: 23, convention: "following" })`, `adjust`, `closingMonth` and `closingPeriod` compute month-based due dates and closing periods with the `following`, `preceding`, `modified-following` and `modified-preceding` conventions ([Month-based due dates](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#month-based-due-dates-and-date-adjustment))
92
+ - `createSchedule` gives a value per day (store hours, a shipping cutoff, a price band) from the same kinds of rules, and `createCalendarFromSchedule` turns such a schedule into a calendar so the hours are the only configuration ([Day-by-day values](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#day-by-day-values-eg-store-hours), [A calendar from a schedule](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#a-business-day-calendar-from-a-schedule))
93
+ - `combineCalendars` builds the days on which all (or any) of several calendars are open, such as your company and the bank for a remittance date ([Combining calendars](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#combining-calendars))
94
+ - Dates can be given as `"YYYY-MM-DD"`, `"YYYY-MM-DDTHH:mm"`, `Date` or Temporal objects; instants are converted in the calendar's time zone ([Date and date-time inputs](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#date-and-date-time-inputs))
693
95
 
694
96
  ### Option diagnostics
695
97
 
696
- `diagnoseCalendar` and `diagnoseSchedule` from `bizdays-toolkit/diagnose` examine options and return a list of problems. They never throw for a problem in the options (an empty array means no problems); only a wrong second argument (see `asOf` below) throws.
697
-
698
98
  ```ts
699
99
  import { diagnoseCalendar } from "bizdays-toolkit/diagnose";
700
100
  import { jp } from "bizdays-toolkit/jp";
@@ -702,74 +102,25 @@ import { jp } from "bizdays-toolkit/jp";
702
102
  diagnoseCalendar({
703
103
  holidays: jp,
704
104
  closedWeekdays: [0],
705
- closedRecurring: [
706
- { weekday: 3, nth: 3, name: "Closed on the 3rd Wednesday" },
707
- { weekday: 3, nth: 3, months: [8], name: "3rd Wednesday in August" }, // covered by the previous rule
708
- { weekday: 0, name: "Closed on Sundays" }, // same as the closed weekday
709
- ],
710
- closedDates: ["2100-01-05"], // outside the supported years of Japanese holidays (1955–2099)
711
- closeAnnual: ["12-31"], // a typo
712
- });
713
- // [
714
- // { severity: "warning", code: "unknown-option", path: "closeAnnual", message: '… (did you mean "closedAnnual"?)' },
715
- // { severity: "warning", code: "out-of-range", path: "closedDates[0]", relatedPath: "holidays", message: "…" },
716
- // { severity: "warning", code: "shadowed", path: "closedRecurring[1]", relatedPath: "closedRecurring[0]", message: "…" },
717
- // { severity: "info", code: "redundant", path: "closedRecurring[2]", message: "…" },
718
- // ]
105
+ closedRecurring: [{ weekday: 0 }],
106
+ closeAnnual: ["12-31"],
107
+ }).map((d) => [d.severity, d.code, d.path]);
108
+ // [["warning", "unknown-option", "closeAnnual"], ["info", "redundant", "closedRecurring[0]"]]
719
109
  ```
720
110
 
721
- | `severity` | `code` | Meaning |
722
- | ---------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
723
- | `error` | The same codes as exceptions | Errors that make `createCalendar` or `createSchedule` throw. Creation stops at the first one; diagnostics return them all |
724
- | `error` | `invalid-provider-data` | Errors in the data returned by a holiday provider. Creation checks only the years used; diagnostics check every supported year |
725
- | `warning` | `unknown-option` | Unknown keys (ignored at creation). If a close known key exists, `message` suggests it |
726
- | `warning` | `shadowed` | A rule fully covered by an earlier rule of the same type (`relatedPath`) and never used |
727
- | `warning` | `never-matches` | A recurring rule that matches no day (such as `{ day: 30, months: [2] }`, a 5th Wednesday with an effective period that has none, or `dayAfterHoliday` without a holiday provider) |
728
- | `warning` | `out-of-range` | A specific date, or the effective period of a rule, outside the supported years of the holiday provider (`relatedPath`) |
729
- | `warning` | `no-business-day` | No business day at all (calendars only; searching for a business day throws) |
730
- | `info` | `redundant` | A rule whose removal changes nothing (covered by a combination of earlier rules, same as a closed weekday, the same value as the weekday in a schedule, and so on), or `unconfirmedHolidays: "throw"` in options whose results do not use holidays |
731
- | `info` | `expired` | Only with `asOf` (below): a rule or date that ended before the given day |
732
-
733
- - Branch on `code`. `message` is in English and its wording may change. Future versions may add codes
734
- - When there is an `error`, the rule relation checks (`shadowed`, `redundant` and so on) are skipped
735
- - `shadowed` compares only rules of the same type (specific dates, annual dates, recurring rules). The order of evaluation is fixed, so rules unused across types are found as `redundant`. Specific open days (`openDates`) and specific closed days (`closedDates`) are of the same type, and a closed day covered by an earlier open day is `shadowed`
736
- - `shadowed` for recurring rules also considers `onHolidayRule`. For example, an `"under-holiday"` rule does not cover an `"over-holiday"` rule on the same days (the later rule applies on holidays)
737
- - `shadowed` for `dayAfterHoliday` rules is checked only against other `dayAfterHoliday` rules (by `months` and period), because which days they match depend on the holidays of each year; overlap with weekday rules is found as `redundant`
738
- - `shadowed` for rules with effective periods also requires the earlier rule's period to contain the later rule's period, and for `interval` rules the earlier rule's cycle to contain the later one (a weekly rule covers an every-other-week rule; two every-other-week rules with opposite phases do not cover each other)
739
- - `redundant` compares the results with and without the rule day by day. Specific dates are checked over their range; annual dates and recurring rules are checked, with specific dates removed, over the holiday provider's supported years (1955–2099 for Japan), or 2001–2028 without a provider (28 years cover every combination of weekday and date; with `interval` rules the range is extended to the length of their cycle, such as 56 years for every other week). Effective periods outside that range are checked as well (with `outOfRange: "throw"`, only within the supported years). `message` names the years checked. Schedule values are compared by content for arrays and plain objects, and by identity otherwise (such as `Date`)
740
- - With Japanese holidays, checking every day takes a few hundred milliseconds. Use it in tests or at startup, not per request. As a development and test tool, it lives in a subpath so it stays out of your app's main bundle
741
-
742
- **Finding rules that have ended** (`asOf`): options accumulate over the years, for example in a multi-store service that stores each store's options as JSON. Pass `{ asOf: "YYYY-MM-DD" }` as the second argument and every rule with an `effectiveTo` before that day, and every specific date or range that ends before it, is returned as `info` with code `expired` (independent of `redundant`: these rules did affect the past). Without `asOf` nothing is returned as `expired`, so a test such as `assert.deepEqual(diagnoseCalendar(options), [])` does not drift with the date. `asOf` takes only a date string (a `Date` would need a time zone); other values throw `invalid-date`, a second argument that is not an object throws `invalid-argument`, and unknown keys in it are ignored.
111
+ `diagnoseCalendar` and `diagnoseSchedule` return every problem in the options without throwing: typos (with a suggestion), rules that never match, rules shadowed by earlier ones, rules whose removal changes nothing, options with no business day, and, with `{ asOf }`, rules that have ended. `assert.deepEqual(diagnoseCalendar(options), [])` in a test keeps options honest. Details: [Option diagnostics](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#option-diagnostics), [Errors](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md#errors).
743
112
 
744
- ```ts
745
- import { diagnoseCalendar } from "bizdays-toolkit/diagnose";
746
- import { jp } from "bizdays-toolkit/jp";
113
+ ### Other countries and regions
747
114
 
748
- diagnoseCalendar(
749
- {
750
- holidays: jp,
751
- closedRecurring: [
752
- { weekday: 1, name: "Closed on Mondays", effectiveTo: "2027-03-31" },
753
- { weekday: 2, name: "Closed on Tuesdays", effectiveFrom: "2027-04-01" },
754
- ],
755
- closedDates: ["2026-02-10"],
756
- },
757
- { asOf: "2028-01-01" },
758
- ).map((d) => [d.code, d.path]); // [["expired", "closedDates[0]"], ["expired", "closedRecurring[0]"]]
759
- ```
760
-
761
- Keeping options safe with a test:
115
+ A holiday provider is an object with `id`, `timeZone`, `supportedYears` and `holidaysInYear(year)`. `bizdays-toolkit/provider` exposes `nthWeekdayOfMonth` and `easterSunday`, which most countries' holidays are built from. See [providers.md](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/providers.md).
762
116
 
763
- ```ts
764
- import assert from "node:assert/strict";
765
- import { test } from "node:test";
766
- import { diagnoseCalendar } from "bizdays-toolkit/diagnose";
767
- import { shippingOptions } from "../src/calendar.ts";
117
+ ## Documentation
768
118
 
769
- test("the shipping calendar options have no problems", () => {
770
- assert.deepEqual(diagnoseCalendar(shippingOptions), []);
771
- });
772
- ```
119
+ - [Guide](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/guide.md): the full description of every feature, with examples run in CI
120
+ - [Writing a holiday provider](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/providers.md)
121
+ - [Migration guide](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/migration.md) (Japanese) and [CHANGELOG](https://github.com/inohan77/bizdays-toolkit/blob/main/CHANGELOG.md)
122
+ - [Design decisions](https://github.com/inohan77/bizdays-toolkit/blob/main/docs/decisions.md) (Japanese): why things are the way they are
123
+ - [RELEASING.md](https://github.com/inohan77/bizdays-toolkit/blob/main/RELEASING.md) (Japanese): versioning, compatibility promise, data updates and monitoring
773
124
 
774
125
  ## Versioning and data updates
775
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bizdays-toolkit",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Holidays, business-day calendar and day-by-day schedules (e.g. store hours) with time zone support. Japanese holidays verified against the Cabinet Office data; UK bank holidays (England, Wales, Scotland, Northern Ireland) verified against GOV.UK; Singapore public holidays verified against data.gov.sg. / 祝日と営業日のカレンダー(タイムゾーン対応・日本の祝日は内閣府、英国の bank holidays は GOV.UK、シンガポールの祝日は data.gov.sg のデータで全日照合)",
5
5
  "keywords": [
6
6
  "bank-holidays",