@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,18 @@
|
|
|
1
|
+
import type { Holiday, State, LongWeekend, LeaveSuggestion } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Natural long weekends for a state in a given year: runs of 3+ consecutive
|
|
4
|
+
* non-working days (weekend days and/or public holidays) that need no leave.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the REST `/holidays/long-weekends` route and the MCP server so the
|
|
7
|
+
* two cannot diverge.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findLongWeekends(year: number, state: State, holidays: readonly Holiday[]): LongWeekend[];
|
|
10
|
+
/**
|
|
11
|
+
* Leave optimizer: where can you spend up to `maxLeave` working days to chain
|
|
12
|
+
* weekends and public holidays into the longest possible continuous break?
|
|
13
|
+
*
|
|
14
|
+
* Returns suggestions sorted by efficiency (days off gained per leave day spent),
|
|
15
|
+
* then by total length. Each suggestion's `leaveDates` are the exact working days
|
|
16
|
+
* to request off — e.g. "take 2026-09-14 & 2026-09-15 → 2026-09-12…2026-09-16".
|
|
17
|
+
*/
|
|
18
|
+
export declare function optimizeLeave(year: number, state: State, holidays: readonly Holiday[], maxLeave?: number): LeaveSuggestion[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { addDays, diffDays, isWeekend } from "./weekend.js";
|
|
2
|
+
import { groupHolidaysByDate } from "./filter.js";
|
|
3
|
+
/**
|
|
4
|
+
* Natural long weekends for a state in a given year: runs of 3+ consecutive
|
|
5
|
+
* non-working days (weekend days and/or public holidays) that need no leave.
|
|
6
|
+
*
|
|
7
|
+
* Shared by the REST `/holidays/long-weekends` route and the MCP server so the
|
|
8
|
+
* two cannot diverge.
|
|
9
|
+
*/
|
|
10
|
+
export function findLongWeekends(year, state, holidays) {
|
|
11
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
12
|
+
const isOff = (d) => isWeekend(d, state) || holidayMap.has(d);
|
|
13
|
+
const yearStart = `${year}-01-01`;
|
|
14
|
+
const yearEnd = `${year}-12-31`;
|
|
15
|
+
// Scan a little past both year boundaries so a run that is still "off" at
|
|
16
|
+
// Dec 31 (or already running on Jan 1) is measured in full instead of being
|
|
17
|
+
// truncated at the boundary. A run is only *reported* when it starts within
|
|
18
|
+
// the requested year, so a boundary-straddling long weekend is attributed to
|
|
19
|
+
// exactly one year and never double-counted.
|
|
20
|
+
const scanStart = addDays(yearStart, -7);
|
|
21
|
+
const scanEnd = addDays(yearEnd, 7);
|
|
22
|
+
const result = [];
|
|
23
|
+
let current = scanStart;
|
|
24
|
+
while (current <= scanEnd) {
|
|
25
|
+
if (!isOff(current)) {
|
|
26
|
+
current = addDays(current, 1);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const startDate = current;
|
|
30
|
+
const streakHolidays = [];
|
|
31
|
+
let weekendDays = 0;
|
|
32
|
+
let totalDays = 0;
|
|
33
|
+
while (current <= scanEnd && isOff(current)) {
|
|
34
|
+
if (isWeekend(current, state))
|
|
35
|
+
weekendDays++;
|
|
36
|
+
const hs = holidayMap.get(current);
|
|
37
|
+
if (hs)
|
|
38
|
+
streakHolidays.push(...hs);
|
|
39
|
+
totalDays++;
|
|
40
|
+
current = addDays(current, 1);
|
|
41
|
+
}
|
|
42
|
+
if (totalDays >= 3 && startDate >= yearStart && startDate <= yearEnd) {
|
|
43
|
+
result.push({
|
|
44
|
+
startDate,
|
|
45
|
+
endDate: addDays(current, -1),
|
|
46
|
+
totalDays,
|
|
47
|
+
holidays: streakHolidays,
|
|
48
|
+
weekendDays,
|
|
49
|
+
bridgeDaysNeeded: 0, // natural long weekend — no leave required
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Leave optimizer: where can you spend up to `maxLeave` working days to chain
|
|
57
|
+
* weekends and public holidays into the longest possible continuous break?
|
|
58
|
+
*
|
|
59
|
+
* Returns suggestions sorted by efficiency (days off gained per leave day spent),
|
|
60
|
+
* then by total length. Each suggestion's `leaveDates` are the exact working days
|
|
61
|
+
* to request off — e.g. "take 2026-09-14 & 2026-09-15 → 2026-09-12…2026-09-16".
|
|
62
|
+
*/
|
|
63
|
+
export function optimizeLeave(year, state, holidays, maxLeave = 3) {
|
|
64
|
+
if (!Number.isInteger(maxLeave) || maxLeave < 1)
|
|
65
|
+
return [];
|
|
66
|
+
const holidayMap = groupHolidaysByDate(holidays, state.code);
|
|
67
|
+
const isOff = (d) => isWeekend(d, state) || holidayMap.has(d);
|
|
68
|
+
const yearEnd = `${year}-12-31`;
|
|
69
|
+
const suggestions = [];
|
|
70
|
+
let s = `${year}-01-01`;
|
|
71
|
+
while (s <= yearEnd) {
|
|
72
|
+
// A break starts on an off-day whose previous day is a working day.
|
|
73
|
+
if (!isOff(s) || isOff(addDays(s, -1))) {
|
|
74
|
+
s = addDays(s, 1);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let leave = 0;
|
|
78
|
+
let d = s;
|
|
79
|
+
while (d <= yearEnd) {
|
|
80
|
+
if (!isOff(d)) {
|
|
81
|
+
leave++;
|
|
82
|
+
if (leave > maxLeave)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const next = addDays(d, 1);
|
|
86
|
+
// Window [s..d] is a complete break when it ends on an off-day followed by
|
|
87
|
+
// a working day, and at least one leave day was spent inside it.
|
|
88
|
+
if (leave >= 1 && isOff(d) && !isOff(next)) {
|
|
89
|
+
const leaveDates = [];
|
|
90
|
+
const within = [];
|
|
91
|
+
let c = s;
|
|
92
|
+
while (c <= d) {
|
|
93
|
+
if (!isOff(c))
|
|
94
|
+
leaveDates.push(c);
|
|
95
|
+
const hs = holidayMap.get(c);
|
|
96
|
+
if (hs)
|
|
97
|
+
within.push(...hs);
|
|
98
|
+
c = addDays(c, 1);
|
|
99
|
+
}
|
|
100
|
+
const totalDays = diffDays(s, d) + 1;
|
|
101
|
+
suggestions.push({
|
|
102
|
+
startDate: s,
|
|
103
|
+
endDate: d,
|
|
104
|
+
totalDays,
|
|
105
|
+
leaveDates,
|
|
106
|
+
leaveCost: leaveDates.length,
|
|
107
|
+
efficiency: Math.round((totalDays / leaveDates.length) * 100) / 100,
|
|
108
|
+
holidays: within,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
d = next;
|
|
112
|
+
}
|
|
113
|
+
s = addDays(s, 1);
|
|
114
|
+
}
|
|
115
|
+
suggestions.sort((a, b) => b.efficiency - a.efficiency ||
|
|
116
|
+
b.totalDays - a.totalDays ||
|
|
117
|
+
a.startDate.localeCompare(b.startDate));
|
|
118
|
+
// Collapse duplicate windows (the same break can be reached from one start).
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
return suggestions.filter((x) => {
|
|
121
|
+
const key = `${x.startDate}:${x.endDate}`;
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
return false;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { isWeekend, nextWorkingDay } from "./weekend.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build the synthetic "Cuti Ganti" (replacement) holiday for a `source` holiday
|
|
4
|
+
* that has been rolled forward to `replacementDate`. Centralised so the
|
|
5
|
+
* weekend-clash and same-day-overlap paths cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
function makeReplacement(source, replacementDate, stateCode, idSuffix, now) {
|
|
8
|
+
return {
|
|
9
|
+
id: `${source.id}-${idSuffix}`,
|
|
10
|
+
date: replacementDate,
|
|
11
|
+
name: {
|
|
12
|
+
ms: `Cuti Ganti ${source.name.ms}`,
|
|
13
|
+
en: `Replacement for ${source.name.en}`,
|
|
14
|
+
zh: source.name.zh ? `补假 ${source.name.zh}` : undefined,
|
|
15
|
+
},
|
|
16
|
+
type: "replacement",
|
|
17
|
+
status: source.status,
|
|
18
|
+
states: [stateCode],
|
|
19
|
+
isPublicHoliday: true,
|
|
20
|
+
gazetteLevel: source.gazetteLevel,
|
|
21
|
+
isReplacementFor: source.id,
|
|
22
|
+
source: source.source,
|
|
23
|
+
createdAt: now,
|
|
24
|
+
updatedAt: now,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function calculateReplacementHolidays(holidays, state) {
|
|
28
|
+
const stateHolidays = holidays.filter((h) => h.status !== "cancelled" &&
|
|
29
|
+
(h.states.includes("*") || h.states.includes(state.code)));
|
|
30
|
+
const holidayDates = new Set(stateHolidays.map((h) => h.date));
|
|
31
|
+
const replacementDates = new Set();
|
|
32
|
+
const replacements = [];
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
// 1. Holidays that fall on a weekend roll forward to the next working day.
|
|
35
|
+
for (const holiday of stateHolidays) {
|
|
36
|
+
if (holiday.type === "replacement")
|
|
37
|
+
continue;
|
|
38
|
+
if (!isWeekend(holiday.date, state))
|
|
39
|
+
continue;
|
|
40
|
+
const occupied = new Set([...holidayDates, ...replacementDates]);
|
|
41
|
+
const replacementDate = nextWorkingDay(holiday.date, state, occupied);
|
|
42
|
+
replacementDates.add(replacementDate);
|
|
43
|
+
replacements.push(makeReplacement(holiday, replacementDate, state.code, "replacement", now));
|
|
44
|
+
}
|
|
45
|
+
// 2. Two holidays on the same date: the federal (gazette "P") holiday takes the
|
|
46
|
+
// day, so the state-level (gazette "N") holiday is the one rolled forward.
|
|
47
|
+
const dateGroups = new Map();
|
|
48
|
+
for (const h of stateHolidays) {
|
|
49
|
+
if (h.type === "replacement")
|
|
50
|
+
continue;
|
|
51
|
+
const existing = dateGroups.get(h.date) ?? [];
|
|
52
|
+
dateGroups.set(h.date, [...existing, h]);
|
|
53
|
+
}
|
|
54
|
+
for (const [, group] of dateGroups) {
|
|
55
|
+
if (group.length <= 1)
|
|
56
|
+
continue;
|
|
57
|
+
const stateHoliday = group.find((h) => h.gazetteLevel === "N");
|
|
58
|
+
if (!stateHoliday)
|
|
59
|
+
continue;
|
|
60
|
+
// Skip if the weekend pass already produced a replacement for it.
|
|
61
|
+
if (replacements.some((r) => r.isReplacementFor === stateHoliday.id))
|
|
62
|
+
continue;
|
|
63
|
+
const occupied = new Set([...holidayDates, ...replacementDates]);
|
|
64
|
+
const replacementDate = nextWorkingDay(stateHoliday.date, state, occupied);
|
|
65
|
+
replacementDates.add(replacementDate);
|
|
66
|
+
replacements.push(makeReplacement(stateHoliday, replacementDate, state.code, "overlap-replacement", now));
|
|
67
|
+
}
|
|
68
|
+
return replacements;
|
|
69
|
+
}
|