From d3119d74863b32ca9b349847a37c6a29056a86e0 Mon Sep 17 00:00:00 2001 From: zachd Date: Mon, 10 Nov 2025 01:29:54 +0100 Subject: [PATCH] Hotfix: Ensure fixed days are counted in days off --- src/lib/CalendarMonth.svelte | 4 +- src/routes/+page.svelte | 95 +++++++++-- src/routes/page.test.ts | 317 +++++++++++++++++++++++++++++++++++ 3 files changed, 397 insertions(+), 19 deletions(-) create mode 100644 src/routes/page.test.ts diff --git a/src/lib/CalendarMonth.svelte b/src/lib/CalendarMonth.svelte index a6df2a9..6db7db8 100644 --- a/src/lib/CalendarMonth.svelte +++ b/src/lib/CalendarMonth.svelte @@ -78,7 +78,7 @@ } else if (isOptimizedDayOff(day)) { return 'Day off (calculated)'; } else { - return 'Tap to select fixed day off'; + return 'Tap to add fixed day off'; } } @@ -228,7 +228,7 @@ cursor: pointer; } .clickable:hover { - opacity: 0.8; + opacity: 0.7; transform: scale(1.05); transition: transform 0.1s, opacity 0.1s; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7fb6a0d..c5bab7f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -46,31 +46,40 @@ $: selectedCountryCode = Object.keys(countriesList).find(code => countriesList[code] === selectedCountry) || ''; - $: if (selectedCountryCode || selectedStateCode || daysOff || year || startDate) { + // Reactive: when year changes, load start date and fixed days off for that year + $: if (year !== undefined && year && typeof window !== 'undefined') { + startDate = getStartDate(year); + loadFixedDaysOff(year); + // Adjust daysOff to include fixed days off if they exist + // Calculate base days off (total - fixed days) + const baseDaysOff = Math.max(0, daysOff - fixedDaysOff.length); + // If we have fixed days but base is 0, get the country default and add fixed days + if (fixedDaysOff.length > 0 && baseDaysOff === 0 && daysOff < fixedDaysOff.length) { + const countryCode = Object.keys(countriesList).find(code => countriesList[code] === selectedCountry) || ''; + const currentDefaultDaysOff = ptoData[countryCode] || 0; + daysOff = currentDefaultDaysOff + fixedDaysOff.length; + } + } + + $: if (selectedCountryCode && year !== undefined && year) { updateHolidays(); } // Reactive: when fixedDaysOff changes, update calculations - $: if (fixedDaysOff) { + $: if (fixedDaysOff && year !== undefined && year) { 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 $: if (year !== undefined && year && startDate) { showExcludedMonths = !hasExcludedMonths(); } - $: if (daysOff) { + $: if (daysOff !== undefined && typeof window !== 'undefined') { localStorage.setItem('daysOff', daysOff.toString()); } - $: if (year) { + $: if (year && typeof window !== 'undefined') { localStorage.setItem('year', year.toString()); } @@ -104,7 +113,6 @@ year = storedYear ? parseInt(storedYear, 10) : defaultYear; selectedCountry = storedCountry || defaultCountry; - daysOff = storedDaysOff ? parseInt(storedDaysOff, 10) : defaultDaysOff; // Load state per country const countryCode = Object.keys(countriesList).find(code => countriesList[code] === selectedCountry) || ''; @@ -115,8 +123,39 @@ selectedState = ''; selectedStateCode = ''; } + + // Get the current country's default days off + const currentDefaultDaysOff = ptoData[countryCode] || 0; + startDate = getStartDate(year); loadFixedDaysOff(year); + + // Initialize daysOff: use stored value if it exists, otherwise use country default + // Then add fixed days off to it + if (storedDaysOff !== null && storedDaysOff !== '') { + const storedValue = parseInt(storedDaysOff, 10); + // If stored value is 0 and there are no fixed days, use default instead + if (storedValue === 0 && fixedDaysOff.length === 0) { + daysOff = currentDefaultDaysOff; + } else { + daysOff = storedValue; + } + } else { + // No stored value, use country default + daysOff = currentDefaultDaysOff; + } + + // Add fixed days off to the base days off + if (fixedDaysOff.length > 0) { + // Calculate base: if daysOff is less than fixed days, base is 0, otherwise subtract fixed days + const baseDaysOff = Math.max(0, daysOff - fixedDaysOff.length); + // If base is 0 and we have fixed days, set base to default and add fixed days + if (baseDaysOff === 0 && daysOff < fixedDaysOff.length) { + daysOff = currentDefaultDaysOff + fixedDaysOff.length; + } else { + daysOff = baseDaysOff + fixedDaysOff.length; + } + } // showExcludedMonths will be set by reactive statement updateHolidays(); }); @@ -166,7 +205,7 @@ } function updateHolidays() { - if (selectedCountryCode) { + if (selectedCountryCode && year !== undefined && year) { updateStatesList(selectedCountryCode); let allHolidays = getHolidaysForYear(selectedCountryCode, year, selectedStateCode); holidays = allHolidays.map(holiday => ({ @@ -175,7 +214,10 @@ hidden: isHolidayHidden(holiday) })); const visibleHolidays = holidays.filter(h => !h.hidden); - optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate, fixedDaysOff); + // Use baseDaysOff for optimization (not including fixed days in the budget) + // Calculate it here to ensure it's always defined + const budgetDaysOff = Math.max(0, (daysOff || 0) - (fixedDaysOff?.length || 0)); + optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, budgetDaysOff, weekendDays, startDate, fixedDaysOff); consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate, fixedDaysOff); } else { holidays = []; @@ -189,21 +231,32 @@ const normalizedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const dateKeyStr = dateKey(normalizedDate); + // Check if this day is already a day off for any reason (holiday, weekend, or optimized) + const isWeekendDay = weekendDays.includes(normalizedDate.getDay()); + const isHolidayDay = holidays.some(h => datesMatch(h.date, normalizedDate)); + const isOptimizedDay = optimizedDaysOff.some(d => datesMatch(d, normalizedDate)); + const isAlreadyDayOff = isWeekendDay || isHolidayDay || isOptimizedDay; + // Check if date is already in fixedDaysOff const existingIndex = fixedDaysOff.findIndex(d => dateKey(d) === dateKeyStr); if (existingIndex >= 0) { - // Remove if already exists + // Remove if already exists - don't subtract from days off count fixedDaysOff = fixedDaysOff.filter((_, i) => i !== existingIndex); } else { // Add if doesn't exist fixedDaysOff = [...fixedDaysOff, normalizedDate]; + // Only increase days off if this day isn't already a day off for another reason + if (!isAlreadyDayOff) { + daysOff++; + } } // Save to localStorage saveFixedDaysOff(year); + localStorage.setItem('daysOff', daysOff.toString()); - // Update calculations + // Update calculations (using baseDaysOff for optimization) updateHolidays(); } @@ -243,7 +296,8 @@ break; case 'ArrowDown': event.preventDefault(); - if (daysOff > 0) { + const minDaysOff = fixedDaysOff.length; + if (daysOff > minDaysOff) { daysOff--; updateHolidays(); } @@ -374,6 +428,13 @@ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } + // Helper function to check if a date matches another date (ignoring time) + function datesMatch(date1: Date, date2: Date): boolean { + return date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate(); + } + // Load fixed days off for a given year from localStorage function loadFixedDaysOff(year: number) { try { @@ -1115,7 +1176,7 @@ aria-label="Select country" /> and have - + {daysOff} diff --git a/src/routes/page.test.ts b/src/routes/page.test.ts new file mode 100644 index 0000000..53ef551 --- /dev/null +++ b/src/routes/page.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +// Helper function to create a date key (same as in +page.svelte) +function dateKey(date: Date): string { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +// Helper function to check if a day is already a day off +function isAlreadyDayOff( + date: Date, + weekendDays: number[], + holidays: Array<{ date: Date; name: string }>, + optimizedDaysOff: Date[] +): boolean { + const isWeekendDay = weekendDays.includes(date.getDay()); + const isHolidayDay = holidays.some(h => + h.date.getFullYear() === date.getFullYear() && + h.date.getMonth() === date.getMonth() && + h.date.getDate() === date.getDate() + ); + const isOptimizedDay = optimizedDaysOff.some(d => + d.getFullYear() === date.getFullYear() && + d.getMonth() === date.getMonth() && + d.getDate() === date.getDate() + ); + return isWeekendDay || isHolidayDay || isOptimizedDay; +} + +// Simulate toggleFixedDayOff logic +function simulateToggleFixedDayOff( + date: Date, + fixedDaysOff: Date[], + daysOff: number, + weekendDays: number[], + holidays: Array<{ date: Date; name: string }>, + optimizedDaysOff: Date[] +): { fixedDaysOff: Date[]; daysOff: number } { + const normalizedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const dateKeyStr = dateKey(normalizedDate); + + const isAlreadyDayOffDay = isAlreadyDayOff(normalizedDate, weekendDays, holidays, optimizedDaysOff); + + const existingIndex = fixedDaysOff.findIndex(d => dateKey(d) === dateKeyStr); + + let newFixedDaysOff: Date[]; + let newDaysOff = daysOff; + + if (existingIndex >= 0) { + // Remove if already exists - don't subtract from days off count + newFixedDaysOff = fixedDaysOff.filter((_, i) => i !== existingIndex); + } else { + // Add if doesn't exist + newFixedDaysOff = [...fixedDaysOff, normalizedDate]; + // Only increase days off if this day isn't already a day off for another reason + if (!isAlreadyDayOffDay) { + newDaysOff = daysOff + 1; + } + } + + return { fixedDaysOff: newFixedDaysOff, daysOff: newDaysOff }; +} + +// Helper to check minimum days off validation +function canDecreaseDaysOff(daysOff: number, fixedDaysOffCount: number): boolean { + return daysOff > fixedDaysOffCount; +} + +describe('Fixed Days Off Logic', () => { + const TEST_YEAR = 2024; + const WEEKEND_DAYS = [0, 6]; // Sunday, Saturday + + describe('toggleFixedDayOff behavior', () => { + it('should increase days off when adding a fixed day off to a regular day', () => { + const regularDay = new Date(TEST_YEAR, 0, 15); // Monday, Jan 15 + const fixedDaysOff: Date[] = []; + const daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = []; + + const result = simulateToggleFixedDayOff( + regularDay, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(1); + expect(result.daysOff).toBe(11); // Increased by 1 + }); + + it('should NOT increase days off when adding a fixed day off to a weekend', () => { + const weekendDay = new Date(TEST_YEAR, 0, 13); // Saturday, Jan 13 + const fixedDaysOff: Date[] = []; + const daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = []; + + const result = simulateToggleFixedDayOff( + weekendDay, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(1); + expect(result.daysOff).toBe(10); // Not increased + }); + + it('should NOT increase days off when adding a fixed day off to a holiday', () => { + const holidayDate = new Date(TEST_YEAR, 0, 1); // New Year's Day + const fixedDaysOff: Date[] = []; + const daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = [ + { date: new Date(TEST_YEAR, 0, 1), name: 'New Year\'s Day' } + ]; + const optimizedDaysOff: Date[] = []; + + const result = simulateToggleFixedDayOff( + holidayDate, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(1); + expect(result.daysOff).toBe(10); // Not increased + }); + + it('should NOT increase days off when adding a fixed day off to an optimized day', () => { + const optimizedDay = new Date(TEST_YEAR, 0, 15); // Monday, Jan 15 + const fixedDaysOff: Date[] = []; + const daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = [new Date(TEST_YEAR, 0, 15)]; + + const result = simulateToggleFixedDayOff( + optimizedDay, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(1); + expect(result.daysOff).toBe(10); // Not increased + }); + + it('should NOT decrease days off when removing a fixed day off', () => { + const fixedDay = new Date(TEST_YEAR, 0, 15); + const fixedDaysOff: Date[] = [fixedDay]; + const daysOff = 11; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = []; + + const result = simulateToggleFixedDayOff( + fixedDay, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(0); + expect(result.daysOff).toBe(11); // Not decreased + }); + + it('should NOT decrease days off when removing a fixed day off that was on a weekend', () => { + const weekendDay = new Date(TEST_YEAR, 0, 13); // Saturday + const fixedDaysOff: Date[] = [weekendDay]; + const daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = []; + + const result = simulateToggleFixedDayOff( + weekendDay, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(0); + expect(result.daysOff).toBe(10); // Not decreased + }); + + it('should handle multiple fixed days off correctly', () => { + const day1 = new Date(TEST_YEAR, 0, 15); + const day2 = new Date(TEST_YEAR, 0, 16); + const day3 = new Date(TEST_YEAR, 0, 17); + let fixedDaysOff: Date[] = []; + let daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = []; + + // Add first day + let result = simulateToggleFixedDayOff(day1, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + fixedDaysOff = result.fixedDaysOff; + daysOff = result.daysOff; + expect(daysOff).toBe(11); + + // Add second day + result = simulateToggleFixedDayOff(day2, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + fixedDaysOff = result.fixedDaysOff; + daysOff = result.daysOff; + expect(daysOff).toBe(12); + + // Add third day + result = simulateToggleFixedDayOff(day3, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + fixedDaysOff = result.fixedDaysOff; + daysOff = result.daysOff; + expect(daysOff).toBe(13); + + // Remove one day - count should stay the same + result = simulateToggleFixedDayOff(day2, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + expect(result.fixedDaysOff.length).toBe(2); + expect(result.daysOff).toBe(13); // Not decreased + }); + }); + + describe('Minimum days off validation', () => { + it('should allow decreasing when days off is greater than fixed days off count', () => { + const daysOff = 15; + const fixedDaysOffCount = 5; + + expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(true); + }); + + it('should NOT allow decreasing when days off equals fixed days off count', () => { + const daysOff = 5; + const fixedDaysOffCount = 5; + + expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(false); + }); + + it('should NOT allow decreasing when days off is less than fixed days off count', () => { + const daysOff = 3; + const fixedDaysOffCount = 5; + + expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(false); + }); + + it('should allow decreasing when there are no fixed days off', () => { + const daysOff = 10; + const fixedDaysOffCount = 0; + + expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(true); + }); + + it('should NOT allow decreasing when days off is 0 and there are no fixed days off', () => { + const daysOff = 0; + const fixedDaysOffCount = 0; + + expect(canDecreaseDaysOff(daysOff, fixedDaysOffCount)).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('should handle adding and removing the same day multiple times', () => { + const day = new Date(TEST_YEAR, 0, 15); + let fixedDaysOff: Date[] = []; + let daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + const optimizedDaysOff: Date[] = []; + + // Add + let result = simulateToggleFixedDayOff(day, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + fixedDaysOff = result.fixedDaysOff; + daysOff = result.daysOff; + expect(fixedDaysOff.length).toBe(1); + expect(daysOff).toBe(11); + + // Remove + result = simulateToggleFixedDayOff(day, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + fixedDaysOff = result.fixedDaysOff; + daysOff = result.daysOff; + expect(fixedDaysOff.length).toBe(0); + expect(daysOff).toBe(11); // Not decreased + + // Add again + result = simulateToggleFixedDayOff(day, fixedDaysOff, daysOff, WEEKEND_DAYS, holidays, optimizedDaysOff); + expect(result.fixedDaysOff.length).toBe(1); + expect(result.daysOff).toBe(12); // Increased again + }); + + it('should handle fixed day off on a day that becomes optimized later', () => { + const day = new Date(TEST_YEAR, 0, 15); + const fixedDaysOff: Date[] = [day]; + const daysOff = 10; + const holidays: Array<{ date: Date; name: string }> = []; + // Day is now optimized + const optimizedDaysOff: Date[] = [day]; + + // Removing fixed day off should not decrease count since it's now optimized + const result = simulateToggleFixedDayOff( + day, + fixedDaysOff, + daysOff, + WEEKEND_DAYS, + holidays, + optimizedDaysOff + ); + + expect(result.fixedDaysOff.length).toBe(0); + expect(result.daysOff).toBe(10); // Not decreased + }); + }); +}); +