@fineanmol/holiday-optimizer 1.0.0
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 +109 -0
- package/country-presets.js +104 -0
- package/index.js +8 -0
- package/package.json +39 -0
- package/vacation_optimizer.js +427 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fineanmol
|
|
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,109 @@
|
|
|
1
|
+
# Holiday Optimizer 🏖️
|
|
2
|
+
|
|
3
|
+
Optimize your vacation days using Dynamic Programming! Maximize your total days off by strategically placing breaks around weekends and public holidays.
|
|
4
|
+
|
|
5
|
+
**Live Demo:** [Try it here](https://fineanmol.github.io/holiday-optimizer/)
|
|
6
|
+
|
|
7
|
+
## Install (npm)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @fineanmol/holiday-optimizer
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import { optimize, formatReport, getPreset } from "@fineanmol/holiday-optimizer";
|
|
15
|
+
|
|
16
|
+
const params = getPreset("germany");
|
|
17
|
+
const result = optimize(params);
|
|
18
|
+
console.log(formatReport(result, params));
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What It Does
|
|
22
|
+
|
|
23
|
+
This tool helps you plan your vacations optimally. Given:
|
|
24
|
+
|
|
25
|
+
- Your total paid leave days
|
|
26
|
+
- Public holidays in your country
|
|
27
|
+
- Your preferences (minimum/maximum break length, spacing between breaks)
|
|
28
|
+
|
|
29
|
+
It finds the best schedule that maximizes your total days off by leveraging weekends and holidays.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- 🎯 **Dynamic Programming Algorithm** - Finds optimal vacation schedule
|
|
34
|
+
- 🌍 **Multi-Country Support** - Pre-configured holidays for Germany and India (easily extensible)
|
|
35
|
+
- ⚙️ **Customizable Constraints** - Set your own min/max break lengths and spacing
|
|
36
|
+
- 📊 **Detailed Reports** - See exactly which dates to take off and how many days you'll get
|
|
37
|
+
- 💻 **Zero Server Cost** - Runs entirely in the browser using GitHub Pages
|
|
38
|
+
|
|
39
|
+
## How It Works
|
|
40
|
+
|
|
41
|
+
The optimizer uses three key techniques:
|
|
42
|
+
|
|
43
|
+
1. **Dominance Pruning** - For each starting day, keeps only the most efficient break options
|
|
44
|
+
2. **Binary Search** - Efficiently finds next valid break that satisfies spacing constraints
|
|
45
|
+
3. **Dynamic Programming** - Optimizes for maximum total days off within your constraints
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
1. Select your country from the dropdown
|
|
50
|
+
2. Set your preferences:
|
|
51
|
+
- Year to optimize
|
|
52
|
+
- Start date
|
|
53
|
+
- Total paid leave days
|
|
54
|
+
- Minimum break length (days)
|
|
55
|
+
- Maximum break length (days)
|
|
56
|
+
- Time between breaks (days)
|
|
57
|
+
3. Click "Calculate Optimal Schedule"
|
|
58
|
+
4. Review your optimized vacation plan!
|
|
59
|
+
|
|
60
|
+
## Example
|
|
61
|
+
|
|
62
|
+
With **19 paid leave days** and default settings:
|
|
63
|
+
|
|
64
|
+
- **Result:** ~52 total days off
|
|
65
|
+
- **Breaks:** ~12 breaks throughout the year
|
|
66
|
+
- **Distribution:** Approximately 1 break per month
|
|
67
|
+
|
|
68
|
+
## Adding New Countries
|
|
69
|
+
|
|
70
|
+
To add holidays for a new country, edit `country-presets.js`:
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
newCountry: {
|
|
74
|
+
name: "Country Name",
|
|
75
|
+
year: 2026,
|
|
76
|
+
defaultPTO: 10,
|
|
77
|
+
holidays: [
|
|
78
|
+
{ date: "2026-01-01", name: "New Year's Day" },
|
|
79
|
+
// ... more holidays
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The country will automatically appear in the dropdown!
|
|
85
|
+
|
|
86
|
+
## Algorithm Details
|
|
87
|
+
|
|
88
|
+
- **Time Complexity:** O(n × m × P) where n = candidates, m = max break length, P = PTO days
|
|
89
|
+
- **Space Complexity:** O(n × P) for DP table
|
|
90
|
+
- **Optimization Goal:** Maximize total days off (including weekends and holidays)
|
|
91
|
+
|
|
92
|
+
## Tech Stack
|
|
93
|
+
|
|
94
|
+
- Pure JavaScript (ES6 modules)
|
|
95
|
+
- No dependencies
|
|
96
|
+
- Works in all modern browsers
|
|
97
|
+
- GitHub Pages compatible
|
|
98
|
+
|
|
99
|
+
## Inspiration
|
|
100
|
+
|
|
101
|
+
Inspired by [Ankit's vacation optimizer](https://gist.github.com/ag5826000/478d3607df0eff50278d57429f2308e9) - converted from Python to JavaScript for browser deployment.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT License - feel free to use and modify!
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
**Made with ❤️ to help you maximize your vacation time!**
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const countryPresets = {
|
|
2
|
+
germany: {
|
|
3
|
+
name: "Germany (Berlin)",
|
|
4
|
+
year: 2026,
|
|
5
|
+
defaultPTO: 19,
|
|
6
|
+
holidays: [
|
|
7
|
+
{ date: "2026-01-01", name: "Neujahr" },
|
|
8
|
+
{ date: "2026-03-08", name: "Internationaler Frauentag (Berlin)" },
|
|
9
|
+
{ date: "2026-04-03", name: "Karfreitag" },
|
|
10
|
+
{ date: "2026-04-06", name: "Ostermontag" },
|
|
11
|
+
{ date: "2026-05-01", name: "Tag der Arbeit" },
|
|
12
|
+
{ date: "2026-05-14", name: "Christi Himmelfahrt" },
|
|
13
|
+
{ date: "2026-05-25", name: "Pfingstmontag" },
|
|
14
|
+
{ date: "2026-10-03", name: "Tag der Deutschen Einheit" },
|
|
15
|
+
{ date: "2026-10-31", name: "Reformationstag (Berlin)" },
|
|
16
|
+
{ date: "2026-12-25", name: "1. Weihnachtstag" },
|
|
17
|
+
{ date: "2026-12-26", name: "2. Weihnachtstag" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
india: {
|
|
22
|
+
name: "India",
|
|
23
|
+
year: 2026,
|
|
24
|
+
defaultPTO: 10,
|
|
25
|
+
holidays: [
|
|
26
|
+
{ date: "2026-01-26", name: "Republic Day" },
|
|
27
|
+
{ date: "2026-03-08", name: "Holi" },
|
|
28
|
+
{ date: "2026-03-29", name: "Good Friday" },
|
|
29
|
+
{ date: "2026-04-14", name: "Ambedkar Jayanti" },
|
|
30
|
+
{ date: "2026-04-17", name: "Ram Navami" },
|
|
31
|
+
{ date: "2026-05-01", name: "Labour Day" },
|
|
32
|
+
{ date: "2026-06-17", name: "Eid ul-Fitr" },
|
|
33
|
+
{ date: "2026-08-15", name: "Independence Day" },
|
|
34
|
+
{ date: "2026-08-26", name: "Raksha Bandhan" },
|
|
35
|
+
{ date: "2026-09-05", name: "Janmashtami" },
|
|
36
|
+
{ date: "2026-10-02", name: "Gandhi Jayanti" },
|
|
37
|
+
{ date: "2026-10-12", name: "Dussehra" },
|
|
38
|
+
{ date: "2026-10-20", name: "Eid ul-Adha" },
|
|
39
|
+
{ date: "2026-10-29", name: "Diwali" },
|
|
40
|
+
{ date: "2026-11-14", name: "Guru Nanak Jayanti" },
|
|
41
|
+
{ date: "2026-12-25", name: "Christmas" },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get all available country keys
|
|
48
|
+
* @returns {string[]} Array of country keys
|
|
49
|
+
*/
|
|
50
|
+
export function getAvailableCountries() {
|
|
51
|
+
return Object.keys(countryPresets);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get country information (name, key pairs)
|
|
56
|
+
* @returns {Array<{key: string, name: string}>} Array of country info objects
|
|
57
|
+
*/
|
|
58
|
+
export function getCountryList() {
|
|
59
|
+
return Object.entries(countryPresets).map(([key, preset]) => ({
|
|
60
|
+
key,
|
|
61
|
+
name: preset.name,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get preset configuration for a country
|
|
67
|
+
* @param {string} countryKey - Key from countryPresets (e.g., 'germany', 'india')
|
|
68
|
+
* @param {number} customPTO - Optional custom PTO days (overrides default)
|
|
69
|
+
* @param {number} customYear - Optional custom year (overrides preset year)
|
|
70
|
+
* @returns {Object} Configuration object ready for optimizer
|
|
71
|
+
*/
|
|
72
|
+
export function getPreset(countryKey, customPTO = null, customYear = null) {
|
|
73
|
+
const preset = countryPresets[countryKey];
|
|
74
|
+
if (!preset) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Unknown country preset: ${countryKey}. Available: ${getAvailableCountries().join(
|
|
77
|
+
", "
|
|
78
|
+
)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const year = customYear ?? preset.year;
|
|
83
|
+
|
|
84
|
+
const holidays = preset.holidays.filter((h) => {
|
|
85
|
+
const holidayYear = parseInt(h.date.split("-")[0]);
|
|
86
|
+
return holidayYear === year;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
numberOfDays: customPTO ?? preset.defaultPTO,
|
|
91
|
+
year: year,
|
|
92
|
+
holidays: holidays,
|
|
93
|
+
companyDaysOff: [],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if a country preset exists
|
|
99
|
+
* @param {string} countryKey - Key to check
|
|
100
|
+
* @returns {boolean} True if preset exists
|
|
101
|
+
*/
|
|
102
|
+
export function hasPreset(countryKey) {
|
|
103
|
+
return countryKey in countryPresets;
|
|
104
|
+
}
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fineanmol/holiday-optimizer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Optimize vacation days using dynamic programming — maximize time off around weekends and public holidays.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"vacation_optimizer.js",
|
|
13
|
+
"country-presets.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"vacation",
|
|
19
|
+
"pto",
|
|
20
|
+
"holiday",
|
|
21
|
+
"optimizer",
|
|
22
|
+
"planning",
|
|
23
|
+
"calendar",
|
|
24
|
+
"leave"
|
|
25
|
+
],
|
|
26
|
+
"author": "fineanmol",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/fineanmol/holiday-optimizer.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/fineanmol/holiday-optimizer/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/fineanmol/holiday-optimizer#readme",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
class Day {
|
|
2
|
+
constructor({ date, isWeekend, isPublicHoliday, isCompanyDay }) {
|
|
3
|
+
this.date = date;
|
|
4
|
+
this.is_weekend = isWeekend;
|
|
5
|
+
this.is_public_holiday = isPublicHoliday;
|
|
6
|
+
this.is_company_day = isCompanyDay;
|
|
7
|
+
|
|
8
|
+
this.is_pto = false;
|
|
9
|
+
this.is_part_of_break = false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class Break {
|
|
14
|
+
constructor({
|
|
15
|
+
start_date,
|
|
16
|
+
end_date,
|
|
17
|
+
total_days,
|
|
18
|
+
pto_days,
|
|
19
|
+
weekends,
|
|
20
|
+
public_holidays,
|
|
21
|
+
company_days,
|
|
22
|
+
days,
|
|
23
|
+
}) {
|
|
24
|
+
this.start_date = start_date;
|
|
25
|
+
this.end_date = end_date;
|
|
26
|
+
this.total_days = total_days;
|
|
27
|
+
this.pto_days = pto_days;
|
|
28
|
+
this.weekends = weekends;
|
|
29
|
+
this.public_holidays = public_holidays;
|
|
30
|
+
this.company_days = company_days;
|
|
31
|
+
this.days = days;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class Stats {
|
|
36
|
+
constructor({
|
|
37
|
+
total_days_off,
|
|
38
|
+
total_paid_leave,
|
|
39
|
+
total_public_holidays,
|
|
40
|
+
total_weekends,
|
|
41
|
+
total_company_days,
|
|
42
|
+
}) {
|
|
43
|
+
this.total_days_off = total_days_off;
|
|
44
|
+
this.total_paid_leave = total_paid_leave;
|
|
45
|
+
this.total_public_holidays = total_public_holidays;
|
|
46
|
+
this.total_weekends = total_weekends;
|
|
47
|
+
this.total_company_days = total_company_days;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class Result {
|
|
52
|
+
constructor({ days, breaks, stats }) {
|
|
53
|
+
this.days = days;
|
|
54
|
+
this.breaks = breaks;
|
|
55
|
+
this.stats = stats;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pad2(n) {
|
|
60
|
+
return String(n).padStart(2, "0");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatDate(d) {
|
|
64
|
+
const y = d.getFullYear();
|
|
65
|
+
const m = pad2(d.getMonth() + 1);
|
|
66
|
+
const day = pad2(d.getDate());
|
|
67
|
+
return `${y}-${m}-${day}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseISO(iso) {
|
|
71
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
72
|
+
return new Date(y, m - 1, d);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addDays(d, n) {
|
|
76
|
+
const copy = new Date(d.getTime());
|
|
77
|
+
copy.setDate(copy.getDate() + n);
|
|
78
|
+
return copy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function compareISO(a, b) {
|
|
82
|
+
if (a < b) return -1;
|
|
83
|
+
if (a > b) return 1;
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isFixedOff(day) {
|
|
88
|
+
return day.is_weekend || day.is_public_holiday || day.is_company_day;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildCalendar(params) {
|
|
92
|
+
const yr = params.year ?? new Date().getFullYear();
|
|
93
|
+
const today = new Date();
|
|
94
|
+
|
|
95
|
+
let start;
|
|
96
|
+
if (params.startDate) {
|
|
97
|
+
start = parseISO(params.startDate);
|
|
98
|
+
} else {
|
|
99
|
+
start =
|
|
100
|
+
yr === today.getFullYear()
|
|
101
|
+
? new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
|
102
|
+
: new Date(yr, 0, 1);
|
|
103
|
+
}
|
|
104
|
+
const end = new Date(yr, 11, 31);
|
|
105
|
+
|
|
106
|
+
const holidays = new Set((params.holidays ?? []).map((h) => h.date));
|
|
107
|
+
const company = new Set((params.companyDaysOff ?? []).map((c) => c.date));
|
|
108
|
+
|
|
109
|
+
const days = [];
|
|
110
|
+
let d = start;
|
|
111
|
+
|
|
112
|
+
while (d <= end) {
|
|
113
|
+
const ds = formatDate(d);
|
|
114
|
+
const weekday = d.getDay();
|
|
115
|
+
const isWeekend = weekday === 0 || weekday === 6;
|
|
116
|
+
|
|
117
|
+
days.push(
|
|
118
|
+
new Day({
|
|
119
|
+
date: ds,
|
|
120
|
+
isWeekend,
|
|
121
|
+
isPublicHoliday: holidays.has(ds),
|
|
122
|
+
isCompanyDay: company.has(ds),
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
d = addDays(d, 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return days;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function generateCandidates(cal, minLen, maxLen) {
|
|
133
|
+
const candidates = [];
|
|
134
|
+
const n = cal.length;
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < n; i++) {
|
|
137
|
+
for (let L = minLen; L <= maxLen; L++) {
|
|
138
|
+
if (i + L > n) break;
|
|
139
|
+
const seg = cal.slice(i, i + L);
|
|
140
|
+
const ptoUsed = seg.reduce((acc, d) => acc + (isFixedOff(d) ? 0 : 1), 0);
|
|
141
|
+
if (ptoUsed === 0) continue;
|
|
142
|
+
|
|
143
|
+
candidates.push({
|
|
144
|
+
start_idx: i,
|
|
145
|
+
end_idx: i + L - 1,
|
|
146
|
+
total_days: L,
|
|
147
|
+
pto_used: ptoUsed,
|
|
148
|
+
eff: L / ptoUsed,
|
|
149
|
+
segment: seg,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return candidates;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function pruneCandidates(cands, maxPTO) {
|
|
158
|
+
cands = cands.filter((c) => c.pto_used <= maxPTO);
|
|
159
|
+
|
|
160
|
+
const byStart = new Map();
|
|
161
|
+
for (const c of cands) {
|
|
162
|
+
if (!byStart.has(c.start_idx)) byStart.set(c.start_idx, []);
|
|
163
|
+
byStart.get(c.start_idx).push(c);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pruned = [];
|
|
167
|
+
for (const [start, items] of byStart.entries()) {
|
|
168
|
+
items.sort(
|
|
169
|
+
(a, b) => a.pto_used - b.pto_used || b.total_days - a.total_days
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const best = [];
|
|
173
|
+
for (const cand of items) {
|
|
174
|
+
const dominated = best.some(
|
|
175
|
+
(b) =>
|
|
176
|
+
b.end_idx >= cand.end_idx &&
|
|
177
|
+
b.pto_used <= cand.pto_used &&
|
|
178
|
+
b.total_days >= cand.total_days
|
|
179
|
+
);
|
|
180
|
+
if (!dominated) best.push(cand);
|
|
181
|
+
}
|
|
182
|
+
pruned.push(...best);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pruned.sort((a, b) => a.start_idx - b.start_idx);
|
|
186
|
+
return pruned;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function binarySearchNext(cands, startPos) {
|
|
190
|
+
let lo = 0;
|
|
191
|
+
let hi = cands.length;
|
|
192
|
+
while (lo < hi) {
|
|
193
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
194
|
+
if (cands[mid].start_idx < startPos) lo = mid + 1;
|
|
195
|
+
else hi = mid;
|
|
196
|
+
}
|
|
197
|
+
return lo;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function dpSelect(cands, maxPTO, spacing) {
|
|
201
|
+
if (!cands.length || maxPTO <= 0) return [];
|
|
202
|
+
|
|
203
|
+
const nextIndices = cands.map((c) =>
|
|
204
|
+
binarySearchNext(cands, c.end_idx + 1 + spacing)
|
|
205
|
+
);
|
|
206
|
+
const n = cands.length;
|
|
207
|
+
|
|
208
|
+
const dpDays = Array.from({ length: n + 1 }, () => Array(maxPTO + 1).fill(0));
|
|
209
|
+
const dpChoice = Array.from({ length: n + 1 }, () =>
|
|
210
|
+
Array.from({ length: maxPTO + 1 }, () => [])
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
for (let idx = n - 1; idx >= 0; idx--) {
|
|
214
|
+
const cand = cands[idx];
|
|
215
|
+
const cost = cand.pto_used;
|
|
216
|
+
const totalDays = cand.total_days;
|
|
217
|
+
const jump = nextIndices[idx];
|
|
218
|
+
|
|
219
|
+
for (let p = 0; p <= maxPTO; p++) {
|
|
220
|
+
let bestDays = dpDays[idx + 1][p];
|
|
221
|
+
let bestChoice = dpChoice[idx + 1][p];
|
|
222
|
+
|
|
223
|
+
if (cost <= p) {
|
|
224
|
+
const takeDays = totalDays + dpDays[jump][p - cost];
|
|
225
|
+
if (takeDays > bestDays) {
|
|
226
|
+
bestDays = takeDays;
|
|
227
|
+
bestChoice = [idx, ...dpChoice[jump][p - cost]];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
dpDays[idx][p] = bestDays;
|
|
232
|
+
dpChoice[idx][p] = bestChoice;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const choice = dpChoice[0][maxPTO];
|
|
237
|
+
return choice.map((i) => cands[i]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function forceExtend(cal, breaks, remaining) {
|
|
241
|
+
if (remaining <= 0) return remaining;
|
|
242
|
+
|
|
243
|
+
for (const br of breaks) {
|
|
244
|
+
const endDate = parseISO(br.end_date);
|
|
245
|
+
const nextDay = addDays(endDate, 1);
|
|
246
|
+
const nextISO = formatDate(nextDay);
|
|
247
|
+
|
|
248
|
+
const idx = cal.findIndex((d) => d.date === nextISO);
|
|
249
|
+
if (
|
|
250
|
+
idx !== -1 &&
|
|
251
|
+
remaining > 0 &&
|
|
252
|
+
!cal[idx].is_part_of_break &&
|
|
253
|
+
!isFixedOff(cal[idx])
|
|
254
|
+
) {
|
|
255
|
+
cal[idx].is_part_of_break = true;
|
|
256
|
+
cal[idx].is_pto = true;
|
|
257
|
+
|
|
258
|
+
br.days.push(cal[idx]);
|
|
259
|
+
br.end_date = cal[idx].date;
|
|
260
|
+
br.total_days += 1;
|
|
261
|
+
br.pto_days += 1;
|
|
262
|
+
remaining -= 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return remaining;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function addForcedSegments(cal, remaining) {
|
|
269
|
+
const forced = [];
|
|
270
|
+
let i = 0;
|
|
271
|
+
const n = cal.length;
|
|
272
|
+
|
|
273
|
+
while (i < n && remaining > 0) {
|
|
274
|
+
if (cal[i].is_part_of_break || isFixedOff(cal[i])) {
|
|
275
|
+
i++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const seg = [];
|
|
280
|
+
while (
|
|
281
|
+
i < n &&
|
|
282
|
+
remaining > 0 &&
|
|
283
|
+
!cal[i].is_part_of_break &&
|
|
284
|
+
!isFixedOff(cal[i])
|
|
285
|
+
) {
|
|
286
|
+
cal[i].is_part_of_break = true;
|
|
287
|
+
cal[i].is_pto = true;
|
|
288
|
+
seg.push(cal[i]);
|
|
289
|
+
remaining--;
|
|
290
|
+
i++;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (seg.length) {
|
|
294
|
+
forced.push(
|
|
295
|
+
new Break({
|
|
296
|
+
start_date: seg[0].date,
|
|
297
|
+
end_date: seg[seg.length - 1].date,
|
|
298
|
+
total_days: seg.length,
|
|
299
|
+
pto_days: seg.length,
|
|
300
|
+
weekends: seg.filter((d) => d.is_weekend).length,
|
|
301
|
+
public_holidays: seg.filter((d) => d.is_public_holiday).length,
|
|
302
|
+
company_days: seg.filter((d) => d.is_company_day).length,
|
|
303
|
+
days: seg,
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
i++;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return forced;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function optimize(params) {
|
|
315
|
+
const maxPTO = Number(params.numberOfDays);
|
|
316
|
+
const minLen = params.minBreak ?? 4;
|
|
317
|
+
const maxLen = params.maxBreak ?? 9;
|
|
318
|
+
const spacing = params.timeBetweenBreaks ?? 21;
|
|
319
|
+
|
|
320
|
+
const cal = buildCalendar(params);
|
|
321
|
+
let candidates = generateCandidates(cal, minLen, maxLen);
|
|
322
|
+
candidates = pruneCandidates(candidates, maxPTO);
|
|
323
|
+
|
|
324
|
+
const chosen = dpSelect(candidates, maxPTO, spacing);
|
|
325
|
+
|
|
326
|
+
const breaks = [];
|
|
327
|
+
for (const seg of chosen) {
|
|
328
|
+
for (let idx = seg.start_idx; idx <= seg.end_idx; idx++) {
|
|
329
|
+
cal[idx].is_part_of_break = true;
|
|
330
|
+
if (!isFixedOff(cal[idx])) cal[idx].is_pto = true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const segmentDays = seg.segment;
|
|
334
|
+
breaks.push(
|
|
335
|
+
new Break({
|
|
336
|
+
start_date: segmentDays[0].date,
|
|
337
|
+
end_date: segmentDays[segmentDays.length - 1].date,
|
|
338
|
+
total_days: seg.total_days,
|
|
339
|
+
pto_days: seg.pto_used,
|
|
340
|
+
weekends: segmentDays.filter((d) => d.is_weekend).length,
|
|
341
|
+
public_holidays: segmentDays.filter((d) => d.is_public_holiday).length,
|
|
342
|
+
company_days: segmentDays.filter((d) => d.is_company_day).length,
|
|
343
|
+
days: [...segmentDays],
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let usedPTO = breaks.reduce((acc, b) => acc + b.pto_days, 0);
|
|
349
|
+
let remaining = maxPTO - usedPTO;
|
|
350
|
+
|
|
351
|
+
let prevRemaining = remaining + 1;
|
|
352
|
+
while (remaining > 0 && remaining < prevRemaining) {
|
|
353
|
+
prevRemaining = remaining;
|
|
354
|
+
remaining = forceExtend(cal, breaks, remaining);
|
|
355
|
+
|
|
356
|
+
const extra = addForcedSegments(cal, remaining);
|
|
357
|
+
breaks.push(...extra);
|
|
358
|
+
|
|
359
|
+
usedPTO = breaks.reduce((acc, b) => acc + b.pto_days, 0);
|
|
360
|
+
remaining = maxPTO - usedPTO;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const stats = new Stats({
|
|
364
|
+
total_days_off: breaks.reduce((acc, b) => acc + b.total_days, 0),
|
|
365
|
+
total_paid_leave: breaks.reduce((acc, b) => acc + b.pto_days, 0),
|
|
366
|
+
total_public_holidays: breaks.reduce(
|
|
367
|
+
(acc, b) => acc + b.public_holidays,
|
|
368
|
+
0
|
|
369
|
+
),
|
|
370
|
+
total_weekends: breaks.reduce((acc, b) => acc + b.weekends, 0),
|
|
371
|
+
total_company_days: breaks.reduce((acc, b) => acc + b.company_days, 0),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return new Result({ days: cal, breaks, stats });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function formatReport(res, params) {
|
|
378
|
+
const lines = [];
|
|
379
|
+
lines.push("Holiday Optimizer Report (JavaScript)");
|
|
380
|
+
lines.push("=====================================");
|
|
381
|
+
lines.push(`Year: ${params.year ?? new Date().getFullYear()}`);
|
|
382
|
+
lines.push(`Requested Paid Leave Days: ${params.numberOfDays}`);
|
|
383
|
+
lines.push("");
|
|
384
|
+
|
|
385
|
+
lines.push("Summary");
|
|
386
|
+
lines.push("-------");
|
|
387
|
+
lines.push(`Total Days Off: ${res.stats.total_days_off}`);
|
|
388
|
+
lines.push(`Total Paid Leave Used: ${res.stats.total_paid_leave}`);
|
|
389
|
+
lines.push(`Public Holidays in Breaks: ${res.stats.total_public_holidays}`);
|
|
390
|
+
lines.push(`Weekends in Breaks: ${res.stats.total_weekends}`);
|
|
391
|
+
if (res.stats.total_company_days > 0) {
|
|
392
|
+
lines.push(`Company Days in Breaks: ${res.stats.total_company_days}`);
|
|
393
|
+
}
|
|
394
|
+
lines.push("");
|
|
395
|
+
|
|
396
|
+
lines.push("Breaks");
|
|
397
|
+
lines.push("------");
|
|
398
|
+
if (!res.breaks.length) {
|
|
399
|
+
lines.push("No breaks were scheduled.");
|
|
400
|
+
} else {
|
|
401
|
+
res.breaks.forEach((br, idx) => {
|
|
402
|
+
const companyPart = br.company_days
|
|
403
|
+
? ` | Company ${br.company_days}`
|
|
404
|
+
: "";
|
|
405
|
+
lines.push(`Break ${idx + 1}: ${br.start_date} → ${br.end_date}`);
|
|
406
|
+
lines.push(
|
|
407
|
+
` • Total ${br.total_days} days | Paid Leave ${br.pto_days} | ` +
|
|
408
|
+
`Weekends ${br.weekends} | Public ${br.public_holidays}${companyPart}`
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const ptoDates = br.days.filter((d) => d.is_pto).map((d) => d.date);
|
|
412
|
+
// if (ptoDates.length) {
|
|
413
|
+
// lines.push(` • Paid leave dates: ${ptoDates.join(", ")}`);
|
|
414
|
+
// }
|
|
415
|
+
lines.push("");
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const ptoFlat = res.days.filter((d) => d.is_pto).map((d) => d.date);
|
|
420
|
+
lines.push("Paid Leave Dates (all)");
|
|
421
|
+
lines.push("----------------------");
|
|
422
|
+
lines.push(ptoFlat.length ? ptoFlat.join(", ") : "None");
|
|
423
|
+
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export { optimize, formatReport };
|