Compare commits

...

12 Commits

Author SHA1 Message Date
zachd
76af4b3eb6 Update dependencies 2025-11-10 01:31:32 +01:00
zachd
d3119d7486 Hotfix: Ensure fixed days are counted in days off 2025-11-10 01:29:54 +01:00
Zachary
3d1ef520d2 Merge pull request #21 from zachd/feat-select-fixed-days-d0988
Add support for fixed days off in calendar component
2025-11-10 01:02:11 +01:00
zachd
ab42bbb65a Add support for fixed days off in calendar component
- Introduced `fixedDaysOff` prop to `CalendarMonth.svelte` for specifying non-working days.
- Enhanced tooltip functionality to indicate fixed days off.
- Updated day click handling to respect fixed days off.
- Modified styles to visually distinguish fixed days off in the calendar.
- Adjusted `optimizeDaysOff` and `calculateConsecutiveDaysOff` functions to account for fixed days off.
- Added tests to ensure fixed days off are excluded from optimization but included in consecutive periods.
- Implemented UI elements for managing fixed days off in the settings panel.
2025-11-10 00:59:54 +01:00
zachd
d09ca4f01f Hotfix: Use gap at the end of the year 2025-11-10 00:45:44 +01:00
Zachary
61d422cce2 Merge pull request #20 from zachd/feat-calendar-start-date-c6a9d
Add start dates
2025-11-10 00:38:37 +01:00
zachd
2b40c2cc88 Add tests 2025-11-10 00:36:51 +01:00
zachd
ef6a167431 Enhance calendar functionality with start date management and excluded month visibility. Added start date state and date picker for user-defined start dates. Updated holiday and PTO calculations to respect the start date. Improved UI to toggle excluded months and display active months based on the start date. Refactored related utility functions for better date handling. 2025-11-10 00:28:42 +01:00
Zachary
1c4aa803bd Improve consecutive group calculation to only exclude all weekend groups 2024-11-22 20:46:35 +01:00
Zachary
98be02de8c Merge pull request #13 from zachd/allow-custom-weekends
Allow custom weekend days to be selected
2024-11-22 20:17:53 +01:00
Zachary
44762113e8 Add a nice cleanup of holidayUtils.js 2024-11-22 20:16:01 +01:00
Zachary
f9dc40a857 Allow customisable Weekend days 2024-11-22 19:39:56 +01:00
8 changed files with 3767 additions and 479 deletions

1420
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,20 +7,23 @@
"build": "vite build",
"preview": "vite preview",
"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": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@vitest/ui": "^4.0.8",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.3"
"vite": "^5.0.3",
"vitest": "^4.0.8"
},
"dependencies": {
"@vercel/analytics": "^1.3.2",
"@vercel/speed-insights": "^1.1.0",
"date-holidays": "^3.23.12",
"i18n-iso-countries": "^7.13.0"
}

View File

@@ -7,13 +7,17 @@
export let optimizedDaysOff: Date[];
export let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }>;
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 getFirstDayOfWeek(locale: string): number {
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) {
@@ -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 {
const startMonth = period.startDate.getMonth();
const endMonth = period.endDate.getMonth();
@@ -80,11 +112,15 @@
});
}
function isWeekend(day: number): boolean {
const dayOfWeek = (adjustedFirstDay + day - 1) % 7;
const saturdayIndex = (6 - firstDayOfWeek + 7) % 7;
const sundayIndex = (7 - firstDayOfWeek + 7) % 7;
return dayOfWeek === saturdayIndex || dayOfWeek === sundayIndex;
function isWeekend(date: Date): boolean {
return weekendDays.includes(date.getDay());
}
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'];
@@ -92,7 +128,7 @@
$: orderedDayInitials = dayInitials.slice(firstDayOfWeek).concat(dayInitials.slice(0, firstDayOfWeek));
</script>
<div class="calendar">
<div class="calendar {isActive ? '' : 'excluded-month'}">
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
{#each orderedDayInitials as dayInitial}
@@ -103,11 +139,22 @@
<div class="day"></div>
{/each}
{#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' : ''}">
{day}
{#if getHoliday(day)}
<Tooltip text={getHoliday(day)?.name} />
{/if}
{@const holiday = getHoliday(day)}
{@const pastDate = isPastDate(day)}
{@const fixedDay = isFixedDayOff(day)}
{@const optimizedDay = isOptimizedDayOff(day)}
{@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>
{/each}
</div>
@@ -139,6 +186,14 @@
color: #c5c6c7;
font-size: 0.6em;
}
.excluded-month .month-name {
color: #666;
}
.excluded-month .day-initial {
color: #666;
}
.day {
aspect-ratio: 1;
text-align: center;
@@ -162,10 +217,21 @@
.optimized {
background-color: #4caf50;
}
.fixed {
background-color: #2e7d32;
border: 2px solid #66bb6a;
}
.holiday {
background-color: #3b1e6e;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
opacity: 0.7;
transform: scale(1.05);
transition: transform 0.1s, opacity 0.1s;
}
.consecutive-day {
border: 1px solid rgba(255, 255, 255, 0.7);
}
@@ -199,4 +265,17 @@
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>

1246
src/lib/holidayUtils.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +1,207 @@
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): 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
// 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);
// Helper function to check if a date is a holiday
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
// 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): Date[] {
const currentYearHolidays = holidays.filter(h => h.date.getFullYear() === year);
const weekends = getWeekends(year);
const allDaysOffSet = new Set([
...currentYearHolidays.map(h => dateKey(h.date)),
...weekends.map(d => dateKey(d))
// 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, fixedDaysOff: Date[] = []): Date[] {
const effectiveStartDate = startDate || new Date(year, 0, 1);
const filteredHolidays = holidays.filter(h => h.date.getFullYear() === year && h.date >= effectiveStartDate);
const filteredFixedDaysOff = fixedDaysOff.filter(d => d.getFullYear() === year && d >= effectiveStartDate);
const allDaysOff = new Set([
...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);
return selectDaysOff(rankedGaps, daysOff, allDaysOffSet, year);
const gaps = findGaps(allDaysOff, year, weekendDays, effectiveStartDate);
return selectDaysOff(rankGapsByEfficiency(gaps, allDaysOff, weekendDays), daysOff, allDaysOff, weekendDays);
}
// Calculate consecutive days off
export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number): { startDate: Date; endDate: Date; usedDaysOff: number; totalDays: number }[] {
const allDaysOffSet = new Set([...holidays.map(h => dateKey(h.date)), ...optimizedDaysOff.map(d => dateKey(d))]);
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], startDate?: Date, fixedDaysOff: 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 filteredFixedDaysOff = fixedDaysOff.filter(d => d >= effectiveStartDate);
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 allDaysOff = new Set([
...filteredHolidays.map(h => dateKey(h.date)),
...filteredOptimizedDaysOff.map(d => dateKey(d)),
...filteredFixedDaysOff.map(d => dateKey(d)),
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
]);
if (isWeekend(date) || isHoliday(date, holidays) || isDayOff(date, allDaysOffSet)) {
currentGroup.push(date);
} else {
if (currentGroup.length > 2) {
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff);
}
currentGroup = [];
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, filteredFixedDaysOff));
}
currentGroup = [];
}
}
if (currentGroup.length > 2) {
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff);
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff, filteredFixedDaysOff));
}
return consecutiveDaysOff;
}
// Get all weekends for a specific year
function getWeekends(year: 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)) weekends.push(date);
// 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
function findGaps(allDaysOffSet: Set<string>, year: 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[], startDate?: Date) {
const effectiveStartDate = startDate || new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
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)) {
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(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;
}
}
if (currentGapStart) {
const lastDayOfYear = new Date(year, 11, 31);
const gapLength = daysBetween(currentGapStart, lastDayOfYear) + 1;
// 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: currentGapStart, end: lastDayOfYear, gapLength });
gaps.push({ start: gapStart, end: endDate, gapLength });
}
}
return gaps;
}
// Rank gaps by efficiency
function rankGapsByEfficiency(gaps: { start: Date; end: Date; gapLength: number }[], allDaysOffSet: Set<string>): any[] {
return gaps.map(gap => {
const backward = calculateChain(gap.start, gap.gapLength, allDaysOffSet, 'backward');
const forward = calculateChain(gap.end, gap.gapLength, allDaysOffSet, 'forward');
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'): { 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))) {
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)) {
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): 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)) {
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);
rankedGaps = rankGapsByEfficiency(newGaps, allDaysOffSet);
}
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;
const usedDaysOff = currentGroup.filter(d => isDayOff(d, new Set(optimizedDaysOff.map(d => dateKey(d))))).length;
// 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;
if (totalDays > 2) {
consecutiveDaysOff.push({
startDate,
endDate,
usedDaysOff,
totalDays
});
}
// 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[], 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
View 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
});
});
});

View File

@@ -2,5 +2,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
plugins: [sveltekit()],
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}']
}
});