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