Files
stretch-my-time-off/src/routes/+page.svelte
zachd ab42bbb65a Add support for fixed days off in calendar component
- Introduced `fixedDaysOff` prop to `CalendarMonth.svelte` for specifying non-working days.
- Enhanced tooltip functionality to indicate fixed days off.
- Updated day click handling to respect fixed days off.
- Modified styles to visually distinguish fixed days off in the calendar.
- Adjusted `optimizeDaysOff` and `calculateConsecutiveDaysOff` functions to account for fixed days off.
- Added tests to ensure fixed days off are excluded from optimization but included in consecutive periods.
- Implemented UI elements for managing fixed days off in the settings panel.
2025-11-10 00:59:54 +01:00

1348 lines
44 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import countries from 'i18n-iso-countries';
import enLocale from 'i18n-iso-countries/langs/en.json';
import CalendarMonth from '../lib/CalendarMonth.svelte';
import { getHolidaysForYear, optimizeDaysOff, calculateConsecutiveDaysOff } from '../lib/holidayUtils';
import { ptoData } from '../lib/ptoData';
import Holidays from 'date-holidays';
countries.registerLocale(enLocale);
let countriesList: Record<string, string> = countries.getNames('en');
let year: number;
let months: number[] = Array.from({ length: 12 }, (_, i) => i);
let selectedCountry: string = '';
let holidays: Array<{ date: Date; name: string; hidden?: boolean }> = [];
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;
let statesInput: HTMLInputElement | null = null;
let showHowItWorks: boolean = false;
// Default settings
let defaultYear: number = new Date().getFullYear();
let defaultCountry: string = '';
let defaultDaysOff: number = 0;
let selectedState: string = '';
let selectedStateCode: string = '';
let statesList: Record<string, string> = {};
let showHolidaysList: boolean = false;
let showWeekendSettings: boolean = false;
let showFixedDaysOffList: 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 || startDate) {
updateHolidays();
}
// 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
$: if (year !== undefined && year && startDate) {
showExcludedMonths = !hasExcludedMonths();
}
$: if (daysOff) {
localStorage.setItem('daysOff', daysOff.toString());
}
$: if (year) {
localStorage.setItem('year', year.toString());
}
function updateStatesList(countryCode: string) {
const hd = new Holidays(countryCode);
statesList = hd.getStates(countryCode) || {};
}
function handleStateChange(event: Event) {
const target = event.target as HTMLInputElement;
const stateName = target.value;
selectedStateCode = Object.keys(statesList).find(code => statesList[code] === stateName) || '';
selectedState = stateName;
// Save state per country
if (selectedCountryCode) {
localStorage.setItem(`selectedState_${selectedCountryCode}`, selectedState);
localStorage.setItem(`selectedStateCode_${selectedCountryCode}`, selectedStateCode);
}
}
onMount(() => {
fetchCountryCode().then(() => {
defaultYear = new Date().getFullYear();
defaultCountry = selectedCountry;
defaultDaysOff = ptoData[selectedCountryCode] || 0;
const storedYear = localStorage.getItem('year');
const storedCountry = localStorage.getItem('selectedCountry');
const storedDaysOff = localStorage.getItem('daysOff');
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) || '';
if (countryCode) {
selectedState = localStorage.getItem(`selectedState_${countryCode}`) || '';
selectedStateCode = localStorage.getItem(`selectedStateCode_${countryCode}`) || '';
} else {
selectedState = '';
selectedStateCode = '';
}
startDate = getStartDate(year);
loadFixedDaysOff(year);
// showExcludedMonths will be set by reactive statement
updateHolidays();
});
if (selectedCountryCode) {
updateStatesList(selectedCountryCode);
}
window.addEventListener('keydown', handleKeyDown);
// Load weekend days from localStorage or set defaults
const storedWeekendDays = localStorage.getItem('weekendDays');
weekendDays = storedWeekendDays
? JSON.parse(storedWeekendDays)
: [6, 0]; // Default to Saturday (6) and Sunday (0)
localStorage.setItem('weekendDays', JSON.stringify(weekendDays));
});
async function fetchCountryCode() {
try {
const response = await fetch('https://stretchmytimeoff.com/cdn-cgi/trace');
const text = await response.text();
const countryCodeMatch = text.match(/loc=(\w+)/);
const countryCode = countryCodeMatch ? countryCodeMatch[1] : '';
selectedCountry = countriesList[countryCode] || '';
} catch (error) {
console.error('Error fetching country code:', error);
}
}
function handleCountryChange(event: Event) {
const target = event.target as HTMLInputElement;
const fullValue = target.value;
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('daysOff', daysOff.toString());
}
}
function updateHolidays() {
if (selectedCountryCode) {
updateStatesList(selectedCountryCode);
let allHolidays = getHolidaysForYear(selectedCountryCode, year, selectedStateCode);
holidays = allHolidays.map(holiday => ({
...holiday,
date: new Date(holiday.date),
hidden: isHolidayHidden(holiday)
}));
const visibleHolidays = holidays.filter(h => !h.hidden);
optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate, fixedDaysOff);
consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate, fixedDaysOff);
} else {
holidays = [];
optimizedDaysOff = [];
consecutiveDaysOff = [];
}
}
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;
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);
}
function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
year++;
updateHolidays();
break;
case 'ArrowLeft':
event.preventDefault();
year--;
updateHolidays();
break;
case 'ArrowUp':
event.preventDefault();
daysOff++;
updateHolidays();
break;
case 'ArrowDown':
event.preventDefault();
if (daysOff > 0) {
daysOff--;
updateHolidays();
}
break;
}
}
function adjustInputWidth(inputElement: HTMLInputElement | null, value: string) {
if (typeof window !== 'undefined' && inputElement) {
const tempSpan = document.createElement('span');
tempSpan.style.visibility = 'hidden';
tempSpan.style.position = 'absolute';
tempSpan.style.whiteSpace = 'nowrap';
tempSpan.textContent = value || inputElement.value || inputElement.placeholder;
document.body.appendChild(tempSpan);
inputElement.style.width = `${tempSpan.offsetWidth + 30}px`;
document.body.removeChild(tempSpan);
}
}
$: if (countriesInput) adjustInputWidth(countriesInput, selectedCountry);
$: if (statesInput) adjustInputWidth(statesInput, selectedState);
function getFlagEmoji(countryCode: string) {
if (!countryCode) return '';
return countryCode
.toUpperCase()
.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt(0)));
}
function toggleHowItWorks() {
showHowItWorks = !showHowItWorks;
if (showHowItWorks) {
const howItWorksElement = document.querySelector('.how-it-works');
if (howItWorksElement) {
howItWorksElement.scrollIntoView({ behavior: 'smooth' });
}
}
}
function toggleHolidayVisibility(holiday: { date: Date; name: string; hidden?: boolean }) {
if (!selectedCountryCode) return;
const storageKey = `hiddenHolidays_${selectedCountryCode}`;
let hiddenHolidays = JSON.parse(localStorage.getItem(storageKey) || '{}');
hiddenHolidays[holiday.date.toString()] = !hiddenHolidays[holiday.date.toString()];
localStorage.setItem(storageKey, JSON.stringify(hiddenHolidays));
holidays = holidays.map(h => {
if (h.date === holiday.date) {
return { ...h, hidden: hiddenHolidays[h.date.toString()] };
}
return h;
});
updateHolidays();
}
function isHolidayHidden(holiday: { date: Date; name: string; hidden?: boolean }) {
if (!selectedCountryCode) return false;
const storageKey = `hiddenHolidays_${selectedCountryCode}`;
const hiddenHolidays = JSON.parse(localStorage.getItem(storageKey) || '{}');
return hiddenHolidays[holiday.date.toString()] || false;
}
function formatDate(date: Date) {
const options: Intl.DateTimeFormatOptions = { day: '2-digit', month: 'short', year: 'numeric' };
return new Date(date).toLocaleDateString('en-GB', options);
}
$: 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}`;
}
// 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();
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()]}&nbsp;${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);
} else {
weekendDays = [...weekendDays, dayNumber];
}
weekendDays.sort();
localStorage.setItem('weekendDays', JSON.stringify(weekendDays));
updateHolidays();
}
function getFirstDayOfWeek(locale: string): number {
const normalizedLocale = locale.toLowerCase() === 'us' ? 'en-US' : `en-${locale.toUpperCase()}`;
try {
// @ts-ignore
const weekFirstDay = new Intl.Locale(normalizedLocale)?.weekInfo?.firstDay;
if (weekFirstDay !== undefined) {
return weekFirstDay;
}
} catch (e) {
// Fallback if weekInfo is not supported
}
return normalizedLocale === 'en-US' ? 0 : 1;
}
function getOrderedDays() {
const days = [
{ name: 'Sunday', index: 0 },
{ name: 'Monday', index: 1 },
{ name: 'Tuesday', index: 2 },
{ name: 'Wednesday', index: 3 },
{ name: 'Thursday', index: 4 },
{ name: 'Friday', index: 5 },
{ name: 'Saturday', index: 6 }
];
const firstDay = getFirstDayOfWeek(selectedCountryCode || 'US');
return [...days.slice(firstDay), ...days.slice(0, firstDay)];
}
</script>
<style>
.header {
max-width: 800px;
margin: 20px auto;
padding: 0 30px;
text-align: center;
}
.header h2 {
font-size: 2.3rem;
margin: 0;
}
.content-box {
max-width: 1200px;
margin: 20px auto;
padding: 15px;
background-color: #111;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.content-box p {
text-align: center;
line-height: 3;
}
input {
margin: 0 5px;
font-size: 1em;
padding: 8px;
background-color: transparent;
border: 1px solid #555;
border-radius: 5px;
color: #fff;
transition: background-color 0.3s, color 0.3s;
width: auto;
}
input:hover {
background-color: rgba(255, 255, 255, 0.1);
}
input:focus {
outline: 2px solid #61dafb;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
justify-items: center;
padding: 20px;
}
@media (max-width: 1024px) {
.calendar-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
padding: 10px;
}
}
@media (max-width: 600px) {
.calendar-grid {
grid-template-columns: repeat(2, 1fr);
gap: 5px;
padding: 5px;
}
}
.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;
background-color: #111;
color: #fff;
border-radius: 5px;
padding: 10px;
box-sizing: border-box;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: visible;
display: flex;
flex-direction: column;
}
.calendar-key {
display: flex;
justify-content: center;
gap: 20px;
padding: 5px;
margin-bottom: 10px;
}
.key-item {
display: flex;
align-items: center;
gap: 5px;
}
.key-label {
display: flex;
align-items: center;
gap: 5px;
}
.color-box {
width: 15px;
height: 15px;
min-width: 15px;
border-radius: 3px;
}
.color-box.weekend {
background-color: #585858;
}
.color-box.optimized {
background-color: #4caf50;
}
.color-box.holiday {
background-color: #3b1e6e;
}
footer {
text-align: center;
padding: 10px;
color: #c5c6c7;
font-size: 0.8em;
}
a {
color: #66fcf1;
text-decoration: none;
}
a:hover {
color: #fff;
text-decoration: none;
}
.arrow-controls {
display: inline-flex;
align-items: center;
}
button {
background-color: #333;
border-left: 4px solid #111;
border-top: 4px solid #111;
border-right: 4px solid #555;
border-bottom: 4px solid #555;
color: #fff;
font-size: 0.8em;
cursor: pointer;
padding: 3px;
margin: 0 3px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.1s;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
font-weight: bold;
}
button:hover {
background-color: #444;
}
button:active {
transform: translateY(2px);
box-shadow: none;
}
button:focus {
outline: none;
}
.bold {
font-weight: bold;
font-size: 1em;
margin: 0 5px;
}
.flag {
font-size: 1.5em;
}
.how-it-works {
margin: 20px auto;
padding: 25px;
background-color: #111;
color: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: max-height 0.3s ease;
overflow: hidden;
}
.how-it-works p {
text-align: left;
}
.how-it-works h3 {
margin-top: 0;
}
.toggle-text {
color: #fff;
cursor: pointer;
margin: 20px auto;
display: block;
text-align: center;
font-size: 1em;
transition: color 0.3s;
display: block;
width: auto;
background: none;
border: none;
padding: 0;
}
.toggle-text:hover {
color: #61dafb;
background: none;
border: none;
padding: 0;
}
.toggle-text:focus {
outline: none;
}
.reset-link {
display: block;
margin-top: 10px;
text-align: center;
font-size: 0.8em;
}
.color-box.holiday {
background-color: #3b1e6e;
display: inline-block;
width: 15px;
height: 15px;
margin-right: 5px;
border-radius: 3px;
}
.content-box ul {
list-style-type: none;
padding: 0;
}
.content-box li {
display: flex;
align-items: center;
}
.content-box button {
background-color: #444;
border: none;
color: #fff;
padding: 5px 10px;
cursor: pointer;
border-radius: 5px;
}
.content-box button:hover {
background-color: #555;
}
.strikethrough {
text-decoration: line-through;
}
.edit-link {
cursor: pointer;
font-size: 0.8em;
text-decoration: none;
}
.edit-link:hover {
color: #fff;
}
.holidays-list {
margin: 10px;
padding: 15px;
background-color: #222;
border-radius: 5px;
}
.holidays-list ul {
list-style-type: none;
padding: 0;
}
.holidays-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 4px;
gap: 5px;
}
.holidays-list li:hover {
background-color: #333;
}
.holidays-list span {
flex: 1;
}
.holidays-list button {
margin-left: 10px;
background-color: #444;
border: none;
color: #fff;
padding: 5px 25px;
cursor: pointer;
border-radius: 5px;
min-width: 100px;
}
.holidays-list button:hover {
background-color: #555;
}
.settings-section {
margin-bottom: 20px;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section h3 {
margin-top: 0;
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;
}
</style>
<main>
<div class="header">
<h2>🌴 Stretch&nbsp;My Time&nbsp;Off</h2>
<p>
In
<strong>
{getFlagEmoji(selectedCountryCode)}
{#if selectedState}
{selectedState},
{/if}
{selectedCountry}
</strong>, there are <strong>{holidays.length}</strong> public holidays{#if visibleHolidaysCount < holidays.length}&nbsp;(<strong>{visibleHolidaysCount} selected</strong>){/if} in&nbsp;<strong>{year}</strong>.
</p>
<p>
Let's stretch your time off from <strong>{daysOff}&nbsp;days</strong> to <strong>{consecutiveDaysOff.reduce((total, group) => total + group.totalDays, 0)}&nbsp;days</strong> (<a href="#how-it-works" on:click={toggleHowItWorks}>how?</a>)
</p>
</div>
<div class="content-box">
<p>
I live in
<span class="flag" style="vertical-align: middle;">{getFlagEmoji(selectedCountryCode)}</span>
{#if Object.keys(statesList).length > 0}
<input bind:this={statesInput} list="states" class="editable-input bold" bind:value={selectedState}
on:input={(e) => handleStateChange(e)}
on:focus={(e) => { (e.target as HTMLInputElement).value = ''; }}
placeholder="State"
aria-label="State" />
<datalist id="states">
{#each Object.entries(statesList) as [code, name]}
<option value={name}>{name}</option>
{/each}
</datalist>
in
{/if}
<input bind:this={countriesInput} list="countries" class="editable-input bold" bind:value={selectedCountry}
on:input={(e) => handleCountryChange(e)}
on:focus={(e) => { (e.target as HTMLInputElement).value = ''; }}
placeholder="Country"
aria-label="Select country" />
and have
<span class="arrow-controls">
<button on:click={() => { if (daysOff > 0) { daysOff--; updateHolidays(); } }} aria-label="Decrease days off"></button>
<span class="bold">{daysOff}</span>
<button on:click={() => { daysOff++; updateHolidays(); }} aria-label="Increase days off"></button>
</span>
days&nbsp;off from
<a href="#" on:click|preventDefault={() => { showDatePicker = true; datePickerValue = formatDateForInput(startDate); }} class="bold start-date-link">
{@html formatStartDate(startDate)}
</a>
until the&nbsp;end&nbsp;of
<span class="arrow-controls">
<button on:click={() => { year--; updateHolidays(); }} aria-label="Previous year"></button>
<span class="bold">{year}</span>
<button on:click={() => { year++; updateHolidays(); }} aria-label="Next year"></button>
</span>
</p>
{#if year !== defaultYear || selectedCountry !== defaultCountry}
{@const yearDifferent = year !== defaultYear}
{@const countryDifferent = selectedCountry !== defaultCountry}
<a href="#" on:click|preventDefault={resetToDefault} class="reset-link">
{yearDifferent && countryDifferent
? 'Reset to current country and year'
: yearDifferent
? 'Reset to current year'
: 'Reset to current country'}
</a>
{/if}
<datalist id="countries">
{#each Object.entries(countriesList) as [code, name]}
<option value={name}>{name}</option>
{/each}
</datalist>
</div>
<div class="content-box" id="calendar">
<div class="calendar-key">
<div class="key-item">
<div class="key-label">
<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">
<span class="color-box holiday"></span>
<span>Public Holiday</span>
</div>
{#if holidays.length > 0}
<a href="#" on:click|preventDefault={() => showHolidaysList = !showHolidaysList} class="edit-link">
(edit)
</a>
{/if}
</div>
<div class="key-item">
<div class="key-label">
<span class="color-box weekend"></span>
<span>Weekend</span>
</div>
<a href="#" on:click|preventDefault={() => showWeekendSettings = !showWeekendSettings} class="edit-link">
(edit)
</a>
</div>
</div>
{#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>
<ul>
{#each holidays as holiday}
<li>
<div class="setting-item-label">
<span class="color-box holiday"></span>
<span class={holiday.hidden ? 'strikethrough' : ''}>
{formatDate(holiday.date)}: {holiday.name}
</span>
</div>
<button on:click={() => toggleHolidayVisibility(holiday)}>
{holiday.hidden ? 'Show' : 'Hide'}
</button>
</li>
{/each}
</ul>
</div>
{/if}
{#if showWeekendSettings}
<div class="settings-section">
<h3>Weekend Days</h3>
<ul>
{#each getOrderedDays() as {name, index}}
<li>
<div class="setting-item-label">
<span class="color-box weekend"></span>
<span>{name}</span>
</div>
<button on:click={() => toggleWeekendDay(index)}>
{weekendDays.includes(index) ? 'Remove' : 'Add'}
</button>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
{#if hasExcludedMonths()}
<div class="toggle-excluded-months-container">
<button
class="toggle-excluded-months"
on:click={() => showExcludedMonths = !showExcludedMonths}
aria-label={showExcludedMonths ? 'Hide excluded months' : 'Show excluded months'}
>
{showExcludedMonths ? 'Hide' : 'Show'} excluded months
</button>
</div>
{/if}
<div class="calendar-grid">
{#each visibleMonths as month}
<div class="calendar-container">
<CalendarMonth
year={year}
month={month}
holidays={holidays}
startDate={startDate}
isActive={isMonthActive(month)}
optimizedDaysOff={optimizedDaysOff}
consecutiveDaysOff={consecutiveDaysOff}
selectedCountryCode={selectedCountryCode}
weekendDays={weekendDays}
fixedDaysOff={fixedDaysOff}
onDayClick={toggleFixedDayOff}
/>
</div>
{/each}
</div>
</div>
{#if showDatePicker}
<div class="date-picker-overlay" on:click|self={() => showDatePicker = false}>
<div class="date-picker-modal" on:click|stopPropagation>
<button class="date-picker-close" on:click={() => showDatePicker = false} aria-label="Close">×</button>
<h3>Set Start Date</h3>
<p>Choose when your time off period begins for {year}</p>
<div class="date-picker-controls">
<input
type="date"
bind:value={datePickerValue}
on:change={handleDatePickerChange}
class="date-input"
min={formatDateForInput(new Date(year, 0, 1))}
max={formatDateForInput(new Date(year, 11, 31))}
/>
</div>
<div class="date-picker-buttons">
{#if isTodayInYear()}
<button on:click={setStartDateToToday} class="date-picker-button">
Set to today
</button>
{/if}
{#if !isStartDateJan1st()}
<button on:click={resetStartDateToJan1} class="date-picker-button">
Reset to Jan 1st
</button>
{/if}
</div>
<button class="date-picker-button date-picker-save" on:click={() => showDatePicker = false}>
Done
</button>
</div>
</div>
{/if}
<button type="button" class="toggle-text" on:click={toggleHowItWorks}>
{showHowItWorks ? 'Hide Details' : 'How does this work?'}
</button>
{#if showHowItWorks}
<div id="how-it-works" class="content-box how-it-works">
<h3>How does this work?</h3>
<p>
This tool detects your country from your IP, uses a default number of government-mandated days off from <a href="https://en.wikipedia.org/wiki/List_of_minimum_annual_leave_by_country" target="_blank" rel="noopener noreferrer">Wikipedia</a>, and a <a href={`https://github.com/commenthol/date-holidays/blob/master/data/countries/${selectedCountryCode}.yaml`} target="_blank" rel="noopener noreferrer">list of holidays</a> for {selectedCountry}.
</p>
<p>
The algorithm prioritizes filling the shortest gaps first. It optimizes for spreading your holidays throughout the year to create the most number of consecutive vacation periods.
</p>
<p>
For the calculation, it counts all vacation stretches that are 3 days or longer, including those lucky 3-day weekends thanks to public holidays. It's slightly optimistic, but captures the spirit of maximising time off.
</p>
<p>
Built with <a href="https://kit.svelte.dev/" target="_blank" rel="noopener noreferrer">SvelteKit</a>. Hosted on <a href="https://vercel.com/" target="_blank" rel="noopener noreferrer">Vercel</a> with <a href="https://www.cloudflare.com/" target="_blank" rel="noopener noreferrer">Cloudflare</a>. Developed using <a href="https://www.cursor.com/" target="_blank" rel="noopener noreferrer">Cursor</a>, an AI-powered code editor, and <a href="https://openai.com/research/gpt-4" target="_blank" rel="noopener noreferrer">GPT-4o</a>.
</p>
</div>
{/if}
</main>
<footer>
<p>Made with ☕ by <a href="https://zach.ie" target="_blank">Zach</a> (+ GPT-4o)</p>
<p><a href="https://github.com/zachd/stretch-my-time-off" target="_blank" rel="noopener noreferrer">View on GitHub</a></p>
</footer>