@catlabtech/mycal-core 0.1.2 → 0.1.3
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/dist/business-days.d.ts +7 -0
- package/dist/business-days.js +106 -0
- package/dist/filter.d.ts +18 -0
- package/dist/filter.js +75 -0
- package/dist/hijri.d.ts +36 -0
- package/dist/hijri.js +59 -0
- package/dist/ical.d.ts +2 -0
- package/dist/ical.js +90 -0
- package/dist/index.d.ts +14 -892
- package/dist/index.js +22 -790
- package/dist/long-weekend.d.ts +18 -0
- package/dist/long-weekend.js +127 -0
- package/dist/replacement.d.ts +2 -0
- package/dist/replacement.js +69 -0
- package/dist/schemas.d.ts +652 -0
- package/dist/schemas.js +136 -0
- package/dist/school.d.ts +10 -0
- package/dist/school.js +44 -0
- package/dist/state-resolver.d.ts +4 -0
- package/dist/state-resolver.js +18 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +2 -0
- package/dist/validation.d.ts +21 -0
- package/dist/validation.js +94 -0
- package/dist/weekend.d.ts +8 -0
- package/dist/weekend.js +57 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,790 +1,22 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
var gazetteLevelSchema = z.enum(["P", "N"]);
|
|
24
|
-
var holidayCategorySchema = z.enum([
|
|
25
|
-
"public",
|
|
26
|
-
"bank",
|
|
27
|
-
"optional",
|
|
28
|
-
"half_day",
|
|
29
|
-
"observance"
|
|
30
|
-
]);
|
|
31
|
-
var holidaySourceSchema = z.enum([
|
|
32
|
-
"jpm",
|
|
33
|
-
"jakim",
|
|
34
|
-
"state-gov",
|
|
35
|
-
"community",
|
|
36
|
-
"admin"
|
|
37
|
-
]);
|
|
38
|
-
var holidaySchema = z.object({
|
|
39
|
-
id: z.string().min(1),
|
|
40
|
-
date: isoDate,
|
|
41
|
-
endDate: isoDate.optional(),
|
|
42
|
-
name: localizedString,
|
|
43
|
-
type: holidayTypeSchema,
|
|
44
|
-
status: holidayStatusSchema,
|
|
45
|
-
states: z.array(z.string().min(1)).min(1),
|
|
46
|
-
isPublicHoliday: z.boolean(),
|
|
47
|
-
gazetteLevel: gazetteLevelSchema,
|
|
48
|
-
isReplacementFor: z.string().optional(),
|
|
49
|
-
hijriDate: z.string().optional(),
|
|
50
|
-
gazetteRef: z.string().optional(),
|
|
51
|
-
source: holidaySourceSchema,
|
|
52
|
-
category: holidayCategorySchema.optional(),
|
|
53
|
-
isEstimated: z.boolean().optional(),
|
|
54
|
-
confirmedAt: z.string().optional(),
|
|
55
|
-
createdAt: z.string(),
|
|
56
|
-
updatedAt: z.string()
|
|
57
|
-
});
|
|
58
|
-
var holidayFileSchema = z.array(holidaySchema);
|
|
59
|
-
var stateGroupSchema = z.enum(["A", "B"]);
|
|
60
|
-
var weekendConfigSchema = z.object({
|
|
61
|
-
from: isoDate,
|
|
62
|
-
to: isoDate.nullable(),
|
|
63
|
-
weekendDays: z.array(z.number().int().min(0).max(6)),
|
|
64
|
-
group: stateGroupSchema
|
|
65
|
-
});
|
|
66
|
-
var stateSchema = z.object({
|
|
67
|
-
code: z.string().min(1),
|
|
68
|
-
aliases: z.array(z.string()),
|
|
69
|
-
name: localizedString,
|
|
70
|
-
type: z.enum(["state", "federal_territory"]),
|
|
71
|
-
weekendHistory: z.array(weekendConfigSchema).min(1),
|
|
72
|
-
group: stateGroupSchema,
|
|
73
|
-
isoCode: z.string().optional()
|
|
74
|
-
});
|
|
75
|
-
var statesFileSchema = z.array(stateSchema);
|
|
76
|
-
var schoolTermSegmentSchema = z.object({
|
|
77
|
-
startDate: isoDate,
|
|
78
|
-
endDate: isoDate,
|
|
79
|
-
schoolDays: z.number().int().min(0)
|
|
80
|
-
});
|
|
81
|
-
var schoolTermSchema = z.object({
|
|
82
|
-
id: z.string().min(1),
|
|
83
|
-
year: z.number().int().min(2020).max(2100),
|
|
84
|
-
term: z.union([z.literal(1), z.literal(2)]),
|
|
85
|
-
group: stateGroupSchema,
|
|
86
|
-
segments: z.array(schoolTermSegmentSchema).min(1),
|
|
87
|
-
totalSchoolDays: z.number().int().min(0),
|
|
88
|
-
startDate: isoDate,
|
|
89
|
-
endDate: isoDate
|
|
90
|
-
});
|
|
91
|
-
var schoolHolidayTypeSchema = z.enum([
|
|
92
|
-
"cuti_penggal_1",
|
|
93
|
-
"cuti_pertengahan",
|
|
94
|
-
"cuti_penggal_2",
|
|
95
|
-
"cuti_akhir",
|
|
96
|
-
"cuti_perayaan_kpm"
|
|
97
|
-
]);
|
|
98
|
-
var schoolHolidaySchema = z.object({
|
|
99
|
-
id: z.string().min(1),
|
|
100
|
-
year: z.number().int().min(2020).max(2100),
|
|
101
|
-
group: stateGroupSchema,
|
|
102
|
-
type: schoolHolidayTypeSchema,
|
|
103
|
-
name: localizedString,
|
|
104
|
-
startDate: isoDate,
|
|
105
|
-
endDate: isoDate,
|
|
106
|
-
days: z.number().int().min(1),
|
|
107
|
-
states: z.array(z.string()).optional(),
|
|
108
|
-
excludeStates: z.array(z.string()).optional(),
|
|
109
|
-
remarks: z.string().optional()
|
|
110
|
-
});
|
|
111
|
-
var schoolTermsFileSchema = z.array(schoolTermSchema);
|
|
112
|
-
var schoolHolidaysFileSchema = z.array(schoolHolidaySchema);
|
|
113
|
-
var examTypeSchema = z.enum([
|
|
114
|
-
"spm",
|
|
115
|
-
"stpm",
|
|
116
|
-
"muet",
|
|
117
|
-
"pt3",
|
|
118
|
-
"stam",
|
|
119
|
-
"other"
|
|
120
|
-
]);
|
|
121
|
-
var examSchema = z.object({
|
|
122
|
-
id: z.string().min(1),
|
|
123
|
-
year: z.number().int().min(2020).max(2100),
|
|
124
|
-
name: z.string().min(1),
|
|
125
|
-
fullName: localizedString,
|
|
126
|
-
type: examTypeSchema,
|
|
127
|
-
startDate: isoDate,
|
|
128
|
-
endDate: isoDate.optional(),
|
|
129
|
-
status: z.enum(["confirmed", "tentative"]),
|
|
130
|
-
resultsDate: isoDate.optional(),
|
|
131
|
-
source: z.enum(["kpm", "mpm"])
|
|
132
|
-
});
|
|
133
|
-
var examsFileSchema = z.array(examSchema);
|
|
134
|
-
|
|
135
|
-
// src/state-resolver.ts
|
|
136
|
-
function resolveStateCode(query, states) {
|
|
137
|
-
const normalized = query.toLowerCase().trim();
|
|
138
|
-
for (const state of states) {
|
|
139
|
-
if (state.code === normalized) return state;
|
|
140
|
-
for (const alias of state.aliases) {
|
|
141
|
-
if (alias.toLowerCase() === normalized) return state;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
function getStateByCode(code, states) {
|
|
147
|
-
return states.find((s) => s.code === code) ?? null;
|
|
148
|
-
}
|
|
149
|
-
function getStatesByGroup(group, states) {
|
|
150
|
-
return states.filter((s) => s.group === group);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// src/weekend.ts
|
|
154
|
-
var DAY_NAMES = [
|
|
155
|
-
"Sunday",
|
|
156
|
-
"Monday",
|
|
157
|
-
"Tuesday",
|
|
158
|
-
"Wednesday",
|
|
159
|
-
"Thursday",
|
|
160
|
-
"Friday",
|
|
161
|
-
"Saturday"
|
|
162
|
-
];
|
|
163
|
-
function getWeekendConfig(state, date) {
|
|
164
|
-
for (const config of state.weekendHistory) {
|
|
165
|
-
const from = config.from;
|
|
166
|
-
const to = config.to;
|
|
167
|
-
if (date >= from && (to === null || date <= to)) {
|
|
168
|
-
return config;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return state.weekendHistory[0];
|
|
172
|
-
}
|
|
173
|
-
function isWeekend(date, state) {
|
|
174
|
-
const config = getWeekendConfig(state, date);
|
|
175
|
-
const dayOfWeek = (/* @__PURE__ */ new Date(date + "T12:00:00Z")).getUTCDay();
|
|
176
|
-
return config.weekendDays.includes(dayOfWeek);
|
|
177
|
-
}
|
|
178
|
-
function getDayOfWeekName(date) {
|
|
179
|
-
const dayIndex = (/* @__PURE__ */ new Date(date + "T12:00:00Z")).getUTCDay();
|
|
180
|
-
return DAY_NAMES[dayIndex];
|
|
181
|
-
}
|
|
182
|
-
function getWeekendDayNames(state, date) {
|
|
183
|
-
const config = getWeekendConfig(state, date);
|
|
184
|
-
return config.weekendDays.map((d) => DAY_NAMES[d]);
|
|
185
|
-
}
|
|
186
|
-
function addDays(date, days) {
|
|
187
|
-
const d = /* @__PURE__ */ new Date(date + "T12:00:00Z");
|
|
188
|
-
d.setUTCDate(d.getUTCDate() + days);
|
|
189
|
-
return d.toISOString().slice(0, 10);
|
|
190
|
-
}
|
|
191
|
-
function nextWorkingDay(date, state, holidayDates) {
|
|
192
|
-
let candidate = addDays(date, 1);
|
|
193
|
-
while (isWeekend(candidate, state) || holidayDates.has(candidate)) {
|
|
194
|
-
candidate = addDays(candidate, 1);
|
|
195
|
-
}
|
|
196
|
-
return candidate;
|
|
197
|
-
}
|
|
198
|
-
function diffDays(start, end) {
|
|
199
|
-
const s = (/* @__PURE__ */ new Date(start + "T12:00:00Z")).getTime();
|
|
200
|
-
const e = (/* @__PURE__ */ new Date(end + "T12:00:00Z")).getTime();
|
|
201
|
-
return Math.round((e - s) / 864e5);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// src/filter.ts
|
|
205
|
-
function filterHolidays(holidays, filter) {
|
|
206
|
-
return holidays.filter((h) => {
|
|
207
|
-
if (filter.year !== void 0) {
|
|
208
|
-
const year = parseInt(h.date.slice(0, 4), 10);
|
|
209
|
-
if (year !== filter.year) return false;
|
|
210
|
-
}
|
|
211
|
-
if (filter.month !== void 0) {
|
|
212
|
-
const month = parseInt(h.date.slice(5, 7), 10);
|
|
213
|
-
if (month !== filter.month) return false;
|
|
214
|
-
}
|
|
215
|
-
if (filter.state !== void 0) {
|
|
216
|
-
if (!h.states.includes("*") && !h.states.includes(filter.state)) {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (filter.type !== void 0 && h.type !== filter.type) return false;
|
|
221
|
-
if (filter.status !== void 0 && h.status !== filter.status) return false;
|
|
222
|
-
return true;
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
function findHolidaysByDate(date, holidays, stateCode) {
|
|
226
|
-
return holidays.filter((h) => {
|
|
227
|
-
if (h.date !== date) return false;
|
|
228
|
-
if (h.status === "cancelled") return false;
|
|
229
|
-
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
return true;
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
function groupHolidaysByDate(holidays, stateCode) {
|
|
236
|
-
const map = /* @__PURE__ */ new Map();
|
|
237
|
-
for (const h of holidays) {
|
|
238
|
-
if (h.status === "cancelled") continue;
|
|
239
|
-
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
const list = map.get(h.date);
|
|
243
|
-
if (list) list.push(h);
|
|
244
|
-
else map.set(h.date, [h]);
|
|
245
|
-
}
|
|
246
|
-
return map;
|
|
247
|
-
}
|
|
248
|
-
function findNextHoliday(afterDate, holidays, stateCode, type, limit = 1) {
|
|
249
|
-
const filtered = holidays.filter((h) => {
|
|
250
|
-
if (h.date <= afterDate) return false;
|
|
251
|
-
if (h.status === "cancelled") return false;
|
|
252
|
-
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
|
-
if (type !== void 0 && h.type !== type) return false;
|
|
256
|
-
return true;
|
|
257
|
-
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
258
|
-
return filtered.slice(0, limit);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// src/replacement.ts
|
|
262
|
-
function makeReplacement(source, replacementDate, stateCode, idSuffix, now) {
|
|
263
|
-
return {
|
|
264
|
-
id: `${source.id}-${idSuffix}`,
|
|
265
|
-
date: replacementDate,
|
|
266
|
-
name: {
|
|
267
|
-
ms: `Cuti Ganti ${source.name.ms}`,
|
|
268
|
-
en: `Replacement for ${source.name.en}`,
|
|
269
|
-
zh: source.name.zh ? `\u8865\u5047 ${source.name.zh}` : void 0
|
|
270
|
-
},
|
|
271
|
-
type: "replacement",
|
|
272
|
-
status: source.status,
|
|
273
|
-
states: [stateCode],
|
|
274
|
-
isPublicHoliday: true,
|
|
275
|
-
gazetteLevel: source.gazetteLevel,
|
|
276
|
-
isReplacementFor: source.id,
|
|
277
|
-
source: source.source,
|
|
278
|
-
createdAt: now,
|
|
279
|
-
updatedAt: now
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
function calculateReplacementHolidays(holidays, state) {
|
|
283
|
-
const stateHolidays = holidays.filter(
|
|
284
|
-
(h) => h.status !== "cancelled" && (h.states.includes("*") || h.states.includes(state.code))
|
|
285
|
-
);
|
|
286
|
-
const holidayDates = new Set(stateHolidays.map((h) => h.date));
|
|
287
|
-
const replacementDates = /* @__PURE__ */ new Set();
|
|
288
|
-
const replacements = [];
|
|
289
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
290
|
-
for (const holiday of stateHolidays) {
|
|
291
|
-
if (holiday.type === "replacement") continue;
|
|
292
|
-
if (!isWeekend(holiday.date, state)) continue;
|
|
293
|
-
const occupied = /* @__PURE__ */ new Set([...holidayDates, ...replacementDates]);
|
|
294
|
-
const replacementDate = nextWorkingDay(holiday.date, state, occupied);
|
|
295
|
-
replacementDates.add(replacementDate);
|
|
296
|
-
replacements.push(
|
|
297
|
-
makeReplacement(holiday, replacementDate, state.code, "replacement", now)
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
const dateGroups = /* @__PURE__ */ new Map();
|
|
301
|
-
for (const h of stateHolidays) {
|
|
302
|
-
if (h.type === "replacement") continue;
|
|
303
|
-
const existing = dateGroups.get(h.date) ?? [];
|
|
304
|
-
dateGroups.set(h.date, [...existing, h]);
|
|
305
|
-
}
|
|
306
|
-
for (const [, group] of dateGroups) {
|
|
307
|
-
if (group.length <= 1) continue;
|
|
308
|
-
const stateHoliday = group.find((h) => h.gazetteLevel === "N");
|
|
309
|
-
if (!stateHoliday) continue;
|
|
310
|
-
if (replacements.some((r) => r.isReplacementFor === stateHoliday.id)) continue;
|
|
311
|
-
const occupied = /* @__PURE__ */ new Set([...holidayDates, ...replacementDates]);
|
|
312
|
-
const replacementDate = nextWorkingDay(stateHoliday.date, state, occupied);
|
|
313
|
-
replacementDates.add(replacementDate);
|
|
314
|
-
replacements.push(
|
|
315
|
-
makeReplacement(
|
|
316
|
-
stateHoliday,
|
|
317
|
-
replacementDate,
|
|
318
|
-
state.code,
|
|
319
|
-
"overlap-replacement",
|
|
320
|
-
now
|
|
321
|
-
)
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
return replacements;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// src/business-days.ts
|
|
328
|
-
var MAX_SPAN_DAYS = 366 * 100;
|
|
329
|
-
function countBusinessDays(start, end, state, holidays) {
|
|
330
|
-
if (start > end) {
|
|
331
|
-
throw new RangeError(`start (${start}) must be on or before end (${end})`);
|
|
332
|
-
}
|
|
333
|
-
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
334
|
-
let businessDays = 0;
|
|
335
|
-
let weekendCount = 0;
|
|
336
|
-
let holidayCount = 0;
|
|
337
|
-
let totalDays = 0;
|
|
338
|
-
const holidayList = [];
|
|
339
|
-
const seenIds = /* @__PURE__ */ new Set();
|
|
340
|
-
let current = start;
|
|
341
|
-
while (current <= end) {
|
|
342
|
-
if (++totalDays > MAX_SPAN_DAYS) {
|
|
343
|
-
throw new RangeError(
|
|
344
|
-
`date range exceeds the maximum span of ${MAX_SPAN_DAYS} days`
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
const weekend = isWeekend(current, state);
|
|
348
|
-
const dayHolidays = holidayMap.get(current);
|
|
349
|
-
if (weekend) {
|
|
350
|
-
weekendCount++;
|
|
351
|
-
} else if (dayHolidays) {
|
|
352
|
-
holidayCount++;
|
|
353
|
-
for (const h of dayHolidays) {
|
|
354
|
-
if (!seenIds.has(h.id)) {
|
|
355
|
-
seenIds.add(h.id);
|
|
356
|
-
holidayList.push(h);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
} else {
|
|
360
|
-
businessDays++;
|
|
361
|
-
}
|
|
362
|
-
current = addDays(current, 1);
|
|
363
|
-
}
|
|
364
|
-
return {
|
|
365
|
-
totalDays,
|
|
366
|
-
businessDays,
|
|
367
|
-
holidays: holidayCount,
|
|
368
|
-
weekendDays: weekendCount,
|
|
369
|
-
holidayList
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
function assertValidStep(value, label) {
|
|
373
|
-
if (!Number.isInteger(value)) {
|
|
374
|
-
throw new RangeError(`${label} must be an integer (received ${value})`);
|
|
375
|
-
}
|
|
376
|
-
if (value < 0) {
|
|
377
|
-
throw new RangeError(`${label} must be non-negative (received ${value})`);
|
|
378
|
-
}
|
|
379
|
-
if (value > MAX_SPAN_DAYS) {
|
|
380
|
-
throw new RangeError(`${label} exceeds the maximum of ${MAX_SPAN_DAYS}`);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
function addBusinessDays(startDate, daysToAdd, state, holidays) {
|
|
384
|
-
assertValidStep(daysToAdd, "daysToAdd");
|
|
385
|
-
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
386
|
-
let remaining = daysToAdd;
|
|
387
|
-
let current = startDate;
|
|
388
|
-
let guard = 0;
|
|
389
|
-
while (remaining > 0) {
|
|
390
|
-
if (++guard > MAX_SPAN_DAYS * 2) {
|
|
391
|
-
throw new RangeError("addBusinessDays exceeded its iteration limit");
|
|
392
|
-
}
|
|
393
|
-
current = addDays(current, 1);
|
|
394
|
-
if (!isWeekend(current, state) && !holidayMap.has(current)) {
|
|
395
|
-
remaining--;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
return current;
|
|
399
|
-
}
|
|
400
|
-
function subtractBusinessDays(startDate, daysToSubtract, state, holidays) {
|
|
401
|
-
assertValidStep(daysToSubtract, "daysToSubtract");
|
|
402
|
-
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
403
|
-
let remaining = daysToSubtract;
|
|
404
|
-
let current = startDate;
|
|
405
|
-
let guard = 0;
|
|
406
|
-
while (remaining > 0) {
|
|
407
|
-
if (++guard > MAX_SPAN_DAYS * 2) {
|
|
408
|
-
throw new RangeError("subtractBusinessDays exceeded its iteration limit");
|
|
409
|
-
}
|
|
410
|
-
current = addDays(current, -1);
|
|
411
|
-
if (!isWeekend(current, state) && !holidayMap.has(current)) {
|
|
412
|
-
remaining--;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return current;
|
|
416
|
-
}
|
|
417
|
-
function isBusinessDay(date, state, holidays) {
|
|
418
|
-
if (isWeekend(date, state)) return false;
|
|
419
|
-
return !groupHolidaysByDate(holidays, state.code).has(date);
|
|
420
|
-
}
|
|
421
|
-
function nextBusinessDay(date, state, holidays) {
|
|
422
|
-
return addBusinessDays(date, 1, state, holidays);
|
|
423
|
-
}
|
|
424
|
-
function previousBusinessDay(date, state, holidays) {
|
|
425
|
-
return subtractBusinessDays(date, 1, state, holidays);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// src/long-weekend.ts
|
|
429
|
-
function findLongWeekends(year, state, holidays) {
|
|
430
|
-
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
431
|
-
const isOff = (d) => isWeekend(d, state) || holidayMap.has(d);
|
|
432
|
-
const yearEnd = `${year}-12-31`;
|
|
433
|
-
const result = [];
|
|
434
|
-
let current = `${year}-01-01`;
|
|
435
|
-
while (current <= yearEnd) {
|
|
436
|
-
if (!isOff(current)) {
|
|
437
|
-
current = addDays(current, 1);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
const startDate = current;
|
|
441
|
-
const streakHolidays = [];
|
|
442
|
-
let weekendDays = 0;
|
|
443
|
-
let totalDays = 0;
|
|
444
|
-
while (current <= yearEnd && isOff(current)) {
|
|
445
|
-
if (isWeekend(current, state)) weekendDays++;
|
|
446
|
-
const hs = holidayMap.get(current);
|
|
447
|
-
if (hs) streakHolidays.push(...hs);
|
|
448
|
-
totalDays++;
|
|
449
|
-
current = addDays(current, 1);
|
|
450
|
-
}
|
|
451
|
-
if (totalDays >= 3) {
|
|
452
|
-
result.push({
|
|
453
|
-
startDate,
|
|
454
|
-
endDate: addDays(current, -1),
|
|
455
|
-
totalDays,
|
|
456
|
-
holidays: streakHolidays,
|
|
457
|
-
weekendDays,
|
|
458
|
-
bridgeDaysNeeded: 0
|
|
459
|
-
// natural long weekend — no leave required
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
return result;
|
|
464
|
-
}
|
|
465
|
-
function optimizeLeave(year, state, holidays, maxLeave = 3) {
|
|
466
|
-
if (!Number.isInteger(maxLeave) || maxLeave < 1) return [];
|
|
467
|
-
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
468
|
-
const isOff = (d) => isWeekend(d, state) || holidayMap.has(d);
|
|
469
|
-
const yearEnd = `${year}-12-31`;
|
|
470
|
-
const suggestions = [];
|
|
471
|
-
let s = `${year}-01-01`;
|
|
472
|
-
while (s <= yearEnd) {
|
|
473
|
-
if (!isOff(s) || isOff(addDays(s, -1))) {
|
|
474
|
-
s = addDays(s, 1);
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
let leave = 0;
|
|
478
|
-
let d = s;
|
|
479
|
-
while (d <= yearEnd) {
|
|
480
|
-
if (!isOff(d)) {
|
|
481
|
-
leave++;
|
|
482
|
-
if (leave > maxLeave) break;
|
|
483
|
-
}
|
|
484
|
-
const next = addDays(d, 1);
|
|
485
|
-
if (leave >= 1 && isOff(d) && !isOff(next)) {
|
|
486
|
-
const leaveDates = [];
|
|
487
|
-
const within = [];
|
|
488
|
-
let c = s;
|
|
489
|
-
while (c <= d) {
|
|
490
|
-
if (!isOff(c)) leaveDates.push(c);
|
|
491
|
-
const hs = holidayMap.get(c);
|
|
492
|
-
if (hs) within.push(...hs);
|
|
493
|
-
c = addDays(c, 1);
|
|
494
|
-
}
|
|
495
|
-
const totalDays = diffDays(s, d) + 1;
|
|
496
|
-
suggestions.push({
|
|
497
|
-
startDate: s,
|
|
498
|
-
endDate: d,
|
|
499
|
-
totalDays,
|
|
500
|
-
leaveDates,
|
|
501
|
-
leaveCost: leaveDates.length,
|
|
502
|
-
efficiency: Math.round(totalDays / leaveDates.length * 100) / 100,
|
|
503
|
-
holidays: within
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
d = next;
|
|
507
|
-
}
|
|
508
|
-
s = addDays(s, 1);
|
|
509
|
-
}
|
|
510
|
-
suggestions.sort(
|
|
511
|
-
(a, b) => b.efficiency - a.efficiency || b.totalDays - a.totalDays || a.startDate.localeCompare(b.startDate)
|
|
512
|
-
);
|
|
513
|
-
const seen = /* @__PURE__ */ new Set();
|
|
514
|
-
return suggestions.filter((x) => {
|
|
515
|
-
const key = `${x.startDate}:${x.endDate}`;
|
|
516
|
-
if (seen.has(key)) return false;
|
|
517
|
-
seen.add(key);
|
|
518
|
-
return true;
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// src/validation.ts
|
|
523
|
-
function isValidISODate(value) {
|
|
524
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
525
|
-
const d = /* @__PURE__ */ new Date(value + "T12:00:00Z");
|
|
526
|
-
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
|
527
|
-
}
|
|
528
|
-
function timingSafeEqualString(a, b) {
|
|
529
|
-
const enc = new TextEncoder();
|
|
530
|
-
const ab = enc.encode(a);
|
|
531
|
-
const bb = enc.encode(b);
|
|
532
|
-
if (ab.length !== bb.length) return false;
|
|
533
|
-
let diff = 0;
|
|
534
|
-
for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
|
|
535
|
-
return diff === 0;
|
|
536
|
-
}
|
|
537
|
-
function isPrivateOrReservedIp(host) {
|
|
538
|
-
if (host.includes(":")) {
|
|
539
|
-
if (host === "::1" || host === "::") return true;
|
|
540
|
-
if (host.startsWith("fe80")) return true;
|
|
541
|
-
if (host.startsWith("fc") || host.startsWith("fd")) return true;
|
|
542
|
-
if (host.startsWith("::ffff:")) return isPrivateOrReservedIp(host.slice(7));
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
546
|
-
if (!m) return false;
|
|
547
|
-
const octets = m.slice(1).map(Number);
|
|
548
|
-
if (octets.some((n) => n > 255)) return true;
|
|
549
|
-
const [a, b] = octets;
|
|
550
|
-
if (a === 0) return true;
|
|
551
|
-
if (a === 10) return true;
|
|
552
|
-
if (a === 127) return true;
|
|
553
|
-
if (a === 169 && b === 254) return true;
|
|
554
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
555
|
-
if (a === 192 && b === 168) return true;
|
|
556
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
557
|
-
if (a >= 224) return true;
|
|
558
|
-
return false;
|
|
559
|
-
}
|
|
560
|
-
function isSafePublicHttpsUrl(raw) {
|
|
561
|
-
let url;
|
|
562
|
-
try {
|
|
563
|
-
url = new URL(raw);
|
|
564
|
-
} catch {
|
|
565
|
-
return false;
|
|
566
|
-
}
|
|
567
|
-
if (url.protocol !== "https:") return false;
|
|
568
|
-
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
569
|
-
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
return !isPrivateOrReservedIp(host);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// src/school.ts
|
|
576
|
-
function findSchoolTermByDate(date, terms, group) {
|
|
577
|
-
return terms.find(
|
|
578
|
-
(t) => t.group === group && date >= t.startDate && date <= t.endDate
|
|
579
|
-
) ?? null;
|
|
580
|
-
}
|
|
581
|
-
function findSchoolHolidayByDate(date, holidays, group, stateCode) {
|
|
582
|
-
return holidays.find((h) => {
|
|
583
|
-
if (h.group !== group) return false;
|
|
584
|
-
if (date < h.startDate || date > h.endDate) return false;
|
|
585
|
-
if (stateCode && h.excludeStates?.includes(stateCode)) return false;
|
|
586
|
-
if (h.states && !h.states.includes(stateCode ?? "")) return false;
|
|
587
|
-
return true;
|
|
588
|
-
}) ?? null;
|
|
589
|
-
}
|
|
590
|
-
function filterSchoolHolidays(holidays, group, stateCode) {
|
|
591
|
-
return holidays.filter((h) => {
|
|
592
|
-
if (h.group !== group) return false;
|
|
593
|
-
if (stateCode && h.excludeStates?.includes(stateCode)) return false;
|
|
594
|
-
if (h.states && stateCode && !h.states.includes(stateCode)) return false;
|
|
595
|
-
return true;
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
function isSchoolDay(date, terms, schoolHolidays, group, isPublicHoliday, isWeekendDay, stateCode) {
|
|
599
|
-
if (isWeekendDay) return false;
|
|
600
|
-
if (isPublicHoliday) return false;
|
|
601
|
-
const schoolHoliday = findSchoolHolidayByDate(
|
|
602
|
-
date,
|
|
603
|
-
schoolHolidays,
|
|
604
|
-
group,
|
|
605
|
-
stateCode
|
|
606
|
-
);
|
|
607
|
-
if (schoolHoliday) return false;
|
|
608
|
-
const term = findSchoolTermByDate(date, terms, group);
|
|
609
|
-
return term !== null;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// src/ical.ts
|
|
613
|
-
function escapeIcal(text) {
|
|
614
|
-
return text.replace(/[\\;,]/g, (c) => `\\${c}`).replace(/\r\n|\r|\n/g, "\\n");
|
|
615
|
-
}
|
|
616
|
-
function formatIcalDate(date) {
|
|
617
|
-
return date.replace(/-/g, "");
|
|
618
|
-
}
|
|
619
|
-
function holidayCategories(h) {
|
|
620
|
-
const cats = ["Public Holiday"];
|
|
621
|
-
switch (h.type) {
|
|
622
|
-
case "federal":
|
|
623
|
-
cats.push("Federal");
|
|
624
|
-
break;
|
|
625
|
-
case "state":
|
|
626
|
-
cats.push("State");
|
|
627
|
-
break;
|
|
628
|
-
case "islamic":
|
|
629
|
-
case "islamic_state":
|
|
630
|
-
cats.push("Religious", "Islamic");
|
|
631
|
-
break;
|
|
632
|
-
case "replacement":
|
|
633
|
-
cats.push("Replacement");
|
|
634
|
-
break;
|
|
635
|
-
case "adhoc":
|
|
636
|
-
cats.push("Ad-hoc");
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
if (h.status === "tentative") cats.push("Tentative");
|
|
640
|
-
return cats.map(escapeIcal).join(",");
|
|
641
|
-
}
|
|
642
|
-
function holidayToVevent(h) {
|
|
643
|
-
const dtstart = formatIcalDate(h.date);
|
|
644
|
-
const dtend = h.endDate ? formatIcalDate(h.endDate) : formatIcalDate(h.date);
|
|
645
|
-
return [
|
|
646
|
-
"BEGIN:VEVENT",
|
|
647
|
-
`DTSTART;VALUE=DATE:${dtstart}`,
|
|
648
|
-
`DTEND;VALUE=DATE:${dtend}`,
|
|
649
|
-
`SUMMARY:${escapeIcal(h.name.en)}`,
|
|
650
|
-
`DESCRIPTION:${escapeIcal(h.name.ms)}${h.hijriDate ? ` (${h.hijriDate})` : ""}`,
|
|
651
|
-
`CATEGORIES:${holidayCategories(h)}`,
|
|
652
|
-
`UID:${h.id}@mycal.my`,
|
|
653
|
-
"TRANSP:TRANSPARENT",
|
|
654
|
-
`STATUS:${h.status === "tentative" ? "TENTATIVE" : "CONFIRMED"}`,
|
|
655
|
-
"END:VEVENT"
|
|
656
|
-
].join("\r\n");
|
|
657
|
-
}
|
|
658
|
-
function schoolHolidayToVevent(h) {
|
|
659
|
-
return [
|
|
660
|
-
"BEGIN:VEVENT",
|
|
661
|
-
`DTSTART;VALUE=DATE:${formatIcalDate(h.startDate)}`,
|
|
662
|
-
`DTEND;VALUE=DATE:${formatIcalDate(h.endDate)}`,
|
|
663
|
-
`SUMMARY:[School] ${escapeIcal(h.name.en)}`,
|
|
664
|
-
`DESCRIPTION:${escapeIcal(h.name.ms)}`,
|
|
665
|
-
"CATEGORIES:School Holiday",
|
|
666
|
-
`UID:${h.id}@mycal.my`,
|
|
667
|
-
"TRANSP:TRANSPARENT",
|
|
668
|
-
"STATUS:CONFIRMED",
|
|
669
|
-
"END:VEVENT"
|
|
670
|
-
].join("\r\n");
|
|
671
|
-
}
|
|
672
|
-
function generateIcal(holidays, schoolHolidays, calendarName, calendarDescription = "Malaysian public holidays and school calendar \u2014 mycal.my") {
|
|
673
|
-
const header = [
|
|
674
|
-
"BEGIN:VCALENDAR",
|
|
675
|
-
"VERSION:2.0",
|
|
676
|
-
"PRODID:-//MyCal//Malaysia Calendar API//EN",
|
|
677
|
-
`X-WR-CALNAME:${escapeIcal(calendarName)}`,
|
|
678
|
-
`NAME:${escapeIcal(calendarName)}`,
|
|
679
|
-
// RFC 7986
|
|
680
|
-
`X-WR-CALDESC:${escapeIcal(calendarDescription)}`,
|
|
681
|
-
`DESCRIPTION:${escapeIcal(calendarDescription)}`,
|
|
682
|
-
// RFC 7986
|
|
683
|
-
"X-WR-TIMEZONE:Asia/Kuala_Lumpur",
|
|
684
|
-
"COLOR:forestgreen",
|
|
685
|
-
// RFC 7986 (CSS3 colour name)
|
|
686
|
-
"X-APPLE-CALENDAR-COLOR:#22C55E",
|
|
687
|
-
"CALSCALE:GREGORIAN",
|
|
688
|
-
"METHOD:PUBLISH",
|
|
689
|
-
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
|
690
|
-
"X-PUBLISHED-TTL:PT6H"
|
|
691
|
-
].join("\r\n");
|
|
692
|
-
const events = [
|
|
693
|
-
...holidays.filter((h) => h.status !== "cancelled").map(holidayToVevent),
|
|
694
|
-
...schoolHolidays.map(schoolHolidayToVevent)
|
|
695
|
-
].join("\r\n");
|
|
696
|
-
return `${header}\r
|
|
697
|
-
${events}\r
|
|
698
|
-
END:VCALENDAR\r
|
|
699
|
-
`;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// src/hijri.ts
|
|
703
|
-
var HIJRI_MONTHS = [
|
|
704
|
-
{ number: 1, nameMs: "Muharam", nameEn: "Muharram", nameAr: "\u0645\u062D\u0631\u0651\u0645" },
|
|
705
|
-
{ number: 2, nameMs: "Safar", nameEn: "Safar", nameAr: "\u0635\u0641\u0631" },
|
|
706
|
-
{ number: 3, nameMs: "Rabiulawal", nameEn: "Rabi al-Awwal", nameAr: "\u0631\u0628\u064A\u0639 \u0627\u0644\u0623\u0648\u0651\u0644" },
|
|
707
|
-
{ number: 4, nameMs: "Rabiulakhir", nameEn: "Rabi al-Thani", nameAr: "\u0631\u0628\u064A\u0639 \u0627\u0644\u062B\u0627\u0646\u064A" },
|
|
708
|
-
{ number: 5, nameMs: "Jamadilawal", nameEn: "Jumada al-Ula", nameAr: "\u062C\u0645\u0627\u062F\u0649 \u0627\u0644\u0623\u0648\u0644\u0649" },
|
|
709
|
-
{ number: 6, nameMs: "Jamadilakhir", nameEn: "Jumada al-Thani", nameAr: "\u062C\u0645\u0627\u062F\u0649 \u0627\u0644\u062B\u0627\u0646\u064A\u0629" },
|
|
710
|
-
{ number: 7, nameMs: "Rejab", nameEn: "Rajab", nameAr: "\u0631\u062C\u0628" },
|
|
711
|
-
{ number: 8, nameMs: "Syaaban", nameEn: "Sha'ban", nameAr: "\u0634\u0639\u0628\u0627\u0646" },
|
|
712
|
-
{ number: 9, nameMs: "Ramadan", nameEn: "Ramadan", nameAr: "\u0631\u0645\u0636\u0627\u0646" },
|
|
713
|
-
{ number: 10, nameMs: "Syawal", nameEn: "Shawwal", nameAr: "\u0634\u0648\u0651\u0627\u0644" },
|
|
714
|
-
{ number: 11, nameMs: "Zulkaedah", nameEn: "Dhu al-Qi'dah", nameAr: "\u0630\u0648 \u0627\u0644\u0642\u0639\u062F\u0629" },
|
|
715
|
-
{ number: 12, nameMs: "Zulhijjah", nameEn: "Dhu al-Hijjah", nameAr: "\u0630\u0648 \u0627\u0644\u062D\u062C\u0651\u0629" }
|
|
716
|
-
];
|
|
717
|
-
function parseHijriDate(hijriStr) {
|
|
718
|
-
const match = hijriStr.match(/^(\d+)\s+(.+?)\s+(\d+)$/);
|
|
719
|
-
if (!match) return null;
|
|
720
|
-
const day = parseInt(match[1], 10);
|
|
721
|
-
const monthName = match[2].toLowerCase();
|
|
722
|
-
const year = parseInt(match[3], 10);
|
|
723
|
-
const month = HIJRI_MONTHS.find(
|
|
724
|
-
(m) => m.nameMs.toLowerCase() === monthName || m.nameEn.toLowerCase() === monthName
|
|
725
|
-
);
|
|
726
|
-
if (!month) return null;
|
|
727
|
-
return { day, month, year };
|
|
728
|
-
}
|
|
729
|
-
function formatHijriDate(day, monthNumber, year) {
|
|
730
|
-
const month = HIJRI_MONTHS.find((m) => m.number === monthNumber);
|
|
731
|
-
if (!month) return `${day} ? ${year}`;
|
|
732
|
-
return `${day} ${month.nameMs} ${year}`;
|
|
733
|
-
}
|
|
734
|
-
var ISLAMIC_HOLIDAY_DATES = [
|
|
735
|
-
{ nameMs: "Awal Muharam", nameEn: "Islamic New Year", hijriMonth: 1, hijriDay: 1 },
|
|
736
|
-
{ nameMs: "Maulidur Rasul", nameEn: "Prophet Muhammad's Birthday", hijriMonth: 3, hijriDay: 12 },
|
|
737
|
-
{ nameMs: "Israk dan Mikraj", nameEn: "Isra' Mi'raj", hijriMonth: 7, hijriDay: 27 },
|
|
738
|
-
{ nameMs: "Nuzul Al-Quran", nameEn: "Revelation of the Quran", hijriMonth: 9, hijriDay: 17 },
|
|
739
|
-
{ nameMs: "Hari Raya Aidilfitri", nameEn: "Eid al-Fitr", hijriMonth: 10, hijriDay: 1 },
|
|
740
|
-
{ nameMs: "Hari Raya Haji", nameEn: "Eid al-Adha", hijriMonth: 12, hijriDay: 10 },
|
|
741
|
-
{ nameMs: "Hari Arafah", nameEn: "Day of Arafah", hijriMonth: 12, hijriDay: 9 }
|
|
742
|
-
];
|
|
743
|
-
export {
|
|
744
|
-
HIJRI_MONTHS,
|
|
745
|
-
ISLAMIC_HOLIDAY_DATES,
|
|
746
|
-
addBusinessDays,
|
|
747
|
-
addDays,
|
|
748
|
-
calculateReplacementHolidays,
|
|
749
|
-
countBusinessDays,
|
|
750
|
-
diffDays,
|
|
751
|
-
examSchema,
|
|
752
|
-
examsFileSchema,
|
|
753
|
-
filterHolidays,
|
|
754
|
-
filterSchoolHolidays,
|
|
755
|
-
findHolidaysByDate,
|
|
756
|
-
findLongWeekends,
|
|
757
|
-
findNextHoliday,
|
|
758
|
-
findSchoolHolidayByDate,
|
|
759
|
-
findSchoolTermByDate,
|
|
760
|
-
formatHijriDate,
|
|
761
|
-
generateIcal,
|
|
762
|
-
getDayOfWeekName,
|
|
763
|
-
getStateByCode,
|
|
764
|
-
getStatesByGroup,
|
|
765
|
-
getWeekendConfig,
|
|
766
|
-
getWeekendDayNames,
|
|
767
|
-
groupHolidaysByDate,
|
|
768
|
-
holidayCategorySchema,
|
|
769
|
-
holidayFileSchema,
|
|
770
|
-
holidaySchema,
|
|
771
|
-
isBusinessDay,
|
|
772
|
-
isSafePublicHttpsUrl,
|
|
773
|
-
isSchoolDay,
|
|
774
|
-
isValidISODate,
|
|
775
|
-
isWeekend,
|
|
776
|
-
nextBusinessDay,
|
|
777
|
-
nextWorkingDay,
|
|
778
|
-
optimizeLeave,
|
|
779
|
-
parseHijriDate,
|
|
780
|
-
previousBusinessDay,
|
|
781
|
-
resolveStateCode,
|
|
782
|
-
schoolHolidaySchema,
|
|
783
|
-
schoolHolidaysFileSchema,
|
|
784
|
-
schoolTermSchema,
|
|
785
|
-
schoolTermsFileSchema,
|
|
786
|
-
stateSchema,
|
|
787
|
-
statesFileSchema,
|
|
788
|
-
subtractBusinessDays,
|
|
789
|
-
timingSafeEqualString
|
|
790
|
-
};
|
|
1
|
+
// Schemas
|
|
2
|
+
export { holidaySchema, holidayCategorySchema, holidayFileSchema, stateSchema, statesFileSchema, schoolTermSchema, schoolTermsFileSchema, schoolHolidaySchema, schoolHolidaysFileSchema, examSchema, examsFileSchema, } from "./schemas.js";
|
|
3
|
+
// State resolution
|
|
4
|
+
export { resolveStateCode, getStateByCode, getStatesByGroup, } from "./state-resolver.js";
|
|
5
|
+
// Weekend utilities
|
|
6
|
+
export { getWeekendConfig, isWeekend, getDayOfWeekName, getWeekendDayNames, addDays, nextWorkingDay, diffDays, } from "./weekend.js";
|
|
7
|
+
// Holiday filtering
|
|
8
|
+
export { filterHolidays, findHolidaysByDate, findNextHoliday, groupHolidaysByDate, } from "./filter.js";
|
|
9
|
+
// Replacement holiday calculation
|
|
10
|
+
export { calculateReplacementHolidays } from "./replacement.js";
|
|
11
|
+
// Business days
|
|
12
|
+
export { countBusinessDays, addBusinessDays, subtractBusinessDays, isBusinessDay, nextBusinessDay, previousBusinessDay, } from "./business-days.js";
|
|
13
|
+
// Long weekends & leave optimization
|
|
14
|
+
export { findLongWeekends, optimizeLeave } from "./long-weekend.js";
|
|
15
|
+
// Input validation & request-safety utilities
|
|
16
|
+
export { isValidISODate, timingSafeEqualString, isSafePublicHttpsUrl, } from "./validation.js";
|
|
17
|
+
// School calendar
|
|
18
|
+
export { findSchoolTermByDate, findSchoolHolidayByDate, filterSchoolHolidays, isSchoolDay, } from "./school.js";
|
|
19
|
+
// iCal generation
|
|
20
|
+
export { generateIcal } from "./ical.js";
|
|
21
|
+
// Hijri utilities
|
|
22
|
+
export { HIJRI_MONTHS, ISLAMIC_HOLIDAY_DATES, parseHijriDate, formatHijriDate, } from "./hijri.js";
|