Merge pull request #21 from zachd/feat-select-fixed-days-d0988

Add support for fixed days off in calendar component
This commit is contained in:
Zachary
2025-11-10 01:02:11 +01:00
committed by GitHub
4 changed files with 569 additions and 14 deletions

View File

@@ -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);
}

View File

@@ -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' },
@@ -762,7 +1150,7 @@ describe('holidayUtils', () => {
expect(hasDate2).toBe(false);
});
it('should correctly identify holidays using dateKey', () => {
it('should correctly identify holidays using dateKey', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 15, 10, 30), name: 'Holiday' },
];
@@ -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 }> = [];

View File

@@ -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],

View File

@@ -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}