bizdays-toolkit 0.14.0 → 0.15.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
@@ -4,6 +4,38 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.15.0] - 2026-10-21
8
+
9
+ ### 移行
10
+
11
+ `businessDayOnOrAfter` に `cutoff` を指定するときは、時刻を持つ入力(`Date`・日時の文字列・時刻を持つ Temporal の値)を渡す。時刻のない入力(暦日の文字列・`PlainDate`)との組み合わせは、これまで暦日を 0 時として扱い締め時刻を黙って無視していたが、`invalid-argument` の Error になる。ほかは動作が変わらない(規則の絞り込み・カレンダーのメソッド・型の追加)。版ごとの作業は [docs/migration.md](./docs/migration.md) にまとめた。
12
+
13
+ ### Added
14
+
15
+ - 祝日の規則(`{ holiday: true }`)と祝日の翌日の規則(`{ dayAfterHoliday: true }`)に、曜日の絞り込み `weekdays`(`months`・`kinds` と同じ絞り込みの配列)を追加した。「祝日の翌日は休館(土日に当たる場合は開館)」は `{ dayAfterHoliday: true, weekdays: [1, 2, 3, 4, 5] }`、「土曜の祝日は午前診療」は `{ holiday: true, weekdays: [6], value: … }`。曜日・日の規則に `weekdays` を付けると `invalid-option`。診断の `never-matches`・`shadowed` も曜日の絞り込みを扱う
16
+ - 営業日カレンダーに、営業日を考慮した締め日 `closingMonth(input, closingDay, { convention })`・`closingPeriod(monthOrDate, closingDay, { convention })` を追加した。各月の締め日を `adjust` と同じ `convention`(必須)で営業日に調整し、日付は調整後の締め日がその日以降になる最初の月に属する。前月の締め日から当月の締め日までに営業日がない月(長期休業)は固有の期間を持たず、その日は次の月に属し、その月の `closingPeriod` は `no-business-day`。合成したカレンダー・スケジュールからのカレンダーにも付く。ルートの `closingMonth`・`closingPeriod` は暦日のまま
17
+ - `HolidayPattern`・`RecurringPattern`・`RecurringRule`・`RecurringValue` に祝日の種別の型引数 `Kind`(既定 `string`)を追加し、`kinds` を `readonly Kind[]` にした。`holidays: jp` のカレンダー・スケジュールでは `kinds` に日本の祝日の種別の補完が効く(型は開いているので、ほかの文字列も通る)。型引数なしの利用はこれまでどおり
18
+
19
+ ### Changed
20
+
21
+ - `businessDayOnOrAfter` の `cutoff` を、時刻を持たない入力と組み合わせると `invalid-argument` にした(上の「移行」)
22
+ - README: `businessDaysBetween` が `addBusinessDays` の逆算になるのは起点が営業日のときで、休業日の起点では `addBusinessDays` が翌営業日を 1 と数える(`+0` と `+1` が同じ日)ことを明記した。`weekdays`、営業日を考慮した締め日、`cutoff` の入力の条件、バッジに npm のダウンロード数(直近 18 か月)を足した。サイズの値を測り直した(ルート約 9.7KB・日本の祝日込み約 11.5KB・英国の祝日込み約 11.4KB。0.15.0 で測定)
23
+ - `docs/decisions.md`: 31 番に「起点が営業日のとき」の条件と休業日の起点の定義を、37 番に「別名」と「文脈を持つ経路」の線引きを、30 番に `reason`・`rulePath`・診断の違いを足した。41 番(`weekdays`・営業日を考慮した締め日と空の月の扱い)・42 番(`kinds` の型・`cutoff` の例外)を追加した
24
+
25
+ ### 開発
26
+
27
+ - 性質のテストに、休業日を起点にした `addBusinessDays`(`addBusinessDays(a, n) = addBusinessDays(businessDayOnOrAfter(a), n − 1)`、n ≥ 1)と、営業日を考慮した締め日(複数のカレンダー × 4 つの方法 × 締め日 1〜31・−1 で、空でない期間が敷き詰められ `closingMonth` と往復する)を足した。規則の診断の性質のテストに `weekdays` を加えた
28
+ - サイズの上限(`size-limits.json`)を上げた。ルート 22,000 → 23,000、`./jp` 25,000 → 26,000、`./diagnose` 28,500 → 29,500(gzip)。`weekdays` の検証・判定と営業日を考慮した締め日のため
29
+
30
+ ## [0.14.1] - 2026-10-20
31
+
32
+ ライブラリの結果は 0.14.0 と同じ(内部の改善と文書だけ)。
33
+
34
+ ### 開発
35
+
36
+ - `createCalendar`・`createSchedule` の作成が 0.13.0 で約 30% 遅くなっていた(CI の `bench` で 1.26 倍。手元では 14.0 → 18.5µs)のを直した。原因は、`createCalendarFromSchedule` のためにスケジュールの内部の判定を `WeakMap` に登録していたこと。`Object.freeze` 済みのオブジェクトに後から印(`WeakMap` のキーのハッシュや `defineProperty` の項目)を付けると、V8 がオブジェクトを遅い形に落とす。内部の判定を Symbol の項目としてオブジェクトリテラルに最初から含める形に変え、作成のコストは 0.12.0 の水準(手元で 14.9µs)に戻った。公開 API・結果は変わらない。Symbol の項目は `Object.keys`・`JSON.stringify` に出ない
37
+ - `docs/decisions.md`: 38 番にこの原因と「作ったあとに印を付けず、リテラルに含める」知見を追記した
38
+
7
39
  ## [0.14.0] - 2026-10-20
8
40
 
9
41
  ### 移行
@@ -373,7 +405,9 @@
373
405
 
374
406
  - 日本: 2026 年 2 月の官報(2027 年の春分・秋分の日)まで反映(`dataVersion: "2026-02"`、`confirmedThrough: 2027`)
375
407
 
376
- [Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.14.0...HEAD
408
+ [Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.15.0...HEAD
409
+ [0.15.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.14.1...v0.15.0
410
+ [0.14.1]: https://github.com/inohan77/bizdays-toolkit/compare/v0.14.0...v0.14.1
377
411
  [0.14.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.13.0...v0.14.0
378
412
  [0.13.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.12.0...v0.13.0
379
413
  [0.12.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.11.1...v0.12.0
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  <p align="center">
9
9
  <a href="https://www.npmjs.com/package/bizdays-toolkit"><img src="https://img.shields.io/npm/v/bizdays-toolkit" alt="npm version"></a>
10
+ <a href="https://www.npmjs.com/package/bizdays-toolkit"><img src="https://img.shields.io/npm/d18m/bizdays-toolkit" alt="npm downloads"></a>
10
11
  <a href="https://github.com/inohan77/bizdays-toolkit/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/inohan77/bizdays-toolkit/ci.yml?branch=main&amp;label=CI" alt="CI status"></a>
11
12
  <a href="https://github.com/inohan77/bizdays-toolkit/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/bizdays-toolkit" alt="license"></a>
12
13
  <img src="https://img.shields.io/node/v/bizdays-toolkit" alt="Node.js version">
@@ -28,7 +29,7 @@ A business-day calendar that combines public holidays with company or store clos
28
29
  - **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
29
30
  - **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
30
31
  - **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) before evaluation. A server running in UTC does not treat 00:00–09:00 in Japan as the previous day
31
- - **Lightweight, zero dependencies**: about 9.3 KB min+gzip (about 11.1 KB with Japanese holidays, about 11.1 KB with UK bank holidays; measured for 0.14.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 and Cloudflare Workers
32
+ - **Lightweight, zero dependencies**: about 9.7 KB min+gzip (about 11.5 KB with Japanese holidays, about 11.4 KB with UK bank holidays; measured for 0.15.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 and Cloudflare Workers
32
33
  - **Extensible to other countries and regions**: holiday providers are pluggable through the `HolidayProvider` interface. Japan and the UK are bundled
33
34
 
34
35
  ## Installation
@@ -112,14 +113,14 @@ shipping.addBusinessDays("2026-12-30", 1); // "2027-01-04"
112
113
  shipping.businessDaysBetween("2026-09-01", "2026-10-01"); // business days from (inclusive) to (exclusive)
113
114
 
114
115
  // Orders before 14:00 ship today, later orders on the next business day
115
- shipping.businessDayOnOrAfter(new Date(), { cutoff: "14:00" });
116
+ shipping.businessDayOnOrAfter(new Date(), { cutoff: "14:00" }); // the input must carry a time of day
116
117
 
117
118
  shipping.dayInfo("2027-01-01");
118
119
  // { date: "2027-01-01", weekday: 5, businessDay: false, reason: "closed-annual", name: "New Year closure", holiday: {…}, rulePath: "closedAnnual[0]" }
119
120
  shipping.daysBetween("2026-12-30", "2027-01-04").map((d) => d.businessDay); // [true, false, false, false, false, true]
120
121
  ```
121
122
 
122
- `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 it is the inverse of `addBusinessDays`.
123
+ `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.
123
124
 
124
125
  `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.
125
126
 
@@ -179,8 +180,8 @@ museum.isBusinessDay("2026-05-07"); // false (the day after the May 3–6 run of
179
180
  - **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
180
181
  - **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
181
182
  - **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`
182
- - **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), `months` to those months; omit both 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
183
- - **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
183
+ - **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
184
+ - **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)
184
185
  - 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
185
186
 
186
187
  **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).
@@ -407,6 +408,18 @@ const office = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
407
408
  office.listBusinessDays(from, to).length; // 22 (business days in the November period)
408
409
  ```
409
410
 
411
+ **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.
412
+
413
+ ```ts
414
+ const office = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
415
+ // September 20, 2026 is a Sunday: with "preceding" the September closing day is Friday the 18th
416
+ office.closingMonth("2026-09-19", 20, { convention: "preceding" }); // "2026-10" (the 19th is after the September closing)
417
+ closingMonth("2026-09-19", 20); // "2026-09" (closing on calendar days)
418
+ office.closingPeriod("2026-10", 20, { convention: "preceding" }); // { from: "2026-09-19", to: "2026-10-20" }
419
+ ```
420
+
421
+ 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.
422
+
410
423
  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.
411
424
 
412
425
  ### Day-by-day values (e.g. store hours)
@@ -81,9 +81,9 @@ export interface CalendarOptions<Kind extends string = string> extends HolidaySo
81
81
  * 祝日に当たったときの扱いは規則ごとに onHolidayRule で指定する(既定: "under-holiday")。
82
82
  * 有効期間(effectiveFrom・effectiveTo)を付けると、期間の外では書かれていないのと同じに扱う
83
83
  */
84
- readonly closedRecurring?: readonly RecurringRule[];
84
+ readonly closedRecurring?: readonly RecurringRule<Kind>[];
85
85
  /** 繰り返しの営業日。closedRecurring と同じ日に当たる場合はこちらが優先 */
86
- readonly openRecurring?: readonly RecurringRule[];
86
+ readonly openRecurring?: readonly RecurringRule<Kind>[];
87
87
  /** 祝日の扱い(既定: "closed") */
88
88
  readonly holidayPolicy?: HolidayPolicy;
89
89
  /** 営業日を探すときに調べる最大日数(既定: 3660)。超えたら Error */
@@ -147,7 +147,8 @@ export interface CombineOptions {
147
147
  export interface BusinessDayOptions {
148
148
  /**
149
149
  * 締め時刻 "HH:mm"(カレンダーのタイムゾーン)。
150
- * 入力の時刻がこの時刻以降なら、その日は対象にしない。暦日だけの入力は 0 時として扱う
150
+ * 入力の時刻がこの時刻以降なら、その日は対象にしない。時刻を持たない入力(暦日の文字列・Temporal の PlainDate など)
151
+ * と組み合わせると code: "invalid-argument" の Error(締め時刻が黙って無視されるのを防ぐ。0.15.0 から)
151
152
  */
152
153
  readonly cutoff?: string;
153
154
  }
@@ -195,7 +196,10 @@ export interface Calendar<Kind extends string = string> extends HolidayLookup<Ki
195
196
  nextBusinessDay(input: DateInput): DateString;
196
197
  /** 前営業日(入力日の前日以前) */
197
198
  previousBusinessDay(input: DateInput): DateString;
198
- /** 入力日が営業日ならその日、そうでなければ翌営業日。cutoff で締め時刻を指定できる */
199
+ /**
200
+ * 入力日が営業日ならその日、そうでなければ翌営業日。cutoff で締め時刻を指定できる
201
+ * (cutoff を指定するときの入力は、時刻を持つもの。時刻のない入力と組み合わせると invalid-argument)
202
+ */
199
203
  businessDayOnOrAfter(input: DateInput, options?: BusinessDayOptions): DateString;
200
204
  /** 入力日が営業日ならその日、そうでなければ前営業日 */
201
205
  businessDayOnOrBefore(input: DateInput): DateString;
@@ -227,6 +231,23 @@ export interface Calendar<Kind extends string = string> extends HolidayLookup<Ki
227
231
  * その月にない日は末日に読み替えてから調整する(例: 2 月の 30 日払いは 2 月末日)
228
232
  */
229
233
  dueDate(month: MonthString, options: DueDateOptions): DateString;
234
+ /**
235
+ * 営業日を考慮した締め日で区切ったときに、日付が属する月。
236
+ * 各月の締め日(締め日がその月にない場合は末日)を convention で営業日に調整した日を D(m) とし、
237
+ * 日付が D(m) 以下となる最小の m を返す。前月の締め日から当月の締め日までに営業日がない月(長期休業)は
238
+ * 固有の期間を持たず、その日は次の月に属する
239
+ */
240
+ closingMonth(input: DateInput, closingDay: number, options: AdjustOptions): MonthString;
241
+ /**
242
+ * 営業日を考慮した締め日で区切ったときの、その月分の期間(両端を含む暦日。前月の調整後の締め日の翌日から
243
+ * 当月の調整後の締め日まで)。暦日を渡すと、その日が属する期間(closingMonth で求めた月の期間)。
244
+ * 月を渡して、その月に固有の期間がない(前月の締め日から当月の締め日までに営業日がない)ときは
245
+ * code: "no-business-day" の Error。返す値は変更できず、name は付かない
246
+ */
247
+ closingPeriod(input: DateInput | MonthString, closingDay: number, options: AdjustOptions): {
248
+ readonly from: DateString;
249
+ readonly to: DateString;
250
+ };
230
251
  }
231
252
  interface CompiledCalendar<Kind extends string> {
232
253
  readonly schedule: CompiledSchedule<boolean, Kind>;
@@ -21,7 +21,7 @@
21
21
  import { addDays, daysInMonth, formatDate, fromDayNumber, toDayNumber, } from "./date.js";
22
22
  import { checkUnknownKeys, IssueCollector, plainError, rangeError } from "./errors.js";
23
23
  import { parseTimeOfDay, toCivil } from "./input.js";
24
- import { dayOfMonth, isDayOfMonthValue, parseMonth } from "./month.js";
24
+ import { checkClosingDay, closingDayIn, closingMonth as calendarClosingMonth, dayOfMonth, formatMonth, isDayOfMonthValue, parseMonth, shiftMonth, } from "./month.js";
25
25
  import { buildSchedule, compileAnnualRules, compileDateRules, compileHolidaySource, compileRecurringRules, createHolidayLookup, infoOfSchedule, isWeekday, mapDays, RECURRING_RULE_KEYS, } from "./schedule.js";
26
26
  const CALENDAR_OPTION_KEYS = [
27
27
  "holidays",
@@ -181,6 +181,23 @@ function buildCalendar(lookup, isOpen, info, maxSearchDays) {
181
181
  "modified-following",
182
182
  "modified-preceding",
183
183
  ];
184
+ /** 月 m の、営業日に調整した締め日 D(m)(decisions.md の 41 番) */
185
+ function closingDate(month, closingDay, options) {
186
+ return adjustDate(dayOfMonth(month, closingDayIn(month, closingDay)), options);
187
+ }
188
+ /** 営業日を考慮した締め日で日付が属する月(date ≤ D(m) となる最小の m)。暦日の締めの月の前月から順に調べる */
189
+ function closingMonthOf(date, closingDay, options) {
190
+ checkClosingDay(closingDay);
191
+ let month = parseMonth(calendarClosingMonth(date, closingDay));
192
+ // D は月とともに単調に増える(減らない)ので、暦日の締めの月から、後ろへ(following で前月の締め日が
193
+ // 長期休業を越えて当月以降に来た場合)、前へ(preceding で当月の締め日が date より前に来た場合)順に探す
194
+ while (date <= closingDate(shiftMonth(month, -1), closingDay, options)) {
195
+ month = shiftMonth(month, -1);
196
+ }
197
+ while (date > closingDate(month, closingDay, options))
198
+ month = shiftMonth(month, 1);
199
+ return month;
200
+ }
184
201
  function adjustDate(date, options) {
185
202
  const convention = options?.convention;
186
203
  if (!CONVENTIONS.includes(convention)) {
@@ -265,7 +282,10 @@ function buildCalendar(lookup, isOpen, info, maxSearchDays) {
265
282
  let start = civil.date;
266
283
  if (opts.cutoff !== undefined) {
267
284
  const cutoff = parseTimeOfDay(opts.cutoff);
268
- if (civil.minutes !== null && civil.minutes >= cutoff)
285
+ if (civil.minutes === null) {
286
+ throw rangeError("invalid-argument", "cutoff requires an input with a time of day (a Date, a date-time string or a Temporal value with a time); a date-only input would silently ignore the cutoff");
287
+ }
288
+ if (civil.minutes >= cutoff)
269
289
  start = addDays(start, 1);
270
290
  }
271
291
  return search(start, 1);
@@ -305,6 +325,21 @@ function buildCalendar(lookup, isOpen, info, maxSearchDays) {
305
325
  nthBusinessDayOfMonth: nthOfMonth,
306
326
  firstBusinessDayOfMonth: (month) => nthOfMonth(month, 1),
307
327
  lastBusinessDayOfMonth: (month) => nthOfMonth(month, -1),
328
+ closingMonth: (input, closingDay, options) => formatMonth(closingMonthOf(dateOf(input), closingDay, options)),
329
+ closingPeriod(input, closingDay, options) {
330
+ checkClosingDay(closingDay);
331
+ // "YYYY-MM" だけを月として扱い、それ以外(暦日・日時の文字列・Date・Temporal の値)は dateOf で暦日にする
332
+ const isMonth = typeof input === "string" && /^\d{4}-\d{2}$/.test(input);
333
+ const month = isMonth
334
+ ? parseMonth(input)
335
+ : closingMonthOf(dateOf(input), closingDay, options);
336
+ const to = closingDate(month, closingDay, options);
337
+ const from = addDays(closingDate(shiftMonth(month, -1), closingDay, options), 1);
338
+ if (from > to) {
339
+ throw rangeError("no-business-day", `${formatMonth(month)} has no closing period of its own: there is no business day between the previous closing day and ${to} (its days belong to the next month)`);
340
+ }
341
+ return Object.freeze({ from, to });
342
+ },
308
343
  dueDate(month, options) {
309
344
  const day = options?.day;
310
345
  if (!isDayOfMonthValue(day)) {
@@ -389,21 +389,26 @@ function analyze(c, compiled, isCalendar) {
389
389
  ? source.providers.length === 0
390
390
  ? "This rule matches no day: there are no holidays without a holiday provider"
391
391
  : rule.holiday
392
- ? "This rule matches no day: no holiday of the selected kinds within the selected months and effective period"
393
- : "This rule matches no day: no day after a holiday within its effective period"
392
+ ? "This rule matches no day: no holiday of the selected kinds, weekdays and months within its effective period"
393
+ : "This rule matches no day: no day after a holiday on the selected weekdays and months within its effective period"
394
394
  : rule.effectiveFrom === null && rule.effectiveTo === null
395
395
  ? "This rule matches no day (for example, a day that does not exist in the selected months)"
396
396
  : "This rule matches no day within its effective period");
397
397
  return;
398
398
  }
399
- // 祝日・祝日の翌日の規則は祝日次第で年ごとに変わるので、形の包含は同じ形の規則どうし(種別・月の絞り込み)だけで調べる
399
+ // 祝日・祝日の翌日の規則は祝日次第で年ごとに変わるので、形の包含は同じ形の規則どうし(種別・曜日・月の絞り込み)だけで調べる
400
400
  const k = recurring.findIndex((r, i) => i < j &&
401
401
  canCover(r, rule) &&
402
402
  periodCovers(r, rule) &&
403
403
  (rule.holiday
404
- ? r.holiday && setCovers(r.kinds, rule.kinds) && setCovers(r.months, rule.months)
404
+ ? r.holiday &&
405
+ setCovers(r.kinds, rule.kinds) &&
406
+ setCovers(r.weekdays, rule.weekdays) &&
407
+ setCovers(r.months, rule.months)
405
408
  : rule.dayAfterHoliday
406
- ? r.dayAfterHoliday && setCovers(r.months, rule.months)
409
+ ? r.dayAfterHoliday &&
410
+ setCovers(r.weekdays, rule.weekdays) &&
411
+ setCovers(r.months, rule.months)
407
412
  : !r.holiday &&
408
413
  !r.dayAfterHoliday &&
409
414
  intervalCovers(r, rule) &&
@@ -48,6 +48,10 @@ export declare function monthOf(input: DateString | MonthString, offset?: number
48
48
  * @public
49
49
  */
50
50
  export declare function closingMonth(date: DateString, closingDay: number): MonthString;
51
+ /** @internal 締め日の検証(closingMonth・closingPeriod・営業日カレンダーの締め日で共用) */
52
+ export declare function checkClosingDay(closingDay: number): void;
53
+ /** @internal その月の締め日(日)。-1 またはその月の日数以上なら末日(decisions.md の 16 番の読み替えはここだけで行う) */
54
+ export declare function closingDayIn({ year, month }: YearMonth, closingDay: number): number;
51
55
  /**
52
56
  * 締め日で区切ったときの、その月分の期間(両端を含む暦日)。closingMonth の逆算。
53
57
  *
@@ -71,13 +71,14 @@ export function closingMonth(date, closingDay) {
71
71
  const { year, month, day } = parseDate(date);
72
72
  return formatMonth(shiftMonth({ year, month }, day <= closingDayIn({ year, month }, closingDay) ? 0 : 1));
73
73
  }
74
- function checkClosingDay(closingDay) {
74
+ /** @internal 締め日の検証(closingMonth・closingPeriod・営業日カレンダーの締め日で共用) */
75
+ export function checkClosingDay(closingDay) {
75
76
  if (!isDayOfMonthValue(closingDay)) {
76
77
  throw rangeError("invalid-argument", `closingDay must be an integer from 1 to 31, or -1 (last day): ${closingDay}`);
77
78
  }
78
79
  }
79
- /** その月の締め日(日)。-1 またはその月の日数以上なら末日(decisions.md の 16 番の読み替えはここだけで行う) */
80
- function closingDayIn({ year, month }, closingDay) {
80
+ /** @internal その月の締め日(日)。-1 またはその月の日数以上なら末日(decisions.md の 16 番の読み替えはここだけで行う) */
81
+ export function closingDayIn({ year, month }, closingDay) {
81
82
  const last = daysInMonth(year, month);
82
83
  return closingDay === -1 || closingDay > last ? last : closingDay;
83
84
  }
@@ -91,6 +91,8 @@ export interface WeekdayPattern {
91
91
  readonly day?: never;
92
92
  readonly dayAfterHoliday?: never;
93
93
  readonly holiday?: never;
94
+ readonly weekdays?: never;
95
+ readonly kinds?: never;
94
96
  }
95
97
  /**
96
98
  * 毎月の日の繰り返し(例: 毎月 20 日、毎月末日)。その月にない日(2 月 30 日など)は当てはまらない。
@@ -107,6 +109,8 @@ export interface DayOfMonthPattern {
107
109
  readonly anchor?: never;
108
110
  readonly dayAfterHoliday?: never;
109
111
  readonly holiday?: never;
112
+ readonly weekdays?: never;
113
+ readonly kinds?: never;
110
114
  }
111
115
  /**
112
116
  * 祝日の翌日(祝日が続く場合は連休が明けた日。その日自身は祝日でない)。「祝日」は提供元の祝日だけ。
@@ -115,6 +119,8 @@ export interface DayOfMonthPattern {
115
119
  */
116
120
  export interface DayAfterHolidayPattern {
117
121
  readonly dayAfterHoliday: true;
122
+ /** 当てはめる曜日(翌日の曜日。省略時はすべての曜日。例: [1, 2, 3, 4, 5] で土日に当たる翌日は除く) */
123
+ readonly weekdays?: readonly Weekday[];
118
124
  /** 当てはめる月(翌日の月。省略時はすべての月) */
119
125
  readonly months?: readonly Month[];
120
126
  readonly weekday?: never;
@@ -123,6 +129,7 @@ export interface DayAfterHolidayPattern {
123
129
  readonly anchor?: never;
124
130
  readonly day?: never;
125
131
  readonly holiday?: never;
132
+ readonly kinds?: never;
126
133
  }
127
134
  /**
128
135
  * 祝日(提供元の祝日である日)。kinds で祝日の種別を絞れる(省略時はすべての祝日)。
@@ -131,10 +138,12 @@ export interface DayAfterHolidayPattern {
131
138
  * 当てはまる日は祝日なので、onHolidayRule は指定できない(指定すると invalid-option)。
132
139
  * @public
133
140
  */
134
- export interface HolidayPattern {
141
+ export interface HolidayPattern<Kind extends string = string> {
135
142
  readonly holiday: true;
136
- /** 当てはめる祝日の種別(Holiday.kind。省略時はすべて) */
137
- readonly kinds?: readonly string[];
143
+ /** 当てはめる祝日の種別(Holiday.kind。省略時はすべて)。提供元の種別の型で補完が効く(型は開いているので他の文字列も通る) */
144
+ readonly kinds?: readonly Kind[];
145
+ /** 当てはめる曜日(省略時はすべての曜日。例: [6] で土曜の祝日だけ) */
146
+ readonly weekdays?: readonly Weekday[];
138
147
  /** 当てはめる月(省略時はすべての月) */
139
148
  readonly months?: readonly Month[];
140
149
  readonly weekday?: never;
@@ -148,12 +157,12 @@ export interface HolidayPattern {
148
157
  * 繰り返しのパターン。
149
158
  * @public
150
159
  */
151
- export type RecurringPattern = WeekdayPattern | DayOfMonthPattern | DayAfterHolidayPattern | HolidayPattern;
160
+ export type RecurringPattern<Kind extends string = string> = WeekdayPattern | DayOfMonthPattern | DayAfterHolidayPattern | HolidayPattern<Kind>;
152
161
  /**
153
162
  * 繰り返しの規則(営業日カレンダーの closedRecurring・openRecurring)。
154
163
  * @public
155
164
  */
156
- export type RecurringRule = RecurringPattern & EffectivePeriod & {
165
+ export type RecurringRule<Kind extends string = string> = RecurringPattern<Kind> & EffectivePeriod & {
157
166
  readonly name?: string;
158
167
  /** 祝日に当たったときの扱い(既定: "under-holiday") */
159
168
  readonly onHolidayRule?: OnHolidayRule;
@@ -162,7 +171,7 @@ export type RecurringRule = RecurringPattern & EffectivePeriod & {
162
171
  * 繰り返しの規則の値(スケジュールの recurring)。
163
172
  * @public
164
173
  */
165
- export type RecurringValue<T> = RecurringRule & {
174
+ export type RecurringValue<T, Kind extends string = string> = RecurringRule<Kind> & {
166
175
  readonly value: T;
167
176
  };
168
177
  /**
@@ -228,7 +237,7 @@ export interface ScheduleOptions<T, Kind extends string = string> extends Holida
228
237
  /** 祝日の値の決め方(既定: "weekday") */
229
238
  readonly holiday?: HolidayValueRule<T>;
230
239
  /** 繰り返しの規則(毎週・毎月の第 n 曜日・毎月の日)の値 */
231
- readonly recurring?: readonly RecurringValue<T>[];
240
+ readonly recurring?: readonly RecurringValue<T, Kind>[];
232
241
  /** 毎年の月日の値 */
233
242
  readonly annual?: readonly (AnnualValue<T> | AnnualRangeValue<T>)[];
234
243
  /** 個別の日付の値 */
@@ -370,6 +379,8 @@ export interface RecurringRuleCompiled<T> extends CompiledPeriod {
370
379
  readonly holiday: boolean;
371
380
  /** 祝日の規則で絞る種別(null はすべての祝日) */
372
381
  readonly kinds: ReadonlySet<string> | null;
382
+ /** 祝日・祝日の翌日の規則で絞る曜日(null はすべての曜日) */
383
+ readonly weekdays: ReadonlySet<number> | null;
373
384
  /** 月の集合(null はすべての月) */
374
385
  readonly months: ReadonlySet<number> | null;
375
386
  readonly onHolidayRule: OnHolidayRule;
@@ -309,6 +309,24 @@ export function compileRecurringRules(c, path, entries, valueOf, keys) {
309
309
  c.error("invalid-option", `${p}.kinds`, '"kinds" can be used only with "holiday"');
310
310
  ok = false;
311
311
  }
312
+ let weekdays = null;
313
+ if ("weekdays" in entry) {
314
+ if (!hasHoliday && !hasAfter) {
315
+ c.error("invalid-option", `${p}.weekdays`, '"weekdays" can be used only with "holiday" or "dayAfterHoliday"');
316
+ ok = false;
317
+ }
318
+ else if (!Array.isArray(entry["weekdays"])) {
319
+ c.error("invalid-option", `${p}.weekdays`, "must be a non-empty array of weekdays (0 = Sunday to 6 = Saturday)");
320
+ ok = false;
321
+ }
322
+ else {
323
+ const set = checkSet(c, `${p}.weekdays`, entry["weekdays"], isWeekday, "a weekday (0 = Sunday to 6 = Saturday)");
324
+ if (set)
325
+ weekdays = set;
326
+ else
327
+ ok = false;
328
+ }
329
+ }
312
330
  let months = null;
313
331
  if (entry["months"] !== undefined) {
314
332
  if (!Array.isArray(entry["months"])) {
@@ -341,6 +359,7 @@ export function compileRecurringRules(c, path, entries, valueOf, keys) {
341
359
  dayAfterHoliday: hasAfter,
342
360
  holiday: hasHoliday,
343
361
  kinds,
362
+ weekdays,
344
363
  months,
345
364
  onHolidayRule: onHoliday,
346
365
  value,
@@ -360,14 +379,16 @@ export function matchPattern(rule, date, holidayOf) {
360
379
  const { year, month, day } = parseDate(date);
361
380
  if (rule.months && !rule.months.has(month))
362
381
  return false;
363
- if (rule.holiday) {
364
- const h = holidayOf?.(date);
365
- return h !== undefined && (rule.kinds === null || rule.kinds.has(h.kind));
366
- }
367
- if (rule.dayAfterHoliday) {
368
- return (holidayOf !== undefined &&
369
- holidayOf(date) === undefined &&
370
- holidayOf(addDays(date, -1)) !== undefined);
382
+ if (rule.holiday || rule.dayAfterHoliday) {
383
+ if (holidayOf === undefined)
384
+ return false;
385
+ if (rule.weekdays && !rule.weekdays.has(weekdayOf(date)))
386
+ return false;
387
+ if (rule.holiday) {
388
+ const h = holidayOf(date);
389
+ return h !== undefined && (rule.kinds === null || rule.kinds.has(h.kind));
390
+ }
391
+ return holidayOf(date) === undefined && holidayOf(addDays(date, -1)) !== undefined;
371
392
  }
372
393
  const dim = daysInMonth(year, month);
373
394
  if (rule.weekday !== null) {
@@ -619,6 +640,7 @@ export const RECURRING_RULE_KEYS = [
619
640
  "dayAfterHoliday",
620
641
  "holiday",
621
642
  "kinds",
643
+ "weekdays",
622
644
  "months",
623
645
  "name",
624
646
  "onHolidayRule",
@@ -778,10 +800,10 @@ export function mapDays(from, to, fn) {
778
800
  * @internal スケジュールの内部の判定(暦日の文字列を直接受け取る)。createCalendarFromSchedule が、公開メソッド
779
801
  * (入力の解析を通る)を経由せずに 1 日分の結果を得るために使う。スケジュールごとに作成時に登録する
780
802
  */
781
- const internalInfo = new WeakMap();
803
+ const INTERNAL_INFO = Symbol("bizdays-toolkit.scheduleInfo");
782
804
  /** @internal スケジュールの内部の判定を取り出す(createSchedule が作ったものでなければ undefined) */
783
805
  export function infoOfSchedule(schedule) {
784
- return internalInfo.get(schedule);
806
+ return schedule[INTERNAL_INFO];
785
807
  }
786
808
  /** @internal 検証済みの規則からスケジュールを作る */
787
809
  export function buildSchedule(compiled) {
@@ -789,6 +811,9 @@ export function buildSchedule(compiled) {
789
811
  const resolve = createResolver(compiled, lookup);
790
812
  const info = (date) => resolve(date).day;
791
813
  const schedule = {
814
+ // 内部の判定(createCalendarFromSchedule が使う)。Symbol の項目なので列挙されず、JSON にも出ない。
815
+ // オブジェクトリテラルに含めるのは、あとから defineProperty や WeakMap で付けると作成が数 µs 遅くなるため
816
+ [INTERNAL_INFO]: info,
792
817
  timeZone: lookup.timeZone,
793
818
  providers: lookup.providers,
794
819
  outOfRange: lookup.outOfRange,
@@ -804,9 +829,7 @@ export function buildSchedule(compiled) {
804
829
  dayInfo: (input) => info(lookup.dateOf(input)),
805
830
  daysBetween: (from, to) => mapDays(lookup.dateOf(from), lookup.dateOf(to), info),
806
831
  };
807
- Object.freeze(schedule);
808
- internalInfo.set(schedule, info);
809
- return schedule;
832
+ return Object.freeze(schedule);
810
833
  }
811
834
  /**
812
835
  * 曜日・祝日・特定日の規則から、日ごとに値を返すスケジュールを作る。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bizdays-toolkit",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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. / 祝日と営業日のカレンダー(タイムゾーン対応・日本の祝日は内閣府、英国の bank holidays は GOV.UK のデータで全日照合)",
5
5
  "keywords": [
6
6
  "bank-holidays",