@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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Holiday, State, BusinessDaysResult } from "./types.js";
|
|
2
|
+
export declare function countBusinessDays(start: string, end: string, state: State, holidays: readonly Holiday[]): BusinessDaysResult;
|
|
3
|
+
export declare function addBusinessDays(startDate: string, daysToAdd: number, state: State, holidays: readonly Holiday[]): string;
|
|
4
|
+
export declare function subtractBusinessDays(startDate: string, daysToSubtract: number, state: State, holidays: readonly Holiday[]): string;
|
|
5
|
+
export declare function isBusinessDay(date: string, state: State, holidays: readonly Holiday[]): boolean;
|
|
6
|
+
export declare function nextBusinessDay(date: string, state: State, holidays: readonly Holiday[]): string;
|
|
7
|
+
export declare function previousBusinessDay(date: string, state: State, holidays: readonly Holiday[]): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { addDays, isWeekend } from "./weekend.js";
|
|
2
|
+
import { groupHolidaysByDate } from "./filter.js";
|
|
3
|
+
// Hard backstop so a malformed or malicious caller cannot spin an unbounded
|
|
4
|
+
// loop. The HTTP layer validates and clamps inputs before reaching here; this
|
|
5
|
+
// also protects direct consumers of the library.
|
|
6
|
+
const MAX_SPAN_DAYS = 366 * 100; // ~100 years
|
|
7
|
+
export function countBusinessDays(start, end, state, holidays) {
|
|
8
|
+
if (start > end) {
|
|
9
|
+
throw new RangeError(`start (${start}) must be on or before end (${end})`);
|
|
10
|
+
}
|
|
11
|
+
// Index holidays by date once — O(D + H) instead of an O(H) scan per day.
|
|
12
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
13
|
+
let businessDays = 0;
|
|
14
|
+
let weekendCount = 0;
|
|
15
|
+
let holidayCount = 0;
|
|
16
|
+
let totalDays = 0;
|
|
17
|
+
const holidayList = [];
|
|
18
|
+
const seenIds = new Set();
|
|
19
|
+
let current = start;
|
|
20
|
+
while (current <= end) {
|
|
21
|
+
if (++totalDays > MAX_SPAN_DAYS) {
|
|
22
|
+
throw new RangeError(`date range exceeds the maximum span of ${MAX_SPAN_DAYS} days`);
|
|
23
|
+
}
|
|
24
|
+
const weekend = isWeekend(current, state);
|
|
25
|
+
const dayHolidays = holidayMap.get(current);
|
|
26
|
+
if (weekend) {
|
|
27
|
+
weekendCount++;
|
|
28
|
+
}
|
|
29
|
+
else if (dayHolidays) {
|
|
30
|
+
holidayCount++;
|
|
31
|
+
for (const h of dayHolidays) {
|
|
32
|
+
if (!seenIds.has(h.id)) {
|
|
33
|
+
seenIds.add(h.id);
|
|
34
|
+
holidayList.push(h);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
businessDays++;
|
|
40
|
+
}
|
|
41
|
+
current = addDays(current, 1);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
totalDays,
|
|
45
|
+
businessDays,
|
|
46
|
+
holidays: holidayCount,
|
|
47
|
+
weekendDays: weekendCount,
|
|
48
|
+
holidayList,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function assertValidStep(value, label) {
|
|
52
|
+
if (!Number.isInteger(value)) {
|
|
53
|
+
throw new RangeError(`${label} must be an integer (received ${value})`);
|
|
54
|
+
}
|
|
55
|
+
if (value < 0) {
|
|
56
|
+
throw new RangeError(`${label} must be non-negative (received ${value})`);
|
|
57
|
+
}
|
|
58
|
+
if (value > MAX_SPAN_DAYS) {
|
|
59
|
+
throw new RangeError(`${label} exceeds the maximum of ${MAX_SPAN_DAYS}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function addBusinessDays(startDate, daysToAdd, state, holidays) {
|
|
63
|
+
assertValidStep(daysToAdd, "daysToAdd");
|
|
64
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
65
|
+
let remaining = daysToAdd;
|
|
66
|
+
let current = startDate;
|
|
67
|
+
let guard = 0;
|
|
68
|
+
while (remaining > 0) {
|
|
69
|
+
if (++guard > MAX_SPAN_DAYS * 2) {
|
|
70
|
+
throw new RangeError("addBusinessDays exceeded its iteration limit");
|
|
71
|
+
}
|
|
72
|
+
current = addDays(current, 1);
|
|
73
|
+
if (!isWeekend(current, state) && !holidayMap.has(current)) {
|
|
74
|
+
remaining--;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return current;
|
|
78
|
+
}
|
|
79
|
+
export function subtractBusinessDays(startDate, daysToSubtract, state, holidays) {
|
|
80
|
+
assertValidStep(daysToSubtract, "daysToSubtract");
|
|
81
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
82
|
+
let remaining = daysToSubtract;
|
|
83
|
+
let current = startDate;
|
|
84
|
+
let guard = 0;
|
|
85
|
+
while (remaining > 0) {
|
|
86
|
+
if (++guard > MAX_SPAN_DAYS * 2) {
|
|
87
|
+
throw new RangeError("subtractBusinessDays exceeded its iteration limit");
|
|
88
|
+
}
|
|
89
|
+
current = addDays(current, -1);
|
|
90
|
+
if (!isWeekend(current, state) && !holidayMap.has(current)) {
|
|
91
|
+
remaining--;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return current;
|
|
95
|
+
}
|
|
96
|
+
export function isBusinessDay(date, state, holidays) {
|
|
97
|
+
if (isWeekend(date, state))
|
|
98
|
+
return false;
|
|
99
|
+
return !groupHolidaysByDate(holidays, state.code).has(date);
|
|
100
|
+
}
|
|
101
|
+
export function nextBusinessDay(date, state, holidays) {
|
|
102
|
+
return addBusinessDays(date, 1, state, holidays);
|
|
103
|
+
}
|
|
104
|
+
export function previousBusinessDay(date, state, holidays) {
|
|
105
|
+
return subtractBusinessDays(date, 1, state, holidays);
|
|
106
|
+
}
|
package/dist/filter.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Holiday, HolidayType, HolidayStatus } from "./types.js";
|
|
2
|
+
export interface HolidayFilter {
|
|
3
|
+
readonly year?: number;
|
|
4
|
+
readonly month?: number;
|
|
5
|
+
readonly state?: string;
|
|
6
|
+
readonly type?: HolidayType;
|
|
7
|
+
readonly status?: HolidayStatus;
|
|
8
|
+
}
|
|
9
|
+
export declare function filterHolidays(holidays: readonly Holiday[], filter: HolidayFilter): readonly Holiday[];
|
|
10
|
+
export declare function findHolidaysByDate(date: string, holidays: readonly Holiday[], stateCode?: string): readonly Holiday[];
|
|
11
|
+
/**
|
|
12
|
+
* Index holidays by their ISO date for O(1) date lookups, skipping cancelled
|
|
13
|
+
* holidays and (when `stateCode` is given) holidays that do not apply to that
|
|
14
|
+
* state. Shared by the business-day and long-weekend scanners so they don't each
|
|
15
|
+
* re-filter the array on every day.
|
|
16
|
+
*/
|
|
17
|
+
export declare function groupHolidaysByDate(holidays: readonly Holiday[], stateCode?: string): Map<string, Holiday[]>;
|
|
18
|
+
export declare function findNextHoliday(afterDate: string, holidays: readonly Holiday[], stateCode?: string, type?: HolidayType, limit?: number): readonly Holiday[];
|
package/dist/filter.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function filterHolidays(holidays, filter) {
|
|
2
|
+
return holidays.filter((h) => {
|
|
3
|
+
if (filter.year !== undefined) {
|
|
4
|
+
const year = parseInt(h.date.slice(0, 4), 10);
|
|
5
|
+
if (year !== filter.year)
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
if (filter.month !== undefined) {
|
|
9
|
+
const month = parseInt(h.date.slice(5, 7), 10);
|
|
10
|
+
if (month !== filter.month)
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (filter.state !== undefined) {
|
|
14
|
+
if (!h.states.includes("*") && !h.states.includes(filter.state)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (filter.type !== undefined && h.type !== filter.type)
|
|
19
|
+
return false;
|
|
20
|
+
if (filter.status !== undefined && h.status !== filter.status)
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function findHolidaysByDate(date, holidays, stateCode) {
|
|
26
|
+
return holidays.filter((h) => {
|
|
27
|
+
if (h.date !== date)
|
|
28
|
+
return false;
|
|
29
|
+
if (h.status === "cancelled")
|
|
30
|
+
return false;
|
|
31
|
+
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Index holidays by their ISO date for O(1) date lookups, skipping cancelled
|
|
39
|
+
* holidays and (when `stateCode` is given) holidays that do not apply to that
|
|
40
|
+
* state. Shared by the business-day and long-weekend scanners so they don't each
|
|
41
|
+
* re-filter the array on every day.
|
|
42
|
+
*/
|
|
43
|
+
export function groupHolidaysByDate(holidays, stateCode) {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
for (const h of holidays) {
|
|
46
|
+
if (h.status === "cancelled")
|
|
47
|
+
continue;
|
|
48
|
+
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const list = map.get(h.date);
|
|
52
|
+
if (list)
|
|
53
|
+
list.push(h);
|
|
54
|
+
else
|
|
55
|
+
map.set(h.date, [h]);
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
export function findNextHoliday(afterDate, holidays, stateCode, type, limit = 1) {
|
|
60
|
+
const filtered = holidays
|
|
61
|
+
.filter((h) => {
|
|
62
|
+
if (h.date <= afterDate)
|
|
63
|
+
return false;
|
|
64
|
+
if (h.status === "cancelled")
|
|
65
|
+
return false;
|
|
66
|
+
if (stateCode && !h.states.includes("*") && !h.states.includes(stateCode)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (type !== undefined && h.type !== type)
|
|
70
|
+
return false;
|
|
71
|
+
return true;
|
|
72
|
+
})
|
|
73
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
74
|
+
return filtered.slice(0, limit);
|
|
75
|
+
}
|
package/dist/hijri.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic Hijri date utilities.
|
|
3
|
+
*
|
|
4
|
+
* Note: Hijri dates for Islamic holidays are approximate until confirmed
|
|
5
|
+
* by JAKIM rukyah (moon sighting). This module provides reference mapping
|
|
6
|
+
* only — actual dates should come from the gazette/JAKIM confirmation.
|
|
7
|
+
*/
|
|
8
|
+
export interface HijriMonth {
|
|
9
|
+
readonly number: number;
|
|
10
|
+
readonly nameMs: string;
|
|
11
|
+
readonly nameEn: string;
|
|
12
|
+
readonly nameAr: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const HIJRI_MONTHS: readonly HijriMonth[];
|
|
15
|
+
/**
|
|
16
|
+
* Parse a hijri date string like "1 Syawal 1447" → { day, month, year }
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseHijriDate(hijriStr: string): {
|
|
19
|
+
day: number;
|
|
20
|
+
month: HijriMonth;
|
|
21
|
+
year: number;
|
|
22
|
+
} | null;
|
|
23
|
+
/**
|
|
24
|
+
* Format a hijri date for display: "1 Syawal 1447"
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatHijriDate(day: number, monthNumber: number, year: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* Key Islamic holidays and their Hijri dates (fixed in the Islamic calendar).
|
|
29
|
+
* Gregorian dates shift ~11 days earlier each year.
|
|
30
|
+
*/
|
|
31
|
+
export declare const ISLAMIC_HOLIDAY_DATES: readonly {
|
|
32
|
+
readonly nameMs: string;
|
|
33
|
+
readonly nameEn: string;
|
|
34
|
+
readonly hijriMonth: number;
|
|
35
|
+
readonly hijriDay: number;
|
|
36
|
+
}[];
|
package/dist/hijri.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic Hijri date utilities.
|
|
3
|
+
*
|
|
4
|
+
* Note: Hijri dates for Islamic holidays are approximate until confirmed
|
|
5
|
+
* by JAKIM rukyah (moon sighting). This module provides reference mapping
|
|
6
|
+
* only — actual dates should come from the gazette/JAKIM confirmation.
|
|
7
|
+
*/
|
|
8
|
+
export const HIJRI_MONTHS = [
|
|
9
|
+
{ number: 1, nameMs: "Muharam", nameEn: "Muharram", nameAr: "محرّم" },
|
|
10
|
+
{ number: 2, nameMs: "Safar", nameEn: "Safar", nameAr: "صفر" },
|
|
11
|
+
{ number: 3, nameMs: "Rabiulawal", nameEn: "Rabi al-Awwal", nameAr: "ربيع الأوّل" },
|
|
12
|
+
{ number: 4, nameMs: "Rabiulakhir", nameEn: "Rabi al-Thani", nameAr: "ربيع الثاني" },
|
|
13
|
+
{ number: 5, nameMs: "Jamadilawal", nameEn: "Jumada al-Ula", nameAr: "جمادى الأولى" },
|
|
14
|
+
{ number: 6, nameMs: "Jamadilakhir", nameEn: "Jumada al-Thani", nameAr: "جمادى الثانية" },
|
|
15
|
+
{ number: 7, nameMs: "Rejab", nameEn: "Rajab", nameAr: "رجب" },
|
|
16
|
+
{ number: 8, nameMs: "Syaaban", nameEn: "Sha'ban", nameAr: "شعبان" },
|
|
17
|
+
{ number: 9, nameMs: "Ramadan", nameEn: "Ramadan", nameAr: "رمضان" },
|
|
18
|
+
{ number: 10, nameMs: "Syawal", nameEn: "Shawwal", nameAr: "شوّال" },
|
|
19
|
+
{ number: 11, nameMs: "Zulkaedah", nameEn: "Dhu al-Qi'dah", nameAr: "ذو القعدة" },
|
|
20
|
+
{ number: 12, nameMs: "Zulhijjah", nameEn: "Dhu al-Hijjah", nameAr: "ذو الحجّة" },
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Parse a hijri date string like "1 Syawal 1447" → { day, month, year }
|
|
24
|
+
*/
|
|
25
|
+
export function parseHijriDate(hijriStr) {
|
|
26
|
+
const match = hijriStr.match(/^(\d+)\s+(.+?)\s+(\d+)$/);
|
|
27
|
+
if (!match)
|
|
28
|
+
return null;
|
|
29
|
+
const day = parseInt(match[1], 10);
|
|
30
|
+
const monthName = match[2].toLowerCase();
|
|
31
|
+
const year = parseInt(match[3], 10);
|
|
32
|
+
const month = HIJRI_MONTHS.find((m) => m.nameMs.toLowerCase() === monthName ||
|
|
33
|
+
m.nameEn.toLowerCase() === monthName);
|
|
34
|
+
if (!month)
|
|
35
|
+
return null;
|
|
36
|
+
return { day, month, year };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Format a hijri date for display: "1 Syawal 1447"
|
|
40
|
+
*/
|
|
41
|
+
export function formatHijriDate(day, monthNumber, year) {
|
|
42
|
+
const month = HIJRI_MONTHS.find((m) => m.number === monthNumber);
|
|
43
|
+
if (!month)
|
|
44
|
+
return `${day} ? ${year}`;
|
|
45
|
+
return `${day} ${month.nameMs} ${year}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Key Islamic holidays and their Hijri dates (fixed in the Islamic calendar).
|
|
49
|
+
* Gregorian dates shift ~11 days earlier each year.
|
|
50
|
+
*/
|
|
51
|
+
export const ISLAMIC_HOLIDAY_DATES = [
|
|
52
|
+
{ nameMs: "Awal Muharam", nameEn: "Islamic New Year", hijriMonth: 1, hijriDay: 1 },
|
|
53
|
+
{ nameMs: "Maulidur Rasul", nameEn: "Prophet Muhammad's Birthday", hijriMonth: 3, hijriDay: 12 },
|
|
54
|
+
{ nameMs: "Israk dan Mikraj", nameEn: "Isra' Mi'raj", hijriMonth: 7, hijriDay: 27 },
|
|
55
|
+
{ nameMs: "Nuzul Al-Quran", nameEn: "Revelation of the Quran", hijriMonth: 9, hijriDay: 17 },
|
|
56
|
+
{ nameMs: "Hari Raya Aidilfitri", nameEn: "Eid al-Fitr", hijriMonth: 10, hijriDay: 1 },
|
|
57
|
+
{ nameMs: "Hari Raya Haji", nameEn: "Eid al-Adha", hijriMonth: 12, hijriDay: 10 },
|
|
58
|
+
{ nameMs: "Hari Arafah", nameEn: "Day of Arafah", hijriMonth: 12, hijriDay: 9 },
|
|
59
|
+
];
|
package/dist/ical.d.ts
ADDED
package/dist/ical.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function escapeIcal(text) {
|
|
2
|
+
// RFC 5545 §3.3.11: escape backslash, semicolon and comma, then fold every
|
|
3
|
+
// newline form (CRLF, lone CR, lone LF) to the literal "\n" sequence so a
|
|
4
|
+
// stray carriage return cannot break the ICS line structure.
|
|
5
|
+
return text
|
|
6
|
+
.replace(/[\\;,]/g, (c) => `\\${c}`)
|
|
7
|
+
.replace(/\r\n|\r|\n/g, "\\n");
|
|
8
|
+
}
|
|
9
|
+
function formatIcalDate(date) {
|
|
10
|
+
return date.replace(/-/g, "");
|
|
11
|
+
}
|
|
12
|
+
// Machine-readable CATEGORIES (RFC 5545 §3.8.1.2) — the main thing Google's bare
|
|
13
|
+
// Malaysia calendar lacks, where holiday type/scope is buried in free-text.
|
|
14
|
+
function holidayCategories(h) {
|
|
15
|
+
const cats = ["Public Holiday"];
|
|
16
|
+
switch (h.type) {
|
|
17
|
+
case "federal":
|
|
18
|
+
cats.push("Federal");
|
|
19
|
+
break;
|
|
20
|
+
case "state":
|
|
21
|
+
cats.push("State");
|
|
22
|
+
break;
|
|
23
|
+
case "islamic":
|
|
24
|
+
case "islamic_state":
|
|
25
|
+
cats.push("Religious", "Islamic");
|
|
26
|
+
break;
|
|
27
|
+
case "replacement":
|
|
28
|
+
cats.push("Replacement");
|
|
29
|
+
break;
|
|
30
|
+
case "adhoc":
|
|
31
|
+
cats.push("Ad-hoc");
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (h.status === "tentative")
|
|
35
|
+
cats.push("Tentative");
|
|
36
|
+
return cats.map(escapeIcal).join(",");
|
|
37
|
+
}
|
|
38
|
+
function holidayToVevent(h) {
|
|
39
|
+
const dtstart = formatIcalDate(h.date);
|
|
40
|
+
const dtend = h.endDate ? formatIcalDate(h.endDate) : formatIcalDate(h.date);
|
|
41
|
+
return [
|
|
42
|
+
"BEGIN:VEVENT",
|
|
43
|
+
`DTSTART;VALUE=DATE:${dtstart}`,
|
|
44
|
+
`DTEND;VALUE=DATE:${dtend}`,
|
|
45
|
+
`SUMMARY:${escapeIcal(h.name.en)}`,
|
|
46
|
+
`DESCRIPTION:${escapeIcal(h.name.ms)}${h.hijriDate ? ` (${h.hijriDate})` : ""}`,
|
|
47
|
+
`CATEGORIES:${holidayCategories(h)}`,
|
|
48
|
+
`UID:${h.id}@mycal.my`,
|
|
49
|
+
"TRANSP:TRANSPARENT",
|
|
50
|
+
`STATUS:${h.status === "tentative" ? "TENTATIVE" : "CONFIRMED"}`,
|
|
51
|
+
"END:VEVENT",
|
|
52
|
+
].join("\r\n");
|
|
53
|
+
}
|
|
54
|
+
function schoolHolidayToVevent(h) {
|
|
55
|
+
return [
|
|
56
|
+
"BEGIN:VEVENT",
|
|
57
|
+
`DTSTART;VALUE=DATE:${formatIcalDate(h.startDate)}`,
|
|
58
|
+
`DTEND;VALUE=DATE:${formatIcalDate(h.endDate)}`,
|
|
59
|
+
`SUMMARY:[School] ${escapeIcal(h.name.en)}`,
|
|
60
|
+
`DESCRIPTION:${escapeIcal(h.name.ms)}`,
|
|
61
|
+
"CATEGORIES:School Holiday",
|
|
62
|
+
`UID:${h.id}@mycal.my`,
|
|
63
|
+
"TRANSP:TRANSPARENT",
|
|
64
|
+
"STATUS:CONFIRMED",
|
|
65
|
+
"END:VEVENT",
|
|
66
|
+
].join("\r\n");
|
|
67
|
+
}
|
|
68
|
+
export function generateIcal(holidays, schoolHolidays, calendarName, calendarDescription = "Malaysian public holidays and school calendar — mycal.my") {
|
|
69
|
+
const header = [
|
|
70
|
+
"BEGIN:VCALENDAR",
|
|
71
|
+
"VERSION:2.0",
|
|
72
|
+
"PRODID:-//MyCal//Malaysia Calendar API//EN",
|
|
73
|
+
`X-WR-CALNAME:${escapeIcal(calendarName)}`,
|
|
74
|
+
`NAME:${escapeIcal(calendarName)}`, // RFC 7986
|
|
75
|
+
`X-WR-CALDESC:${escapeIcal(calendarDescription)}`,
|
|
76
|
+
`DESCRIPTION:${escapeIcal(calendarDescription)}`, // RFC 7986
|
|
77
|
+
"X-WR-TIMEZONE:Asia/Kuala_Lumpur",
|
|
78
|
+
"COLOR:forestgreen", // RFC 7986 (CSS3 colour name)
|
|
79
|
+
"X-APPLE-CALENDAR-COLOR:#22C55E",
|
|
80
|
+
"CALSCALE:GREGORIAN",
|
|
81
|
+
"METHOD:PUBLISH",
|
|
82
|
+
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
|
83
|
+
"X-PUBLISHED-TTL:PT6H",
|
|
84
|
+
].join("\r\n");
|
|
85
|
+
const events = [
|
|
86
|
+
...holidays.filter((h) => h.status !== "cancelled").map(holidayToVevent),
|
|
87
|
+
...schoolHolidays.map(schoolHolidayToVevent),
|
|
88
|
+
].join("\r\n");
|
|
89
|
+
return `${header}\r\n${events}\r\nEND:VCALENDAR\r\n`;
|
|
90
|
+
}
|