@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/dist/index.js CHANGED
@@ -1,517 +1,22 @@
1
- // src/schemas.ts
2
- import { z } from "zod";
3
- var isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO 8601 date (YYYY-MM-DD)");
4
- var localizedString = z.object({
5
- ms: z.string().min(1),
6
- en: z.string().min(1),
7
- zh: z.string().optional()
8
- });
9
- var holidayTypeSchema = z.enum([
10
- "federal",
11
- "state",
12
- "islamic",
13
- "islamic_state",
14
- "replacement",
15
- "adhoc"
16
- ]);
17
- var holidayStatusSchema = z.enum([
18
- "confirmed",
19
- "tentative",
20
- "announced",
21
- "cancelled"
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
+ }
@@ -0,0 +1,2 @@
1
+ import type { Holiday, State } from "./types.js";
2
+ export declare function calculateReplacementHolidays(holidays: readonly Holiday[], state: State): readonly Holiday[];