@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 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
+ [![npm version](https://img.shields.io/npm/v/@catlabtech/mycal-core.svg)](https://www.npmjs.com/package/@catlabtech/mycal-core)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 + "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));
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
- 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
- }
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 allOccupied = /* @__PURE__ */ new Set([...holidayDates, ...replacementDates]);
285
- const replacementDate = nextWorkingDay(stateHoliday.date, state, allOccupied);
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
- 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
- });
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
- const totalDays = diffDays(start, end) + 1;
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 = findHolidaysByDate(current, holidays, state.code);
320
- const isHoliday = dayHolidays.length > 0;
348
+ const dayHolidays = holidayMap.get(current);
321
349
  if (weekend) {
322
350
  weekendCount++;
323
- } else if (isHoliday) {
351
+ } else if (dayHolidays) {
324
352
  holidayCount++;
325
353
  for (const h of dayHolidays) {
326
- if (!holidayList.some((existing) => existing.id === h.id)) {
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
- const weekend = isWeekend(current, state);
349
- const isHoliday = findHolidaysByDate(current, holidays, state.code).length > 0;
350
- if (!weekend && !isHoliday) {
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.0",
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": "https://github.com/Junhui20/malaysia-calendar-api/issues",
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
  }