203 lines
8.9 KiB
TypeScript
203 lines
8.9 KiB
TypeScript
import Holidays from 'date-holidays';
|
|
|
|
const MS_IN_A_DAY = 86400000;
|
|
const MAX_GAP_LENGTH = 5;
|
|
|
|
// Core date helper functions
|
|
const dateKey = (date: Date): string => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
const isWeekend = (date: Date, weekendDays: number[]): boolean => weekendDays.includes(date.getDay());
|
|
const isHoliday = (date: Date, holidays: { date: Date }[]): boolean => holidays.some(h => dateKey(h.date) === dateKey(date));
|
|
const daysBetween = (start: Date, end: Date): number => Math.round((end.getTime() - start.getTime()) / MS_IN_A_DAY);
|
|
|
|
// Get holidays for a year, handling multi-day holidays and timezone differences
|
|
export function getHolidaysForYear(countryCode: string, year: number, stateCode?: string): { date: Date; name: string }[] {
|
|
// Use browser's languages and timezone to get localized holiday names
|
|
const opts = {
|
|
languages: navigator.languages.map(lang => lang.split('-')[0]),
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
};
|
|
const hd = stateCode ? new Holidays(countryCode, stateCode, opts) : new Holidays(countryCode, opts);
|
|
|
|
return hd.getHolidays(year)
|
|
.filter(holiday => holiday.type === 'public')
|
|
.flatMap(holiday => Array.from(
|
|
{ length: daysBetween(holiday.start, holiday.end) },
|
|
(_, i) => ({
|
|
date: new Date(holiday.start.getFullYear(), holiday.start.getMonth(), holiday.start.getDate() + i),
|
|
name: holiday.name,
|
|
})
|
|
))
|
|
.sort((a, b) => a.date.getTime() - b.date.getTime() || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
// Find optimal placement of PTO days to maximize consecutive time off
|
|
export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOff: number, weekendDays: number[] = [0, 6], startDate?: Date): Date[] {
|
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
|
const filteredHolidays = holidays.filter(h => h.date.getFullYear() === year && h.date >= effectiveStartDate);
|
|
const allDaysOff = new Set([
|
|
...filteredHolidays.map(h => dateKey(h.date)),
|
|
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
|
|
]);
|
|
|
|
const gaps = findGaps(allDaysOff, year, weekendDays, effectiveStartDate);
|
|
return selectDaysOff(rankGapsByEfficiency(gaps, allDaysOff, weekendDays), daysOff, allDaysOff, weekendDays);
|
|
}
|
|
|
|
// Calculate periods of consecutive days off (weekends + holidays + PTO)
|
|
export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6], startDate?: Date) {
|
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
|
const filteredHolidays = holidays.filter(h => h.date >= effectiveStartDate);
|
|
const filteredOptimizedDaysOff = optimizedDaysOff.filter(d => d >= effectiveStartDate);
|
|
|
|
const allDaysOff = new Set([
|
|
...filteredHolidays.map(h => dateKey(h.date)),
|
|
...filteredOptimizedDaysOff.map(d => dateKey(d)),
|
|
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
|
|
]);
|
|
|
|
const consecutiveDaysOff = [];
|
|
let currentGroup = [];
|
|
|
|
for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) {
|
|
if (isWeekend(d, weekendDays) || isHoliday(d, filteredHolidays) || allDaysOff.has(dateKey(d))) {
|
|
currentGroup.push(new Date(d));
|
|
} else if (currentGroup.length > 0) {
|
|
if (isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
|
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
|
|
}
|
|
currentGroup = [];
|
|
}
|
|
}
|
|
|
|
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
|
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
|
|
}
|
|
|
|
return consecutiveDaysOff;
|
|
}
|
|
|
|
// Get all weekend days for a year
|
|
function getWeekends(year: number, weekendDays: number[], startDate?: Date): Date[] {
|
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
|
const weekends = [];
|
|
for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) {
|
|
if (d.getMonth() === d.getMonth() && isWeekend(d, weekendDays)) {
|
|
weekends.push(new Date(d));
|
|
}
|
|
}
|
|
return weekends;
|
|
}
|
|
|
|
// Find gaps between days off that could be filled with PTO
|
|
function findGaps(allDaysOff: Set<string>, year: number, weekendDays: number[], startDate?: Date) {
|
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
|
const endDate = new Date(year, 11, 31);
|
|
const gaps = [];
|
|
let gapStart = null;
|
|
|
|
for (let d = new Date(effectiveStartDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
if (!allDaysOff.has(dateKey(d)) && !isWeekend(d, weekendDays)) {
|
|
if (!gapStart) gapStart = new Date(d);
|
|
} else if (gapStart) {
|
|
const gapLength = daysBetween(gapStart, d);
|
|
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
|
|
gaps.push({ start: gapStart, end: new Date(d.getTime() - MS_IN_A_DAY), gapLength });
|
|
}
|
|
gapStart = null;
|
|
}
|
|
}
|
|
|
|
// Handle gap that extends to the end of the year
|
|
if (gapStart) {
|
|
const gapLength = daysBetween(gapStart, new Date(endDate.getTime() + MS_IN_A_DAY));
|
|
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
|
|
gaps.push({ start: gapStart, end: endDate, gapLength });
|
|
}
|
|
}
|
|
|
|
return gaps;
|
|
}
|
|
|
|
// Rank gaps by how efficiently they can be used to create longer periods off
|
|
function rankGapsByEfficiency(gaps: any[], allDaysOff: Set<string>, weekendDays: number[]) {
|
|
return gaps
|
|
.map(gap => {
|
|
const [backward, forward] = ['backward', 'forward'].map(direction =>
|
|
calculateChain(direction === 'backward' ? gap.start : gap.end, gap.gapLength, allDaysOff, direction as 'backward' | 'forward', weekendDays)
|
|
);
|
|
return forward.chainLength > backward.chainLength ||
|
|
(forward.chainLength === backward.chainLength && forward.usedDaysOff <= backward.usedDaysOff)
|
|
? { ...gap, ...forward, fillFrom: 'end' }
|
|
: { ...gap, ...backward, fillFrom: 'start' };
|
|
})
|
|
.sort((a, b) => a.gapLength - b.gapLength || b.chainLength - a.chainLength || a.usedDaysOff - b.usedDaysOff);
|
|
}
|
|
|
|
// Calculate potential chain length in either direction from a gap
|
|
function calculateChain(date: Date, gapLength: number, allDaysOff: Set<string>, direction: 'backward' | 'forward', weekendDays: number[]) {
|
|
const increment = direction === 'backward' ? -1 : 1;
|
|
let chainLength = gapLength;
|
|
let currentDate = new Date(date);
|
|
|
|
while (allDaysOff.has(dateKey(new Date(currentDate.getTime() + MS_IN_A_DAY * increment))) ||
|
|
isWeekend(new Date(currentDate.getTime() + MS_IN_A_DAY * increment), weekendDays)) {
|
|
chainLength++;
|
|
currentDate.setDate(currentDate.getDate() + increment);
|
|
}
|
|
|
|
return {
|
|
chainLength,
|
|
usedDaysOff: Array.from({ length: gapLength }, (_, i) => {
|
|
const d = new Date(date);
|
|
d.setDate(d.getDate() + i * increment);
|
|
return !allDaysOff.has(dateKey(d)) && !isWeekend(d, weekendDays);
|
|
}).filter(Boolean).length
|
|
};
|
|
}
|
|
|
|
// Select optimal days off based on ranked gaps
|
|
function selectDaysOff(rankedGaps: any[], daysOff: number, allDaysOff: Set<string>, weekendDays: number[]): Date[] {
|
|
const selectedDays = [];
|
|
let remainingDays = daysOff;
|
|
|
|
for (const gap of rankedGaps) {
|
|
if (remainingDays <= 0) break;
|
|
|
|
const increment = gap.fillFrom === 'start' ? 1 : -1;
|
|
const startDate = gap.fillFrom === 'start' ? gap.start : gap.end;
|
|
|
|
for (let i = 0; i < gap.gapLength && remainingDays > 0; i++) {
|
|
const day = new Date(startDate);
|
|
day.setDate(day.getDate() + (i * increment));
|
|
|
|
if (!allDaysOff.has(dateKey(day)) && !isWeekend(day, weekendDays)) {
|
|
selectedDays.push(day);
|
|
remainingDays--;
|
|
}
|
|
}
|
|
}
|
|
|
|
return selectedDays;
|
|
}
|
|
|
|
// Check if a group is valid (2+ days, not just weekends)
|
|
function isValidConsecutiveGroup(group: Date[], weekendDays: number[]): boolean {
|
|
// Must be at least 2 days
|
|
if (group.length < 2) return false;
|
|
|
|
// Check if ALL days are weekends
|
|
const allDaysAreWeekends = group.every(d => weekendDays.includes(d.getDay()));
|
|
|
|
// Valid if not all days are weekends
|
|
return !allDaysAreWeekends;
|
|
}
|
|
|
|
// Create a period object from a group of consecutive days
|
|
function createPeriod(group: Date[], optimizedDaysOff: Date[]) {
|
|
return {
|
|
startDate: group[0],
|
|
endDate: group[group.length - 1],
|
|
totalDays: daysBetween(group[0], group[group.length - 1]) + 1,
|
|
usedDaysOff: group.filter(d => optimizedDaysOff.some(od => dateKey(od) === dateKey(d))).length
|
|
};
|
|
} |