bizdays-toolkit 0.1.2 → 0.3.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.
@@ -9,7 +9,10 @@
9
9
  * - Temporal.PlainDate / PlainDateTime / ZonedDateTime / Instant(ダックタイピング。依存は持たない)
10
10
  */
11
11
  import { type DateString } from "./date.ts";
12
- /** Temporal のオブジェクトとして受け付ける最小限の形 */
12
+ /**
13
+ * Temporal.PlainDate / PlainDateTime として受け付ける最小限の形。
14
+ * @public
15
+ */
13
16
  export interface TemporalDateLike {
14
17
  readonly year: number;
15
18
  readonly month: number;
@@ -18,25 +21,42 @@ export interface TemporalDateLike {
18
21
  readonly minute?: number;
19
22
  readonly calendarId?: string;
20
23
  }
21
- /** Temporal.Instant / ZonedDateTime として受け付ける最小限の形 */
24
+ /**
25
+ * Temporal.Instant / ZonedDateTime として受け付ける最小限の形。
26
+ * @public
27
+ */
22
28
  export interface TemporalInstantLike {
23
29
  readonly epochMilliseconds: number;
24
30
  }
25
- /** 日付・日時として受け付ける入力 */
31
+ /**
32
+ * 日付・日時として受け付ける入力。
33
+ * @public
34
+ */
26
35
  export type DateInput = string | Date | TemporalDateLike | TemporalInstantLike;
27
- /** 暦日と、分かる場合は 0 時からの経過分 */
36
+ /** @internal 暦日と、分かる場合は 0 時からの経過分 */
28
37
  export interface CivilDateTime {
29
38
  readonly date: DateString;
30
39
  /** 0 時からの経過分。暦日だけの入力では null */
31
40
  readonly minutes: number | null;
32
41
  }
33
- /** IANA タイムゾーン名として使えるか確かめ、正規化した名前を返す(不正なら RangeError) */
42
+ /** @internal IANA タイムゾーン名として使えるか確かめ、正規化した名前を返す(不正なら RangeError) */
34
43
  export declare function validateTimeZone(timeZone: string): string;
35
- /** 瞬間(エポックミリ秒)を、タイムゾーンでの暦日と時刻に変換する */
44
+ /** @internal 瞬間(エポックミリ秒)を、タイムゾーンでの暦日と時刻に変換する */
36
45
  export declare function instantToCivil(epochMilliseconds: number, timeZone: string): CivilDateTime;
37
- /** 入力をタイムゾーンでの暦日と時刻に変換する */
46
+ /** @internal 入力をタイムゾーンでの暦日と時刻に変換する */
38
47
  export declare function toCivil(input: DateInput, timeZone: string): CivilDateTime;
39
- /** 入力を、タイムゾーンでの暦日 "YYYY-MM-DD" に変換する */
48
+ /**
49
+ * 入力を、タイムゾーンでの暦日 "YYYY-MM-DD" に変換する。
50
+ * @public
51
+ */
40
52
  export declare function toDateString(input: DateInput, timeZone: string): DateString;
41
- /** "HH:mm" を 0 時からの経過分に変換する(00:00〜23:59) */
53
+ /**
54
+ * 入力を、タイムゾーンでの暦日と時刻("HH:mm"。暦日だけの入力では null)に変換する。
55
+ * @public
56
+ */
57
+ export declare function toDateTimeParts(input: DateInput, timeZone: string): {
58
+ readonly date: DateString;
59
+ readonly time: string | null;
60
+ };
61
+ /** @internal "HH:mm" を 0 時からの経過分に変換する(00:00〜23:59) */
42
62
  export declare function parseTimeOfDay(value: string): number;
@@ -9,6 +9,7 @@
9
9
  * - Temporal.PlainDate / PlainDateTime / ZonedDateTime / Instant(ダックタイピング。依存は持たない)
10
10
  */
11
11
  import { formatDate, parseDate } from "./date.js";
12
+ import { rangeError, typeError } from "./errors.js";
12
13
  const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/i;
13
14
  const formatters = new Map();
14
15
  function formatterFor(timeZone) {
@@ -27,24 +28,27 @@ function formatterFor(timeZone) {
27
28
  }
28
29
  return f;
29
30
  }
30
- /** IANA タイムゾーン名として使えるか確かめ、正規化した名前を返す(不正なら RangeError) */
31
+ /** @internal IANA タイムゾーン名として使えるか確かめ、正規化した名前を返す(不正なら RangeError) */
31
32
  export function validateTimeZone(timeZone) {
33
+ if (typeof timeZone !== "string" || timeZone === "") {
34
+ throw rangeError("invalid-time-zone", `Invalid time zone: ${JSON.stringify(timeZone)}`);
35
+ }
32
36
  try {
33
37
  return formatterFor(timeZone).resolvedOptions().timeZone;
34
38
  }
35
39
  catch {
36
- throw new RangeError(`Invalid time zone: ${JSON.stringify(timeZone)}`);
40
+ throw rangeError("invalid-time-zone", `Invalid time zone: ${JSON.stringify(timeZone)}`);
37
41
  }
38
42
  }
39
- /** 瞬間(エポックミリ秒)を、タイムゾーンでの暦日と時刻に変換する */
43
+ /** @internal 瞬間(エポックミリ秒)を、タイムゾーンでの暦日と時刻に変換する */
40
44
  export function instantToCivil(epochMilliseconds, timeZone) {
41
45
  if (!Number.isFinite(epochMilliseconds))
42
- throw new RangeError("Invalid instant");
46
+ throw rangeError("invalid-time", "Invalid instant");
43
47
  const parts = formatterFor(timeZone).formatToParts(new Date(epochMilliseconds));
44
48
  const get = (type) => {
45
49
  const p = parts.find((x) => x.type === type);
46
50
  if (!p)
47
- throw new RangeError(`Cannot resolve ${type} in ${timeZone}`);
51
+ throw rangeError("invalid-time-zone", `Cannot resolve ${type} in ${timeZone}`);
48
52
  return Number(p.value);
49
53
  };
50
54
  return {
@@ -59,7 +63,7 @@ function wallTime(hour, minute) {
59
63
  !Number.isInteger(minute) ||
60
64
  minute < 0 ||
61
65
  minute > 59) {
62
- throw new RangeError(`Invalid time: ${hour}:${minute}`);
66
+ throw rangeError("invalid-time", `Invalid time: ${hour}:${minute}`);
63
67
  }
64
68
  return hour * 60 + minute;
65
69
  }
@@ -70,21 +74,23 @@ function parseString(value, timeZone) {
70
74
  }
71
75
  const m = DATETIME_RE.exec(value);
72
76
  if (!m) {
73
- throw new RangeError(`Invalid date/time string: ${JSON.stringify(value)} (expected YYYY-MM-DD or ISO 8601 date-time)`);
77
+ throw rangeError(/^\d{4}-\d{2}-\d{2}[T ]/i.test(value) ? "invalid-time" : "invalid-date", `Invalid date/time string: ${JSON.stringify(value)} (expected YYYY-MM-DD or ISO 8601 date-time)`);
74
78
  }
75
79
  const date = m[1];
76
80
  parseDate(date);
77
81
  const minutes = wallTime(Number(m[2]), Number(m[3]));
78
- if (m[4] !== undefined && Number(m[4]) > 59)
79
- throw new RangeError(`Invalid time: ${value}`);
82
+ if (m[4] !== undefined && Number(m[4]) > 59) {
83
+ throw rangeError("invalid-time", `Invalid time: ${value}`);
84
+ }
80
85
  const offset = m[5];
81
86
  if (offset === undefined)
82
87
  return { date, minutes };
83
88
  // オフセット付きは瞬間として扱う
84
89
  const normalized = value.replace(" ", "T").replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
85
90
  const ms = Date.parse(normalized);
86
- if (Number.isNaN(ms))
87
- throw new RangeError(`Invalid date/time string: ${JSON.stringify(value)}`);
91
+ if (Number.isNaN(ms)) {
92
+ throw rangeError("invalid-time", `Invalid date/time string: ${JSON.stringify(value)}`);
93
+ }
88
94
  return instantToCivil(ms, timeZone);
89
95
  }
90
96
  function isInstantLike(value) {
@@ -94,18 +100,25 @@ function isDateLike(value) {
94
100
  const v = value;
95
101
  return typeof v.year === "number" && typeof v.month === "number" && typeof v.day === "number";
96
102
  }
97
- /** 入力をタイムゾーンでの暦日と時刻に変換する */
103
+ /**
104
+ * Date か(iframe や vm など、別の実行環境で作られた Date も含む)。
105
+ * instanceof は実行環境ごとに Date が別物になるため使わない
106
+ */
107
+ function isDate(value) {
108
+ return Object.prototype.toString.call(value) === "[object Date]";
109
+ }
110
+ /** @internal 入力をタイムゾーンでの暦日と時刻に変換する */
98
111
  export function toCivil(input, timeZone) {
99
112
  if (typeof input === "string")
100
113
  return parseString(input, timeZone);
101
- if (input instanceof Date)
102
- return instantToCivil(input.getTime(), timeZone);
114
+ if (isDate(input))
115
+ return instantToCivil(Date.prototype.getTime.call(input), timeZone);
103
116
  if (typeof input === "object" && input !== null) {
104
117
  if (isInstantLike(input))
105
118
  return instantToCivil(input.epochMilliseconds, timeZone);
106
119
  if (isDateLike(input)) {
107
120
  if (input.calendarId !== undefined && input.calendarId !== "iso8601") {
108
- throw new RangeError(`Unsupported calendar: ${input.calendarId} (only iso8601)`);
121
+ throw rangeError("invalid-input", `Unsupported calendar: ${input.calendarId} (only iso8601)`);
109
122
  }
110
123
  const date = formatDate(input.year, input.month, input.day);
111
124
  if (typeof input.hour === "number") {
@@ -114,16 +127,31 @@ export function toCivil(input, timeZone) {
114
127
  return { date, minutes: null };
115
128
  }
116
129
  }
117
- throw new TypeError("Unsupported date input: expected a YYYY-MM-DD string, an ISO 8601 date-time string, a Date, or a Temporal object");
130
+ throw typeError("invalid-input", "Unsupported date input: expected a YYYY-MM-DD string, an ISO 8601 date-time string, a Date, or a Temporal object");
118
131
  }
119
- /** 入力を、タイムゾーンでの暦日 "YYYY-MM-DD" に変換する */
132
+ /**
133
+ * 入力を、タイムゾーンでの暦日 "YYYY-MM-DD" に変換する。
134
+ * @public
135
+ */
120
136
  export function toDateString(input, timeZone) {
121
137
  return toCivil(input, validateTimeZone(timeZone)).date;
122
138
  }
123
- /** "HH:mm" を 0 時からの経過分に変換する(00:00〜23:59) */
139
+ /**
140
+ * 入力を、タイムゾーンでの暦日と時刻("HH:mm"。暦日だけの入力では null)に変換する。
141
+ * @public
142
+ */
143
+ export function toDateTimeParts(input, timeZone) {
144
+ const civil = toCivil(input, validateTimeZone(timeZone));
145
+ const time = civil.minutes === null
146
+ ? null
147
+ : `${String(Math.floor(civil.minutes / 60)).padStart(2, "0")}:${String(civil.minutes % 60).padStart(2, "0")}`;
148
+ return Object.freeze({ date: civil.date, time });
149
+ }
150
+ /** @internal "HH:mm" を 0 時からの経過分に変換する(00:00〜23:59) */
124
151
  export function parseTimeOfDay(value) {
125
152
  const m = /^(\d{2}):(\d{2})$/.exec(value);
126
- if (!m)
127
- throw new RangeError(`Invalid time of day: ${JSON.stringify(value)} (expected HH:mm)`);
153
+ if (!m) {
154
+ throw rangeError("invalid-time", `Invalid time of day: ${JSON.stringify(value)} (expected HH:mm)`);
155
+ }
128
156
  return wallTime(Number(m[1]), Number(m[2]));
129
157
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * 日ごとに値を返すスケジュール(営業時間・受付枠など、曜日・祝日・特定日で変わるもの)。
3
+ *
4
+ * 1 日の値は次の優先順位で決まる(上が優先):
5
+ * 1. dates … 個別の日付・期間(配列で先に書いたものが優先)
6
+ * 2. annual … 毎年の月日・月日の期間(年をまたぐ期間も可。配列で先に書いたものが優先)
7
+ * 3. 祝日 … holiday の規則("weekday": 曜日どおり / { sameAs: 曜日 }: その曜日の値 / { value }: 指定の値)
8
+ * 4. weekly … 曜日ごとの値
9
+ *
10
+ * 営業日カレンダー(createCalendar)は、このスケジュールの真偽値版として作られている。
11
+ */
12
+ import { type DateString, type Weekday } from "./date.ts";
13
+ import { IssueCollector } from "./errors.ts";
14
+ import { type DateInput } from "./input.ts";
15
+ import type { Holiday, HolidayProvider } from "./types.ts";
16
+ /**
17
+ * 祝日の値の決め方。
18
+ * @public
19
+ */
20
+ export type HolidayRule<T> =
21
+ /** 祝日を考えず、曜日の値を使う */
22
+ "weekday"
23
+ /** 指定した曜日の値を使う(例: 祝日は日曜日の営業時間) */
24
+ | {
25
+ readonly sameAs: Weekday;
26
+ }
27
+ /** 指定した値を使う */
28
+ | {
29
+ readonly value: T;
30
+ };
31
+ /**
32
+ * 毎年の月日 "MM-DD" の値。
33
+ * @public
34
+ */
35
+ export interface AnnualValue<T> {
36
+ readonly date: string;
37
+ readonly value: T;
38
+ readonly name?: string;
39
+ }
40
+ /**
41
+ * 毎年の月日の期間の値(from > to なら年をまたぐ。例: 12-31 〜 01-03)。
42
+ * @public
43
+ */
44
+ export interface AnnualRangeValue<T> {
45
+ readonly from: string;
46
+ readonly to: string;
47
+ readonly value: T;
48
+ readonly name?: string;
49
+ }
50
+ /**
51
+ * 個別の日付 "YYYY-MM-DD" の値。
52
+ * @public
53
+ */
54
+ export interface DateValue<T> {
55
+ readonly date: DateString;
56
+ readonly value: T;
57
+ readonly name?: string;
58
+ }
59
+ /**
60
+ * 個別の日付の期間(両端を含む)の値。
61
+ * @public
62
+ */
63
+ export interface DateRangeValue<T> {
64
+ readonly from: DateString;
65
+ readonly to: DateString;
66
+ readonly value: T;
67
+ readonly name?: string;
68
+ }
69
+ /**
70
+ * 祝日の提供元とタイムゾーンの設定(スケジュールと営業日カレンダーで共通)。
71
+ * @public
72
+ */
73
+ export interface HolidaySourceOptions<Kind extends string = string> {
74
+ /** 祝日の提供元(例: jp)。複数渡すと和集合になる */
75
+ readonly holidays?: HolidayProvider<Kind> | readonly HolidayProvider<Kind>[];
76
+ /**
77
+ * 日時の入力(Date・オフセット付き文字列など)を暦日に変換するタイムゾーン。
78
+ * 省略時は祝日の提供元のタイムゾーン、提供元もなければ "UTC"
79
+ */
80
+ readonly timeZone?: string;
81
+ /** 祝日の提供元の対応年を外れた日付の扱い(既定: "throw") */
82
+ readonly outOfRange?: "throw" | "ignore";
83
+ }
84
+ /**
85
+ * スケジュールの設定。
86
+ * @public
87
+ */
88
+ export interface ScheduleOptions<T, Kind extends string = string> extends HolidaySourceOptions<Kind> {
89
+ /** 曜日ごとの値([日, 月, 火, 水, 木, 金, 土]) */
90
+ readonly weekly: readonly [T, T, T, T, T, T, T];
91
+ /** 祝日の値の決め方(既定: "weekday") */
92
+ readonly holiday?: HolidayRule<T>;
93
+ /** 毎年の月日の値 */
94
+ readonly annual?: readonly (AnnualValue<T> | AnnualRangeValue<T>)[];
95
+ /** 個別の日付の値 */
96
+ readonly dates?: readonly (DateValue<T> | DateRangeValue<T>)[];
97
+ }
98
+ /**
99
+ * 値を決めた規則(既知の値)。
100
+ * @public
101
+ */
102
+ export type KnownScheduleReason = "date" | "annual" | "holiday" | "weekday";
103
+ /**
104
+ * 値を決めた規則。将来の版で値が増えることがあるので、分岐では「その他」も扱うこと。
105
+ * すべての値を網羅したことを型で確かめたい場合は KnownScheduleReason を使う。
106
+ * @public
107
+ */
108
+ export type ScheduleReason = KnownScheduleReason | (string & {});
109
+ /**
110
+ * 1 日分の値と、その値を決めた規則。
111
+ * @public
112
+ */
113
+ export interface ScheduleDay<T, Kind extends string = string> {
114
+ readonly date: DateString;
115
+ readonly weekday: Weekday;
116
+ readonly value: T;
117
+ /** 値を決めた規則 */
118
+ readonly reason: ScheduleReason;
119
+ /** 値を決めた規則の名前(dates・annual の name、祝日なら祝日名) */
120
+ readonly name?: string;
121
+ /** その日が祝日なら祝日(holiday の規則に関係なく付く) */
122
+ readonly holiday?: Holiday<Kind>;
123
+ }
124
+ /**
125
+ * 暦日と時刻(カレンダーのタイムゾーン)。
126
+ * @public
127
+ */
128
+ export interface CivilDateTimeParts {
129
+ /** 暦日 "YYYY-MM-DD" */
130
+ readonly date: DateString;
131
+ /** 時刻 "HH:mm"。暦日だけの入力では null */
132
+ readonly time: string | null;
133
+ }
134
+ /**
135
+ * 祝日を調べる機能(スケジュールと営業日カレンダーで共通)。
136
+ * @public
137
+ */
138
+ export interface HolidayLookup<Kind extends string = string> {
139
+ /** 日時の入力を暦日に変換するタイムゾーン */
140
+ readonly timeZone: string;
141
+ /** 祝日の提供元 */
142
+ readonly providers: readonly HolidayProvider<Kind>[];
143
+ /** 入力をタイムゾーンでの暦日に変換する */
144
+ dateOf(input: DateInput): DateString;
145
+ /** 入力をタイムゾーンでの暦日と時刻に変換する */
146
+ civilOf(input: DateInput): CivilDateTimeParts;
147
+ /** 今日の日付(タイムゾーン) */
148
+ today(now?: Date): DateString;
149
+ /** 祝日か */
150
+ isHoliday(input: DateInput): boolean;
151
+ /** 祝日なら祝日を返す */
152
+ getHoliday(input: DateInput): Holiday<Kind> | undefined;
153
+ /** その年の祝日 */
154
+ holidaysInYear(year: number): readonly Holiday<Kind>[];
155
+ /** 期間内の祝日(両端を含む) */
156
+ holidaysBetween(from: DateInput, to: DateInput): readonly Holiday<Kind>[];
157
+ }
158
+ /**
159
+ * 日ごとに値を返すスケジュール。
160
+ * @public
161
+ */
162
+ export interface Schedule<T, Kind extends string = string> extends HolidayLookup<Kind> {
163
+ /** その日の値 */
164
+ valueOn(input: DateInput): T;
165
+ /** その日の値と、値を決めた規則 */
166
+ dayInfo(input: DateInput): ScheduleDay<T, Kind>;
167
+ }
168
+ /** @internal "MM-DD" として正しいか(2 月 29 日は可) */
169
+ export declare function isValidMonthDay(value: unknown): value is string;
170
+ /** @internal 曜日の番号として正しいか */
171
+ export declare const isWeekday: (value: unknown) => value is Weekday;
172
+ /** @internal 個別の日付の規則(日数で表した期間) */
173
+ export interface DateRule<T> {
174
+ readonly from: number;
175
+ readonly to: number;
176
+ readonly value: T;
177
+ readonly name: string | undefined;
178
+ }
179
+ /** @internal 毎年の月日の規則 */
180
+ export interface AnnualRule<T> {
181
+ readonly from: string;
182
+ readonly to: string;
183
+ readonly value: T;
184
+ readonly name: string | undefined;
185
+ }
186
+ /**
187
+ * @internal 個別の日付の規則を検証して取り込む。
188
+ * entry は "YYYY-MM-DD"・{ date }・{ from, to } のいずれか。値は valueOf で決める
189
+ */
190
+ export declare function compileDateRules<T>(c: IssueCollector, path: string, entries: unknown, valueOf: (entry: Record<string, unknown>, path: string) => T | undefined, allowString: boolean): DateRule<T>[];
191
+ /** @internal 毎年の月日の規則を検証して取り込む(entry は "MM-DD"・{ date }・{ from, to }) */
192
+ export declare function compileAnnualRules<T>(c: IssueCollector, path: string, entries: unknown, valueOf: (entry: Record<string, unknown>, path: string) => T | undefined, allowString: boolean): AnnualRule<T>[];
193
+ /** @internal 祝日の提供元とタイムゾーンの設定を検証した結果 */
194
+ export interface HolidaySource<Kind extends string> {
195
+ readonly providers: readonly HolidayProvider<Kind>[];
196
+ readonly timeZone: string;
197
+ readonly outOfRange: "throw" | "ignore";
198
+ }
199
+ /** @internal 祝日の提供元とタイムゾーンの設定を検証する */
200
+ export declare function compileHolidaySource<Kind extends string>(c: IssueCollector, options: Record<string, unknown>): HolidaySource<Kind>;
201
+ /** @internal 祝日を調べる機能を作る(設定は検証済みであること) */
202
+ export declare function createHolidayLookup<Kind extends string>(source: HolidaySource<Kind>): HolidayLookup<Kind> & {
203
+ holidayOn(date: DateString): Holiday<Kind> | undefined;
204
+ };
205
+ /** @internal 検証済みのスケジュールの規則 */
206
+ export interface CompiledSchedule<T, Kind extends string> {
207
+ readonly source: HolidaySource<Kind>;
208
+ readonly weekly: readonly T[];
209
+ readonly holiday: HolidayRule<T>;
210
+ readonly annual: readonly AnnualRule<T>[];
211
+ readonly dates: readonly DateRule<T>[];
212
+ }
213
+ /** @internal スケジュールの設定を検証して取り込む(誤りは c に集める) */
214
+ export declare function compileScheduleOptions<T, Kind extends string>(c: IssueCollector, options: unknown): CompiledSchedule<T, Kind> | undefined;
215
+ /** @internal 検証済みの規則からスケジュールを作る */
216
+ export declare function buildSchedule<T, Kind extends string>(compiled: CompiledSchedule<T, Kind>): Schedule<T, Kind>;
217
+ /**
218
+ * 曜日・祝日・特定日の規則から、日ごとに値を返すスケジュールを作る。
219
+ *
220
+ * 設定は作る時点で複製されるので、あとから元の配列やオブジェクトを書き換えても結果は変わらない
221
+ * (値 T そのものは複製しない)。設定の誤りは code 付きの RangeError になる。
222
+ * @public
223
+ */
224
+ export declare function createSchedule<T, Kind extends string = string>(options: ScheduleOptions<T, Kind>): Schedule<T, Kind>;