diff --git a/src/lib/CalendarMonth.svelte b/src/lib/CalendarMonth.svelte index 40e5262..326dc9a 100644 --- a/src/lib/CalendarMonth.svelte +++ b/src/lib/CalendarMonth.svelte @@ -8,6 +8,8 @@ export let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }>; export let selectedCountryCode: string; export let weekendDays: number[] = [6, 0]; + export let startDate: Date = new Date(year, 0, 1); + export let isActive: boolean = true; // Function to determine the first day of the week based on locale function getFirstDayOfWeek(locale: string): number { @@ -84,12 +86,19 @@ return weekendDays.includes(date.getDay()); } + function isPastDate(day: number): boolean { + const date = new Date(year, month, day); + // Normalize startDate to current year for comparison + const startDateInYear = new Date(year, startDate.getMonth(), startDate.getDate()); + return date < startDateInYear; + } + const dayInitials = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; $: orderedDayInitials = dayInitials.slice(firstDayOfWeek).concat(dayInitials.slice(0, firstDayOfWeek)); -
+
{new Date(year, month).toLocaleString('default', { month: 'long' })}
{#each orderedDayInitials as dayInitial} @@ -101,7 +110,8 @@ {/each} {#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day} {@const holiday = getHoliday(day)} -
+ {@const pastDate = isPastDate(day)} +
{day} {#if holiday} @@ -137,6 +147,14 @@ color: #c5c6c7; font-size: 0.6em; } + + .excluded-month .month-name { + color: #666; + } + + .excluded-month .day-initial { + color: #666; + } .day { aspect-ratio: 1; text-align: center; @@ -202,4 +220,12 @@ text-decoration: line-through; opacity: 0.5; } + + .past-date { + opacity: 0.4; + } + + .past-date span { + text-decoration: line-through; + } \ No newline at end of file diff --git a/src/lib/holidayUtils.ts b/src/lib/holidayUtils.ts index 35296c7..51acb95 100644 --- a/src/lib/holidayUtils.ts +++ b/src/lib/holidayUtils.ts @@ -31,49 +31,56 @@ 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]): Date[] { +export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOff: number, weekendDays: number[] = [0, 6], startDate?: Date): Date[] { + const effectiveStartDate = startDate || new Date(year, 0, 1); + const filteredHolidays = holidays.filter(h => h.date.getFullYear() === year && h.date >= effectiveStartDate); const allDaysOff = new Set([ - ...holidays.filter(h => h.date.getFullYear() === year).map(h => dateKey(h.date)), - ...getWeekends(year, weekendDays).map(d => dateKey(d)) + ...filteredHolidays.map(h => dateKey(h.date)), + ...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d)) ]); - const gaps = findGaps(allDaysOff, year, weekendDays); + const gaps = findGaps(allDaysOff, year, weekendDays, effectiveStartDate); return selectDaysOff(rankGapsByEfficiency(gaps, allDaysOff, weekendDays), daysOff, allDaysOff, weekendDays); } // Calculate periods of consecutive days off (weekends + holidays + PTO) -export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6]) { +export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6], startDate?: 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 allDaysOff = new Set([ - ...holidays.map(h => dateKey(h.date)), - ...optimizedDaysOff.map(d => dateKey(d)), - ...getWeekends(year, weekendDays).map(d => dateKey(d)) + ...filteredHolidays.map(h => dateKey(h.date)), + ...filteredOptimizedDaysOff.map(d => dateKey(d)), + ...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d)) ]); const consecutiveDaysOff = []; let currentGroup = []; - for (let d = new Date(year, 0, 1); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) { - if (isWeekend(d, weekendDays) || isHoliday(d, holidays) || allDaysOff.has(dateKey(d))) { + for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) { + if (isWeekend(d, weekendDays) || isHoliday(d, filteredHolidays) || allDaysOff.has(dateKey(d))) { currentGroup.push(new Date(d)); } else if (currentGroup.length > 0) { if (isValidConsecutiveGroup(currentGroup, weekendDays)) { - consecutiveDaysOff.push(createPeriod(currentGroup, optimizedDaysOff)); + consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff)); } currentGroup = []; } } if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) { - consecutiveDaysOff.push(createPeriod(currentGroup, optimizedDaysOff)); + consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff)); } return consecutiveDaysOff; } // Get all weekend days for a year -function getWeekends(year: number, weekendDays: number[]): Date[] { +function getWeekends(year: number, weekendDays: number[], startDate?: Date): Date[] { + const effectiveStartDate = startDate || new Date(year, 0, 1); const weekends = []; - for (let d = new Date(year, 0, 1); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) { + for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) { if (d.getMonth() === d.getMonth() && isWeekend(d, weekendDays)) { weekends.push(new Date(d)); } @@ -82,11 +89,12 @@ function getWeekends(year: number, weekendDays: number[]): Date[] { } // Find gaps between days off that could be filled with PTO -function findGaps(allDaysOff: Set, year: number, weekendDays: number[]) { +function findGaps(allDaysOff: Set, year: number, weekendDays: number[], startDate?: Date) { + const effectiveStartDate = startDate || new Date(year, 0, 1); const gaps = []; let gapStart = null; - for (let d = new Date(year, 0, 1); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) { + for (let d = new Date(effectiveStartDate); d <= new Date(year, 11, 31); d.setDate(d.getDate() + 1)) { if (!allDaysOff.has(dateKey(d)) && !isWeekend(d, weekendDays)) { if (!gapStart) gapStart = new Date(d); } else if (gapStart) { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5fa057c..2f109d3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -17,6 +17,8 @@ let daysOff: number = 0; let optimizedDaysOff: Date[] = []; let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }> = []; + let showExcludedMonths: boolean = true; + let visibleMonths: number[] = []; let countriesInput: HTMLInputElement | null = null; let statesInput: HTMLInputElement | null = null; let showHowItWorks: boolean = false; @@ -35,12 +37,27 @@ let showWeekendSettings: boolean = false; let weekendDays: number[] = []; + // Start date state + let startDate: Date = new Date(new Date().getFullYear(), 0, 1); + let showDatePicker: boolean = false; + let datePickerValue: string = ''; + $: selectedCountryCode = Object.keys(countriesList).find(code => countriesList[code] === selectedCountry) || ''; - $: if (selectedCountryCode || selectedStateCode || daysOff || year) { + $: if (selectedCountryCode || selectedStateCode || daysOff || year || startDate) { updateHolidays(); } + // Reactive: when year changes, load start date for that year + $: if (year !== undefined && year) { + startDate = getStartDate(year); + } + + // Reactive: when startDate or year changes, update excluded months visibility + $: if (year !== undefined && year && startDate) { + showExcludedMonths = !hasExcludedMonths(); + } + $: if (daysOff) { localStorage.setItem('daysOff', daysOff.toString()); } @@ -59,8 +76,11 @@ const stateName = target.value; selectedStateCode = Object.keys(statesList).find(code => statesList[code] === stateName) || ''; selectedState = stateName; - localStorage.setItem('selectedState', selectedState); - localStorage.setItem('selectedStateCode', selectedStateCode); + // Save state per country + if (selectedCountryCode) { + localStorage.setItem(`selectedState_${selectedCountryCode}`, selectedState); + localStorage.setItem(`selectedStateCode_${selectedCountryCode}`, selectedStateCode); + } } onMount(() => { @@ -73,14 +93,22 @@ const storedYear = localStorage.getItem('year'); const storedCountry = localStorage.getItem('selectedCountry'); const storedDaysOff = localStorage.getItem('daysOff'); - const storedState = localStorage.getItem('selectedState'); - const storedStateCode = localStorage.getItem('selectedStateCode'); year = storedYear ? parseInt(storedYear, 10) : defaultYear; selectedCountry = storedCountry || defaultCountry; daysOff = storedDaysOff ? parseInt(storedDaysOff, 10) : defaultDaysOff; - selectedState = storedState || ''; - selectedStateCode = storedStateCode || ''; + + // Load state per country + const countryCode = Object.keys(countriesList).find(code => countriesList[code] === selectedCountry) || ''; + if (countryCode) { + selectedState = localStorage.getItem(`selectedState_${countryCode}`) || ''; + selectedStateCode = localStorage.getItem(`selectedStateCode_${countryCode}`) || ''; + } else { + selectedState = ''; + selectedStateCode = ''; + } + startDate = getStartDate(year); + // showExcludedMonths will be set by reactive statement updateHolidays(); }); @@ -113,14 +141,17 @@ function handleCountryChange(event: Event) { const target = event.target as HTMLInputElement; const fullValue = target.value; - if (selectedCountryCode) { - daysOff = ptoData[selectedCountryCode] || 0; - selectedState = ''; // Reset state - selectedStateCode = ''; // Reset state code - updateStatesList(selectedCountryCode); // Update states list for the new country + selectedCountry = fullValue; + // Get the country code for the new country (selectedCountryCode will update reactively) + const newCountryCode = Object.keys(countriesList).find(code => countriesList[code] === fullValue) || ''; + if (newCountryCode) { + // Update days off to the new country's default + daysOff = ptoData[newCountryCode] || 0; + // Load state for the new country + selectedState = localStorage.getItem(`selectedState_${newCountryCode}`) || ''; + selectedStateCode = localStorage.getItem(`selectedStateCode_${newCountryCode}`) || ''; + // updateStatesList and updateHolidays will be called by reactive statements localStorage.setItem('selectedCountry', selectedCountry); - localStorage.setItem('selectedState', selectedState); - localStorage.setItem('selectedStateCode', selectedStateCode); localStorage.setItem('daysOff', daysOff.toString()); } } @@ -135,8 +166,8 @@ hidden: isHolidayHidden(holiday) })); const visibleHolidays = holidays.filter(h => !h.hidden); - optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays); - consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays); + optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate); + consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate); } else { holidays = []; optimizedDaysOff = []; @@ -147,14 +178,18 @@ function resetToDefault() { year = defaultYear; selectedCountry = defaultCountry; - selectedState = ''; - selectedStateCode = ''; - daysOff = defaultDaysOff; + const defaultCountryCode = Object.keys(countriesList).find(code => countriesList[code] === defaultCountry) || ''; + // Load state for default country + if (defaultCountryCode) { + selectedState = localStorage.getItem(`selectedState_${defaultCountryCode}`) || ''; + selectedStateCode = localStorage.getItem(`selectedStateCode_${defaultCountryCode}`) || ''; + } else { + selectedState = ''; + selectedStateCode = ''; + } + // Keep current daysOff value, don't reset it localStorage.setItem('year', year.toString()); localStorage.setItem('selectedCountry', selectedCountry); - localStorage.setItem('selectedState', selectedState); - localStorage.setItem('selectedStateCode', selectedStateCode); - localStorage.setItem('daysOff', daysOff.toString()); } function handleKeyDown(event: KeyboardEvent) { @@ -252,6 +287,156 @@ $: visibleHolidaysCount = holidays.filter(h => !h.hidden).length; + // Get start date for a given year from localStorage + function getStartDate(year: number): Date { + try { + const stored = localStorage.getItem('startDates'); + if (stored) { + const startDates: string[] = JSON.parse(stored); + // Find date that matches the year (extract year from date string) + const dateStr = startDates.find(date => { + const dateYear = parseInt(date.split('-')[0] || date.split('T')[0].split('-')[0]); + return dateYear === year; + }); + if (dateStr) { + // Parse date string - handle both YYYY-MM-DD and ISO format + const parsed = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr; + const [y, m, d] = parsed.split('-').map(Number); + return new Date(y, m - 1, d); + } + } + } catch (e) { + console.error('Error loading start date:', e); + } + return new Date(year, 0, 1); // Default to Jan 1st + } + + // Save start date for a given year to localStorage + function saveStartDate(year: number, date: Date) { + try { + const stored = localStorage.getItem('startDates'); + let startDates: string[] = stored ? JSON.parse(stored) : []; + // Remove existing date for this year (extract year from date string) + startDates = startDates.filter(dateStr => { + const dateYear = parseInt(dateStr.split('-')[0] || dateStr.split('T')[0].split('-')[0]); + return dateYear !== year; + }); + // Add new date + startDates.push(formatDateForInput(date)); + localStorage.setItem('startDates', JSON.stringify(startDates)); + } catch (e) { + console.error('Error saving start date:', e); + } + } + + // Format date as YYYY-MM-DD for date input (no timezone conversion) + function formatDateForInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + // Format start date for display + function formatStartDate(date: Date): string { + const today = new Date(); + if (date.getTime() === new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() && date.getFullYear() === year) { + return 'Today'; + } + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const day = date.getDate(); + const suffix = getDaySuffix(day); + return `${monthNames[date.getMonth()]} ${day}${suffix}`; + } + + // Get day suffix (st, nd, rd, th) + function getDaySuffix(day: number): string { + if (day > 3 && day < 21) return 'th'; + switch (day % 10) { + case 1: return 'st'; + case 2: return 'nd'; + case 3: return 'rd'; + default: return 'th'; + } + } + + // Handle start date change + function handleStartDateChange(newDate: Date) { + startDate = newDate; + saveStartDate(year, newDate); + updateHolidays(); + // showExcludedMonths will be updated by reactive statement + } + + // Handle date picker input change (auto-save) + function handleDatePickerChange() { + if (datePickerValue) { + // Parse YYYY-MM-DD format in local time (consistent with getStartDate) + const [y, m, d] = datePickerValue.split('-').map(Number); + const newDate = new Date(y, m - 1, d); + handleStartDateChange(newDate); + } + } + + // Set start date to today + function setStartDateToToday() { + const today = new Date(); + if (today.getFullYear() === year) { + handleStartDateChange(new Date(today.getFullYear(), today.getMonth(), today.getDate())); + showDatePicker = false; + } + } + + // Reset start date to Jan 1st + function resetStartDateToJan1() { + const jan1st = new Date(year, 0, 1); + handleStartDateChange(jan1st); + showDatePicker = false; + } + + // Check if today is in the current year + function isTodayInYear(): boolean { + const today = new Date(); + return today.getFullYear() === year; + } + + // Helper: Get start date normalized to the current year + function getStartDateInYear(): Date { + return new Date(year, startDate.getMonth(), startDate.getDate()); + } + + // Check if a month is active (not entirely before the start date) + function isMonthActive(monthIndex: number): boolean { + const startDateInYear = getStartDateInYear(); + const startMonth = startDateInYear.getMonth(); + + // Month is active if: + // 1. The start date falls within this month (same month), OR + // 2. The month starts on or after the start date (later month) + // This means only months entirely before the start date's month are excluded + return monthIndex >= startMonth; + } + + // Check if start date is Jan 1st + function isStartDateJan1st(): boolean { + const startDateInYear = getStartDateInYear(); + return startDateInYear.getTime() === new Date(year, 0, 1).getTime(); + } + + // Check if there are any excluded months (months entirely before the start date) + function hasExcludedMonths(): boolean { + return months.some(month => !isMonthActive(month)); + } + + // Filter months based on showExcludedMonths setting + // Explicitly depend on startDate, year, and showExcludedMonths to ensure proper reactivity + // Use a computed value that depends on all relevant variables + $: visibleMonths = (startDate && year !== undefined && year) + ? (showExcludedMonths + ? months + : months.filter(month => isMonthActive(month))) + : []; + function toggleWeekendDay(dayNumber: number) { if (weekendDays.includes(dayNumber)) { weekendDays = weekendDays.filter(d => d !== dayNumber); @@ -318,7 +503,7 @@ .content-box p { text-align: center; - line-height: 2; + line-height: 3; } input { @@ -365,6 +550,29 @@ } } + .toggle-excluded-months-container { + text-align: center; + margin: 20px 0; + } + + .toggle-excluded-months { + padding: 8px 16px; + background-color: #333; + border: 1px solid #555; + border-radius: 5px; + color: #fff; + cursor: pointer; + font-size: 0.9em; + transition: background-color 0.3s; + width: auto; + display: inline-block; + white-space: nowrap; + } + + .toggle-excluded-months:hover { + background-color: #444; + } + .calendar-container { width: 100%; max-width: 300px; @@ -557,7 +765,6 @@ } .content-box button { - margin-left: 10px; background-color: #444; border: none; color: #fff; @@ -641,6 +848,161 @@ margin-bottom: 15px; color: #fff; } + + .start-date-link { + text-decoration: underline; + text-decoration-style: dotted; + cursor: pointer; + color: inherit; + } + + .start-date-link:hover { + text-decoration-style: solid; + } + + .date-picker-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + } + + .date-picker-modal { + background-color: #222; + border-radius: 10px; + padding: 25px; + max-width: min(400px, calc(100vw - 40px)); + width: 100%; + box-sizing: border-box; + position: relative; + color: #fff; + } + + .date-picker-close { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: none; + color: #fff; + font-size: 24px; + cursor: pointer; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; + } + + .date-picker-close:hover, + .date-picker-close:active, + .date-picker-close:focus { + background: transparent; + color: #ccc; + } + + .date-picker-modal h3 { + margin: 0 0 10px 0; + padding: 0; + text-align: center; + font-size: 1.5em; + } + + .date-picker-modal p { + margin: 0 0 20px 0; + padding: 0; + text-align: center; + color: #ccc; + } + + .date-picker-controls { + margin: 0 0 20px 0; + padding: 0; + width: 100%; + } + + .date-input { + width: 100%; + padding: 10px; + font-size: 1em; + background-color: #222; + border: 1px solid #555; + border-radius: 5px; + color: #fff; + box-sizing: border-box; + text-align: center; + margin: 0; + display: block; + } + + .date-input:focus { + outline: 2px solid #61dafb; + border-color: #61dafb; + } + + /* Make calendar icon white */ + .date-input::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; + } + + .date-input::-webkit-inner-spin-button, + .date-input::-webkit-clear-button { + filter: invert(1); + } + + .date-picker-buttons { + display: flex; + gap: 10px; + justify-content: center; + margin: 0 0 15px 0; + width: 100%; + box-sizing: border-box; + } + + .date-picker-button, + .date-picker-save { + margin: 0; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s; + box-sizing: border-box; + } + + .date-picker-button { + flex: 1; + min-width: 0; + padding: 20px; + background-color: #444; + color: #fff; + font-size: 0.9em; + } + + .date-picker-button:hover { + background-color: #555; + } + + .date-picker-save { + width: 100%; + margin: 0; + font-size: 1.1em; + font-weight: bold; + background-color: #61dafb; + color: #000; + } + + .date-picker-save:hover { + background-color: #4fa8c5; + }
@@ -689,15 +1051,27 @@ {daysOff} - days off in + days off from + { showDatePicker = true; datePickerValue = formatDateForInput(startDate); }} class="bold start-date-link"> + {@html formatStartDate(startDate)} + + until the end of {year}

- {#if year !== defaultYear || selectedCountry !== defaultCountry || daysOff !== defaultDaysOff} - Reset to my country + {#if year !== defaultYear || selectedCountry !== defaultCountry} + {@const yearDifferent = year !== defaultYear} + {@const countryDifferent = selectedCountry !== defaultCountry} + + {yearDifferent && countryDifferent + ? 'Reset to current country and year' + : yearDifferent + ? 'Reset to current year' + : 'Reset to current country'} + {/if} @@ -781,13 +1155,27 @@
{/if} + {#if hasExcludedMonths()} +
+ +
+ {/if} +
- {#each months as month} + {#each visibleMonths as month}
+ {#if showDatePicker} +
showDatePicker = false}> +
+ +

Set Start Date

+

Choose when your time off period begins for {year}

+
+ +
+
+ {#if isTodayInYear()} + + {/if} + {#if !isStartDateJan1st()} + + {/if} +
+ +
+
+ {/if} +