@catlabtech/mycal-core 0.1.1 → 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/LICENSE +21 -0
- package/README.md +3 -0
- 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 -803
- package/dist/index.js +22 -517
- 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 +8 -3
package/dist/index.js
CHANGED
|
@@ -1,517 +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 holidaySourceSchema = z.enum([
|
|
25
|
-
"jpm",
|
|
26
|
-
"jakim",
|
|
27
|
-
"state-gov",
|
|
28
|
-
"community",
|
|
29
|
-
"admin"
|
|
30
|
-
]);
|
|
31
|
-
var holidaySchema = z.object({
|
|
32
|
-
id: z.string().min(1),
|
|
33
|
-
date: isoDate,
|
|
34
|
-
endDate: isoDate.optional(),
|
|
35
|
-
name: localizedString,
|
|
36
|
-
type: holidayTypeSchema,
|
|
37
|
-
status: holidayStatusSchema,
|
|
38
|
-
states: z.array(z.string().min(1)).min(1),
|
|
39
|
-
isPublicHoliday: z.boolean(),
|
|
40
|
-
gazetteLevel: gazetteLevelSchema,
|
|
41
|
-
isReplacementFor: z.string().optional(),
|
|
42
|
-
hijriDate: z.string().optional(),
|
|
43
|
-
gazetteRef: z.string().optional(),
|
|
44
|
-
source: holidaySourceSchema,
|
|
45
|
-
confirmedAt: z.string().optional(),
|
|
46
|
-
createdAt: z.string(),
|
|
47
|
-
updatedAt: z.string()
|
|
48
|
-
});
|
|
49
|
-
var holidayFileSchema = z.array(holidaySchema);
|
|
50
|
-
var stateGroupSchema = z.enum(["A", "B"]);
|
|
51
|
-
var weekendConfigSchema = z.object({
|
|
52
|
-
from: isoDate,
|
|
53
|
-
to: isoDate.nullable(),
|
|
54
|
-
weekendDays: z.array(z.number().int().min(0).max(6)),
|
|
55
|
-
group: stateGroupSchema
|
|
56
|
-
});
|
|
57
|
-
var stateSchema = z.object({
|
|
58
|
-
code: z.string().min(1),
|
|
59
|
-
aliases: z.array(z.string()),
|
|
60
|
-
name: localizedString,
|
|
61
|
-
type: z.enum(["state", "federal_territory"]),
|
|
62
|
-
weekendHistory: z.array(weekendConfigSchema).min(1),
|
|
63
|
-
group: stateGroupSchema
|
|
64
|
-
});
|
|
65
|
-
var statesFileSchema = z.array(stateSchema);
|
|
66
|
-
var schoolTermSegmentSchema = z.object({
|
|
67
|
-
startDate: isoDate,
|
|
68
|
-
endDate: isoDate,
|
|
69
|
-
schoolDays: z.number().int().min(0)
|
|
70
|
-
});
|
|
71
|
-
var schoolTermSchema = z.object({
|
|
72
|
-
id: z.string().min(1),
|
|
73
|
-
year: z.number().int().min(2020).max(2100),
|
|
74
|
-
term: z.union([z.literal(1), z.literal(2)]),
|
|
75
|
-
group: stateGroupSchema,
|
|
76
|
-
segments: z.array(schoolTermSegmentSchema).min(1),
|
|
77
|
-
totalSchoolDays: z.number().int().min(0),
|
|
78
|
-
startDate: isoDate,
|
|
79
|
-
endDate: isoDate
|
|
80
|
-
});
|
|
81
|
-
var schoolHolidayTypeSchema = z.enum([
|
|
82
|
-
"cuti_penggal_1",
|
|
83
|
-
"cuti_pertengahan",
|
|
84
|
-
"cuti_penggal_2",
|
|
85
|
-
"cuti_akhir",
|
|
86
|
-
"cuti_perayaan_kpm"
|
|
87
|
-
]);
|
|
88
|
-
var schoolHolidaySchema = z.object({
|
|
89
|
-
id: z.string().min(1),
|
|
90
|
-
year: z.number().int().min(2020).max(2100),
|
|
91
|
-
group: stateGroupSchema,
|
|
92
|
-
type: schoolHolidayTypeSchema,
|
|
93
|
-
name: localizedString,
|
|
94
|
-
startDate: isoDate,
|
|
95
|
-
endDate: isoDate,
|
|
96
|
-
days: z.number().int().min(1),
|
|
97
|
-
states: z.array(z.string()).optional(),
|
|
98
|
-
excludeStates: z.array(z.string()).optional(),
|
|
99
|
-
remarks: z.string().optional()
|
|
100
|
-
});
|
|
101
|
-
var schoolTermsFileSchema = z.array(schoolTermSchema);
|
|
102
|
-
var schoolHolidaysFileSchema = z.array(schoolHolidaySchema);
|
|
103
|
-
var examTypeSchema = z.enum([
|
|
104
|
-
"spm",
|
|
105
|
-
"stpm",
|
|
106
|
-
"muet",
|
|
107
|
-
"pt3",
|
|
108
|
-
"stam",
|
|
109
|
-
"other"
|
|
110
|
-
]);
|
|
111
|
-
var examSchema = z.object({
|
|
112
|
-
id: z.string().min(1),
|
|
113
|
-
year: z.number().int().min(2020).max(2100),
|
|
114
|
-
name: z.string().min(1),
|
|
115
|
-
fullName: localizedString,
|
|
116
|
-
type: examTypeSchema,
|
|
117
|
-
startDate: isoDate,
|
|
118
|
-
endDate: isoDate.optional(),
|
|
119
|
-
status: z.enum(["confirmed", "tentative"]),
|
|
120
|
-
resultsDate: isoDate.optional(),
|
|
121
|
-
source: z.enum(["kpm", "mpm"])
|
|
122
|
-
});
|
|
123
|
-
var examsFileSchema = z.array(examSchema);
|
|
124
|
-
|
|
125
|
-
// src/state-resolver.ts
|
|
126
|
-
function resolveStateCode(query, states) {
|
|
127
|
-
const normalized = query.toLowerCase().trim();
|
|
128
|
-
for (const state of states) {
|
|
129
|
-
if (state.code === normalized) return state;
|
|
130
|
-
for (const alias of state.aliases) {
|
|
131
|
-
if (alias.toLowerCase() === normalized) return state;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
function getStateByCode(code, states) {
|
|
137
|
-
return states.find((s) => s.code === code) ?? null;
|
|
138
|
-
}
|
|
139
|
-
function getStatesByGroup(group, states) {
|
|
140
|
-
return states.filter((s) => s.group === group);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// src/weekend.ts
|
|
144
|
-
var DAY_NAMES = [
|
|
145
|
-
"Sunday",
|
|
146
|
-
"Monday",
|
|
147
|
-
"Tuesday",
|
|
148
|
-
"Wednesday",
|
|
149
|
-
"Thursday",
|
|
150
|
-
"Friday",
|
|
151
|
-
"Saturday"
|
|
152
|
-
];
|
|
153
|
-
function getWeekendConfig(state, date) {
|
|
154
|
-
for (const config of state.weekendHistory) {
|
|
155
|
-
const from = config.from;
|
|
156
|
-
const to = config.to;
|
|
157
|
-
if (date >= from && (to === null || date <= to)) {
|
|
158
|
-
return config;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return state.weekendHistory[0];
|
|
162
|
-
}
|
|
163
|
-
function isWeekend(date, state) {
|
|
164
|
-
const config = getWeekendConfig(state, date);
|
|
165
|
-
const dayOfWeek = (/* @__PURE__ */ new Date(date + "T12:00:00Z")).getUTCDay();
|
|
166
|
-
return config.weekendDays.includes(dayOfWeek);
|
|
167
|
-
}
|
|
168
|
-
function getDayOfWeekName(date) {
|
|
169
|
-
const dayIndex = (/* @__PURE__ */ new Date(date + "T12:00:00Z")).getUTCDay();
|
|
170
|
-
return DAY_NAMES[dayIndex];
|
|
171
|
-
}
|
|
172
|
-
function getWeekendDayNames(state, date) {
|
|
173
|
-
const config = getWeekendConfig(state, date);
|
|
174
|
-
return config.weekendDays.map((d) => DAY_NAMES[d]);
|
|
175
|
-
}
|
|
176
|
-
function addDays(date, days) {
|
|
177
|
-
const d = /* @__PURE__ */ new Date(date + "T12:00:00Z");
|
|
178
|
-
d.setUTCDate(d.getUTCDate() + days);
|
|
179
|
-
return d.toISOString().slice(0, 10);
|
|
180
|
-
}
|
|
181
|
-
function nextWorkingDay(date, state, holidayDates) {
|
|
182
|
-
let candidate = addDays(date, 1);
|
|
183
|
-
while (isWeekend(candidate, state) || holidayDates.has(candidate)) {
|
|
184
|
-
candidate = addDays(candidate, 1);
|
|
185
|
-
}
|
|
186
|
-
return candidate;
|
|
187
|
-
}
|
|
188
|
-
function diffDays(start, end) {
|
|
189
|
-
const s = (/* @__PURE__ */ new Date(start + "T00:00:00")).getTime();
|
|
190
|
-
const e = (/* @__PURE__ */ new Date(end + "T00:00:00")).getTime();
|
|
191
|
-
return Math.round((e - s) / (1e3 * 60 * 60 * 24));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// src/filter.ts
|
|
195
|
-
function filterHolidays(holidays, filter) {
|
|
196
|
-
return holidays.filter((h) => {
|
|
197
|
-
if (filter.year !== void 0) {
|
|
198
|
-
const year = parseInt(h.date.slice(0, 4), 10);
|
|
199
|
-
if (year !== filter.year) return false;
|
|
200
|
-
}
|
|
201
|
-
if (filter.month !== void 0) {
|
|
202
|
-
const month = parseInt(h.date.slice(5, 7), 10);
|
|
203
|
-
if (month !== filter.month) return false;
|
|
204
|
-
}
|
|
205
|
-
if (filter.state !== void 0) {
|
|
206
|
-
if (!h.states.includes("*") && !h.states.includes(filter.state)) {
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (filter.type !== void 0 && h.type !== filter.type) return false;
|
|
211
|
-
if (filter.status !== void 0 && h.status !== filter.status) return false;
|
|
212
|
-
return true;
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
function findHolidaysByDate(date, holidays, stateCode) {
|
|
216
|
-
return holidays.filter((h) => {
|
|
217
|
-
if (h.date !== date) return false;
|
|
218
|
-
if (h.status === "cancelled") return false;
|
|
219
|
-
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
return true;
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
function findNextHoliday(afterDate, holidays, stateCode, type, limit = 1) {
|
|
226
|
-
const filtered = holidays.filter((h) => {
|
|
227
|
-
if (h.date <= afterDate) return false;
|
|
228
|
-
if (h.status === "cancelled") return false;
|
|
229
|
-
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
if (type !== void 0 && h.type !== type) return false;
|
|
233
|
-
return true;
|
|
234
|
-
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
235
|
-
return filtered.slice(0, limit);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// src/replacement.ts
|
|
239
|
-
function calculateReplacementHolidays(holidays, state) {
|
|
240
|
-
const stateHolidays = holidays.filter(
|
|
241
|
-
(h) => h.status !== "cancelled" && (h.states.includes("*") || h.states.includes(state.code))
|
|
242
|
-
);
|
|
243
|
-
const holidayDates = new Set(stateHolidays.map((h) => h.date));
|
|
244
|
-
const replacementDates = /* @__PURE__ */ new Set();
|
|
245
|
-
const replacements = [];
|
|
246
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
247
|
-
for (const holiday of stateHolidays) {
|
|
248
|
-
if (holiday.type === "replacement") continue;
|
|
249
|
-
if (isWeekend(holiday.date, state)) {
|
|
250
|
-
const allOccupied = /* @__PURE__ */ new Set([...holidayDates, ...replacementDates]);
|
|
251
|
-
const replacementDate = nextWorkingDay(holiday.date, state, allOccupied);
|
|
252
|
-
replacementDates.add(replacementDate);
|
|
253
|
-
replacements.push({
|
|
254
|
-
id: `${holiday.id}-replacement`,
|
|
255
|
-
date: replacementDate,
|
|
256
|
-
name: {
|
|
257
|
-
ms: `Cuti Ganti ${holiday.name.ms}`,
|
|
258
|
-
en: `Replacement for ${holiday.name.en}`,
|
|
259
|
-
zh: holiday.name.zh ? `\u8865\u5047 ${holiday.name.zh}` : void 0
|
|
260
|
-
},
|
|
261
|
-
type: "replacement",
|
|
262
|
-
status: holiday.status,
|
|
263
|
-
states: [state.code],
|
|
264
|
-
isPublicHoliday: true,
|
|
265
|
-
gazetteLevel: holiday.gazetteLevel,
|
|
266
|
-
isReplacementFor: holiday.id,
|
|
267
|
-
source: holiday.source,
|
|
268
|
-
createdAt: now,
|
|
269
|
-
updatedAt: now
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
const dateGroups = /* @__PURE__ */ new Map();
|
|
274
|
-
for (const h of stateHolidays) {
|
|
275
|
-
if (h.type === "replacement") continue;
|
|
276
|
-
const existing = dateGroups.get(h.date) ?? [];
|
|
277
|
-
dateGroups.set(h.date, [...existing, h]);
|
|
278
|
-
}
|
|
279
|
-
for (const [, group] of dateGroups) {
|
|
280
|
-
if (group.length <= 1) continue;
|
|
281
|
-
const stateHoliday = group.find((h) => h.gazetteLevel === "N");
|
|
282
|
-
if (!stateHoliday) continue;
|
|
283
|
-
if (replacements.some((r) => r.isReplacementFor === stateHoliday.id)) continue;
|
|
284
|
-
const allOccupied = /* @__PURE__ */ new Set([...holidayDates, ...replacementDates]);
|
|
285
|
-
const replacementDate = nextWorkingDay(stateHoliday.date, state, allOccupied);
|
|
286
|
-
replacementDates.add(replacementDate);
|
|
287
|
-
replacements.push({
|
|
288
|
-
id: `${stateHoliday.id}-overlap-replacement`,
|
|
289
|
-
date: replacementDate,
|
|
290
|
-
name: {
|
|
291
|
-
ms: `Cuti Ganti ${stateHoliday.name.ms}`,
|
|
292
|
-
en: `Replacement for ${stateHoliday.name.en}`,
|
|
293
|
-
zh: stateHoliday.name.zh ? `\u8865\u5047 ${stateHoliday.name.zh}` : void 0
|
|
294
|
-
},
|
|
295
|
-
type: "replacement",
|
|
296
|
-
status: stateHoliday.status,
|
|
297
|
-
states: [state.code],
|
|
298
|
-
isPublicHoliday: true,
|
|
299
|
-
gazetteLevel: stateHoliday.gazetteLevel,
|
|
300
|
-
isReplacementFor: stateHoliday.id,
|
|
301
|
-
source: stateHoliday.source,
|
|
302
|
-
createdAt: now,
|
|
303
|
-
updatedAt: now
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
return replacements;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// src/business-days.ts
|
|
310
|
-
function countBusinessDays(start, end, state, holidays) {
|
|
311
|
-
const totalDays = diffDays(start, end) + 1;
|
|
312
|
-
let businessDays = 0;
|
|
313
|
-
let weekendCount = 0;
|
|
314
|
-
let holidayCount = 0;
|
|
315
|
-
const holidayList = [];
|
|
316
|
-
let current = start;
|
|
317
|
-
while (current <= end) {
|
|
318
|
-
const weekend = isWeekend(current, state);
|
|
319
|
-
const dayHolidays = findHolidaysByDate(current, holidays, state.code);
|
|
320
|
-
const isHoliday = dayHolidays.length > 0;
|
|
321
|
-
if (weekend) {
|
|
322
|
-
weekendCount++;
|
|
323
|
-
} else if (isHoliday) {
|
|
324
|
-
holidayCount++;
|
|
325
|
-
for (const h of dayHolidays) {
|
|
326
|
-
if (!holidayList.some((existing) => existing.id === h.id)) {
|
|
327
|
-
holidayList.push(h);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
} else {
|
|
331
|
-
businessDays++;
|
|
332
|
-
}
|
|
333
|
-
current = addDays(current, 1);
|
|
334
|
-
}
|
|
335
|
-
return {
|
|
336
|
-
totalDays,
|
|
337
|
-
businessDays,
|
|
338
|
-
holidays: holidayCount,
|
|
339
|
-
weekendDays: weekendCount,
|
|
340
|
-
holidayList
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
function addBusinessDays(startDate, daysToAdd, state, holidays) {
|
|
344
|
-
let remaining = daysToAdd;
|
|
345
|
-
let current = startDate;
|
|
346
|
-
while (remaining > 0) {
|
|
347
|
-
current = addDays(current, 1);
|
|
348
|
-
const weekend = isWeekend(current, state);
|
|
349
|
-
const isHoliday = findHolidaysByDate(current, holidays, state.code).length > 0;
|
|
350
|
-
if (!weekend && !isHoliday) {
|
|
351
|
-
remaining--;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
return current;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// src/school.ts
|
|
358
|
-
function findSchoolTermByDate(date, terms, group) {
|
|
359
|
-
return terms.find(
|
|
360
|
-
(t) => t.group === group && date >= t.startDate && date <= t.endDate
|
|
361
|
-
) ?? null;
|
|
362
|
-
}
|
|
363
|
-
function findSchoolHolidayByDate(date, holidays, group, stateCode) {
|
|
364
|
-
return holidays.find((h) => {
|
|
365
|
-
if (h.group !== group) return false;
|
|
366
|
-
if (date < h.startDate || date > h.endDate) return false;
|
|
367
|
-
if (stateCode && h.excludeStates?.includes(stateCode)) return false;
|
|
368
|
-
if (h.states && !h.states.includes(stateCode ?? "")) return false;
|
|
369
|
-
return true;
|
|
370
|
-
}) ?? null;
|
|
371
|
-
}
|
|
372
|
-
function isSchoolDay(date, terms, schoolHolidays, group, isPublicHoliday, isWeekendDay, stateCode) {
|
|
373
|
-
if (isWeekendDay) return false;
|
|
374
|
-
if (isPublicHoliday) return false;
|
|
375
|
-
const schoolHoliday = findSchoolHolidayByDate(
|
|
376
|
-
date,
|
|
377
|
-
schoolHolidays,
|
|
378
|
-
group,
|
|
379
|
-
stateCode
|
|
380
|
-
);
|
|
381
|
-
if (schoolHoliday) return false;
|
|
382
|
-
const term = findSchoolTermByDate(date, terms, group);
|
|
383
|
-
return term !== null;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// src/ical.ts
|
|
387
|
-
function escapeIcal(text) {
|
|
388
|
-
return text.replace(/[\\;,]/g, (c) => `\\${c}`).replace(/\n/g, "\\n");
|
|
389
|
-
}
|
|
390
|
-
function formatIcalDate(date) {
|
|
391
|
-
return date.replace(/-/g, "");
|
|
392
|
-
}
|
|
393
|
-
function holidayToVevent(h) {
|
|
394
|
-
const dtstart = formatIcalDate(h.date);
|
|
395
|
-
const dtend = h.endDate ? formatIcalDate(h.endDate) : formatIcalDate(h.date);
|
|
396
|
-
return [
|
|
397
|
-
"BEGIN:VEVENT",
|
|
398
|
-
`DTSTART;VALUE=DATE:${dtstart}`,
|
|
399
|
-
`DTEND;VALUE=DATE:${dtend}`,
|
|
400
|
-
`SUMMARY:${escapeIcal(h.name.en)}`,
|
|
401
|
-
`DESCRIPTION:${escapeIcal(h.name.ms)}${h.hijriDate ? ` (${h.hijriDate})` : ""}`,
|
|
402
|
-
`UID:${h.id}@mycal.my`,
|
|
403
|
-
`STATUS:${h.status === "tentative" ? "TENTATIVE" : "CONFIRMED"}`,
|
|
404
|
-
"END:VEVENT"
|
|
405
|
-
].join("\r\n");
|
|
406
|
-
}
|
|
407
|
-
function schoolHolidayToVevent(h) {
|
|
408
|
-
return [
|
|
409
|
-
"BEGIN:VEVENT",
|
|
410
|
-
`DTSTART;VALUE=DATE:${formatIcalDate(h.startDate)}`,
|
|
411
|
-
`DTEND;VALUE=DATE:${formatIcalDate(h.endDate)}`,
|
|
412
|
-
`SUMMARY:[School] ${escapeIcal(h.name.en)}`,
|
|
413
|
-
`DESCRIPTION:${escapeIcal(h.name.ms)}`,
|
|
414
|
-
`UID:${h.id}@mycal.my`,
|
|
415
|
-
"STATUS:CONFIRMED",
|
|
416
|
-
"END:VEVENT"
|
|
417
|
-
].join("\r\n");
|
|
418
|
-
}
|
|
419
|
-
function generateIcal(holidays, schoolHolidays, calendarName) {
|
|
420
|
-
const header = [
|
|
421
|
-
"BEGIN:VCALENDAR",
|
|
422
|
-
"VERSION:2.0",
|
|
423
|
-
"PRODID:-//MyCal//Malaysia Calendar API//EN",
|
|
424
|
-
`X-WR-CALNAME:${escapeIcal(calendarName)}`,
|
|
425
|
-
"X-WR-TIMEZONE:Asia/Kuala_Lumpur",
|
|
426
|
-
"CALSCALE:GREGORIAN",
|
|
427
|
-
"METHOD:PUBLISH",
|
|
428
|
-
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
|
429
|
-
"X-PUBLISHED-TTL:PT6H"
|
|
430
|
-
].join("\r\n");
|
|
431
|
-
const events = [
|
|
432
|
-
...holidays.filter((h) => h.status !== "cancelled").map(holidayToVevent),
|
|
433
|
-
...schoolHolidays.map(schoolHolidayToVevent)
|
|
434
|
-
].join("\r\n");
|
|
435
|
-
return `${header}\r
|
|
436
|
-
${events}\r
|
|
437
|
-
END:VCALENDAR\r
|
|
438
|
-
`;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// src/hijri.ts
|
|
442
|
-
var HIJRI_MONTHS = [
|
|
443
|
-
{ number: 1, nameMs: "Muharam", nameEn: "Muharram", nameAr: "\u0645\u062D\u0631\u0651\u0645" },
|
|
444
|
-
{ number: 2, nameMs: "Safar", nameEn: "Safar", nameAr: "\u0635\u0641\u0631" },
|
|
445
|
-
{ number: 3, nameMs: "Rabiulawal", nameEn: "Rabi al-Awwal", nameAr: "\u0631\u0628\u064A\u0639 \u0627\u0644\u0623\u0648\u0651\u0644" },
|
|
446
|
-
{ number: 4, nameMs: "Rabiulakhir", nameEn: "Rabi al-Thani", nameAr: "\u0631\u0628\u064A\u0639 \u0627\u0644\u062B\u0627\u0646\u064A" },
|
|
447
|
-
{ number: 5, nameMs: "Jamadilawal", nameEn: "Jumada al-Ula", nameAr: "\u062C\u0645\u0627\u062F\u0649 \u0627\u0644\u0623\u0648\u0644\u0649" },
|
|
448
|
-
{ number: 6, nameMs: "Jamadilakhir", nameEn: "Jumada al-Thani", nameAr: "\u062C\u0645\u0627\u062F\u0649 \u0627\u0644\u062B\u0627\u0646\u064A\u0629" },
|
|
449
|
-
{ number: 7, nameMs: "Rejab", nameEn: "Rajab", nameAr: "\u0631\u062C\u0628" },
|
|
450
|
-
{ number: 8, nameMs: "Syaaban", nameEn: "Sha'ban", nameAr: "\u0634\u0639\u0628\u0627\u0646" },
|
|
451
|
-
{ number: 9, nameMs: "Ramadan", nameEn: "Ramadan", nameAr: "\u0631\u0645\u0636\u0627\u0646" },
|
|
452
|
-
{ number: 10, nameMs: "Syawal", nameEn: "Shawwal", nameAr: "\u0634\u0648\u0651\u0627\u0644" },
|
|
453
|
-
{ number: 11, nameMs: "Zulkaedah", nameEn: "Dhu al-Qi'dah", nameAr: "\u0630\u0648 \u0627\u0644\u0642\u0639\u062F\u0629" },
|
|
454
|
-
{ number: 12, nameMs: "Zulhijjah", nameEn: "Dhu al-Hijjah", nameAr: "\u0630\u0648 \u0627\u0644\u062D\u062C\u0651\u0629" }
|
|
455
|
-
];
|
|
456
|
-
function parseHijriDate(hijriStr) {
|
|
457
|
-
const match = hijriStr.match(/^(\d+)\s+(.+?)\s+(\d+)$/);
|
|
458
|
-
if (!match) return null;
|
|
459
|
-
const day = parseInt(match[1], 10);
|
|
460
|
-
const monthName = match[2].toLowerCase();
|
|
461
|
-
const year = parseInt(match[3], 10);
|
|
462
|
-
const month = HIJRI_MONTHS.find(
|
|
463
|
-
(m) => m.nameMs.toLowerCase() === monthName || m.nameEn.toLowerCase() === monthName
|
|
464
|
-
);
|
|
465
|
-
if (!month) return null;
|
|
466
|
-
return { day, month, year };
|
|
467
|
-
}
|
|
468
|
-
function formatHijriDate(day, monthNumber, year) {
|
|
469
|
-
const month = HIJRI_MONTHS.find((m) => m.number === monthNumber);
|
|
470
|
-
if (!month) return `${day} ? ${year}`;
|
|
471
|
-
return `${day} ${month.nameMs} ${year}`;
|
|
472
|
-
}
|
|
473
|
-
var ISLAMIC_HOLIDAY_DATES = [
|
|
474
|
-
{ nameMs: "Awal Muharam", nameEn: "Islamic New Year", hijriMonth: 1, hijriDay: 1 },
|
|
475
|
-
{ nameMs: "Maulidur Rasul", nameEn: "Prophet Muhammad's Birthday", hijriMonth: 3, hijriDay: 12 },
|
|
476
|
-
{ nameMs: "Israk dan Mikraj", nameEn: "Isra' Mi'raj", hijriMonth: 7, hijriDay: 27 },
|
|
477
|
-
{ nameMs: "Nuzul Al-Quran", nameEn: "Revelation of the Quran", hijriMonth: 9, hijriDay: 17 },
|
|
478
|
-
{ nameMs: "Hari Raya Aidilfitri", nameEn: "Eid al-Fitr", hijriMonth: 10, hijriDay: 1 },
|
|
479
|
-
{ nameMs: "Hari Raya Haji", nameEn: "Eid al-Adha", hijriMonth: 12, hijriDay: 10 },
|
|
480
|
-
{ nameMs: "Hari Arafah", nameEn: "Day of Arafah", hijriMonth: 12, hijriDay: 9 }
|
|
481
|
-
];
|
|
482
|
-
export {
|
|
483
|
-
HIJRI_MONTHS,
|
|
484
|
-
ISLAMIC_HOLIDAY_DATES,
|
|
485
|
-
addBusinessDays,
|
|
486
|
-
addDays,
|
|
487
|
-
calculateReplacementHolidays,
|
|
488
|
-
countBusinessDays,
|
|
489
|
-
diffDays,
|
|
490
|
-
examSchema,
|
|
491
|
-
examsFileSchema,
|
|
492
|
-
filterHolidays,
|
|
493
|
-
findHolidaysByDate,
|
|
494
|
-
findNextHoliday,
|
|
495
|
-
findSchoolHolidayByDate,
|
|
496
|
-
findSchoolTermByDate,
|
|
497
|
-
formatHijriDate,
|
|
498
|
-
generateIcal,
|
|
499
|
-
getDayOfWeekName,
|
|
500
|
-
getStateByCode,
|
|
501
|
-
getStatesByGroup,
|
|
502
|
-
getWeekendConfig,
|
|
503
|
-
getWeekendDayNames,
|
|
504
|
-
holidayFileSchema,
|
|
505
|
-
holidaySchema,
|
|
506
|
-
isSchoolDay,
|
|
507
|
-
isWeekend,
|
|
508
|
-
nextWorkingDay,
|
|
509
|
-
parseHijriDate,
|
|
510
|
-
resolveStateCode,
|
|
511
|
-
schoolHolidaySchema,
|
|
512
|
-
schoolHolidaysFileSchema,
|
|
513
|
-
schoolTermSchema,
|
|
514
|
-
schoolTermsFileSchema,
|
|
515
|
-
stateSchema,
|
|
516
|
-
statesFileSchema
|
|
517
|
-
};
|
|
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";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Holiday, State, LongWeekend, LeaveSuggestion } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Natural long weekends for a state in a given year: runs of 3+ consecutive
|
|
4
|
+
* non-working days (weekend days and/or public holidays) that need no leave.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the REST `/holidays/long-weekends` route and the MCP server so the
|
|
7
|
+
* two cannot diverge.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findLongWeekends(year: number, state: State, holidays: readonly Holiday[]): LongWeekend[];
|
|
10
|
+
/**
|
|
11
|
+
* Leave optimizer: where can you spend up to `maxLeave` working days to chain
|
|
12
|
+
* weekends and public holidays into the longest possible continuous break?
|
|
13
|
+
*
|
|
14
|
+
* Returns suggestions sorted by efficiency (days off gained per leave day spent),
|
|
15
|
+
* then by total length. Each suggestion's `leaveDates` are the exact working days
|
|
16
|
+
* to request off — e.g. "take 2026-09-14 & 2026-09-15 → 2026-09-12…2026-09-16".
|
|
17
|
+
*/
|
|
18
|
+
export declare function optimizeLeave(year: number, state: State, holidays: readonly Holiday[], maxLeave?: number): LeaveSuggestion[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { addDays, diffDays, isWeekend } from "./weekend.js";
|
|
2
|
+
import { groupHolidaysByDate } from "./filter.js";
|
|
3
|
+
/**
|
|
4
|
+
* Natural long weekends for a state in a given year: runs of 3+ consecutive
|
|
5
|
+
* non-working days (weekend days and/or public holidays) that need no leave.
|
|
6
|
+
*
|
|
7
|
+
* Shared by the REST `/holidays/long-weekends` route and the MCP server so the
|
|
8
|
+
* two cannot diverge.
|
|
9
|
+
*/
|
|
10
|
+
export function findLongWeekends(year, state, holidays) {
|
|
11
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
12
|
+
const isOff = (d) => isWeekend(d, state) || holidayMap.has(d);
|
|
13
|
+
const yearStart = `${year}-01-01`;
|
|
14
|
+
const yearEnd = `${year}-12-31`;
|
|
15
|
+
// Scan a little past both year boundaries so a run that is still "off" at
|
|
16
|
+
// Dec 31 (or already running on Jan 1) is measured in full instead of being
|
|
17
|
+
// truncated at the boundary. A run is only *reported* when it starts within
|
|
18
|
+
// the requested year, so a boundary-straddling long weekend is attributed to
|
|
19
|
+
// exactly one year and never double-counted.
|
|
20
|
+
const scanStart = addDays(yearStart, -7);
|
|
21
|
+
const scanEnd = addDays(yearEnd, 7);
|
|
22
|
+
const result = [];
|
|
23
|
+
let current = scanStart;
|
|
24
|
+
while (current <= scanEnd) {
|
|
25
|
+
if (!isOff(current)) {
|
|
26
|
+
current = addDays(current, 1);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const startDate = current;
|
|
30
|
+
const streakHolidays = [];
|
|
31
|
+
let weekendDays = 0;
|
|
32
|
+
let totalDays = 0;
|
|
33
|
+
while (current <= scanEnd && isOff(current)) {
|
|
34
|
+
if (isWeekend(current, state))
|
|
35
|
+
weekendDays++;
|
|
36
|
+
const hs = holidayMap.get(current);
|
|
37
|
+
if (hs)
|
|
38
|
+
streakHolidays.push(...hs);
|
|
39
|
+
totalDays++;
|
|
40
|
+
current = addDays(current, 1);
|
|
41
|
+
}
|
|
42
|
+
if (totalDays >= 3 && startDate >= yearStart && startDate <= yearEnd) {
|
|
43
|
+
result.push({
|
|
44
|
+
startDate,
|
|
45
|
+
endDate: addDays(current, -1),
|
|
46
|
+
totalDays,
|
|
47
|
+
holidays: streakHolidays,
|
|
48
|
+
weekendDays,
|
|
49
|
+
bridgeDaysNeeded: 0, // natural long weekend — no leave required
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Leave optimizer: where can you spend up to `maxLeave` working days to chain
|
|
57
|
+
* weekends and public holidays into the longest possible continuous break?
|
|
58
|
+
*
|
|
59
|
+
* Returns suggestions sorted by efficiency (days off gained per leave day spent),
|
|
60
|
+
* then by total length. Each suggestion's `leaveDates` are the exact working days
|
|
61
|
+
* to request off — e.g. "take 2026-09-14 & 2026-09-15 → 2026-09-12…2026-09-16".
|
|
62
|
+
*/
|
|
63
|
+
export function optimizeLeave(year, state, holidays, maxLeave = 3) {
|
|
64
|
+
if (!Number.isInteger(maxLeave) || maxLeave < 1)
|
|
65
|
+
return [];
|
|
66
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
67
|
+
const isOff = (d) => isWeekend(d, state) || holidayMap.has(d);
|
|
68
|
+
const yearEnd = `${year}-12-31`;
|
|
69
|
+
const suggestions = [];
|
|
70
|
+
let s = `${year}-01-01`;
|
|
71
|
+
while (s <= yearEnd) {
|
|
72
|
+
// A break starts on an off-day whose previous day is a working day.
|
|
73
|
+
if (!isOff(s) || isOff(addDays(s, -1))) {
|
|
74
|
+
s = addDays(s, 1);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let leave = 0;
|
|
78
|
+
let d = s;
|
|
79
|
+
while (d <= yearEnd) {
|
|
80
|
+
if (!isOff(d)) {
|
|
81
|
+
leave++;
|
|
82
|
+
if (leave > maxLeave)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const next = addDays(d, 1);
|
|
86
|
+
// Window [s..d] is a complete break when it ends on an off-day followed by
|
|
87
|
+
// a working day, and at least one leave day was spent inside it.
|
|
88
|
+
if (leave >= 1 && isOff(d) && !isOff(next)) {
|
|
89
|
+
const leaveDates = [];
|
|
90
|
+
const within = [];
|
|
91
|
+
let c = s;
|
|
92
|
+
while (c <= d) {
|
|
93
|
+
if (!isOff(c))
|
|
94
|
+
leaveDates.push(c);
|
|
95
|
+
const hs = holidayMap.get(c);
|
|
96
|
+
if (hs)
|
|
97
|
+
within.push(...hs);
|
|
98
|
+
c = addDays(c, 1);
|
|
99
|
+
}
|
|
100
|
+
const totalDays = diffDays(s, d) + 1;
|
|
101
|
+
suggestions.push({
|
|
102
|
+
startDate: s,
|
|
103
|
+
endDate: d,
|
|
104
|
+
totalDays,
|
|
105
|
+
leaveDates,
|
|
106
|
+
leaveCost: leaveDates.length,
|
|
107
|
+
efficiency: Math.round((totalDays / leaveDates.length) * 100) / 100,
|
|
108
|
+
holidays: within,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
d = next;
|
|
112
|
+
}
|
|
113
|
+
s = addDays(s, 1);
|
|
114
|
+
}
|
|
115
|
+
suggestions.sort((a, b) => b.efficiency - a.efficiency ||
|
|
116
|
+
b.totalDays - a.totalDays ||
|
|
117
|
+
a.startDate.localeCompare(b.startDate));
|
|
118
|
+
// Collapse duplicate windows (the same break can be reached from one start).
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
return suggestions.filter((x) => {
|
|
121
|
+
const key = `${x.startDate}:${x.endDate}`;
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
return false;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|