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.
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
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 {
|
||||
@@ -59,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 select 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();
|
||||
@@ -111,11 +141,20 @@
|
||||
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
|
||||
{@const holiday = getHoliday(day)}
|
||||
{@const pastDate = isPastDate(day)}
|
||||
<div class="day {isWeekend(new Date(year, month, day)) ? 'weekend' : ''} {holiday ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''} {pastDate ? 'past-date' : ''}">
|
||||
{@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>
|
||||
{#if holiday}
|
||||
<Tooltip text={holiday.name} />
|
||||
{/if}
|
||||
<Tooltip text={tooltipText} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -178,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.8;
|
||||
transform: scale(1.05);
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.consecutive-day {
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,21 @@ describe('holidayUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include fixed days off in optimized days', () => {
|
||||
const fixedDaysOff = [
|
||||
new Date(TEST_YEAR, 0, 5),
|
||||
new Date(TEST_YEAR, 0, 10),
|
||||
];
|
||||
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const fixedDaysOffKeys = new Set(fixedDaysOff.map(d =>
|
||||
`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
));
|
||||
result.forEach(date => {
|
||||
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
expect(fixedDaysOffKeys.has(dateKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not select days that are already holidays or weekends', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
@@ -158,6 +173,24 @@ describe('holidayUtils', () => {
|
||||
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
|
||||
});
|
||||
});
|
||||
|
||||
it('should not select days that are fixed days off, treating them like weekends/holidays', () => {
|
||||
const fixedDaysOff = [
|
||||
new Date(TEST_YEAR, 0, 5),
|
||||
new Date(TEST_YEAR, 0, 7),
|
||||
];
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const fixedDaysOffSet = new Set(fixedDaysOff.map(d =>
|
||||
`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
));
|
||||
result.forEach(date => {
|
||||
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
expect(fixedDaysOffSet.has(dateKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameters', () => {
|
||||
@@ -186,6 +219,25 @@ describe('holidayUtils', () => {
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter fixed days off by year and startDate', () => {
|
||||
const fixedDaysOff = [
|
||||
new Date(2023, 11, 31),
|
||||
new Date(TEST_YEAR, 0, 5),
|
||||
new Date(TEST_YEAR, 5, 15),
|
||||
];
|
||||
const startDate = new Date(TEST_YEAR, 2, 1);
|
||||
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate, fixedDaysOff);
|
||||
// Should not include fixed days off before startDate
|
||||
const fixedDaysOffKeys = new Set(fixedDaysOff
|
||||
.filter(d => d.getFullYear() === TEST_YEAR && d >= startDate)
|
||||
.map(d => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
);
|
||||
result.forEach(date => {
|
||||
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
expect(fixedDaysOffKeys.has(dateKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gap finding and prioritization', () => {
|
||||
@@ -202,6 +254,30 @@ describe('holidayUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should exclude gaps longer than MAX_GAP_LENGTH (5) days', () => {
|
||||
// Create a gap of 6 weekdays (should be excluded)
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
|
||||
{ date: new Date(TEST_YEAR, 0, 9), name: 'Tue' }, // 6 weekdays gap (Jan 2-8)
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 10);
|
||||
// Should not fill the 6-day gap, but might find other smaller gaps
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include gaps exactly at MAX_GAP_LENGTH (5) days', () => {
|
||||
// Create a gap of exactly 5 weekdays
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
|
||||
{ date: new Date(TEST_YEAR, 0, 8), name: 'Mon' }, // 5 weekdays gap (Jan 2-7)
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
result.forEach(date => {
|
||||
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
|
||||
});
|
||||
});
|
||||
|
||||
it('should find and fill gaps of 1-5 days', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
@@ -255,6 +331,42 @@ describe('holidayUtils', () => {
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer forward filling when forward chain is longer', () => {
|
||||
// Create a scenario where forward chain is longer
|
||||
// Holiday on Thursday, gap before it, weekend after
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 4), name: 'Thursday Holiday' },
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
|
||||
expect(result.length).toBe(1);
|
||||
// Should select a day that creates a longer consecutive period
|
||||
// The exact day depends on the algorithm's analysis, but should be in a valid gap
|
||||
expect(result[0].getFullYear()).toBe(TEST_YEAR);
|
||||
expect(result[0].getMonth()).toBe(0);
|
||||
expect(DEFAULT_WEEKENDS).not.toContain(result[0].getDay());
|
||||
});
|
||||
|
||||
it('should prefer backward filling when backward chain is longer', () => {
|
||||
// Create a scenario where backward chain is longer
|
||||
// Weekend before, gap, holiday on Monday
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Monday Holiday' },
|
||||
];
|
||||
// If we have a gap before Monday, backward might be better
|
||||
// This depends on the specific day of week, but we can test the logic
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle equal chain lengths by choosing direction with fewer usedDaysOff', () => {
|
||||
// When chains are equal length, should prefer direction with fewer usedDaysOff
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 3), name: 'Wednesday Holiday' },
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should optimize to create longer consecutive periods', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 4), name: 'Holiday' },
|
||||
@@ -513,6 +625,22 @@ describe('holidayUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should exclude periods that are only weekends', () => {
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
const optimizedDays: Date[] = [];
|
||||
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
|
||||
result.forEach(period => {
|
||||
let hasNonWeekend = false;
|
||||
for (let d = new Date(period.startDate); d <= period.endDate; d.setDate(d.getDate() + 1)) {
|
||||
if (!DEFAULT_WEEKENDS.includes(d.getDay())) {
|
||||
hasNonWeekend = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(hasNonWeekend).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle groups that are only weekends correctly', () => {
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
const optimizedDays: Date[] = [];
|
||||
@@ -551,6 +679,49 @@ describe('holidayUtils', () => {
|
||||
const result = calculateConsecutiveDaysOff(mockHolidays, [], TEST_YEAR);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include fixed days off in consecutive periods', () => {
|
||||
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
|
||||
const fixedDaysOff = [new Date(TEST_YEAR, 0, 3)];
|
||||
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const periodWithFixed = result.find(period => {
|
||||
const fixedDate = fixedDaysOff[0];
|
||||
return period.startDate <= fixedDate && period.endDate >= fixedDate;
|
||||
});
|
||||
expect(periodWithFixed).toBeDefined();
|
||||
});
|
||||
|
||||
it('should treat fixed days off as part of consecutive periods', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
|
||||
const fixedDaysOff = [new Date(TEST_YEAR, 0, 3)];
|
||||
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const period = result.find(p =>
|
||||
p.startDate <= holidays[0].date &&
|
||||
p.endDate >= fixedDaysOff[0]
|
||||
);
|
||||
expect(period).toBeDefined();
|
||||
if (period) {
|
||||
expect(period.totalDays).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter fixed days off by year and startDate', () => {
|
||||
const optimizedDays = [new Date(TEST_YEAR, 5, 2)];
|
||||
const fixedDaysOff = [
|
||||
new Date(2023, 11, 31),
|
||||
new Date(TEST_YEAR, 0, 5),
|
||||
new Date(TEST_YEAR, 5, 15),
|
||||
];
|
||||
const startDate = new Date(TEST_YEAR, 2, 1);
|
||||
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, startDate, fixedDaysOff);
|
||||
// Should only include fixed days off from the correct year and after startDate
|
||||
result.forEach(period => {
|
||||
expect(period.startDate.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
@@ -669,6 +840,130 @@ describe('holidayUtils', () => {
|
||||
expect(Array.isArray(optimizedDays)).toBe(true);
|
||||
expect(Array.isArray(periods)).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with fixed days off: exclude from optimization, include in periods', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const fixedDaysOff = [
|
||||
new Date(TEST_YEAR, 0, 5),
|
||||
new Date(TEST_YEAR, 0, 10),
|
||||
];
|
||||
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
|
||||
// Fixed days off should not be in optimized days
|
||||
const fixedDaysOffKeys = new Set(fixedDaysOff.map(d =>
|
||||
`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
));
|
||||
optimizedDays.forEach(date => {
|
||||
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
expect(fixedDaysOffKeys.has(dateKey)).toBe(false);
|
||||
});
|
||||
|
||||
// Fixed days off should be included in consecutive periods
|
||||
fixedDaysOff.forEach(fixedDay => {
|
||||
const periodWithFixed = periods.find(period =>
|
||||
period.startDate <= fixedDay && period.endDate >= fixedDay
|
||||
);
|
||||
expect(periodWithFixed).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should prioritize fixed days off over calculated days off', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const fixedDaysOff = [new Date(TEST_YEAR, 0, 5)];
|
||||
// Request optimization for a day that's already fixed
|
||||
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
|
||||
// The fixed day should not appear in optimized days
|
||||
const fixedDayKey = `${fixedDaysOff[0].getFullYear()}-${fixedDaysOff[0].getMonth()}-${fixedDaysOff[0].getDate()}`;
|
||||
const hasFixedDay = optimizedDays.some(day => {
|
||||
const dayKey = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
return dayKey === fixedDayKey;
|
||||
});
|
||||
expect(hasFixedDay).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty fixedDaysOff array the same as undefined', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const resultWithEmpty = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, []);
|
||||
const resultWithUndefined = optimizeDaysOff(holidays, TEST_YEAR, 5);
|
||||
|
||||
expect(resultWithEmpty.length).toBe(resultWithUndefined.length);
|
||||
});
|
||||
|
||||
it('should not count fixed days off in usedDaysOff calculation', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
|
||||
const fixedDaysOff = [new Date(TEST_YEAR, 0, 3), new Date(TEST_YEAR, 0, 4)];
|
||||
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
|
||||
const period = result.find(p =>
|
||||
p.startDate <= holidays[0].date &&
|
||||
p.endDate >= fixedDaysOff[1]
|
||||
);
|
||||
expect(period).toBeDefined();
|
||||
if (period) {
|
||||
// usedDaysOff should only count optimized days, not fixed days
|
||||
expect(period.usedDaysOff).toBe(1); // Only the one optimized day
|
||||
expect(period.totalDays).toBeGreaterThanOrEqual(4); // But total days includes fixed days
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle case where all weekdays are fixed days off', () => {
|
||||
// Create fixed days off for all weekdays in January
|
||||
const fixedDaysOff: Date[] = [];
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const date = new Date(TEST_YEAR, 0, day);
|
||||
if (!DEFAULT_WEEKENDS.includes(date.getDay())) {
|
||||
fixedDaysOff.push(date);
|
||||
}
|
||||
}
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
|
||||
// Should not try to optimize days that are already fixed
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
result.forEach(date => {
|
||||
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
const isFixed = fixedDaysOff.some(fd =>
|
||||
`${fd.getFullYear()}-${fd.getMonth()}-${fd.getDate()}` === dateKey
|
||||
);
|
||||
expect(isFixed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should extend consecutive periods with fixed days off', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
|
||||
];
|
||||
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
|
||||
const fixedDaysOff = [
|
||||
new Date(TEST_YEAR, 0, 3),
|
||||
new Date(TEST_YEAR, 0, 4),
|
||||
new Date(TEST_YEAR, 0, 5),
|
||||
];
|
||||
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
|
||||
const period = result.find(p =>
|
||||
p.startDate <= holidays[0].date &&
|
||||
p.endDate >= fixedDaysOff[2]
|
||||
);
|
||||
expect(period).toBeDefined();
|
||||
if (period) {
|
||||
// Should include all the fixed days in the period
|
||||
expect(period.totalDays).toBeGreaterThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
@@ -706,6 +1001,99 @@ describe('holidayUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle startDate on Dec 31', () => {
|
||||
const startDate = new Date(TEST_YEAR, 11, 31);
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
|
||||
result.forEach(date => {
|
||||
expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
|
||||
expect(date.getFullYear()).toBe(TEST_YEAR);
|
||||
expect(date.getMonth()).toBe(11);
|
||||
expect(date.getDate()).toBe(31);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fixed days off with startDate at end of year', () => {
|
||||
const startDate = new Date(TEST_YEAR, 11, 15);
|
||||
const fixedDaysOff = [new Date(TEST_YEAR, 11, 20)];
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 11, 25), name: 'Christmas' },
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate, fixedDaysOff);
|
||||
const fixedDayKey = `${fixedDaysOff[0].getFullYear()}-${fixedDaysOff[0].getMonth()}-${fixedDaysOff[0].getDate()}`;
|
||||
result.forEach(date => {
|
||||
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
expect(dateKey).not.toBe(fixedDayKey);
|
||||
expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fixed days off that are on weekends (should still be excluded)', () => {
|
||||
// Create a fixed day off on a Saturday
|
||||
const saturday = new Date(TEST_YEAR, 0, 6); // Jan 6, 2024 is a Saturday
|
||||
if (saturday.getDay() === 6) {
|
||||
const fixedDaysOff = [saturday];
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
// Should not include the Saturday in optimized days (it's already a weekend)
|
||||
const hasSaturday = result.some(d =>
|
||||
d.getFullYear() === saturday.getFullYear() &&
|
||||
d.getMonth() === saturday.getMonth() &&
|
||||
d.getDate() === saturday.getDate()
|
||||
);
|
||||
expect(hasSaturday).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle fixed days off that are on holidays (should still be excluded)', () => {
|
||||
const holidayDate = new Date(TEST_YEAR, 0, 1);
|
||||
const fixedDaysOff = [holidayDate];
|
||||
const holidays = [
|
||||
{ date: holidayDate, name: 'New Year' },
|
||||
];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const hasHoliday = result.some(d =>
|
||||
d.getFullYear() === holidayDate.getFullYear() &&
|
||||
d.getMonth() === holidayDate.getMonth() &&
|
||||
d.getDate() === holidayDate.getDate()
|
||||
);
|
||||
expect(hasHoliday).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case where no gaps are available (all days are off)', () => {
|
||||
// Create holidays for all weekdays
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
for (let month = 0; month < 12; month++) {
|
||||
const daysInMonth = new Date(TEST_YEAR, month + 1, 0).getDate();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(TEST_YEAR, month, day);
|
||||
if (!DEFAULT_WEEKENDS.includes(date.getDay())) {
|
||||
holidays.push({ date, name: `Holiday ${month}-${day}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle selectDaysOff when gap has some days already in allDaysOff', () => {
|
||||
// Create a scenario where a gap has some days that are already off
|
||||
const holidays = [
|
||||
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
|
||||
{ date: new Date(TEST_YEAR, 0, 5), name: 'Fri' },
|
||||
];
|
||||
const fixedDaysOff = [new Date(TEST_YEAR, 0, 3)]; // Wednesday in the gap
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
// Should skip the fixed day and fill other days in the gap
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const hasFixedDay = result.some(d =>
|
||||
d.getFullYear() === fixedDaysOff[0].getFullYear() &&
|
||||
d.getMonth() === fixedDaysOff[0].getMonth() &&
|
||||
d.getDate() === fixedDaysOff[0].getDate()
|
||||
);
|
||||
expect(hasFixedDay).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle holidays from previous year correctly', () => {
|
||||
const holidays = [
|
||||
{ date: new Date(2023, 11, 31), name: 'Old Year' },
|
||||
@@ -775,6 +1163,21 @@ describe('holidayUtils', () => {
|
||||
expect(hasHolidayDate).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify fixed days off using dateKey (ignoring time component)', () => {
|
||||
const fixedDaysOff = [
|
||||
new Date(TEST_YEAR, 0, 15, 10, 30), // Same day, different time
|
||||
new Date(TEST_YEAR, 0, 15, 14, 0), // Same day, different time
|
||||
];
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
|
||||
const hasFixedDay = result.some(d =>
|
||||
d.getFullYear() === TEST_YEAR &&
|
||||
d.getMonth() === 0 &&
|
||||
d.getDate() === 15
|
||||
);
|
||||
expect(hasFixedDay).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly get weekends for the year with startDate', () => {
|
||||
const startDate = new Date(TEST_YEAR, 5, 1);
|
||||
const holidays: Array<{ date: Date; name: string }> = [];
|
||||
|
||||
@@ -31,11 +31,13 @@ export function getHolidaysForYear(countryCode: string, year: number, stateCode?
|
||||
}
|
||||
|
||||
// Find optimal placement of PTO days to maximize consecutive time off
|
||||
export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOff: number, weekendDays: number[] = [0, 6], startDate?: Date): Date[] {
|
||||
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))
|
||||
]);
|
||||
|
||||
@@ -44,14 +46,16 @@ export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOf
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
|
||||
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))
|
||||
]);
|
||||
|
||||
@@ -63,14 +67,14 @@ export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimize
|
||||
currentGroup.push(new Date(d));
|
||||
} else if (currentGroup.length > 0) {
|
||||
if (isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
||||
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
|
||||
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff, filteredFixedDaysOff));
|
||||
}
|
||||
currentGroup = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
||||
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
|
||||
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff, filteredFixedDaysOff));
|
||||
}
|
||||
|
||||
return consecutiveDaysOff;
|
||||
@@ -193,7 +197,7 @@ function isValidConsecutiveGroup(group: Date[], weekendDays: number[]): boolean
|
||||
}
|
||||
|
||||
// Create a period object from a group of consecutive days
|
||||
function createPeriod(group: Date[], optimizedDaysOff: Date[]) {
|
||||
function createPeriod(group: Date[], optimizedDaysOff: Date[], fixedDaysOff: Date[] = []) {
|
||||
return {
|
||||
startDate: group[0],
|
||||
endDate: group[group.length - 1],
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
let daysOff: number = 0;
|
||||
let optimizedDaysOff: Date[] = [];
|
||||
let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }> = [];
|
||||
let fixedDaysOff: Date[] = [];
|
||||
let showExcludedMonths: boolean = true;
|
||||
let visibleMonths: number[] = [];
|
||||
let countriesInput: HTMLInputElement | null = null;
|
||||
@@ -35,6 +36,7 @@
|
||||
let showHolidaysList: boolean = false;
|
||||
|
||||
let showWeekendSettings: boolean = false;
|
||||
let showFixedDaysOffList: boolean = false;
|
||||
let weekendDays: number[] = [];
|
||||
|
||||
// Start date state
|
||||
@@ -48,9 +50,15 @@
|
||||
updateHolidays();
|
||||
}
|
||||
|
||||
// Reactive: when year changes, load start date for that year
|
||||
// Reactive: when fixedDaysOff changes, update calculations
|
||||
$: if (fixedDaysOff) {
|
||||
updateHolidays();
|
||||
}
|
||||
|
||||
// Reactive: when year changes, load start date and fixed days off for that year
|
||||
$: if (year !== undefined && year) {
|
||||
startDate = getStartDate(year);
|
||||
loadFixedDaysOff(year);
|
||||
}
|
||||
|
||||
// Reactive: when startDate or year changes, update excluded months visibility
|
||||
@@ -108,6 +116,7 @@
|
||||
selectedStateCode = '';
|
||||
}
|
||||
startDate = getStartDate(year);
|
||||
loadFixedDaysOff(year);
|
||||
// showExcludedMonths will be set by reactive statement
|
||||
updateHolidays();
|
||||
});
|
||||
@@ -166,8 +175,8 @@
|
||||
hidden: isHolidayHidden(holiday)
|
||||
}));
|
||||
const visibleHolidays = holidays.filter(h => !h.hidden);
|
||||
optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate);
|
||||
consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate);
|
||||
optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate, fixedDaysOff);
|
||||
consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate, fixedDaysOff);
|
||||
} else {
|
||||
holidays = [];
|
||||
optimizedDaysOff = [];
|
||||
@@ -175,6 +184,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFixedDayOff(date: Date) {
|
||||
// Normalize date to remove time component
|
||||
const normalizedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const dateKeyStr = dateKey(normalizedDate);
|
||||
|
||||
// Check if date is already in fixedDaysOff
|
||||
const existingIndex = fixedDaysOff.findIndex(d => dateKey(d) === dateKeyStr);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove if already exists
|
||||
fixedDaysOff = fixedDaysOff.filter((_, i) => i !== existingIndex);
|
||||
} else {
|
||||
// Add if doesn't exist
|
||||
fixedDaysOff = [...fixedDaysOff, normalizedDate];
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
saveFixedDaysOff(year);
|
||||
|
||||
// Update calculations
|
||||
updateHolidays();
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
year = defaultYear;
|
||||
selectedCountry = defaultCountry;
|
||||
@@ -337,6 +369,42 @@
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Helper function to create a date key (same as in holidayUtils.ts)
|
||||
function dateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
}
|
||||
|
||||
// Load fixed days off for a given year from localStorage
|
||||
function loadFixedDaysOff(year: number) {
|
||||
try {
|
||||
const stored = localStorage.getItem(`fixedDaysOff_${year}`);
|
||||
if (stored) {
|
||||
const dateStrings: string[] = JSON.parse(stored);
|
||||
fixedDaysOff = dateStrings.map(dateStr => {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
return new Date(y, m, d);
|
||||
});
|
||||
} else {
|
||||
fixedDaysOff = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading fixed days off:', e);
|
||||
fixedDaysOff = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save fixed days off for a given year to localStorage
|
||||
function saveFixedDaysOff(year: number) {
|
||||
try {
|
||||
const dateStrings = fixedDaysOff.map(date =>
|
||||
`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
||||
);
|
||||
localStorage.setItem(`fixedDaysOff_${year}`, JSON.stringify(dateStrings));
|
||||
} catch (e) {
|
||||
console.error('Error saving fixed days off:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Format start date for display
|
||||
function formatStartDate(date: Date): string {
|
||||
const today = new Date();
|
||||
@@ -1088,6 +1156,9 @@
|
||||
<span class="color-box optimized"></span>
|
||||
<span>Day Off</span>
|
||||
</div>
|
||||
<a href="#" on:click|preventDefault={() => showFixedDaysOffList = !showFixedDaysOffList} class="edit-link">
|
||||
(edit)
|
||||
</a>
|
||||
</div>
|
||||
<div class="key-item">
|
||||
<div class="key-label">
|
||||
@@ -1111,8 +1182,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showHolidaysList || showWeekendSettings}
|
||||
{#if showHolidaysList || showWeekendSettings || showFixedDaysOffList}
|
||||
<div class="holidays-list">
|
||||
{#if showFixedDaysOffList}
|
||||
<div class="settings-section">
|
||||
<h3>Fixed Days Off</h3>
|
||||
{#if fixedDaysOff.length > 0}
|
||||
<ul>
|
||||
{#each fixedDaysOff.sort((a, b) => a.getTime() - b.getTime()) as fixedDay}
|
||||
<li>
|
||||
<div class="setting-item-label">
|
||||
<span class="color-box optimized"></span>
|
||||
<span>{formatDate(fixedDay)}</span>
|
||||
</div>
|
||||
<button on:click={() => toggleFixedDayOff(fixedDay)}>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p style="color: #ccc; text-align: center; padding: 20px;">
|
||||
Tap on a calendar day below to select it as a fixed day off
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showHolidaysList}
|
||||
<div class="settings-section">
|
||||
<h3>Public Holidays</h3>
|
||||
@@ -1180,6 +1276,8 @@
|
||||
consecutiveDaysOff={consecutiveDaysOff}
|
||||
selectedCountryCode={selectedCountryCode}
|
||||
weekendDays={weekendDays}
|
||||
fixedDaysOff={fixedDaysOff}
|
||||
onDayClick={toggleFixedDayOff}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user