Compare commits
12 Commits
50fb4f399f
...
76af4b3eb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76af4b3eb6 | ||
|
|
d3119d7486 | ||
|
|
3d1ef520d2 | ||
|
|
ab42bbb65a | ||
|
|
d09ca4f01f | ||
|
|
61d422cce2 | ||
|
|
2b40c2cc88 | ||
|
|
ef6a167431 | ||
|
|
1c4aa803bd | ||
|
|
98be02de8c | ||
|
|
44762113e8 | ||
|
|
f9dc40a857 |
1418
package-lock.json
generated
1418
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -7,20 +7,23 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"@vitest/ui": "^4.0.8",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^5.0.3"
|
"vite": "^5.0.3",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vercel/analytics": "^1.3.2",
|
|
||||||
"@vercel/speed-insights": "^1.1.0",
|
|
||||||
"date-holidays": "^3.23.12",
|
"date-holidays": "^3.23.12",
|
||||||
"i18n-iso-countries": "^7.13.0"
|
"i18n-iso-countries": "^7.13.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
export let optimizedDaysOff: Date[];
|
export let optimizedDaysOff: Date[];
|
||||||
export let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }>;
|
export let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }>;
|
||||||
export let selectedCountryCode: string;
|
export let selectedCountryCode: string;
|
||||||
|
export let weekendDays: number[] = [6, 0];
|
||||||
|
export let startDate: Date = new Date(year, 0, 1);
|
||||||
|
export let isActive: boolean = true;
|
||||||
|
export let fixedDaysOff: Date[] = [];
|
||||||
|
export let onDayClick: ((date: Date) => void) | undefined = undefined;
|
||||||
|
|
||||||
// Function to determine the first day of the week based on locale
|
// Function to determine the first day of the week based on locale
|
||||||
function getFirstDayOfWeek(locale: string): number {
|
function getFirstDayOfWeek(locale: string): number {
|
||||||
const normalizedLocale = locale.toLowerCase() === 'us' ? 'en-US' : `en-${locale.toUpperCase()}`;
|
const normalizedLocale = locale.toLowerCase() === 'us' ? 'en-US' : `en-${locale.toUpperCase()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get firstDay from Intl.Locale weekInfo
|
|
||||||
// @ts-ignore .weekInfo exists on all browsers except Firefox
|
// @ts-ignore .weekInfo exists on all browsers except Firefox
|
||||||
const weekFirstDay = new Intl.Locale(normalizedLocale)?.weekInfo?.firstDay;
|
const weekFirstDay = new Intl.Locale(normalizedLocale)?.weekInfo?.firstDay;
|
||||||
if (weekFirstDay !== undefined) {
|
if (weekFirstDay !== undefined) {
|
||||||
@@ -57,6 +61,34 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFixedDayOff(day: number): boolean {
|
||||||
|
return fixedDaysOff.some(date =>
|
||||||
|
date.getFullYear() === year &&
|
||||||
|
date.getMonth() === month &&
|
||||||
|
date.getDate() === day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayTooltip(day: number): string {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
if (isWeekend(date)) {
|
||||||
|
return 'Weekend';
|
||||||
|
} else if (isFixedDayOff(day)) {
|
||||||
|
return 'Day off (fixed)';
|
||||||
|
} else if (isOptimizedDayOff(day)) {
|
||||||
|
return 'Day off (calculated)';
|
||||||
|
} else {
|
||||||
|
return 'Tap to add fixed day off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(day: number) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const holiday = getHoliday(day);
|
||||||
|
if (isPastDate(day) || !onDayClick || isWeekend(date) || holiday) return;
|
||||||
|
onDayClick(date);
|
||||||
|
}
|
||||||
|
|
||||||
function getDominantMonth(period: { startDate: Date; endDate: Date }): number {
|
function getDominantMonth(period: { startDate: Date; endDate: Date }): number {
|
||||||
const startMonth = period.startDate.getMonth();
|
const startMonth = period.startDate.getMonth();
|
||||||
const endMonth = period.endDate.getMonth();
|
const endMonth = period.endDate.getMonth();
|
||||||
@@ -80,11 +112,15 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWeekend(day: number): boolean {
|
function isWeekend(date: Date): boolean {
|
||||||
const dayOfWeek = (adjustedFirstDay + day - 1) % 7;
|
return weekendDays.includes(date.getDay());
|
||||||
const saturdayIndex = (6 - firstDayOfWeek + 7) % 7;
|
}
|
||||||
const sundayIndex = (7 - firstDayOfWeek + 7) % 7;
|
|
||||||
return dayOfWeek === saturdayIndex || dayOfWeek === sundayIndex;
|
function isPastDate(day: number): boolean {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
// Normalize startDate to current year for comparison
|
||||||
|
const startDateInYear = new Date(year, startDate.getMonth(), startDate.getDate());
|
||||||
|
return date < startDateInYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayInitials = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
const dayInitials = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||||
@@ -92,7 +128,7 @@
|
|||||||
$: orderedDayInitials = dayInitials.slice(firstDayOfWeek).concat(dayInitials.slice(0, firstDayOfWeek));
|
$: orderedDayInitials = dayInitials.slice(firstDayOfWeek).concat(dayInitials.slice(0, firstDayOfWeek));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="calendar">
|
<div class="calendar {isActive ? '' : 'excluded-month'}">
|
||||||
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
|
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
|
||||||
|
|
||||||
{#each orderedDayInitials as dayInitial}
|
{#each orderedDayInitials as dayInitial}
|
||||||
@@ -103,11 +139,22 @@
|
|||||||
<div class="day"></div>
|
<div class="day"></div>
|
||||||
{/each}
|
{/each}
|
||||||
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
|
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
|
||||||
<div class="day {isWeekend(day) ? 'weekend' : ''} {getHoliday(day) ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''}">
|
{@const holiday = getHoliday(day)}
|
||||||
{day}
|
{@const pastDate = isPastDate(day)}
|
||||||
{#if getHoliday(day)}
|
{@const fixedDay = isFixedDayOff(day)}
|
||||||
<Tooltip text={getHoliday(day)?.name} />
|
{@const optimizedDay = isOptimizedDayOff(day)}
|
||||||
{/if}
|
{@const dayDate = new Date(year, month, day)}
|
||||||
|
{@const isWeekendDay = isWeekend(dayDate)}
|
||||||
|
{@const tooltipText = holiday ? holiday.name : getDayTooltip(day)}
|
||||||
|
{@const canClick = onDayClick && !pastDate && !isWeekendDay && !holiday}
|
||||||
|
<div
|
||||||
|
class="day {isWeekendDay ? 'weekend' : ''} {holiday ? 'holiday' : ''} {optimizedDay ? 'optimized' : ''} {fixedDay ? 'fixed' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''} {pastDate ? 'past-date' : ''} {canClick ? 'clickable' : ''}"
|
||||||
|
on:click={() => handleDayClick(day)}
|
||||||
|
role={canClick ? 'button' : undefined}
|
||||||
|
tabindex={canClick ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<span class={holiday?.hidden ? 'strikethrough' : ''}>{day}</span>
|
||||||
|
<Tooltip text={tooltipText} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,6 +186,14 @@
|
|||||||
color: #c5c6c7;
|
color: #c5c6c7;
|
||||||
font-size: 0.6em;
|
font-size: 0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excluded-month .month-name {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-month .day-initial {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
.day {
|
.day {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -162,10 +217,21 @@
|
|||||||
.optimized {
|
.optimized {
|
||||||
background-color: #4caf50;
|
background-color: #4caf50;
|
||||||
}
|
}
|
||||||
|
.fixed {
|
||||||
|
background-color: #2e7d32;
|
||||||
|
border: 2px solid #66bb6a;
|
||||||
|
}
|
||||||
.holiday {
|
.holiday {
|
||||||
background-color: #3b1e6e;
|
background-color: #3b1e6e;
|
||||||
|
}
|
||||||
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.clickable:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: transform 0.1s, opacity 0.1s;
|
||||||
|
}
|
||||||
.consecutive-day {
|
.consecutive-day {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
@@ -199,4 +265,17 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-date {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-date span {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
1246
src/lib/holidayUtils.test.ts
Normal file
1246
src/lib/holidayUtils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,223 +1,207 @@
|
|||||||
import Holidays from 'date-holidays';
|
import Holidays from 'date-holidays';
|
||||||
|
|
||||||
// Constants
|
|
||||||
const MS_IN_A_DAY = 86400000;
|
const MS_IN_A_DAY = 86400000;
|
||||||
const MAX_GAP_LENGTH = 5;
|
const MAX_GAP_LENGTH = 5;
|
||||||
|
|
||||||
// Helper function to check if a date is a weekend
|
// Core date helper functions
|
||||||
const isWeekend = (date: Date): boolean => date.getDay() === 0 || date.getDay() === 6;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const dateKey = (date: Date): string => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
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);
|
||||||
|
|
||||||
// Helper function to check if a date is a holiday
|
// Get holidays for a year, handling multi-day holidays and timezone differences
|
||||||
const isHoliday = (date: Date, holidays: { date: Date }[]): boolean => holidays.some(h => isSameDay(h.date, date));
|
|
||||||
|
|
||||||
// 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
|
|
||||||
export function getHolidaysForYear(countryCode: string, year: number, stateCode?: string): { date: Date; name: string }[] {
|
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.
|
// Use browser's languages and timezone to get localized holiday names
|
||||||
// We can pass in the browser's preferred languages (though the lib doesn't fall back, e.g. from `de-AT` to `de`)
|
const opts = {
|
||||||
const languages = navigator.languages.map(lang => lang.split('-')[0]);
|
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
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
const opts = { languages, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
};
|
||||||
const hd = stateCode ? new Holidays(countryCode, stateCode, opts) : new Holidays(countryCode, opts);
|
const hd = stateCode ? new Holidays(countryCode, stateCode, opts) : new Holidays(countryCode, opts);
|
||||||
console.log(hd.getHolidays(year));
|
|
||||||
return hd.getHolidays(year)
|
return hd.getHolidays(year)
|
||||||
.filter(holiday => holiday.type === 'public')
|
.filter(holiday => holiday.type === 'public')
|
||||||
.flatMap(holiday =>
|
.flatMap(holiday => Array.from(
|
||||||
// To handle single- and multi-day holidays, we generate a holiday entry for each day in the period
|
{ length: daysBetween(holiday.start, holiday.end) },
|
||||||
Array.from({ length: daysBetween(holiday.start, holiday.end) }, (_, i) => ({
|
(_, i) => ({
|
||||||
date: new Date(holiday.start.getFullYear(), holiday.start.getMonth(), holiday.start.getDate() + i),
|
date: new Date(holiday.start.getFullYear(), holiday.start.getMonth(), holiday.start.getDate() + i),
|
||||||
name: holiday.name,
|
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
|
// Find optimal placement of PTO days to maximize consecutive time off
|
||||||
export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOff: number): Date[] {
|
export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOff: number, weekendDays: number[] = [0, 6], startDate?: Date, fixedDaysOff: Date[] = []): Date[] {
|
||||||
const currentYearHolidays = holidays.filter(h => h.date.getFullYear() === year);
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
||||||
const weekends = getWeekends(year);
|
const filteredHolidays = holidays.filter(h => h.date.getFullYear() === year && h.date >= effectiveStartDate);
|
||||||
const allDaysOffSet = new Set([
|
const filteredFixedDaysOff = fixedDaysOff.filter(d => d.getFullYear() === year && d >= effectiveStartDate);
|
||||||
...currentYearHolidays.map(h => dateKey(h.date)),
|
const allDaysOff = new Set([
|
||||||
...weekends.map(d => dateKey(d))
|
...filteredHolidays.map(h => dateKey(h.date)),
|
||||||
|
...filteredFixedDaysOff.map(d => dateKey(d)),
|
||||||
|
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let rankedGaps = rankGapsByEfficiency(findGaps(allDaysOffSet, year), allDaysOffSet);
|
const gaps = findGaps(allDaysOff, year, weekendDays, effectiveStartDate);
|
||||||
|
return selectDaysOff(rankGapsByEfficiency(gaps, allDaysOff, weekendDays), daysOff, allDaysOff, weekendDays);
|
||||||
return selectDaysOff(rankedGaps, daysOff, allDaysOffSet, year);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate consecutive days off
|
// Calculate periods of consecutive days off (weekends + holidays + PTO)
|
||||||
export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number): { startDate: Date; endDate: Date; usedDaysOff: number; totalDays: number }[] {
|
export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6], startDate?: Date, fixedDaysOff: Date[] = []) {
|
||||||
const allDaysOffSet = new Set([...holidays.map(h => dateKey(h.date)), ...optimizedDaysOff.map(d => dateKey(d))]);
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
||||||
const consecutiveDaysOff: { startDate: Date; endDate: Date; usedDaysOff: number; totalDays: number }[] = [];
|
const filteredHolidays = holidays.filter(h => h.date >= effectiveStartDate);
|
||||||
let currentGroup: Date[] = [];
|
const filteredOptimizedDaysOff = optimizedDaysOff.filter(d => d >= effectiveStartDate);
|
||||||
|
const filteredFixedDaysOff = fixedDaysOff.filter(d => d >= effectiveStartDate);
|
||||||
|
|
||||||
for (let month = 0; month < 12; month++) {
|
const allDaysOff = new Set([
|
||||||
for (let day = 1; day <= 31; day++) {
|
...filteredHolidays.map(h => dateKey(h.date)),
|
||||||
const date = new Date(year, month, day);
|
...filteredOptimizedDaysOff.map(d => dateKey(d)),
|
||||||
if (date.getMonth() !== month) break;
|
...filteredFixedDaysOff.map(d => dateKey(d)),
|
||||||
|
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
|
||||||
|
]);
|
||||||
|
|
||||||
if (isWeekend(date) || isHoliday(date, holidays) || isDayOff(date, allDaysOffSet)) {
|
const consecutiveDaysOff = [];
|
||||||
currentGroup.push(date);
|
let currentGroup = [];
|
||||||
} else {
|
|
||||||
if (currentGroup.length > 2) {
|
for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) {
|
||||||
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff);
|
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, filteredFixedDaysOff));
|
||||||
}
|
}
|
||||||
currentGroup = [];
|
currentGroup = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (currentGroup.length > 2) {
|
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
||||||
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff);
|
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff, filteredFixedDaysOff));
|
||||||
}
|
}
|
||||||
|
|
||||||
return consecutiveDaysOff;
|
return consecutiveDaysOff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all weekends for a specific year
|
// Get all weekend days for a year
|
||||||
function getWeekends(year: number): Date[] {
|
function getWeekends(year: number, weekendDays: number[], startDate?: Date): Date[] {
|
||||||
const weekends: Date[] = [];
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
||||||
for (let month = 0; month < 12; month++) {
|
const weekends = [];
|
||||||
for (let day = 1; day <= 31; day++) {
|
for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) {
|
||||||
const date = new Date(year, month, day);
|
if (d.getMonth() === d.getMonth() && isWeekend(d, weekendDays)) {
|
||||||
if (date.getMonth() !== month) break;
|
weekends.push(new Date(d));
|
||||||
if (isWeekend(date)) weekends.push(date);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return weekends;
|
return weekends;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find gaps between days off
|
// Find gaps between days off that could be filled with PTO
|
||||||
function findGaps(allDaysOffSet: Set<string>, year: number): { start: Date; end: Date; gapLength: number }[] {
|
function findGaps(allDaysOff: Set<string>, year: number, weekendDays: number[], startDate?: Date) {
|
||||||
const gaps: { start: Date; end: Date; gapLength: number }[] = [];
|
const effectiveStartDate = startDate || new Date(year, 0, 1);
|
||||||
let currentGapStart: Date | null = null;
|
const endDate = new Date(year, 11, 31);
|
||||||
|
const gaps = [];
|
||||||
|
let gapStart = null;
|
||||||
|
|
||||||
for (let month = 0; month < 12; month++) {
|
for (let d = new Date(effectiveStartDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||||
for (let day = 1; day <= 31; day++) {
|
if (!allDaysOff.has(dateKey(d)) && !isWeekend(d, weekendDays)) {
|
||||||
const date = new Date(year, month, day);
|
if (!gapStart) gapStart = new Date(d);
|
||||||
if (date.getMonth() !== month) break;
|
} else if (gapStart) {
|
||||||
|
const gapLength = daysBetween(gapStart, d);
|
||||||
const isDayOff = allDaysOffSet.has(dateKey(date));
|
|
||||||
|
|
||||||
if (!isDayOff && !isWeekend(date)) {
|
|
||||||
if (!currentGapStart) currentGapStart = date;
|
|
||||||
} else if (currentGapStart) {
|
|
||||||
const gapLength = daysBetween(currentGapStart, date);
|
|
||||||
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
|
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
|
||||||
gaps.push({ start: currentGapStart, end: new Date(date.getTime() - MS_IN_A_DAY), gapLength });
|
gaps.push({ start: gapStart, end: new Date(d.getTime() - MS_IN_A_DAY), gapLength });
|
||||||
}
|
|
||||||
currentGapStart = null;
|
|
||||||
}
|
}
|
||||||
|
gapStart = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentGapStart) {
|
// Handle gap that extends to the end of the year
|
||||||
const lastDayOfYear = new Date(year, 11, 31);
|
if (gapStart) {
|
||||||
const gapLength = daysBetween(currentGapStart, lastDayOfYear) + 1;
|
const gapLength = daysBetween(gapStart, new Date(endDate.getTime() + MS_IN_A_DAY));
|
||||||
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
|
if (gapLength > 0 && gapLength <= MAX_GAP_LENGTH) {
|
||||||
gaps.push({ start: currentGapStart, end: lastDayOfYear, gapLength });
|
gaps.push({ start: gapStart, end: endDate, gapLength });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return gaps;
|
return gaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rank gaps by efficiency
|
// Rank gaps by how efficiently they can be used to create longer periods off
|
||||||
function rankGapsByEfficiency(gaps: { start: Date; end: Date; gapLength: number }[], allDaysOffSet: Set<string>): any[] {
|
function rankGapsByEfficiency(gaps: any[], allDaysOff: Set<string>, weekendDays: number[]) {
|
||||||
return gaps.map(gap => {
|
return gaps
|
||||||
const backward = calculateChain(gap.start, gap.gapLength, allDaysOffSet, 'backward');
|
.map(gap => {
|
||||||
const forward = calculateChain(gap.end, gap.gapLength, allDaysOffSet, 'forward');
|
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)
|
);
|
||||||
|
return forward.chainLength > backward.chainLength ||
|
||||||
|
(forward.chainLength === backward.chainLength && forward.usedDaysOff <= backward.usedDaysOff)
|
||||||
? { ...gap, ...forward, fillFrom: 'end' }
|
? { ...gap, ...forward, fillFrom: 'end' }
|
||||||
: { ...gap, ...backward, fillFrom: 'start' };
|
: { ...gap, ...backward, fillFrom: 'start' };
|
||||||
}).sort((a, b) => a.gapLength - b.gapLength || b.chainLength - a.chainLength || a.usedDaysOff - b.usedDaysOff);
|
})
|
||||||
|
.sort((a, b) => a.gapLength - b.gapLength || b.chainLength - a.chainLength || a.usedDaysOff - b.usedDaysOff);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate potential chain length and days off used
|
// Calculate potential chain length in either direction from a gap
|
||||||
function calculateChain(startDate: Date, gapLength: number, allDaysOffSet: Set<string>, direction: 'backward' | 'forward'): { chainLength: number; usedDaysOff: number } {
|
function calculateChain(date: Date, gapLength: number, allDaysOff: Set<string>, direction: 'backward' | 'forward', weekendDays: number[]) {
|
||||||
let chainLength = gapLength;
|
|
||||||
let usedDaysOff = 0;
|
|
||||||
let currentDate = new Date(startDate);
|
|
||||||
|
|
||||||
const increment = direction === 'backward' ? -1 : 1;
|
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))) {
|
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++;
|
chainLength++;
|
||||||
currentDate.setDate(currentDate.getDate() + increment);
|
currentDate.setDate(currentDate.getDate() + increment);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < gapLength; i++) {
|
return {
|
||||||
const potentialDayOff = new Date(startDate);
|
chainLength,
|
||||||
potentialDayOff.setDate(potentialDayOff.getDate() + (i * increment));
|
usedDaysOff: Array.from({ length: gapLength }, (_, i) => {
|
||||||
if (!allDaysOffSet.has(dateKey(potentialDayOff)) && !isWeekend(potentialDayOff)) {
|
const d = new Date(date);
|
||||||
usedDaysOff++;
|
d.setDate(d.getDate() + i * increment);
|
||||||
}
|
return !allDaysOff.has(dateKey(d)) && !isWeekend(d, weekendDays);
|
||||||
|
}).filter(Boolean).length
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { chainLength, usedDaysOff };
|
// 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;
|
||||||
|
|
||||||
// Select days off based on ranked gaps
|
for (const gap of rankedGaps) {
|
||||||
function selectDaysOff(rankedGaps: any[], daysOff: number, allDaysOffSet: Set<string>, year: number): Date[] {
|
if (remainingDays <= 0) break;
|
||||||
const selectedDays: Date[] = [];
|
|
||||||
|
|
||||||
while (daysOff > 0 && rankedGaps.length > 0) {
|
|
||||||
const gap = rankedGaps.shift();
|
|
||||||
|
|
||||||
const increment = gap.fillFrom === 'start' ? 1 : -1;
|
const increment = gap.fillFrom === 'start' ? 1 : -1;
|
||||||
const startDate = gap.fillFrom === 'start' ? gap.start : gap.end;
|
const startDate = gap.fillFrom === 'start' ? gap.start : gap.end;
|
||||||
|
|
||||||
for (let i = 0; i < gap.gapLength && daysOff > 0; i++) {
|
for (let i = 0; i < gap.gapLength && remainingDays > 0; i++) {
|
||||||
const potentialDayOff = new Date(startDate);
|
const day = new Date(startDate);
|
||||||
potentialDayOff.setDate(potentialDayOff.getDate() + (i * increment));
|
day.setDate(day.getDate() + (i * increment));
|
||||||
|
|
||||||
if (!allDaysOffSet.has(dateKey(potentialDayOff)) && !isWeekend(potentialDayOff)) {
|
if (!allDaysOff.has(dateKey(day)) && !isWeekend(day, weekendDays)) {
|
||||||
selectedDays.push(potentialDayOff);
|
selectedDays.push(day);
|
||||||
allDaysOffSet.add(dateKey(potentialDayOff));
|
remainingDays--;
|
||||||
daysOff--;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newGaps = findGaps(allDaysOffSet, year);
|
|
||||||
rankedGaps = rankGapsByEfficiency(newGaps, allDaysOffSet);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedDays;
|
return selectedDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add consecutive days off to the list
|
// Check if a group is valid (2+ days, not just weekends)
|
||||||
function addConsecutiveDaysOff(consecutiveDaysOff: { startDate: Date; endDate: Date; usedDaysOff: number; totalDays: number }[], currentGroup: Date[], optimizedDaysOff: Date[]) {
|
function isValidConsecutiveGroup(group: Date[], weekendDays: number[]): boolean {
|
||||||
const startDate = currentGroup[0];
|
// Must be at least 2 days
|
||||||
const endDate = currentGroup[currentGroup.length - 1];
|
if (group.length < 2) return false;
|
||||||
const totalDays = daysBetween(startDate, endDate) + 1;
|
|
||||||
const usedDaysOff = currentGroup.filter(d => isDayOff(d, new Set(optimizedDaysOff.map(d => dateKey(d))))).length;
|
|
||||||
|
|
||||||
if (totalDays > 2) {
|
// Check if ALL days are weekends
|
||||||
consecutiveDaysOff.push({
|
const allDaysAreWeekends = group.every(d => weekendDays.includes(d.getDay()));
|
||||||
startDate,
|
|
||||||
endDate,
|
// Valid if not all days are weekends
|
||||||
usedDaysOff,
|
return !allDaysAreWeekends;
|
||||||
totalDays
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a period object from a group of consecutive days
|
||||||
|
function createPeriod(group: Date[], optimizedDaysOff: Date[], fixedDaysOff: 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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
317
src/routes/page.test.ts
Normal file
317
src/routes/page.test.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Helper function to create a date key (same as in +page.svelte)
|
||||||
|
function dateKey(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a day is already a day off
|
||||||
|
function isAlreadyDayOff(
|
||||||
|
date: Date,
|
||||||
|
weekendDays: number[],
|
||||||
|
holidays: Array<{ date: Date; name: string }>,
|
||||||
|
optimizedDaysOff: Date[]
|
||||||
|
): boolean {
|
||||||
|
const isWeekendDay = weekendDays.includes(date.getDay());
|
||||||
|
const isHolidayDay = holidays.some(h =>
|
||||||
|
h.date.getFullYear() === date.getFullYear() &&
|
||||||
|
h.date.getMonth() === date.getMonth() &&
|
||||||
|
h.date.getDate() === date.getDate()
|
||||||
|
);
|
||||||
|
const isOptimizedDay = optimizedDaysOff.some(d =>
|
||||||
|
d.getFullYear() === date.getFullYear() &&
|
||||||
|
d.getMonth() === date.getMonth() &&
|
||||||
|
d.getDate() === date.getDate()
|
||||||
|
);
|
||||||
|
return isWeekendDay || isHolidayDay || isOptimizedDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate toggleFixedDayOff logic
|
||||||
|
function simulateToggleFixedDayOff(
|
||||||
|
date: Date,
|
||||||
|
fixedDaysOff: Date[],
|
||||||
|
daysOff: number,
|
||||||
|
weekendDays: number[],
|
||||||
|
holidays: Array<{ date: Date; name: string }>,
|
||||||
|
optimizedDaysOff: Date[]
|
||||||
|
): { fixedDaysOff: Date[]; daysOff: number } {
|
||||||
|
const normalizedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
const dateKeyStr = dateKey(normalizedDate);
|
||||||
|
|
||||||
|
const isAlreadyDayOffDay = isAlreadyDayOff(normalizedDate, weekendDays, holidays, optimizedDaysOff);
|
||||||
|
|
||||||
|
const existingIndex = fixedDaysOff.findIndex(d => dateKey(d) === dateKeyStr);
|
||||||
|
|
||||||
|
let newFixedDaysOff: Date[];
|
||||||
|
let newDaysOff = daysOff;
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Remove if already exists - don't subtract from days off count
|
||||||
|
newFixedDaysOff = fixedDaysOff.filter((_, i) => i !== existingIndex);
|
||||||
|
} else {
|
||||||
|
// Add if doesn't exist
|
||||||
|
newFixedDaysOff = [...fixedDaysOff, normalizedDate];
|
||||||
|
// Only increase days off if this day isn't already a day off for another reason
|
||||||
|
if (!isAlreadyDayOffDay) {
|
||||||
|
newDaysOff = daysOff + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fixedDaysOff: newFixedDaysOff, daysOff: newDaysOff };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check minimum days off validation
|
||||||
|
function canDecreaseDaysOff(daysOff: number, fixedDaysOffCount: number): boolean {
|
||||||
|
return daysOff > fixedDaysOffCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Fixed Days Off Logic', () => {
|
||||||
|
const TEST_YEAR = 2024;
|
||||||
|
const WEEKEND_DAYS = [0, 6]; // Sunday, Saturday
|
||||||
|
|
||||||
|
describe('toggleFixedDayOff behavior', () => {
|
||||||
|
it('should increase days off when adding a fixed day off to a regular day', () => {
|
||||||
|
const regularDay = new Date(TEST_YEAR, 0, 15); // Monday, Jan 15
|
||||||
|
const fixedDaysOff: Date[] = [];
|
||||||
|
const daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
regularDay,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(1);
|
||||||
|
expect(result.daysOff).toBe(11); // Increased by 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT increase days off when adding a fixed day off to a weekend', () => {
|
||||||
|
const weekendDay = new Date(TEST_YEAR, 0, 13); // Saturday, Jan 13
|
||||||
|
const fixedDaysOff: Date[] = [];
|
||||||
|
const daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
weekendDay,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(1);
|
||||||
|
expect(result.daysOff).toBe(10); // Not increased
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT increase days off when adding a fixed day off to a holiday', () => {
|
||||||
|
const holidayDate = new Date(TEST_YEAR, 0, 1); // New Year's Day
|
||||||
|
const fixedDaysOff: Date[] = [];
|
||||||
|
const daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [
|
||||||
|
{ date: new Date(TEST_YEAR, 0, 1), name: 'New Year\'s Day' }
|
||||||
|
];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
holidayDate,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(1);
|
||||||
|
expect(result.daysOff).toBe(10); // Not increased
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT increase days off when adding a fixed day off to an optimized day', () => {
|
||||||
|
const optimizedDay = new Date(TEST_YEAR, 0, 15); // Monday, Jan 15
|
||||||
|
const fixedDaysOff: Date[] = [];
|
||||||
|
const daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [new Date(TEST_YEAR, 0, 15)];
|
||||||
|
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
optimizedDay,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(1);
|
||||||
|
expect(result.daysOff).toBe(10); // Not increased
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT decrease days off when removing a fixed day off', () => {
|
||||||
|
const fixedDay = new Date(TEST_YEAR, 0, 15);
|
||||||
|
const fixedDaysOff: Date[] = [fixedDay];
|
||||||
|
const daysOff = 11;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
fixedDay,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(0);
|
||||||
|
expect(result.daysOff).toBe(11); // Not decreased
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT decrease days off when removing a fixed day off that was on a weekend', () => {
|
||||||
|
const weekendDay = new Date(TEST_YEAR, 0, 13); // Saturday
|
||||||
|
const fixedDaysOff: Date[] = [weekendDay];
|
||||||
|
const daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
weekendDay,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(0);
|
||||||
|
expect(result.daysOff).toBe(10); // Not decreased
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple fixed days off correctly', () => {
|
||||||
|
const day1 = new Date(TEST_YEAR, 0, 15);
|
||||||
|
const day2 = new Date(TEST_YEAR, 0, 16);
|
||||||
|
const day3 = new Date(TEST_YEAR, 0, 17);
|
||||||
|
let fixedDaysOff: Date[] = [];
|
||||||
|
let daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
// Add first day
|
||||||
|
let result = simulateToggleFixedDayOff(day1, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
fixedDaysOff = result.fixedDaysOff;
|
||||||
|
daysOff = result.daysOff;
|
||||||
|
expect(daysOff).toBe(11);
|
||||||
|
|
||||||
|
// Add second day
|
||||||
|
result = simulateToggleFixedDayOff(day2, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
fixedDaysOff = result.fixedDaysOff;
|
||||||
|
daysOff = result.daysOff;
|
||||||
|
expect(daysOff).toBe(12);
|
||||||
|
|
||||||
|
// Add third day
|
||||||
|
result = simulateToggleFixedDayOff(day3, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
fixedDaysOff = result.fixedDaysOff;
|
||||||
|
daysOff = result.daysOff;
|
||||||
|
expect(daysOff).toBe(13);
|
||||||
|
|
||||||
|
// Remove one day - count should stay the same
|
||||||
|
result = simulateToggleFixedDayOff(day2, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
expect(result.fixedDaysOff.length).toBe(2);
|
||||||
|
expect(result.daysOff).toBe(13); // Not decreased
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Minimum days off validation', () => {
|
||||||
|
it('should allow decreasing when days off is greater than fixed days off count', () => {
|
||||||
|
const daysOff = 15;
|
||||||
|
const fixedDaysOffCount = 5;
|
||||||
|
|
||||||
|
expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT allow decreasing when days off equals fixed days off count', () => {
|
||||||
|
const daysOff = 5;
|
||||||
|
const fixedDaysOffCount = 5;
|
||||||
|
|
||||||
|
expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT allow decreasing when days off is less than fixed days off count', () => {
|
||||||
|
const daysOff = 3;
|
||||||
|
const fixedDaysOffCount = 5;
|
||||||
|
|
||||||
|
expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow decreasing when there are no fixed days off', () => {
|
||||||
|
const daysOff = 10;
|
||||||
|
const fixedDaysOffCount = 0;
|
||||||
|
|
||||||
|
expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT allow decreasing when days off is 0 and there are no fixed days off', () => {
|
||||||
|
const daysOff = 0;
|
||||||
|
const fixedDaysOffCount = 0;
|
||||||
|
|
||||||
|
expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle adding and removing the same day multiple times', () => {
|
||||||
|
const day = new Date(TEST_YEAR, 0, 15);
|
||||||
|
let fixedDaysOff: Date[] = [];
|
||||||
|
let daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
const optimizedDaysOff: Date[] = [];
|
||||||
|
|
||||||
|
// Add
|
||||||
|
let result = simulateToggleFixedDayOff(day, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
fixedDaysOff = result.fixedDaysOff;
|
||||||
|
daysOff = result.daysOff;
|
||||||
|
expect(fixedDaysOff.length).toBe(1);
|
||||||
|
expect(daysOff).toBe(11);
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
result = simulateToggleFixedDayOff(day, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
fixedDaysOff = result.fixedDaysOff;
|
||||||
|
daysOff = result.daysOff;
|
||||||
|
expect(fixedDaysOff.length).toBe(0);
|
||||||
|
expect(daysOff).toBe(11); // Not decreased
|
||||||
|
|
||||||
|
// Add again
|
||||||
|
result = simulateToggleFixedDayOff(day, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff);
|
||||||
|
expect(result.fixedDaysOff.length).toBe(1);
|
||||||
|
expect(result.daysOff).toBe(12); // Increased again
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fixed day off on a day that becomes optimized later', () => {
|
||||||
|
const day = new Date(TEST_YEAR, 0, 15);
|
||||||
|
const fixedDaysOff: Date[] = [day];
|
||||||
|
const daysOff = 10;
|
||||||
|
const holidays: Array<{ date: Date; name: string }> = [];
|
||||||
|
// Day is now optimized
|
||||||
|
const optimizedDaysOff: Date[] = [day];
|
||||||
|
|
||||||
|
// Removing fixed day off should not decrease count since it's now optimized
|
||||||
|
const result = simulateToggleFixedDayOff(
|
||||||
|
day,
|
||||||
|
fixedDaysOff,
|
||||||
|
daysOff,
|
||||||
|
WEEKEND_DAYS,
|
||||||
|
holidays,
|
||||||
|
optimizedDaysOff
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fixedDaysOff.length).toBe(0);
|
||||||
|
expect(result.daysOff).toBe(10); // Not decreased
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -2,5 +2,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user