diff --git a/src/lib/CalendarMonth.svelte b/src/lib/CalendarMonth.svelte index 326dc9a..a6df2a9 100644 --- a/src/lib/CalendarMonth.svelte +++ b/src/lib/CalendarMonth.svelte @@ -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)} -
+ {@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} +
handleDayClick(day)} + role={canClick ? 'button' : undefined} + tabindex={canClick ? 0 : undefined} + > {day} - {#if holiday} - - {/if} +
{/each}
@@ -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); } diff --git a/src/lib/holidayUtils.test.ts b/src/lib/holidayUtils.test.ts index 9e891f3..6ae5141 100644 --- a/src/lib/holidayUtils.test.ts +++ b/src/lib/holidayUtils.test.ts @@ -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 }> = []; diff --git a/src/lib/holidayUtils.ts b/src/lib/holidayUtils.ts index 3484cc1..683c1ad 100644 --- a/src/lib/holidayUtils.ts +++ b/src/lib/holidayUtils.ts @@ -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], diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2f109d3..7fb6a0d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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 @@ Day Off + showFixedDaysOffList = !showFixedDaysOffList} class="edit-link"> + (edit) +
@@ -1111,8 +1182,33 @@
- {#if showHolidaysList || showWeekendSettings} + {#if showHolidaysList || showWeekendSettings || showFixedDaysOffList}
+ {#if showFixedDaysOffList} +
+

Fixed Days Off

+ {#if fixedDaysOff.length > 0} + + {:else} +

+ Tap on a calendar day below to select it as a fixed day off +

+ {/if} +
+ {/if} + {#if showHolidaysList}

Public Holidays

@@ -1180,6 +1276,8 @@ consecutiveDaysOff={consecutiveDaysOff} selectedCountryCode={selectedCountryCode} weekendDays={weekendDays} + fixedDaysOff={fixedDaysOff} + onDayClick={toggleFixedDayOff} />
{/each}