bizdays-toolkit 0.0.0 → 0.1.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 +23 -0
- package/LICENSE +21 -0
- package/README.md +160 -1
- package/dist/core/calendar.d.ts +123 -0
- package/dist/core/calendar.js +254 -0
- package/dist/core/date.d.ts +36 -0
- package/dist/core/date.js +87 -0
- package/dist/core/input.d.ts +42 -0
- package/dist/core/input.js +129 -0
- package/dist/core/types.d.ts +39 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/jp/index.d.ts +36 -0
- package/dist/jp/index.js +58 -0
- package/dist/jp/rules.d.ts +36 -0
- package/dist/jp/rules.js +180 -0
- package/package.json +61 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
この形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) に、版の付け方は [Semantic Versioning](https://semver.org/lang/ja/) と [RELEASING.md](./RELEASING.md) の方針に従います。区分には Keep a Changelog の Added / Changed / Deprecated / Removed / Fixed / Security に加えて、祝日データの更新を表す **Data** を使います。
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `createCalendar()`: 祝日・定休日の曜日・毎年の休業日(年をまたぐ期間を含む)・臨時休業日・臨時営業日を組み合わせた営業日カレンダー
|
|
12
|
+
- 営業日の判定と計算: `isBusinessDay`・`dayInfo`(判定の理由付き)・`nextBusinessDay`・`previousBusinessDay`・`businessDayOnOrAfter`(締め時刻 `cutoff`)・`businessDayOnOrBefore`・`addBusinessDays`・`businessDaysBetween`・`listBusinessDays`
|
|
13
|
+
- 祝日の扱い `holidayPolicy`(`closed` / `open` / `ignore`)
|
|
14
|
+
- 日時の入力: `YYYY-MM-DD`・ISO 8601 日時(オフセット付きは瞬間)・`Date`・Temporal 互換オブジェクト。カレンダーのタイムゾーンで暦日に変換
|
|
15
|
+
- 祝日の提供元のインターフェース `HolidayProvider`(他の国・地域を追加できる)
|
|
16
|
+
- `bizdays-toolkit/jp`: 日本の祝日(1955〜2099 年)。国民の祝日・振替休日・国民の休日・皇室行事による休日
|
|
17
|
+
|
|
18
|
+
### Data
|
|
19
|
+
|
|
20
|
+
- 日本: 2026 年 2 月の官報(2027 年の春分・秋分の日)まで反映(`dataVersion: "2026-02"`、`confirmedThrough: 2027`)
|
|
21
|
+
|
|
22
|
+
[Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.1.0...HEAD
|
|
23
|
+
[0.1.0]: https://github.com/inohan77/bizdays-toolkit/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bizdays-toolkit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1 +1,160 @@
|
|
|
1
|
-
|
|
1
|
+
# bizdays-toolkit
|
|
2
|
+
|
|
3
|
+
祝日と、会社・店舗の休業日を組み合わせた営業日カレンダーです。
|
|
4
|
+
|
|
5
|
+
- **日本の祝日を内閣府のデータで全日照合**: 1955 年〜官報公表済みの年まで、内閣府「国民の祝日」CSV のすべての日付と一致することをテストで確かめています(振替休日・国民の休日・皇室行事による休日・五輪特措法による移動を含む)
|
|
6
|
+
- **営業日の計算**: 定休日の曜日・毎年の休業日(年末年始のような年をまたぐ期間も可)・臨時休業日・臨時営業日と祝日を組み合わせ、「N 営業日後」「締め時刻を過ぎたら翌営業日」などを計算します
|
|
7
|
+
- **タイムゾーンを間違えない**: `Date` などの瞬間は、カレンダーのタイムゾーン(日本は `Asia/Tokyo`)で暦日に変換してから判定します。UTC で動くサーバーでも、日本時間 0〜9 時が前日扱いになりません
|
|
8
|
+
- **軽量・依存ゼロ**: minify + gzip で約 3KB(日本の祝日込みで約 5KB)。ESM・型定義同梱・`sideEffects: false` で、Node.js・ブラウザー・Edge ランタイムで動作します
|
|
9
|
+
- **他の国・地域に拡張できる**: 祝日の提供元はインターフェース(`HolidayProvider`)で差し替えられます。現在は日本のみ同梱しています
|
|
10
|
+
|
|
11
|
+
[English](#english)
|
|
12
|
+
|
|
13
|
+
## インストール
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add bizdays-toolkit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Node.js 22.12 以上。ESM のみですが、Node.js 22.12 以降は CommonJS からも `require()` で読み込めます。
|
|
20
|
+
|
|
21
|
+
## 使い方
|
|
22
|
+
|
|
23
|
+
### 祝日を調べる
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { getHoliday, holidaysInYear, isHoliday } from "bizdays-toolkit/jp";
|
|
27
|
+
|
|
28
|
+
isHoliday("2026-11-23"); // true
|
|
29
|
+
getHoliday("2026-09-22");
|
|
30
|
+
// { date: "2026-09-22", name: "国民の休日", nameEn: "Citizens' Holiday", kind: "citizens", confirmed: true }
|
|
31
|
+
holidaysInYear(2027);
|
|
32
|
+
|
|
33
|
+
// 瞬間(Date)は日本時間で判定する
|
|
34
|
+
isHoliday(new Date("2026-11-22T15:30:00Z")); // true(日本時間では 11/23)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`kind` は `national`(国民の祝日)・`substitute`(振替休日)・`citizens`(国民の休日)・`special`(皇室行事などによる休日)のいずれかです。
|
|
38
|
+
|
|
39
|
+
`confirmed` は、その日付が公式に確定しているかを表します。春分・秋分の日は前年 2 月の官報で確定するため、それより先の年は近似式による予測(`confirmed: false`)です。
|
|
40
|
+
|
|
41
|
+
### 営業日カレンダー
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createCalendar } from "bizdays-toolkit";
|
|
45
|
+
import { jp } from "bizdays-toolkit/jp";
|
|
46
|
+
|
|
47
|
+
const shipping = createCalendar({
|
|
48
|
+
holidays: jp,
|
|
49
|
+
closedWeekdays: [0], // 日曜日(0 = 日曜 … 6 = 土曜)
|
|
50
|
+
closedAnnual: [{ from: "12-31", to: "01-03", name: "年末年始" }],
|
|
51
|
+
closedDates: [{ from: "2027-08-12", to: "2027-08-16", name: "夏季休業" }],
|
|
52
|
+
openDates: ["2026-12-27"], // 臨時営業日
|
|
53
|
+
holidayPolicy: "closed", // 祝日は休み("open": 営業 / "ignore": 判定に使わない)
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
shipping.isBusinessDay("2026-11-23"); // false(勤労感謝の日)
|
|
57
|
+
shipping.addBusinessDays("2026-12-30", 1); // "2027-01-04"
|
|
58
|
+
shipping.businessDaysBetween("2026-09-01", "2026-10-01"); // from 以上 to 未満の営業日数
|
|
59
|
+
|
|
60
|
+
// 14 時より前の注文は当日、それ以降は翌営業日に出荷
|
|
61
|
+
shipping.businessDayOnOrAfter(new Date(), { cutoff: "14:00" });
|
|
62
|
+
|
|
63
|
+
shipping.dayInfo("2027-01-01");
|
|
64
|
+
// { date: "2027-01-01", weekday: 5, businessDay: false, reason: "closed-annual", name: "年末年始", holiday: {…} }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
1 日の判定は次の優先順位で行います(上が優先)。
|
|
68
|
+
|
|
69
|
+
1. `openDates`(臨時営業日)
|
|
70
|
+
2. `closedDates`(臨時休業日)
|
|
71
|
+
3. `closedAnnual`(毎年の休業日)
|
|
72
|
+
4. 祝日(`holidayPolicy` に従う。`"open"` は定休日の曜日より優先して営業日にする)
|
|
73
|
+
5. `closedWeekdays`(定休日の曜日)
|
|
74
|
+
6. それ以外は営業日
|
|
75
|
+
|
|
76
|
+
### 日付・日時の入力
|
|
77
|
+
|
|
78
|
+
すべてのメソッドは次の入力を受け付けます。戻り値の日付は常に `"YYYY-MM-DD"` 文字列です。
|
|
79
|
+
|
|
80
|
+
| 入力 | 扱い |
|
|
81
|
+
| ---------------------------------------------------- | ---------------------------------------------- |
|
|
82
|
+
| `"2026-11-23"` | 暦日 |
|
|
83
|
+
| `"2026-11-23T10:00"` | カレンダーのタイムゾーンでの日時(壁時計) |
|
|
84
|
+
| `"2026-11-22T15:30:00Z"`・`"2026-11-23T00:30+09:00"` | 瞬間。カレンダーのタイムゾーンで暦日に変換する |
|
|
85
|
+
| `Date` | 瞬間 |
|
|
86
|
+
| Temporal の `PlainDate`・`PlainDateTime` | 暦日・日時(Temporal への依存はありません) |
|
|
87
|
+
| Temporal の `Instant`・`ZonedDateTime` | 瞬間 |
|
|
88
|
+
|
|
89
|
+
カレンダーのタイムゾーンは、`timeZone` オプション、祝日の提供元のタイムゾーン、`"UTC"` の順に決まります。
|
|
90
|
+
|
|
91
|
+
### 他の国・地域の祝日
|
|
92
|
+
|
|
93
|
+
`HolidayProvider` を実装して `holidays` に渡します。複数渡すと和集合になります(タイムゾーンが異なる場合は `timeZone` の指定が必要です)。
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { createCalendar, type HolidayProvider } from "bizdays-toolkit";
|
|
97
|
+
|
|
98
|
+
const myCountry: HolidayProvider = {
|
|
99
|
+
id: "XX",
|
|
100
|
+
timeZone: "Europe/London",
|
|
101
|
+
supportedYears: { from: 2000, to: 2100 },
|
|
102
|
+
dataVersion: "2026-10",
|
|
103
|
+
holidaysInYear: (year) => [
|
|
104
|
+
{
|
|
105
|
+
date: `${year}-12-25`,
|
|
106
|
+
name: "Christmas Day",
|
|
107
|
+
nameEn: "Christmas Day",
|
|
108
|
+
kind: "public",
|
|
109
|
+
confirmed: true,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
const calendar = createCalendar({ holidays: myCountry, closedWeekdays: [0, 6] });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 対応年の外
|
|
117
|
+
|
|
118
|
+
祝日の提供元の対応年(日本は 1955〜2099 年)を外れた日付を判定すると `RangeError` になります。祝日がない日として扱いたい場合は `outOfRange: "ignore"` を指定してください。
|
|
119
|
+
|
|
120
|
+
## バージョン番号とデータの更新
|
|
121
|
+
|
|
122
|
+
[Semantic Versioning](https://semver.org/lang/ja/) に従います。詳しくは [RELEASING.md](./RELEASING.md) を参照してください。
|
|
123
|
+
|
|
124
|
+
- 法改正・官報公表の反映(祝日の新設・移動、春分・秋分の日の確定)は**マイナー版**です。`^` で指定していれば自動で届きます
|
|
125
|
+
- 祝日の判定結果を固定したい場合は、版を完全一致で指定してください(例: `"bizdays-toolkit": "0.1.0"`)
|
|
126
|
+
- 反映済みのデータは `dataVersion`(日本は反映済みの官報の年月)と `confirmedThrough` で確認できます
|
|
127
|
+
- 1.0.0 までは、マイナー版で破壊的変更を行うことがあります
|
|
128
|
+
|
|
129
|
+
## 出典
|
|
130
|
+
|
|
131
|
+
日本の祝日の検証には、内閣府ホームページで公開されている「国民の祝日」の CSV(<https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv>) を利用しています(テスト用フィクスチャのみ。パッケージには含みません)。
|
|
132
|
+
|
|
133
|
+
## ライセンス
|
|
134
|
+
|
|
135
|
+
MIT
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## English
|
|
140
|
+
|
|
141
|
+
A business-day calendar that combines public holidays with company or store closures.
|
|
142
|
+
|
|
143
|
+
- **Japanese holidays verified day by day** against the Cabinet Office CSV, from 1955 through the latest year officially announced (substitute holidays, citizens' holidays, imperial ceremonies, and the Olympic moves included)
|
|
144
|
+
- **Business-day arithmetic**: closed weekdays, annual closures (including ranges across the new year), specific closed/open dates, holiday policy, `addBusinessDays`, cut-off times, and more
|
|
145
|
+
- **Time-zone safe**: instants (`Date`, ISO strings with offsets, Temporal) are converted to a civil date in the calendar's time zone before evaluation
|
|
146
|
+
- **Lightweight, zero dependencies**: about 3 KB min+gzip (about 5 KB with Japanese holidays), ESM, bundled types, `sideEffects: false`
|
|
147
|
+
- **Extensible** through the `HolidayProvider` interface (only Japan is bundled for now)
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { createCalendar } from "bizdays-toolkit";
|
|
151
|
+
import { jp, isHoliday } from "bizdays-toolkit/jp";
|
|
152
|
+
|
|
153
|
+
isHoliday("2026-11-23"); // true
|
|
154
|
+
|
|
155
|
+
const office = createCalendar({ holidays: jp, closedWeekdays: [0, 6] });
|
|
156
|
+
office.addBusinessDays("2026-09-18", 1); // "2026-09-24"
|
|
157
|
+
office.businessDayOnOrAfter(new Date(), { cutoff: "14:00" });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Versioning follows SemVer. Updates that reflect law amendments or official announcements are released as **minor** versions; pin an exact version if you need frozen results. Before 1.0.0, minor versions may contain breaking changes. See [RELEASING.md](./RELEASING.md) (Japanese) for details.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 祝日と、会社・店舗の休業日を組み合わせた営業日カレンダー。
|
|
3
|
+
*
|
|
4
|
+
* 1 日の判定は次の優先順位で行う(上が優先):
|
|
5
|
+
* 1. openDates … 臨時営業日(個別の日付・期間)
|
|
6
|
+
* 2. closedDates … 臨時休業日(個別の日付・期間)
|
|
7
|
+
* 3. closedAnnual … 毎年の休業日(月日・月日の期間。年をまたぐ期間も可)
|
|
8
|
+
* 4. 祝日 … holidayPolicy に従う(closed: 休業 / open: 営業 / ignore: 判定に使わない)
|
|
9
|
+
* 5. closedWeekdays … 定休日の曜日
|
|
10
|
+
* 6. それ以外は営業日
|
|
11
|
+
*/
|
|
12
|
+
import { type DateString, type Weekday } from "./date.ts";
|
|
13
|
+
import { type DateInput } from "./input.ts";
|
|
14
|
+
import type { Holiday, HolidayProvider } from "./types.ts";
|
|
15
|
+
/** 祝日の扱い */
|
|
16
|
+
export type HolidayPolicy = "closed" | "open" | "ignore";
|
|
17
|
+
/** 毎年の月日 "MM-DD"(名前付きも可) */
|
|
18
|
+
export type AnnualDate = string | {
|
|
19
|
+
readonly date: string;
|
|
20
|
+
readonly name?: string;
|
|
21
|
+
};
|
|
22
|
+
/** 毎年の月日の期間(from > to なら年をまたぐ。例: 12-31 〜 01-03) */
|
|
23
|
+
export interface AnnualRange {
|
|
24
|
+
readonly from: string;
|
|
25
|
+
readonly to: string;
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
}
|
|
28
|
+
/** 個別の日付 "YYYY-MM-DD"(名前付きも可) */
|
|
29
|
+
export type SpecificDate = DateString | {
|
|
30
|
+
readonly date: DateString;
|
|
31
|
+
readonly name?: string;
|
|
32
|
+
};
|
|
33
|
+
/** 個別の日付の期間(両端を含む) */
|
|
34
|
+
export interface DateRange {
|
|
35
|
+
readonly from: DateString;
|
|
36
|
+
readonly to: DateString;
|
|
37
|
+
readonly name?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface CalendarOptions<Kind extends string = string> {
|
|
40
|
+
/** 祝日の提供元(例: jp)。複数渡すと和集合になる */
|
|
41
|
+
readonly holidays?: HolidayProvider<Kind> | readonly HolidayProvider<Kind>[];
|
|
42
|
+
/**
|
|
43
|
+
* 日時の入力(Date・オフセット付き文字列など)を暦日に変換するタイムゾーン。
|
|
44
|
+
* 省略時は祝日の提供元のタイムゾーン、提供元もなければ "UTC"
|
|
45
|
+
*/
|
|
46
|
+
readonly timeZone?: string;
|
|
47
|
+
/** 定休日の曜日(0 = 日曜日) */
|
|
48
|
+
readonly closedWeekdays?: readonly Weekday[];
|
|
49
|
+
/** 毎年の休業日 */
|
|
50
|
+
readonly closedAnnual?: readonly (AnnualDate | AnnualRange)[];
|
|
51
|
+
/** 臨時休業日 */
|
|
52
|
+
readonly closedDates?: readonly (SpecificDate | DateRange)[];
|
|
53
|
+
/** 臨時営業日(ほかのすべての規則より優先) */
|
|
54
|
+
readonly openDates?: readonly (SpecificDate | DateRange)[];
|
|
55
|
+
/** 祝日の扱い(既定: "closed") */
|
|
56
|
+
readonly holidayPolicy?: HolidayPolicy;
|
|
57
|
+
/** 祝日の提供元の対応年を外れた日付の扱い(既定: "throw") */
|
|
58
|
+
readonly outOfRange?: "throw" | "ignore";
|
|
59
|
+
/** 営業日を探すときに調べる最大日数(既定: 3660)。超えたら Error */
|
|
60
|
+
readonly maxSearchDays?: number;
|
|
61
|
+
}
|
|
62
|
+
/** 営業・休業を決めた規則 */
|
|
63
|
+
export type DayReason = "open-date" | "closed-date" | "closed-annual" | "holiday" | "closed-weekday" | "regular";
|
|
64
|
+
/** 1 日分の判定結果 */
|
|
65
|
+
export interface DayInfo<Kind extends string = string> {
|
|
66
|
+
readonly date: DateString;
|
|
67
|
+
readonly weekday: Weekday;
|
|
68
|
+
readonly businessDay: boolean;
|
|
69
|
+
/** 判定を決めた規則 */
|
|
70
|
+
readonly reason: DayReason;
|
|
71
|
+
/** 判定を決めた規則の名前(休業日の名前・祝日名など) */
|
|
72
|
+
readonly name?: string;
|
|
73
|
+
/** その日が祝日なら祝日(holidayPolicy に関係なく付く) */
|
|
74
|
+
readonly holiday?: Holiday<Kind>;
|
|
75
|
+
}
|
|
76
|
+
export interface BusinessDayOptions {
|
|
77
|
+
/**
|
|
78
|
+
* 締め時刻 "HH:mm"(カレンダーのタイムゾーン)。
|
|
79
|
+
* 入力の時刻がこの時刻以降なら、その日は対象にしない。暦日だけの入力は 0 時として扱う
|
|
80
|
+
*/
|
|
81
|
+
readonly cutoff?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface Calendar<Kind extends string = string> {
|
|
84
|
+
/** 日時の入力を暦日に変換するタイムゾーン */
|
|
85
|
+
readonly timeZone: string;
|
|
86
|
+
/** 祝日の提供元 */
|
|
87
|
+
readonly providers: readonly HolidayProvider<Kind>[];
|
|
88
|
+
/** 入力をカレンダーのタイムゾーンでの暦日に変換する */
|
|
89
|
+
dateOf(input: DateInput): DateString;
|
|
90
|
+
/** 今日の日付(カレンダーのタイムゾーン) */
|
|
91
|
+
today(now?: Date): DateString;
|
|
92
|
+
/** 祝日か */
|
|
93
|
+
isHoliday(input: DateInput): boolean;
|
|
94
|
+
/** 祝日なら祝日を返す */
|
|
95
|
+
getHoliday(input: DateInput): Holiday<Kind> | undefined;
|
|
96
|
+
/** その年の祝日 */
|
|
97
|
+
holidaysInYear(year: number): readonly Holiday<Kind>[];
|
|
98
|
+
/** 期間内の祝日(両端を含む) */
|
|
99
|
+
holidaysBetween(from: DateInput, to: DateInput): readonly Holiday<Kind>[];
|
|
100
|
+
/** 1 日分の判定結果(営業日か・理由・祝日) */
|
|
101
|
+
dayInfo(input: DateInput): DayInfo<Kind>;
|
|
102
|
+
/** 営業日か */
|
|
103
|
+
isBusinessDay(input: DateInput): boolean;
|
|
104
|
+
/** 翌営業日(入力日の翌日以降) */
|
|
105
|
+
nextBusinessDay(input: DateInput): DateString;
|
|
106
|
+
/** 前営業日(入力日の前日以前) */
|
|
107
|
+
previousBusinessDay(input: DateInput): DateString;
|
|
108
|
+
/** 入力日が営業日ならその日、そうでなければ翌営業日。cutoff で締め時刻を指定できる */
|
|
109
|
+
businessDayOnOrAfter(input: DateInput, options?: BusinessDayOptions): DateString;
|
|
110
|
+
/** 入力日が営業日ならその日、そうでなければ前営業日 */
|
|
111
|
+
businessDayOnOrBefore(input: DateInput): DateString;
|
|
112
|
+
/**
|
|
113
|
+
* n 営業日後(負なら前)。入力日自身は数えない。
|
|
114
|
+
* n = 0 のときは businessDayOnOrAfter と同じ
|
|
115
|
+
*/
|
|
116
|
+
addBusinessDays(input: DateInput, n: number): DateString;
|
|
117
|
+
/** from 以上 to 未満の営業日の数(to が前なら負) */
|
|
118
|
+
businessDaysBetween(from: DateInput, to: DateInput): number;
|
|
119
|
+
/** 期間内の営業日(両端を含む) */
|
|
120
|
+
listBusinessDays(from: DateInput, to: DateInput): readonly DateString[];
|
|
121
|
+
}
|
|
122
|
+
/** 祝日と休業日の規則から営業日カレンダーを作る */
|
|
123
|
+
export declare function createCalendar<Kind extends string = string>(options?: CalendarOptions<Kind>): Calendar<Kind>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 祝日と、会社・店舗の休業日を組み合わせた営業日カレンダー。
|
|
3
|
+
*
|
|
4
|
+
* 1 日の判定は次の優先順位で行う(上が優先):
|
|
5
|
+
* 1. openDates … 臨時営業日(個別の日付・期間)
|
|
6
|
+
* 2. closedDates … 臨時休業日(個別の日付・期間)
|
|
7
|
+
* 3. closedAnnual … 毎年の休業日(月日・月日の期間。年をまたぐ期間も可)
|
|
8
|
+
* 4. 祝日 … holidayPolicy に従う(closed: 休業 / open: 営業 / ignore: 判定に使わない)
|
|
9
|
+
* 5. closedWeekdays … 定休日の曜日
|
|
10
|
+
* 6. それ以外は営業日
|
|
11
|
+
*/
|
|
12
|
+
import { addDays, fromDayNumber, parseDate, toDayNumber, weekdayOf, } from "./date.js";
|
|
13
|
+
import { instantToCivil, parseTimeOfDay, toCivil, validateTimeZone, } from "./input.js";
|
|
14
|
+
const MONTH_DAY_RE = /^(\d{2})-(\d{2})$/;
|
|
15
|
+
const MAX_DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
16
|
+
function parseMonthDay(value) {
|
|
17
|
+
const m = MONTH_DAY_RE.exec(value);
|
|
18
|
+
const month = Number(m?.[1]);
|
|
19
|
+
const day = Number(m?.[2]);
|
|
20
|
+
if (!m || month < 1 || month > 12 || day < 1 || day > MAX_DAYS_IN_MONTH[month - 1]) {
|
|
21
|
+
throw new RangeError(`Invalid month-day: ${JSON.stringify(value)} (expected MM-DD)`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function compileDates(entries, label) {
|
|
26
|
+
return (entries ?? []).map((e) => {
|
|
27
|
+
if (typeof e === "string") {
|
|
28
|
+
const n = toDayNumber(e);
|
|
29
|
+
return { from: n, to: n, name: undefined };
|
|
30
|
+
}
|
|
31
|
+
if ("date" in e) {
|
|
32
|
+
const n = toDayNumber(e.date);
|
|
33
|
+
return { from: n, to: n, name: e.name };
|
|
34
|
+
}
|
|
35
|
+
const from = toDayNumber(e.from);
|
|
36
|
+
const to = toDayNumber(e.to);
|
|
37
|
+
if (from > to)
|
|
38
|
+
throw new RangeError(`${label}: "from" must not be after "to" (${e.from} > ${e.to})`);
|
|
39
|
+
return { from, to, name: e.name };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function compileAnnual(entries) {
|
|
43
|
+
return (entries ?? []).map((e) => {
|
|
44
|
+
if (typeof e === "string") {
|
|
45
|
+
const md = parseMonthDay(e);
|
|
46
|
+
return { from: md, to: md, name: undefined };
|
|
47
|
+
}
|
|
48
|
+
if ("date" in e) {
|
|
49
|
+
const md = parseMonthDay(e.date);
|
|
50
|
+
return { from: md, to: md, name: e.name };
|
|
51
|
+
}
|
|
52
|
+
return { from: parseMonthDay(e.from), to: parseMonthDay(e.to), name: e.name };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function matchAnnual(rule, monthDay) {
|
|
56
|
+
return rule.from <= rule.to
|
|
57
|
+
? monthDay >= rule.from && monthDay <= rule.to
|
|
58
|
+
: monthDay >= rule.from || monthDay <= rule.to;
|
|
59
|
+
}
|
|
60
|
+
/** 祝日と休業日の規則から営業日カレンダーを作る */
|
|
61
|
+
export function createCalendar(options = {}) {
|
|
62
|
+
const providers = Object.freeze(options.holidays === undefined
|
|
63
|
+
? []
|
|
64
|
+
: Array.isArray(options.holidays)
|
|
65
|
+
? [...options.holidays]
|
|
66
|
+
: [options.holidays]);
|
|
67
|
+
let timeZone;
|
|
68
|
+
if (options.timeZone !== undefined) {
|
|
69
|
+
timeZone = validateTimeZone(options.timeZone);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const zones = [...new Set(providers.map((p) => validateTimeZone(p.timeZone)))];
|
|
73
|
+
if (zones.length > 1) {
|
|
74
|
+
throw new RangeError(`Holiday providers use different time zones (${zones.join(", ")}); specify options.timeZone`);
|
|
75
|
+
}
|
|
76
|
+
timeZone = zones[0] ?? "UTC";
|
|
77
|
+
}
|
|
78
|
+
const closedWeekdays = new Set();
|
|
79
|
+
for (const w of options.closedWeekdays ?? []) {
|
|
80
|
+
if (!Number.isInteger(w) || w < 0 || w > 6)
|
|
81
|
+
throw new RangeError(`Invalid weekday: ${w}`);
|
|
82
|
+
closedWeekdays.add(w);
|
|
83
|
+
}
|
|
84
|
+
const closedAnnual = compileAnnual(options.closedAnnual);
|
|
85
|
+
const closedDates = compileDates(options.closedDates, "closedDates");
|
|
86
|
+
const openDates = compileDates(options.openDates, "openDates");
|
|
87
|
+
const holidayPolicy = options.holidayPolicy ?? "closed";
|
|
88
|
+
if (!["closed", "open", "ignore"].includes(holidayPolicy)) {
|
|
89
|
+
throw new RangeError(`Invalid holidayPolicy: ${String(holidayPolicy)}`);
|
|
90
|
+
}
|
|
91
|
+
const outOfRange = options.outOfRange ?? "throw";
|
|
92
|
+
const maxSearchDays = options.maxSearchDays ?? 3660;
|
|
93
|
+
if (!Number.isInteger(maxSearchDays) || maxSearchDays < 1) {
|
|
94
|
+
throw new RangeError(`Invalid maxSearchDays: ${maxSearchDays}`);
|
|
95
|
+
}
|
|
96
|
+
const yearCache = new Map();
|
|
97
|
+
function providerYears(provider, year) {
|
|
98
|
+
const { from, to } = provider.supportedYears;
|
|
99
|
+
if (year < from || year > to) {
|
|
100
|
+
if (outOfRange === "ignore")
|
|
101
|
+
return [];
|
|
102
|
+
throw new RangeError(`Holidays for ${provider.id} are supported for ${from}-${to}; requested ${year}`);
|
|
103
|
+
}
|
|
104
|
+
return provider.holidaysInYear(year);
|
|
105
|
+
}
|
|
106
|
+
function holidayMap(year) {
|
|
107
|
+
let map = yearCache.get(year);
|
|
108
|
+
if (!map) {
|
|
109
|
+
map = new Map();
|
|
110
|
+
for (const provider of providers) {
|
|
111
|
+
for (const h of providerYears(provider, year)) {
|
|
112
|
+
// 同じ日に複数の提供元の祝日がある場合は、先に渡した提供元を優先する
|
|
113
|
+
if (!map.has(h.date))
|
|
114
|
+
map.set(h.date, h);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
yearCache.set(year, map);
|
|
118
|
+
}
|
|
119
|
+
return map;
|
|
120
|
+
}
|
|
121
|
+
const dateOf = (input) => toCivil(input, timeZone).date;
|
|
122
|
+
function holidayOn(date) {
|
|
123
|
+
if (providers.length === 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
return holidayMap(parseDate(date).year).get(date);
|
|
126
|
+
}
|
|
127
|
+
function findRule(rules, dayNumber) {
|
|
128
|
+
return rules.find((r) => dayNumber >= r.from && dayNumber <= r.to);
|
|
129
|
+
}
|
|
130
|
+
function info(date) {
|
|
131
|
+
const dayNumber = toDayNumber(date);
|
|
132
|
+
const weekday = weekdayOf(date);
|
|
133
|
+
const holiday = holidayOn(date);
|
|
134
|
+
const make = (businessDay, reason, name) => {
|
|
135
|
+
const result = {
|
|
136
|
+
date,
|
|
137
|
+
weekday,
|
|
138
|
+
businessDay,
|
|
139
|
+
reason,
|
|
140
|
+
};
|
|
141
|
+
if (name !== undefined)
|
|
142
|
+
result.name = name;
|
|
143
|
+
if (holiday !== undefined)
|
|
144
|
+
result.holiday = holiday;
|
|
145
|
+
return Object.freeze(result);
|
|
146
|
+
};
|
|
147
|
+
const open = findRule(openDates, dayNumber);
|
|
148
|
+
if (open)
|
|
149
|
+
return make(true, "open-date", open.name);
|
|
150
|
+
const closed = findRule(closedDates, dayNumber);
|
|
151
|
+
if (closed)
|
|
152
|
+
return make(false, "closed-date", closed.name);
|
|
153
|
+
const monthDay = date.slice(5);
|
|
154
|
+
const annual = closedAnnual.find((r) => matchAnnual(r, monthDay));
|
|
155
|
+
if (annual)
|
|
156
|
+
return make(false, "closed-annual", annual.name);
|
|
157
|
+
if (holiday && holidayPolicy !== "ignore") {
|
|
158
|
+
return make(holidayPolicy === "open", "holiday", holiday.name);
|
|
159
|
+
}
|
|
160
|
+
if (closedWeekdays.has(weekday))
|
|
161
|
+
return make(false, "closed-weekday", undefined);
|
|
162
|
+
return make(true, "regular", undefined);
|
|
163
|
+
}
|
|
164
|
+
const isOpen = (date) => info(date).businessDay;
|
|
165
|
+
function search(start, step) {
|
|
166
|
+
let n = toDayNumber(start);
|
|
167
|
+
for (let i = 0; i < maxSearchDays; i++, n += step) {
|
|
168
|
+
const date = fromDayNumber(n);
|
|
169
|
+
if (isOpen(date))
|
|
170
|
+
return date;
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`No business day found within ${maxSearchDays} days from ${start}`);
|
|
173
|
+
}
|
|
174
|
+
function ordered(from, to) {
|
|
175
|
+
const a = dateOf(from);
|
|
176
|
+
const b = dateOf(to);
|
|
177
|
+
if (a > b)
|
|
178
|
+
throw new RangeError(`"from" must not be after "to" (${a} > ${b})`);
|
|
179
|
+
return [a, b];
|
|
180
|
+
}
|
|
181
|
+
function countOpen(from, toExclusive) {
|
|
182
|
+
let count = 0;
|
|
183
|
+
for (let n = from; n < toExclusive; n++)
|
|
184
|
+
if (isOpen(fromDayNumber(n)))
|
|
185
|
+
count++;
|
|
186
|
+
return count;
|
|
187
|
+
}
|
|
188
|
+
const calendar = {
|
|
189
|
+
timeZone,
|
|
190
|
+
providers,
|
|
191
|
+
dateOf,
|
|
192
|
+
today: (now = new Date()) => instantToCivil(now.getTime(), timeZone).date,
|
|
193
|
+
isHoliday: (input) => holidayOn(dateOf(input)) !== undefined,
|
|
194
|
+
getHoliday: (input) => holidayOn(dateOf(input)),
|
|
195
|
+
holidaysInYear(year) {
|
|
196
|
+
if (!Number.isInteger(year))
|
|
197
|
+
throw new RangeError(`Invalid year: ${year}`);
|
|
198
|
+
return Object.freeze([...holidayMap(year).values()].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
199
|
+
},
|
|
200
|
+
holidaysBetween(from, to) {
|
|
201
|
+
const [a, b] = ordered(from, to);
|
|
202
|
+
const result = [];
|
|
203
|
+
for (let y = parseDate(a).year; y <= parseDate(b).year; y++) {
|
|
204
|
+
for (const h of calendar.holidaysInYear(y))
|
|
205
|
+
if (h.date >= a && h.date <= b)
|
|
206
|
+
result.push(h);
|
|
207
|
+
}
|
|
208
|
+
return Object.freeze(result);
|
|
209
|
+
},
|
|
210
|
+
dayInfo: (input) => info(dateOf(input)),
|
|
211
|
+
isBusinessDay: (input) => isOpen(dateOf(input)),
|
|
212
|
+
nextBusinessDay: (input) => search(addDays(dateOf(input), 1), 1),
|
|
213
|
+
previousBusinessDay: (input) => search(addDays(dateOf(input), -1), -1),
|
|
214
|
+
businessDayOnOrAfter(input, opts = {}) {
|
|
215
|
+
const civil = toCivil(input, timeZone);
|
|
216
|
+
let start = civil.date;
|
|
217
|
+
if (opts.cutoff !== undefined) {
|
|
218
|
+
const cutoff = parseTimeOfDay(opts.cutoff);
|
|
219
|
+
if (civil.minutes !== null && civil.minutes >= cutoff)
|
|
220
|
+
start = addDays(start, 1);
|
|
221
|
+
}
|
|
222
|
+
return search(start, 1);
|
|
223
|
+
},
|
|
224
|
+
businessDayOnOrBefore: (input) => search(dateOf(input), -1),
|
|
225
|
+
addBusinessDays(input, n) {
|
|
226
|
+
if (!Number.isInteger(n))
|
|
227
|
+
throw new RangeError(`n must be an integer: ${n}`);
|
|
228
|
+
let date = dateOf(input);
|
|
229
|
+
if (n === 0)
|
|
230
|
+
return search(date, 1);
|
|
231
|
+
const step = n > 0 ? 1 : -1;
|
|
232
|
+
for (let remaining = Math.abs(n); remaining > 0; remaining--) {
|
|
233
|
+
date = search(addDays(date, step), step);
|
|
234
|
+
}
|
|
235
|
+
return date;
|
|
236
|
+
},
|
|
237
|
+
businessDaysBetween(from, to) {
|
|
238
|
+
const a = toDayNumber(dateOf(from));
|
|
239
|
+
const b = toDayNumber(dateOf(to));
|
|
240
|
+
return a <= b ? countOpen(a, b) : -countOpen(b, a);
|
|
241
|
+
},
|
|
242
|
+
listBusinessDays(from, to) {
|
|
243
|
+
const [a, b] = ordered(from, to);
|
|
244
|
+
const result = [];
|
|
245
|
+
for (let n = toDayNumber(a); n <= toDayNumber(b); n++) {
|
|
246
|
+
const date = fromDayNumber(n);
|
|
247
|
+
if (isOpen(date))
|
|
248
|
+
result.push(date);
|
|
249
|
+
}
|
|
250
|
+
return Object.freeze(result);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
return Object.freeze(calendar);
|
|
254
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 暦日(タイムゾーンを持たない日付)の計算。
|
|
3
|
+
*
|
|
4
|
+
* 日付は常に "YYYY-MM-DD" 文字列で扱い、内部では 1970-01-01 からの日数(day number)に
|
|
5
|
+
* 変換して計算する。Date オブジェクトのローカル時刻は使わない。
|
|
6
|
+
*/
|
|
7
|
+
/** 暦日を表す "YYYY-MM-DD" 文字列 */
|
|
8
|
+
export type DateString = string;
|
|
9
|
+
/** 曜日(0 = 日曜日 … 6 = 土曜日。Date#getDay と同じ) */
|
|
10
|
+
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
11
|
+
/** 年月日に分解する。形式または暦として不正なら RangeError */
|
|
12
|
+
export declare function parseDate(value: string): {
|
|
13
|
+
year: number;
|
|
14
|
+
month: number;
|
|
15
|
+
day: number;
|
|
16
|
+
};
|
|
17
|
+
/** "YYYY-MM-DD" として正しい暦日か */
|
|
18
|
+
export declare function isValidDate(value: unknown): value is DateString;
|
|
19
|
+
/** 年月日から "YYYY-MM-DD" を作る(範囲外なら RangeError) */
|
|
20
|
+
export declare function formatDate(year: number, month: number, day: number): DateString;
|
|
21
|
+
/** うるう年か */
|
|
22
|
+
export declare function isLeapYear(year: number): boolean;
|
|
23
|
+
/** その月の日数 */
|
|
24
|
+
export declare function daysInMonth(year: number, month: number): number;
|
|
25
|
+
/** 1970-01-01 からの日数 */
|
|
26
|
+
export declare function toDayNumber(date: DateString): number;
|
|
27
|
+
/** 1970-01-01 からの日数を "YYYY-MM-DD" に戻す */
|
|
28
|
+
export declare function fromDayNumber(dayNumber: number): DateString;
|
|
29
|
+
/** n 日後(負なら前)の日付 */
|
|
30
|
+
export declare function addDays(date: DateString, days: number): DateString;
|
|
31
|
+
/** from から to までの日数(to が後なら正) */
|
|
32
|
+
export declare function diffDays(from: DateString, to: DateString): number;
|
|
33
|
+
/** 曜日(0 = 日曜日) */
|
|
34
|
+
export declare function weekdayOf(date: DateString): Weekday;
|
|
35
|
+
/** その月の第 n 何曜日(n は 1 始まり) */
|
|
36
|
+
export declare function nthWeekdayOfMonth(year: number, month: number, weekday: Weekday, n: number): DateString;
|