From ef6a167431faa1fb3dec1d89c69182c270838986 Mon Sep 17 00:00:00 2001 From: zachd Date: Mon, 10 Nov 2025 00:28:42 +0100 Subject: [PATCH] Enhance calendar functionality with start date management and excluded month visibility. Added start date state and date picker for user-defined start dates. Updated holiday and PTO calculations to respect the start date. Improved UI to toggle excluded months and display active months based on the start date. Refactored related utility functions for better date handling. --- src/lib/CalendarMonth.svelte | 30 ++- src/lib/holidayUtils.ts | 40 +-- src/routes/+page.svelte | 479 +++++++++++++++++++++++++++++++++-- 3 files changed, 503 insertions(+), 46 deletions(-) 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} +