@catlabtech/mycal-core 0.1.0 → 0.1.2
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 +56 -0
- package/dist/index.d.ts +91 -2
- package/dist/index.js +331 -58
- package/package.json +9 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CatLab Tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @catlabtech/mycal-core
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@catlabtech/mycal-core)
|
|
4
|
+
[](https://github.com/Junhui20/malaysia-calendar-api/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
Shared types, Zod schemas, and business logic for the **Malaysia Calendar API**. This is the underlying library used by [`@catlabtech/mycal-sdk`](https://www.npmjs.com/package/@catlabtech/mycal-sdk) and [`@catlabtech/mycal-mcp-server`](https://www.npmjs.com/package/@catlabtech/mycal-mcp-server).
|
|
7
|
+
|
|
8
|
+
> Most users should install the SDK or MCP server instead. Use `@catlabtech/mycal-core` directly only if you need the raw types, schemas, or pure business logic (e.g. for server-side implementations or custom runtimes).
|
|
9
|
+
|
|
10
|
+
## What's inside
|
|
11
|
+
|
|
12
|
+
- **TypeScript types** — `Holiday`, `State`, `SchoolTerm`, `SchoolHoliday`, `Exam`, `CheckDateResult`, `BusinessDaysResult`, `LongWeekend`, etc.
|
|
13
|
+
- **Zod schemas** — runtime validation matching the types exactly
|
|
14
|
+
- **State resolver** — alias → canonical state code (e.g. `kl` → `kuala-lumpur`)
|
|
15
|
+
- **Weekend logic** — per-state weekend history including Johor's 2014–2024 Fri–Sat switch
|
|
16
|
+
- **Business day calculations** — state-aware, holiday-aware
|
|
17
|
+
- **Cuti ganti generator** — replacement holiday logic per state rules
|
|
18
|
+
- **iCal generator** — RFC 5545 output with auto-refresh hints
|
|
19
|
+
- **School calendar logic** — term / holiday lookups for Kumpulan A and B
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @catlabtech/mycal-core
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Node 18+. ESM only.
|
|
28
|
+
|
|
29
|
+
## Example
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import {
|
|
33
|
+
resolveStateCode,
|
|
34
|
+
countBusinessDays,
|
|
35
|
+
isWeekend,
|
|
36
|
+
type Holiday,
|
|
37
|
+
type State,
|
|
38
|
+
} from "@catlabtech/mycal-core";
|
|
39
|
+
|
|
40
|
+
// Alias resolution
|
|
41
|
+
const { canonical } = resolveStateCode("kl", allStates); // "kuala-lumpur"
|
|
42
|
+
|
|
43
|
+
// Type-safe filtering
|
|
44
|
+
const federal = holidays.filter((h): h is Holiday => h.type === "federal");
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Documentation
|
|
48
|
+
|
|
49
|
+
- **Full docs:** [https://mycal-web.pages.dev/docs](https://mycal-web.pages.dev/docs)
|
|
50
|
+
- **Type reference:** [https://mycal-web.pages.dev/docs/sdk/reference](https://mycal-web.pages.dev/docs/sdk/reference)
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT — © catlab.tech
|
|
55
|
+
|
|
56
|
+
Issues and contributions: [github.com/Junhui20/malaysia-calendar-api](https://github.com/Junhui20/malaysia-calendar-api)
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ interface LocalizedString {
|
|
|
6
6
|
readonly zh?: string;
|
|
7
7
|
}
|
|
8
8
|
type HolidayType = "federal" | "state" | "islamic" | "islamic_state" | "replacement" | "adhoc";
|
|
9
|
+
type HolidayCategory = "public" | "bank" | "optional" | "half_day" | "observance";
|
|
9
10
|
type HolidayStatus = "confirmed" | "tentative" | "announced" | "cancelled";
|
|
10
11
|
type GazetteLevel = "P" | "N";
|
|
11
12
|
type HolidaySource = "jpm" | "jakim" | "state-gov" | "community" | "admin";
|
|
@@ -23,6 +24,8 @@ interface Holiday {
|
|
|
23
24
|
readonly hijriDate?: string;
|
|
24
25
|
readonly gazetteRef?: string;
|
|
25
26
|
readonly source: HolidaySource;
|
|
27
|
+
readonly category?: HolidayCategory;
|
|
28
|
+
readonly isEstimated?: boolean;
|
|
26
29
|
readonly confirmedAt?: string;
|
|
27
30
|
readonly createdAt: string;
|
|
28
31
|
readonly updatedAt: string;
|
|
@@ -42,6 +45,7 @@ interface State {
|
|
|
42
45
|
readonly type: StateType;
|
|
43
46
|
readonly weekendHistory: readonly WeekendConfig[];
|
|
44
47
|
readonly group: StateGroup;
|
|
48
|
+
readonly isoCode?: string;
|
|
45
49
|
}
|
|
46
50
|
interface SchoolTermSegment {
|
|
47
51
|
readonly startDate: string;
|
|
@@ -120,6 +124,15 @@ interface LongWeekend {
|
|
|
120
124
|
readonly weekendDays: number;
|
|
121
125
|
readonly bridgeDaysNeeded: number;
|
|
122
126
|
}
|
|
127
|
+
interface LeaveSuggestion {
|
|
128
|
+
readonly startDate: string;
|
|
129
|
+
readonly endDate: string;
|
|
130
|
+
readonly totalDays: number;
|
|
131
|
+
readonly leaveDates: readonly string[];
|
|
132
|
+
readonly leaveCost: number;
|
|
133
|
+
readonly efficiency: number;
|
|
134
|
+
readonly holidays: readonly Holiday[];
|
|
135
|
+
}
|
|
123
136
|
interface ChangelogEntry {
|
|
124
137
|
readonly timestamp: string;
|
|
125
138
|
readonly event: string;
|
|
@@ -132,6 +145,7 @@ interface ChangelogEntry {
|
|
|
132
145
|
}>;
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
declare const holidayCategorySchema: z.ZodEnum<["public", "bank", "optional", "half_day", "observance"]>;
|
|
135
149
|
declare const holidaySchema: z.ZodObject<{
|
|
136
150
|
id: z.ZodString;
|
|
137
151
|
date: z.ZodString;
|
|
@@ -158,6 +172,8 @@ declare const holidaySchema: z.ZodObject<{
|
|
|
158
172
|
hijriDate: z.ZodOptional<z.ZodString>;
|
|
159
173
|
gazetteRef: z.ZodOptional<z.ZodString>;
|
|
160
174
|
source: z.ZodEnum<["jpm", "jakim", "state-gov", "community", "admin"]>;
|
|
175
|
+
category: z.ZodOptional<z.ZodEnum<["public", "bank", "optional", "half_day", "observance"]>>;
|
|
176
|
+
isEstimated: z.ZodOptional<z.ZodBoolean>;
|
|
161
177
|
confirmedAt: z.ZodOptional<z.ZodString>;
|
|
162
178
|
createdAt: z.ZodString;
|
|
163
179
|
updatedAt: z.ZodString;
|
|
@@ -181,6 +197,8 @@ declare const holidaySchema: z.ZodObject<{
|
|
|
181
197
|
isReplacementFor?: string | undefined;
|
|
182
198
|
hijriDate?: string | undefined;
|
|
183
199
|
gazetteRef?: string | undefined;
|
|
200
|
+
category?: "public" | "bank" | "optional" | "half_day" | "observance" | undefined;
|
|
201
|
+
isEstimated?: boolean | undefined;
|
|
184
202
|
confirmedAt?: string | undefined;
|
|
185
203
|
}, {
|
|
186
204
|
type: "federal" | "state" | "islamic" | "islamic_state" | "replacement" | "adhoc";
|
|
@@ -202,6 +220,8 @@ declare const holidaySchema: z.ZodObject<{
|
|
|
202
220
|
isReplacementFor?: string | undefined;
|
|
203
221
|
hijriDate?: string | undefined;
|
|
204
222
|
gazetteRef?: string | undefined;
|
|
223
|
+
category?: "public" | "bank" | "optional" | "half_day" | "observance" | undefined;
|
|
224
|
+
isEstimated?: boolean | undefined;
|
|
205
225
|
confirmedAt?: string | undefined;
|
|
206
226
|
}>;
|
|
207
227
|
declare const holidayFileSchema: z.ZodArray<z.ZodObject<{
|
|
@@ -230,6 +250,8 @@ declare const holidayFileSchema: z.ZodArray<z.ZodObject<{
|
|
|
230
250
|
hijriDate: z.ZodOptional<z.ZodString>;
|
|
231
251
|
gazetteRef: z.ZodOptional<z.ZodString>;
|
|
232
252
|
source: z.ZodEnum<["jpm", "jakim", "state-gov", "community", "admin"]>;
|
|
253
|
+
category: z.ZodOptional<z.ZodEnum<["public", "bank", "optional", "half_day", "observance"]>>;
|
|
254
|
+
isEstimated: z.ZodOptional<z.ZodBoolean>;
|
|
233
255
|
confirmedAt: z.ZodOptional<z.ZodString>;
|
|
234
256
|
createdAt: z.ZodString;
|
|
235
257
|
updatedAt: z.ZodString;
|
|
@@ -253,6 +275,8 @@ declare const holidayFileSchema: z.ZodArray<z.ZodObject<{
|
|
|
253
275
|
isReplacementFor?: string | undefined;
|
|
254
276
|
hijriDate?: string | undefined;
|
|
255
277
|
gazetteRef?: string | undefined;
|
|
278
|
+
category?: "public" | "bank" | "optional" | "half_day" | "observance" | undefined;
|
|
279
|
+
isEstimated?: boolean | undefined;
|
|
256
280
|
confirmedAt?: string | undefined;
|
|
257
281
|
}, {
|
|
258
282
|
type: "federal" | "state" | "islamic" | "islamic_state" | "replacement" | "adhoc";
|
|
@@ -274,6 +298,8 @@ declare const holidayFileSchema: z.ZodArray<z.ZodObject<{
|
|
|
274
298
|
isReplacementFor?: string | undefined;
|
|
275
299
|
hijriDate?: string | undefined;
|
|
276
300
|
gazetteRef?: string | undefined;
|
|
301
|
+
category?: "public" | "bank" | "optional" | "half_day" | "observance" | undefined;
|
|
302
|
+
isEstimated?: boolean | undefined;
|
|
277
303
|
confirmedAt?: string | undefined;
|
|
278
304
|
}>, "many">;
|
|
279
305
|
declare const stateSchema: z.ZodObject<{
|
|
@@ -310,6 +336,7 @@ declare const stateSchema: z.ZodObject<{
|
|
|
310
336
|
group: "A" | "B";
|
|
311
337
|
}>, "many">;
|
|
312
338
|
group: z.ZodEnum<["A", "B"]>;
|
|
339
|
+
isoCode: z.ZodOptional<z.ZodString>;
|
|
313
340
|
}, "strip", z.ZodTypeAny, {
|
|
314
341
|
code: string;
|
|
315
342
|
type: "state" | "federal_territory";
|
|
@@ -326,6 +353,7 @@ declare const stateSchema: z.ZodObject<{
|
|
|
326
353
|
weekendDays: number[];
|
|
327
354
|
group: "A" | "B";
|
|
328
355
|
}[];
|
|
356
|
+
isoCode?: string | undefined;
|
|
329
357
|
}, {
|
|
330
358
|
code: string;
|
|
331
359
|
type: "state" | "federal_territory";
|
|
@@ -342,6 +370,7 @@ declare const stateSchema: z.ZodObject<{
|
|
|
342
370
|
weekendDays: number[];
|
|
343
371
|
group: "A" | "B";
|
|
344
372
|
}[];
|
|
373
|
+
isoCode?: string | undefined;
|
|
345
374
|
}>;
|
|
346
375
|
declare const statesFileSchema: z.ZodArray<z.ZodObject<{
|
|
347
376
|
code: z.ZodString;
|
|
@@ -377,6 +406,7 @@ declare const statesFileSchema: z.ZodArray<z.ZodObject<{
|
|
|
377
406
|
group: "A" | "B";
|
|
378
407
|
}>, "many">;
|
|
379
408
|
group: z.ZodEnum<["A", "B"]>;
|
|
409
|
+
isoCode: z.ZodOptional<z.ZodString>;
|
|
380
410
|
}, "strip", z.ZodTypeAny, {
|
|
381
411
|
code: string;
|
|
382
412
|
type: "state" | "federal_territory";
|
|
@@ -393,6 +423,7 @@ declare const statesFileSchema: z.ZodArray<z.ZodObject<{
|
|
|
393
423
|
weekendDays: number[];
|
|
394
424
|
group: "A" | "B";
|
|
395
425
|
}[];
|
|
426
|
+
isoCode?: string | undefined;
|
|
396
427
|
}, {
|
|
397
428
|
code: string;
|
|
398
429
|
type: "state" | "federal_territory";
|
|
@@ -409,6 +440,7 @@ declare const statesFileSchema: z.ZodArray<z.ZodObject<{
|
|
|
409
440
|
weekendDays: number[];
|
|
410
441
|
group: "A" | "B";
|
|
411
442
|
}[];
|
|
443
|
+
isoCode?: string | undefined;
|
|
412
444
|
}>, "many">;
|
|
413
445
|
declare const schoolTermSchema: z.ZodObject<{
|
|
414
446
|
id: z.ZodString;
|
|
@@ -750,18 +782,75 @@ interface HolidayFilter {
|
|
|
750
782
|
}
|
|
751
783
|
declare function filterHolidays(holidays: readonly Holiday[], filter: HolidayFilter): readonly Holiday[];
|
|
752
784
|
declare function findHolidaysByDate(date: string, holidays: readonly Holiday[], stateCode?: string): readonly Holiday[];
|
|
785
|
+
/**
|
|
786
|
+
* Index holidays by their ISO date for O(1) date lookups, skipping cancelled
|
|
787
|
+
* holidays and (when `stateCode` is given) holidays that do not apply to that
|
|
788
|
+
* state. Shared by the business-day and long-weekend scanners so they don't each
|
|
789
|
+
* re-filter the array on every day.
|
|
790
|
+
*/
|
|
791
|
+
declare function groupHolidaysByDate(holidays: readonly Holiday[], stateCode?: string): Map<string, Holiday[]>;
|
|
753
792
|
declare function findNextHoliday(afterDate: string, holidays: readonly Holiday[], stateCode?: string, type?: HolidayType, limit?: number): readonly Holiday[];
|
|
754
793
|
|
|
755
794
|
declare function calculateReplacementHolidays(holidays: readonly Holiday[], state: State): readonly Holiday[];
|
|
756
795
|
|
|
757
796
|
declare function countBusinessDays(start: string, end: string, state: State, holidays: readonly Holiday[]): BusinessDaysResult;
|
|
758
797
|
declare function addBusinessDays(startDate: string, daysToAdd: number, state: State, holidays: readonly Holiday[]): string;
|
|
798
|
+
declare function subtractBusinessDays(startDate: string, daysToSubtract: number, state: State, holidays: readonly Holiday[]): string;
|
|
799
|
+
declare function isBusinessDay(date: string, state: State, holidays: readonly Holiday[]): boolean;
|
|
800
|
+
declare function nextBusinessDay(date: string, state: State, holidays: readonly Holiday[]): string;
|
|
801
|
+
declare function previousBusinessDay(date: string, state: State, holidays: readonly Holiday[]): string;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Natural long weekends for a state in a given year: runs of 3+ consecutive
|
|
805
|
+
* non-working days (weekend days and/or public holidays) that need no leave.
|
|
806
|
+
*
|
|
807
|
+
* Shared by the REST `/holidays/long-weekends` route and the MCP server so the
|
|
808
|
+
* two cannot diverge.
|
|
809
|
+
*/
|
|
810
|
+
declare function findLongWeekends(year: number, state: State, holidays: readonly Holiday[]): LongWeekend[];
|
|
811
|
+
/**
|
|
812
|
+
* Leave optimizer: where can you spend up to `maxLeave` working days to chain
|
|
813
|
+
* weekends and public holidays into the longest possible continuous break?
|
|
814
|
+
*
|
|
815
|
+
* Returns suggestions sorted by efficiency (days off gained per leave day spent),
|
|
816
|
+
* then by total length. Each suggestion's `leaveDates` are the exact working days
|
|
817
|
+
* to request off — e.g. "take 2026-09-14 & 2026-09-15 → 2026-09-12…2026-09-16".
|
|
818
|
+
*/
|
|
819
|
+
declare function optimizeLeave(year: number, state: State, holidays: readonly Holiday[], maxLeave?: number): LeaveSuggestion[];
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Input-validation and request-safety helpers shared by the API layer. Kept here
|
|
823
|
+
* (pure, dependency-free) so they are unit-testable and reusable by any consumer.
|
|
824
|
+
*/
|
|
825
|
+
/** True only for a real calendar date in strict `YYYY-MM-DD` form (rejects 2026-02-30). */
|
|
826
|
+
declare function isValidISODate(value: string): boolean;
|
|
827
|
+
/**
|
|
828
|
+
* Constant-time string comparison for secrets (API keys). Returns early only on
|
|
829
|
+
* a length mismatch; otherwise compares every byte so timing does not leak how
|
|
830
|
+
* many leading characters matched.
|
|
831
|
+
*/
|
|
832
|
+
declare function timingSafeEqualString(a: string, b: string): boolean;
|
|
833
|
+
/**
|
|
834
|
+
* SSRF guard for user-supplied callback/webhook URLs. Requires HTTPS and rejects
|
|
835
|
+
* obviously-internal targets (localhost, *.local/.internal, and private/loopback/
|
|
836
|
+
* link-local/reserved IP literals incl. the 169.254.169.254 cloud-metadata IP).
|
|
837
|
+
*
|
|
838
|
+
* Best-effort at registration time: DNS is NOT resolved here, so delivery code
|
|
839
|
+
* MUST re-validate the resolved IP to defend against DNS rebinding.
|
|
840
|
+
*/
|
|
841
|
+
declare function isSafePublicHttpsUrl(raw: string): boolean;
|
|
759
842
|
|
|
760
843
|
declare function findSchoolTermByDate(date: string, terms: readonly SchoolTerm[], group: StateGroup): SchoolTerm | null;
|
|
761
844
|
declare function findSchoolHolidayByDate(date: string, holidays: readonly SchoolHoliday[], group: StateGroup, stateCode?: string): SchoolHoliday | null;
|
|
845
|
+
/**
|
|
846
|
+
* List the school holidays that apply to a group (and optionally a specific
|
|
847
|
+
* state, honouring `states`/`excludeStates` overrides). Shared by the REST
|
|
848
|
+
* `/school/holidays` and iCal feed routes so the filter logic lives in one place.
|
|
849
|
+
*/
|
|
850
|
+
declare function filterSchoolHolidays(holidays: readonly SchoolHoliday[], group: StateGroup, stateCode?: string): readonly SchoolHoliday[];
|
|
762
851
|
declare function isSchoolDay(date: string, terms: readonly SchoolTerm[], schoolHolidays: readonly SchoolHoliday[], group: StateGroup, isPublicHoliday: boolean, isWeekendDay: boolean, stateCode?: string): boolean;
|
|
763
852
|
|
|
764
|
-
declare function generateIcal(holidays: readonly Holiday[], schoolHolidays: readonly SchoolHoliday[], calendarName: string): string;
|
|
853
|
+
declare function generateIcal(holidays: readonly Holiday[], schoolHolidays: readonly SchoolHoliday[], calendarName: string, calendarDescription?: string): string;
|
|
765
854
|
|
|
766
855
|
/**
|
|
767
856
|
* Basic Hijri date utilities.
|
|
@@ -800,4 +889,4 @@ declare const ISLAMIC_HOLIDAY_DATES: readonly {
|
|
|
800
889
|
readonly hijriDay: number;
|
|
801
890
|
}[];
|
|
802
891
|
|
|
803
|
-
export { type BusinessDaysResult, type ChangelogEntry, type CheckDateResult, type Exam, type ExamSource, type ExamType, type GazetteLevel, HIJRI_MONTHS, type HijriMonth, type Holiday, type HolidayFilter, type HolidaySource, type HolidayStatus, type HolidayType, ISLAMIC_HOLIDAY_DATES, type LocalizedString, type LongWeekend, type SchoolHoliday, type SchoolHolidayType, type SchoolTerm, type SchoolTermSegment, type State, type StateGroup, type StateType, type WeekendConfig, addBusinessDays, addDays, calculateReplacementHolidays, countBusinessDays, diffDays, examSchema, examsFileSchema, filterHolidays, findHolidaysByDate, findNextHoliday, findSchoolHolidayByDate, findSchoolTermByDate, formatHijriDate, generateIcal, getDayOfWeekName, getStateByCode, getStatesByGroup, getWeekendConfig, getWeekendDayNames, holidayFileSchema, holidaySchema, isSchoolDay, isWeekend, nextWorkingDay, parseHijriDate, resolveStateCode, schoolHolidaySchema, schoolHolidaysFileSchema, schoolTermSchema, schoolTermsFileSchema, stateSchema, statesFileSchema };
|
|
892
|
+
export { type BusinessDaysResult, type ChangelogEntry, type CheckDateResult, type Exam, type ExamSource, type ExamType, type GazetteLevel, HIJRI_MONTHS, type HijriMonth, type Holiday, type HolidayCategory, type HolidayFilter, type HolidaySource, type HolidayStatus, type HolidayType, ISLAMIC_HOLIDAY_DATES, type LeaveSuggestion, type LocalizedString, type LongWeekend, type SchoolHoliday, type SchoolHolidayType, type SchoolTerm, type SchoolTermSegment, type State, type StateGroup, type StateType, type WeekendConfig, addBusinessDays, addDays, calculateReplacementHolidays, countBusinessDays, diffDays, examSchema, examsFileSchema, filterHolidays, filterSchoolHolidays, findHolidaysByDate, findLongWeekends, findNextHoliday, findSchoolHolidayByDate, findSchoolTermByDate, formatHijriDate, generateIcal, getDayOfWeekName, getStateByCode, getStatesByGroup, getWeekendConfig, getWeekendDayNames, groupHolidaysByDate, holidayCategorySchema, holidayFileSchema, holidaySchema, isBusinessDay, isSafePublicHttpsUrl, isSchoolDay, isValidISODate, isWeekend, nextBusinessDay, nextWorkingDay, optimizeLeave, parseHijriDate, previousBusinessDay, resolveStateCode, schoolHolidaySchema, schoolHolidaysFileSchema, schoolTermSchema, schoolTermsFileSchema, stateSchema, statesFileSchema, subtractBusinessDays, timingSafeEqualString };
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,13 @@ var holidayStatusSchema = z.enum([
|
|
|
21
21
|
"cancelled"
|
|
22
22
|
]);
|
|
23
23
|
var gazetteLevelSchema = z.enum(["P", "N"]);
|
|
24
|
+
var holidayCategorySchema = z.enum([
|
|
25
|
+
"public",
|
|
26
|
+
"bank",
|
|
27
|
+
"optional",
|
|
28
|
+
"half_day",
|
|
29
|
+
"observance"
|
|
30
|
+
]);
|
|
24
31
|
var holidaySourceSchema = z.enum([
|
|
25
32
|
"jpm",
|
|
26
33
|
"jakim",
|
|
@@ -42,6 +49,8 @@ var holidaySchema = z.object({
|
|
|
42
49
|
hijriDate: z.string().optional(),
|
|
43
50
|
gazetteRef: z.string().optional(),
|
|
44
51
|
source: holidaySourceSchema,
|
|
52
|
+
category: holidayCategorySchema.optional(),
|
|
53
|
+
isEstimated: z.boolean().optional(),
|
|
45
54
|
confirmedAt: z.string().optional(),
|
|
46
55
|
createdAt: z.string(),
|
|
47
56
|
updatedAt: z.string()
|
|
@@ -60,7 +69,8 @@ var stateSchema = z.object({
|
|
|
60
69
|
name: localizedString,
|
|
61
70
|
type: z.enum(["state", "federal_territory"]),
|
|
62
71
|
weekendHistory: z.array(weekendConfigSchema).min(1),
|
|
63
|
-
group: stateGroupSchema
|
|
72
|
+
group: stateGroupSchema,
|
|
73
|
+
isoCode: z.string().optional()
|
|
64
74
|
});
|
|
65
75
|
var statesFileSchema = z.array(stateSchema);
|
|
66
76
|
var schoolTermSegmentSchema = z.object({
|
|
@@ -186,9 +196,9 @@ function nextWorkingDay(date, state, holidayDates) {
|
|
|
186
196
|
return candidate;
|
|
187
197
|
}
|
|
188
198
|
function diffDays(start, end) {
|
|
189
|
-
const s = (/* @__PURE__ */ new Date(start + "
|
|
190
|
-
const e = (/* @__PURE__ */ new Date(end + "
|
|
191
|
-
return Math.round((e - s) /
|
|
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);
|
|
192
202
|
}
|
|
193
203
|
|
|
194
204
|
// src/filter.ts
|
|
@@ -222,6 +232,19 @@ function findHolidaysByDate(date, holidays, stateCode) {
|
|
|
222
232
|
return true;
|
|
223
233
|
});
|
|
224
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
|
+
}
|
|
225
248
|
function findNextHoliday(afterDate, holidays, stateCode, type, limit = 1) {
|
|
226
249
|
const filtered = holidays.filter((h) => {
|
|
227
250
|
if (h.date <= afterDate) return false;
|
|
@@ -236,6 +259,26 @@ function findNextHoliday(afterDate, holidays, stateCode, type, limit = 1) {
|
|
|
236
259
|
}
|
|
237
260
|
|
|
238
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
|
+
}
|
|
239
282
|
function calculateReplacementHolidays(holidays, state) {
|
|
240
283
|
const stateHolidays = holidays.filter(
|
|
241
284
|
(h) => h.status !== "cancelled" && (h.states.includes("*") || h.states.includes(state.code))
|
|
@@ -246,29 +289,13 @@ function calculateReplacementHolidays(holidays, state) {
|
|
|
246
289
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
247
290
|
for (const holiday of stateHolidays) {
|
|
248
291
|
if (holiday.type === "replacement") continue;
|
|
249
|
-
if (isWeekend(holiday.date, state))
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
}
|
|
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
|
+
);
|
|
272
299
|
}
|
|
273
300
|
const dateGroups = /* @__PURE__ */ new Map();
|
|
274
301
|
for (const h of stateHolidays) {
|
|
@@ -281,49 +308,51 @@ function calculateReplacementHolidays(holidays, state) {
|
|
|
281
308
|
const stateHoliday = group.find((h) => h.gazetteLevel === "N");
|
|
282
309
|
if (!stateHoliday) continue;
|
|
283
310
|
if (replacements.some((r) => r.isReplacementFor === stateHoliday.id)) continue;
|
|
284
|
-
const
|
|
285
|
-
const replacementDate = nextWorkingDay(stateHoliday.date, state,
|
|
311
|
+
const occupied = /* @__PURE__ */ new Set([...holidayDates, ...replacementDates]);
|
|
312
|
+
const replacementDate = nextWorkingDay(stateHoliday.date, state, occupied);
|
|
286
313
|
replacementDates.add(replacementDate);
|
|
287
|
-
replacements.push(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
});
|
|
314
|
+
replacements.push(
|
|
315
|
+
makeReplacement(
|
|
316
|
+
stateHoliday,
|
|
317
|
+
replacementDate,
|
|
318
|
+
state.code,
|
|
319
|
+
"overlap-replacement",
|
|
320
|
+
now
|
|
321
|
+
)
|
|
322
|
+
);
|
|
305
323
|
}
|
|
306
324
|
return replacements;
|
|
307
325
|
}
|
|
308
326
|
|
|
309
327
|
// src/business-days.ts
|
|
328
|
+
var MAX_SPAN_DAYS = 366 * 100;
|
|
310
329
|
function countBusinessDays(start, end, state, holidays) {
|
|
311
|
-
|
|
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);
|
|
312
334
|
let businessDays = 0;
|
|
313
335
|
let weekendCount = 0;
|
|
314
336
|
let holidayCount = 0;
|
|
337
|
+
let totalDays = 0;
|
|
315
338
|
const holidayList = [];
|
|
339
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
316
340
|
let current = start;
|
|
317
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
|
+
}
|
|
318
347
|
const weekend = isWeekend(current, state);
|
|
319
|
-
const dayHolidays =
|
|
320
|
-
const isHoliday = dayHolidays.length > 0;
|
|
348
|
+
const dayHolidays = holidayMap.get(current);
|
|
321
349
|
if (weekend) {
|
|
322
350
|
weekendCount++;
|
|
323
|
-
} else if (
|
|
351
|
+
} else if (dayHolidays) {
|
|
324
352
|
holidayCount++;
|
|
325
353
|
for (const h of dayHolidays) {
|
|
326
|
-
if (!
|
|
354
|
+
if (!seenIds.has(h.id)) {
|
|
355
|
+
seenIds.add(h.id);
|
|
327
356
|
holidayList.push(h);
|
|
328
357
|
}
|
|
329
358
|
}
|
|
@@ -340,19 +369,208 @@ function countBusinessDays(start, end, state, holidays) {
|
|
|
340
369
|
holidayList
|
|
341
370
|
};
|
|
342
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
|
+
}
|
|
343
383
|
function addBusinessDays(startDate, daysToAdd, state, holidays) {
|
|
384
|
+
assertValidStep(daysToAdd, "daysToAdd");
|
|
385
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
344
386
|
let remaining = daysToAdd;
|
|
345
387
|
let current = startDate;
|
|
388
|
+
let guard = 0;
|
|
346
389
|
while (remaining > 0) {
|
|
390
|
+
if (++guard > MAX_SPAN_DAYS * 2) {
|
|
391
|
+
throw new RangeError("addBusinessDays exceeded its iteration limit");
|
|
392
|
+
}
|
|
347
393
|
current = addDays(current, 1);
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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)) {
|
|
351
412
|
remaining--;
|
|
352
413
|
}
|
|
353
414
|
}
|
|
354
415
|
return current;
|
|
355
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
|
+
}
|
|
356
574
|
|
|
357
575
|
// src/school.ts
|
|
358
576
|
function findSchoolTermByDate(date, terms, group) {
|
|
@@ -369,6 +587,14 @@ function findSchoolHolidayByDate(date, holidays, group, stateCode) {
|
|
|
369
587
|
return true;
|
|
370
588
|
}) ?? null;
|
|
371
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
|
+
}
|
|
372
598
|
function isSchoolDay(date, terms, schoolHolidays, group, isPublicHoliday, isWeekendDay, stateCode) {
|
|
373
599
|
if (isWeekendDay) return false;
|
|
374
600
|
if (isPublicHoliday) return false;
|
|
@@ -385,11 +611,34 @@ function isSchoolDay(date, terms, schoolHolidays, group, isPublicHoliday, isWeek
|
|
|
385
611
|
|
|
386
612
|
// src/ical.ts
|
|
387
613
|
function escapeIcal(text) {
|
|
388
|
-
return text.replace(/[\\;,]/g, (c) => `\\${c}`).replace(/\n/g, "\\n");
|
|
614
|
+
return text.replace(/[\\;,]/g, (c) => `\\${c}`).replace(/\r\n|\r|\n/g, "\\n");
|
|
389
615
|
}
|
|
390
616
|
function formatIcalDate(date) {
|
|
391
617
|
return date.replace(/-/g, "");
|
|
392
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
|
+
}
|
|
393
642
|
function holidayToVevent(h) {
|
|
394
643
|
const dtstart = formatIcalDate(h.date);
|
|
395
644
|
const dtend = h.endDate ? formatIcalDate(h.endDate) : formatIcalDate(h.date);
|
|
@@ -399,7 +648,9 @@ function holidayToVevent(h) {
|
|
|
399
648
|
`DTEND;VALUE=DATE:${dtend}`,
|
|
400
649
|
`SUMMARY:${escapeIcal(h.name.en)}`,
|
|
401
650
|
`DESCRIPTION:${escapeIcal(h.name.ms)}${h.hijriDate ? ` (${h.hijriDate})` : ""}`,
|
|
651
|
+
`CATEGORIES:${holidayCategories(h)}`,
|
|
402
652
|
`UID:${h.id}@mycal.my`,
|
|
653
|
+
"TRANSP:TRANSPARENT",
|
|
403
654
|
`STATUS:${h.status === "tentative" ? "TENTATIVE" : "CONFIRMED"}`,
|
|
404
655
|
"END:VEVENT"
|
|
405
656
|
].join("\r\n");
|
|
@@ -411,18 +662,28 @@ function schoolHolidayToVevent(h) {
|
|
|
411
662
|
`DTEND;VALUE=DATE:${formatIcalDate(h.endDate)}`,
|
|
412
663
|
`SUMMARY:[School] ${escapeIcal(h.name.en)}`,
|
|
413
664
|
`DESCRIPTION:${escapeIcal(h.name.ms)}`,
|
|
665
|
+
"CATEGORIES:School Holiday",
|
|
414
666
|
`UID:${h.id}@mycal.my`,
|
|
667
|
+
"TRANSP:TRANSPARENT",
|
|
415
668
|
"STATUS:CONFIRMED",
|
|
416
669
|
"END:VEVENT"
|
|
417
670
|
].join("\r\n");
|
|
418
671
|
}
|
|
419
|
-
function generateIcal(holidays, schoolHolidays, calendarName) {
|
|
672
|
+
function generateIcal(holidays, schoolHolidays, calendarName, calendarDescription = "Malaysian public holidays and school calendar \u2014 mycal.my") {
|
|
420
673
|
const header = [
|
|
421
674
|
"BEGIN:VCALENDAR",
|
|
422
675
|
"VERSION:2.0",
|
|
423
676
|
"PRODID:-//MyCal//Malaysia Calendar API//EN",
|
|
424
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
|
|
425
683
|
"X-WR-TIMEZONE:Asia/Kuala_Lumpur",
|
|
684
|
+
"COLOR:forestgreen",
|
|
685
|
+
// RFC 7986 (CSS3 colour name)
|
|
686
|
+
"X-APPLE-CALENDAR-COLOR:#22C55E",
|
|
426
687
|
"CALSCALE:GREGORIAN",
|
|
427
688
|
"METHOD:PUBLISH",
|
|
428
689
|
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
|
@@ -490,7 +751,9 @@ export {
|
|
|
490
751
|
examSchema,
|
|
491
752
|
examsFileSchema,
|
|
492
753
|
filterHolidays,
|
|
754
|
+
filterSchoolHolidays,
|
|
493
755
|
findHolidaysByDate,
|
|
756
|
+
findLongWeekends,
|
|
494
757
|
findNextHoliday,
|
|
495
758
|
findSchoolHolidayByDate,
|
|
496
759
|
findSchoolTermByDate,
|
|
@@ -501,17 +764,27 @@ export {
|
|
|
501
764
|
getStatesByGroup,
|
|
502
765
|
getWeekendConfig,
|
|
503
766
|
getWeekendDayNames,
|
|
767
|
+
groupHolidaysByDate,
|
|
768
|
+
holidayCategorySchema,
|
|
504
769
|
holidayFileSchema,
|
|
505
770
|
holidaySchema,
|
|
771
|
+
isBusinessDay,
|
|
772
|
+
isSafePublicHttpsUrl,
|
|
506
773
|
isSchoolDay,
|
|
774
|
+
isValidISODate,
|
|
507
775
|
isWeekend,
|
|
776
|
+
nextBusinessDay,
|
|
508
777
|
nextWorkingDay,
|
|
778
|
+
optimizeLeave,
|
|
509
779
|
parseHijriDate,
|
|
780
|
+
previousBusinessDay,
|
|
510
781
|
resolveStateCode,
|
|
511
782
|
schoolHolidaySchema,
|
|
512
783
|
schoolHolidaysFileSchema,
|
|
513
784
|
schoolTermSchema,
|
|
514
785
|
schoolTermsFileSchema,
|
|
515
786
|
stateSchema,
|
|
516
|
-
statesFileSchema
|
|
787
|
+
statesFileSchema,
|
|
788
|
+
subtractBusinessDays,
|
|
789
|
+
timingSafeEqualString
|
|
517
790
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catlabtech/mycal-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Core types, schemas, and business logic for the Malaysia Calendar API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"malaysia",
|
|
@@ -10,14 +10,18 @@
|
|
|
10
10
|
"typescript"
|
|
11
11
|
],
|
|
12
12
|
"license": "MIT",
|
|
13
|
+
"author": "CatLab Tech",
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
|
15
16
|
"url": "git+https://github.com/Junhui20/malaysia-calendar-api.git",
|
|
16
17
|
"directory": "packages/core"
|
|
17
18
|
},
|
|
18
|
-
"homepage": "https://mycal.pages.dev",
|
|
19
|
-
"bugs":
|
|
19
|
+
"homepage": "https://mycal-web.pages.dev",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/Junhui20/malaysia-calendar-api/issues"
|
|
22
|
+
},
|
|
20
23
|
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
21
25
|
"main": "./dist/index.js",
|
|
22
26
|
"types": "./dist/index.d.ts",
|
|
23
27
|
"exports": {
|
|
@@ -28,6 +32,7 @@
|
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
30
34
|
"dist",
|
|
35
|
+
"LICENSE",
|
|
31
36
|
"README.md"
|
|
32
37
|
],
|
|
33
38
|
"publishConfig": {
|
|
@@ -42,7 +47,7 @@
|
|
|
42
47
|
"zod": "^3.24.0"
|
|
43
48
|
},
|
|
44
49
|
"scripts": {
|
|
45
|
-
"build": "tsup src/index.ts --format esm --dts",
|
|
50
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
46
51
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
47
52
|
"test": "node __tests__/run-tests.cjs"
|
|
48
53
|
}
|