Add a nice cleanup of holidayUtils.js

This commit is contained in:
Zachary
2024-11-22 20:16:01 +01:00
parent f9dc40a857
commit 44762113e8
3 changed files with 134 additions and 286 deletions

View File

@@ -14,7 +14,6 @@
const normalizedLocale = locale.toLowerCase() === 'us' ? 'en-US' : `en-${locale.toUpperCase()}`;
try {
// Try to get firstDay from Intl.Locale weekInfo
// @ts-ignore .weekInfo exists on all browsers except Firefox
const weekFirstDay = new Intl.Locale(normalizedLocale)?.weekInfo?.firstDay;
if (weekFirstDay !== undefined) {
@@ -101,10 +100,11 @@
<div class="day"></div>
{/each}
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
<div class="day {isWeekend(new Date(year, month, day)) ? 'weekend' : ''} {getHoliday(day) ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''}">
{day}
{#if getHoliday(day)}
<Tooltip text={getHoliday(day)?.name} />
{@const holiday = getHoliday(day)}
<div class="day {isWeekend(new Date(year, month, day)) ? 'weekend' : ''} {holiday ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''}">
<span class={holiday?.hidden ? 'strikethrough' : ''}>{day}</span>
{#if holiday}
<Tooltip text={holiday.name} />
{/if}
</div>
{/each}
@@ -197,4 +197,9 @@
font-size: 0.8em;
}
}
.strikethrough {
text-decoration: line-through;
opacity: 0.5;
}
</style>

View File

@@ -1,336 +1,179 @@
import Holidays from 'date-holidays';
// Constants
const MS_IN_A_DAY = 86400000;
const MAX_GAP_LENGTH = 5;
// Helper function to check if a date is a weekend
const isWeekend = (date: Date, weekendDays: number[]): boolean => weekendDays.includes(date.getDay());
// Helper function to check if two dates are the same day
const isSameDay = (date1: Date, date2: Date): boolean =>
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
// Helper function to generate a unique key for a date
// Core date helper functions
const dateKey = (date: Date): string => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
// Helper function to check if a date is a holiday
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);
// Helper function to check if a date is a day off
const isDayOff = (date: Date, allDaysOffSet: Set<string>): boolean => allDaysOffSet.has(dateKey(date));
// Helper function to calculate the number of days between two dates
const daysBetween = (startDate: Date, endDate: Date): number => Math.round((endDate.getTime() - startDate.getTime()) / MS_IN_A_DAY);
// Helper function to format a date
const formatDate = (date: Date): string => date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
// Get holidays for a specific year and country
// 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 }[] {
// The date-holidays lib has translations for many holidays, but defaults to using the language of the country.
// We can pass in the browser's preferred languages (though the lib doesn't fall back, e.g. from `de-AT` to `de`)
const languages = navigator.languages.map(lang => lang.split('-')[0]);
// Start/end dates are returned in that country/state's time zone, so we need to provide our time zone to localise
const opts = { languages, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
// 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);
console.log(hd.getHolidays(year));
return hd.getHolidays(year)
.filter(holiday => holiday.type === 'public')
.flatMap(holiday =>
// To handle single- and multi-day holidays, we generate a holiday entry for each day in the period
Array.from({ length: daysBetween(holiday.start, holiday.end) }, (_, i) => ({
.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((holiday1, holiday2) => holiday1.date.getTime() - holiday2.date.getTime() || holiday1.name.localeCompare(holiday2.name));
})
))
.sort((a, b) => a.date.getTime() - b.date.getTime() || a.name.localeCompare(b.name));
}
// Optimize days off to create the longest possible chains
export function optimizeDaysOff(
holidays: { date: Date }[],
year: number,
daysOff: number,
weekendDays: number[] = [0, 6]
): Date[] {
const currentYearHolidays = holidays.filter(h => h.date.getFullYear() === year);
const weekends = getWeekends(year, weekendDays);
const allDaysOffSet = new Set([
...currentYearHolidays.map(h => dateKey(h.date)),
...weekends.map(d => dateKey(d))
]);
let rankedGaps = rankGapsByEfficiency(
findGaps(allDaysOffSet, year, weekendDays),
allDaysOffSet,
weekendDays
);
return selectDaysOff(rankedGaps, daysOff, allDaysOffSet, year, weekendDays);
}
// Calculate consecutive days off
export function calculateConsecutiveDaysOff(
holidays: Array<{ date: Date }>,
optimizedDaysOff: Date[],
year: number,
weekendDays: number[] = [0, 6]
) {
// 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]): Date[] {
const allDaysOff = new Set([
...holidays.map(h => h.date.toISOString()),
...optimizedDaysOff.map(d => d.toISOString())
...holidays.filter(h => h.date.getFullYear() === year).map(h => dateKey(h.date)),
...getWeekends(year, weekendDays).map(d => dateKey(d))
]);
// Add all weekend days for the year
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
if (weekendDays.includes(d.getDay())) {
allDaysOff.add(new Date(d).toISOString());
}
}
const gaps = findGaps(allDaysOff, year, weekendDays);
return selectDaysOff(rankGapsByEfficiency(gaps, allDaysOff, weekendDays), daysOff, allDaysOff, weekendDays);
}
const consecutiveDaysOff: { startDate: Date; endDate: Date; usedDaysOff: number; totalDays: number }[] = [];
let currentGroup: Date[] = [];
// Calculate periods of consecutive days off (weekends + holidays + PTO)
export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6]) {
const allDaysOff = new Set([
...holidays.map(h => dateKey(h.date)),
...optimizedDaysOff.map(d => dateKey(d)),
...getWeekends(year, weekendDays).map(d => dateKey(d))
]);
for (let month = 0; month < 12; month++) {
for (let day = 1; day <= 31; day++) {
const date = new Date(year, month, day);
if (date.getMonth() !== month) break;
const consecutiveDaysOff = [];
let currentGroup = [];
if (isWeekend(date, weekendDays) || isHoliday(date, holidays) || allDaysOff.has(date.toISOString())) {
currentGroup.push(date);
} else {
if (isValidPeriod(currentGroup, weekendDays, holidays, optimizedDaysOff)) {
const startDate = currentGroup[0];
const endDate = currentGroup[currentGroup.length - 1];
const totalDays = daysBetween(startDate, endDate) + 1;
const usedDaysOff = currentGroup.filter(d =>
optimizedDaysOff.some(od =>
od.getFullYear() === d.getFullYear() &&
od.getMonth() === d.getMonth() &&
od.getDate() === d.getDate()
)
).length;
consecutiveDaysOff.push({
startDate,
endDate,
usedDaysOff,
totalDays
});
}
currentGroup = [];
for (let d = new Date(year, 0, 1); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) {
if (isWeekend(d, weekendDays) || isHoliday(d, holidays) || allDaysOff.has(dateKey(d))) {
currentGroup.push(new Date(d));
} else if (currentGroup.length > 0) {
if (hasWeekendAndNonWeekendHoliday(currentGroup, weekendDays, holidays, optimizedDaysOff)) {
consecutiveDaysOff.push(createPeriod(currentGroup, optimizedDaysOff));
}
currentGroup = [];
}
}
if (isValidPeriod(currentGroup, weekendDays, holidays, optimizedDaysOff)) {
const startDate = currentGroup[0];
const endDate = currentGroup[currentGroup.length - 1];
const totalDays = daysBetween(startDate, endDate) + 1;
const usedDaysOff = currentGroup.filter(d =>
optimizedDaysOff.some(od =>
od.getFullYear() === d.getFullYear() &&
od.getMonth() === d.getMonth() &&
od.getDate() === d.getDate()
)
).length;
consecutiveDaysOff.push({
startDate,
endDate,
usedDaysOff,
totalDays
});
if (currentGroup.length > 0 && hasWeekendAndNonWeekendHoliday(currentGroup, weekendDays, holidays, optimizedDaysOff)) {
consecutiveDaysOff.push(createPeriod(currentGroup, optimizedDaysOff));
}
return consecutiveDaysOff;
}
// Update the isValidPeriod function
function isValidPeriod(
group: Date[],
weekendDays: number[],
holidays: Array<{ date: Date }>,
optimizedDaysOff: Date[]
): boolean {
if (group.length < 2) return false;
// Count weekend days
const weekendDates = group.filter(date => weekendDays.includes(date.getDay()));
if (weekendDates.length === 0) return false; // Must have at least one weekend day
// Count non-weekend days that are either holidays or PTO days
const nonWeekendHolidayOrPTO = group.some(date => {
// Skip if it's a weekend day
if (weekendDays.includes(date.getDay())) return false;
// Check if it's either a holiday or PTO day
return isHoliday(date, holidays) ||
optimizedDaysOff.some(od =>
od.getFullYear() === date.getFullYear() &&
od.getMonth() === date.getMonth() &&
od.getDate() === date.getDate()
);
});
// Must have at least one weekend day AND one non-weekend holiday/PTO day
return nonWeekendHolidayOrPTO;
}
// Get all weekends for a specific year
// Get all weekend days for a year
function getWeekends(year: number, weekendDays: number[]): Date[] {
const weekends: Date[] = [];
for (let month = 0; month < 12; month++) {
for (let day = 1; day <= 31; day++) {
const date = new Date(year, month, day);
if (date.getMonth() !== month) break;
if (isWeekend(date, weekendDays)) weekends.push(date);
const weekends = [];
for (let d = new Date(year, 0, 1); 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
function findGaps(allDaysOffSet: Set<string>, year: number, weekendDays: number[]): { start: Date; end: Date; gapLength: number }[] {
const gaps: { start: Date; end: Date; gapLength: number }[] = [];
let currentGapStart: Date | null = null;
// Find gaps between days off that could be filled with PTO
function findGaps(allDaysOff: Set<string>, year: number, weekendDays: number[]) {
const gaps = [];
let gapStart = null;
for (let month = 0; month < 12; month++) {
for (let day = 1; day <= 31; day++) {
const date = new Date(year, month, day);
if (date.getMonth() !== month) break;
const isDayOff = allDaysOffSet.has(dateKey(date));
if (!isDayOff && !isWeekend(date, weekendDays)) {
if (!currentGapStart) currentGapStart = date;
} else if (currentGapStart) {
const gapLength = daysBetween(currentGapStart, date);
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
gaps.push({ start: currentGapStart, end: new Date(date.getTime() - MS_IN_A_DAY), gapLength });
}
currentGapStart = null;
for (let d = new Date(year, 0, 1); d <= new Date(year, 11, 31); 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;
}
}
if (currentGapStart) {
const lastDayOfYear = new Date(year, 11, 31);
const gapLength = daysBetween(currentGapStart, lastDayOfYear) + 1;
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
gaps.push({ start: currentGapStart, end: lastDayOfYear, gapLength });
}
}
return gaps;
}
// Rank gaps by efficiency
function rankGapsByEfficiency(
gaps: { start: Date; end: Date; gapLength: number }[],
allDaysOffSet: Set<string>,
weekendDays: number[] = [0, 6]
): any[] {
return gaps.map(gap => {
const backward = calculateChain(gap.start, gap.gapLength, allDaysOffSet, 'backward', weekendDays);
const forward = calculateChain(gap.end, gap.gapLength, allDaysOffSet, '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
);
// 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 and days off used
function calculateChain(
startDate: Date,
gapLength: number,
allDaysOffSet: Set<string>,
direction: 'backward' | 'forward',
weekendDays: number[] = [0, 6]
): { chainLength: number; usedDaysOff: number } {
let chainLength = gapLength;
let usedDaysOff = 0;
let currentDate = new Date(startDate);
// 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;
const boundaryCheck = direction === 'backward' ? -MS_IN_A_DAY : MS_IN_A_DAY;
let chainLength = gapLength;
let currentDate = new Date(date);
while (allDaysOffSet.has(dateKey(new Date(currentDate.getTime() + boundaryCheck))) ||
isWeekend(new Date(currentDate.getTime() + boundaryCheck), weekendDays)) {
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);
}
for (let i = 0; i < gapLength; i++) {
const potentialDayOff = new Date(startDate);
potentialDayOff.setDate(potentialDayOff.getDate() + (i * increment));
if (!allDaysOffSet.has(dateKey(potentialDayOff)) && !isWeekend(potentialDayOff, weekendDays)) {
usedDaysOff++;
}
}
return { chainLength, usedDaysOff };
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 days off based on ranked gaps
function selectDaysOff(rankedGaps: any[], daysOff: number, allDaysOffSet: Set<string>, year: number, weekendDays: number[]): Date[] {
const selectedDays: Date[] = [];
// 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;
while (daysOff > 0 && rankedGaps.length > 0) {
const gap = rankedGaps.shift();
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 && daysOff > 0; i++) {
const potentialDayOff = new Date(startDate);
potentialDayOff.setDate(potentialDayOff.getDate() + (i * increment));
for (let i = 0; i < gap.gapLength && remainingDays > 0; i++) {
const day = new Date(startDate);
day.setDate(day.getDate() + (i * increment));
if (!allDaysOffSet.has(dateKey(potentialDayOff)) && !isWeekend(potentialDayOff, weekendDays)) {
selectedDays.push(potentialDayOff);
allDaysOffSet.add(dateKey(potentialDayOff));
daysOff--;
if (!allDaysOff.has(dateKey(day)) && !isWeekend(day, weekendDays)) {
selectedDays.push(day);
remainingDays--;
}
}
const newGaps = findGaps(allDaysOffSet, year, weekendDays);
rankedGaps = rankGapsByEfficiency(newGaps, allDaysOffSet, weekendDays);
}
return selectedDays;
}
// Add consecutive days off to the list
function addConsecutiveDaysOff(
consecutiveDaysOff: { startDate: Date; endDate: Date; usedDaysOff: number; totalDays: number }[],
currentGroup: Date[],
optimizedDaysOff: Date[]
) {
const startDate = currentGroup[0];
const endDate = currentGroup[currentGroup.length - 1];
const totalDays = daysBetween(startDate, endDate) + 1;
// Create a Set of optimized days off for faster lookup
const optimizedDaysOffSet = new Set(optimizedDaysOff.map(d => dateKey(d)));
// Count only the days that were actually used from our PTO days
const usedDaysOff = currentGroup.filter(d => optimizedDaysOffSet.has(dateKey(d))).length;
consecutiveDaysOff.push({
startDate,
endDate,
usedDaysOff,
totalDays
});
// Check if a group contains both a weekend day and a non-weekend holiday/PTO day
function hasWeekendAndNonWeekendHoliday(group: Date[], weekendDays: number[], holidays: { date: Date }[], optimizedDaysOff: Date[]) {
return group.some(d => weekendDays.includes(d.getDay())) &&
group.some(d => !weekendDays.includes(d.getDay()) && (isHoliday(d, holidays) || optimizedDaysOff.some(od => dateKey(od) === dateKey(d))));
}
// 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
};
}

View File

@@ -253,6 +253,7 @@
$: visibleHolidaysCount = holidays.filter(h => !h.hidden).length;
function toggleWeekendDay(dayNumber: number) {
console.log('Toggling weekend day:', dayNumber);
if (weekendDays.includes(dayNumber)) {
weekendDays = weekendDays.filter(d => d !== dayNumber);
} else {
@@ -554,7 +555,6 @@
.content-box li {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.content-box button {
@@ -603,7 +603,7 @@
justify-content: space-between;
padding: 8px;
border-radius: 4px;
gap: 10px;
gap: 5px;
}
.holidays-list li:hover {
@@ -710,15 +710,6 @@
<div class="content-box" id="calendar">
<div class="calendar-key">
<div class="key-item">
<div class="key-label">
<span class="color-box weekend"></span>
<span>Weekend</span>
</div>
<a href="#" on:click|preventDefault={() => showWeekendSettings = !showWeekendSettings} class="edit-link">
(edit)
</a>
</div>
<div class="key-item">
<div class="key-label">
<span class="color-box optimized"></span>
@@ -736,6 +727,15 @@
</a>
{/if}
</div>
<div class="key-item">
<div class="key-label">
<span class="color-box weekend"></span>
<span>Weekend</span>
</div>
<a href="#" on:click|preventDefault={() => showWeekendSettings = !showWeekendSettings} class="edit-link">
(edit)
</a>
</div>
</div>
{#if showHolidaysList || showWeekendSettings}