From 44762113e8c32431545caa2b69bfdb214504259d Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 22 Nov 2024 20:16:01 +0100 Subject: [PATCH] Add a nice cleanup of holidayUtils.js --- src/lib/CalendarMonth.svelte | 15 +- src/lib/holidayUtils.ts | 383 +++++++++++------------------------ src/routes/+page.svelte | 22 +- 3 files changed, 134 insertions(+), 286 deletions(-) diff --git a/src/lib/CalendarMonth.svelte b/src/lib/CalendarMonth.svelte index 2fd2b85..40e5262 100644 --- a/src/lib/CalendarMonth.svelte +++ b/src/lib/CalendarMonth.svelte @@ -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 @@
{/each} {#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day} -
- {day} - {#if getHoliday(day)} - + {@const holiday = getHoliday(day)} +
+ {day} + {#if holiday} + {/if}
{/each} @@ -197,4 +197,9 @@ font-size: 0.8em; } } + + .strikethrough { + text-decoration: line-through; + opacity: 0.5; + } \ No newline at end of file diff --git a/src/lib/holidayUtils.ts b/src/lib/holidayUtils.ts index 4c7ea3f..99a6296 100644 --- a/src/lib/holidayUtils.ts +++ b/src/lib/holidayUtils.ts @@ -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): 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[] = []; - - 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; +// 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)) + ]); - 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; + const consecutiveDaysOff = []; + let currentGroup = []; - 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, 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, 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, - 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, 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, - 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, direction: 'backward' | 'forward', weekendDays: number[]) { const increment = direction === 'backward' ? -1 : 1; - const boundaryCheck = direction === 'backward' ? -MS_IN_A_DAY : MS_IN_A_DAY; - - while (allDaysOffSet.has(dateKey(new Date(currentDate.getTime() + boundaryCheck))) || - isWeekend(new Date(currentDate.getTime() + boundaryCheck), weekendDays)) { + 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); } - 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, year: number, weekendDays: number[]): Date[] { - const selectedDays: Date[] = []; - - while (daysOff > 0 && rankedGaps.length > 0) { - const gap = rankedGaps.shift(); +// Select optimal days off based on ranked gaps +function selectDaysOff(rankedGaps: any[], daysOff: number, allDaysOff: Set, 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 && daysOff > 0; i++) { - const potentialDayOff = new Date(startDate); - potentialDayOff.setDate(potentialDayOff.getDate() + (i * increment)); - - if (!allDaysOffSet.has(dateKey(potentialDayOff)) && !isWeekend(potentialDayOff, weekendDays)) { - selectedDays.push(potentialDayOff); - allDaysOffSet.add(dateKey(potentialDayOff)); - daysOff--; + 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--; } } - - 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; +// 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)))); +} - consecutiveDaysOff.push({ - startDate, - endDate, - usedDaysOff, - totalDays - }); +// 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 + }; } \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9f45761..be864f2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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 @@
- {#if showHolidaysList || showWeekendSettings}