This commit is contained in:
Zachary
2024-11-11 18:19:45 +01:00
parent 4216b83837
commit 1c52afa674
5 changed files with 306 additions and 233 deletions

View File

@@ -3,15 +3,25 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stretch My Time Off - Optimise Your Vacation Days</title>
<meta name="description" content="Unlock the secret to extending your vacation days by strategically aligning public holidays and personal leave. Plan your ultimate getaway with ease and efficiency." />
<meta name="keywords" content="vacation, holidays, time off, optimise, travel, planning, calendar" />
<meta name="author" content="Zach" />
<meta property="og:title" content="Stretch My Time Off - Optimise Your Vacation Days" />
<meta property="og:description" content="Unlock the secret to extending your vacation days by strategically aligning public holidays and personal leave. Plan your ultimate getaway with ease and efficiency." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://stretchmytimeoff.com" />
<meta property="og:image" content="https://stretchmytimeoff.com/og-image.jpg" />
<link rel="canonical" href="https://stretchmytimeoff.com" />
%sveltekit.head%
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap');
body {
font-family: 'Poppins', sans-serif; /* Modern, cooler font */
background-color: #121212; /* Dark background */
color: #e0e0e0; /* Light gray text for readability */
font-family: 'Poppins', sans-serif;
background-color: #000;
color: #fff;
margin: 0;
padding: 0;
line-height: 1.6;

View File

@@ -5,6 +5,7 @@
export let month;
export let holidays = [];
export let optimizedDaysOff = [];
export let consecutiveDaysOff = [];
// Reactive declarations
$: daysInMonth = getDaysInMonth(year, month);
@@ -33,16 +34,55 @@
date.getDate() === day
);
}
// Determine the dominant month for each consecutive days off period
function getDominantMonth(period) {
const startMonth = period.startDate.getMonth();
const endMonth = period.endDate.getMonth();
if (startMonth === endMonth) {
return startMonth;
}
const startDays = getDaysInMonth(year, startMonth) - period.startDate.getDate() + 1;
const endDays = period.endDate.getDate();
return startDays > endDays ? startMonth : endMonth;
}
</script>
<div class="calendar">
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
{#each Array.from({ length: firstDay }) as _}
<div class="day"></div>
{/each}
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
<div class="day {(firstDay + day - 1) % 7 === 0 || (firstDay + day - 1) % 7 === 6 ? 'weekend' : ''} {getHoliday(day) ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''}">
{day}
{#if getHoliday(day)}
<Tooltip text={getHoliday(day).name} />
{/if}
</div>
{/each}
</div>
<div class="consecutive-days-off">
<ul>
{#each consecutiveDaysOff.filter(period => getDominantMonth(period) === month) as period}
<li>
{period.startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} to
{period.endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}:
<strong>{period.totalDays} days</strong>
</li>
{/each}
</ul>
</div>
<style>
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
border: 1px solid #444;
padding: 5px;
margin: 5px;
box-sizing: border-box;
width: 100%;
height: auto;
@@ -58,7 +98,6 @@
background-color: #222;
position: relative;
}
.day:hover {
:global(.tooltip) {
opacity: 1;
@@ -66,37 +105,34 @@
}
}
.weekend {
background-color: #333;
background-color: #585858;
}
.optimized {
background-color: #4caf50;
}
.holiday {
background-color: #3b1e6e;
cursor: pointer;
}
.optimized {
background-color: #4caf50;
color: white;
}
.month-name {
grid-column: span 7;
text-align: center;
font-weight: bold;
font-size: 0.8em;
margin-bottom: 2px;
letter-spacing: 0.1em;
font-size: 0.9em;
text-transform: uppercase;
color: #c5c6c7;
margin-bottom: 5px;
}
.consecutive-days-off {
margin-top: 10px;
color: #fff;
}
</style>
<div class="calendar">
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
{#each Array.from({ length: firstDay }) as _}
<div class="day"></div>
{/each}
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
<div class="day {(firstDay + day - 1) % 7 === 0 || (firstDay + day - 1) % 7 === 6 ? 'weekend' : ''} {getHoliday(day) ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''}">
{day}
{#if getHoliday(day)}
<Tooltip text={getHoliday(day).name} />
{/if}
</div>
{/each}
</div>
.consecutive-days-off ul {
list-style: none;
padding: 0;
margin: 0;
}
.consecutive-days-off li {
font-size: 0.9em;
}
</style>

View File

@@ -9,7 +9,7 @@
color: #fff;
padding: 5px;
border-radius: 3px;
font-size: 0.8em;
font-size: 1.2em;
white-space: nowrap;
z-index: 1000;
pointer-events: none;

View File

@@ -41,20 +41,51 @@ export function getHolidaysForYear(countryCode, year) {
// Optimize days off to create the longest possible chains
export function optimizeDaysOff(holidays, year, daysOff) {
// Filter holidays to include only those in the current year
const currentYearHolidays = holidays.filter(h => h.date.getFullYear() === year);
// Recalculate weekends for the current year
const weekends = getWeekends(year);
const allDaysOffSet = new Set([...holidays.map(h => dateKey(h.date)), ...weekends.map(d => dateKey(d))]);
// Initialize a new Set for all days off
const allDaysOffSet = new Set([
...currentYearHolidays.map(h => dateKey(h.date)),
...weekends.map(d => dateKey(d))
]);
let rankedGaps = rankGapsByEfficiency(findGaps(allDaysOffSet, year), allDaysOffSet);
return selectDaysOff(rankedGaps, daysOff, allDaysOffSet);
return selectDaysOff(rankedGaps, daysOff, allDaysOffSet, year);
}
// Calculate consecutive days off
export function calculateConsecutiveDaysOff(holidays, optimizedDaysOff, year) {
const allDays = [...holidays.map(h => h.date), ...optimizedDaysOff];
allDays.sort((a, b) => a - b);
const allDaysOffSet = new Set([...holidays.map(h => dateKey(h.date)), ...optimizedDaysOff.map(d => dateKey(d))]);
const consecutiveDaysOff = [];
let currentGroup = [];
for (let month = 0; month < 12; month++) {
for (let day = 1; day <= 31; day++) {
const date = new Date(year, month, day);
if (date.getMonth() !== month) break; // Skip invalid dates
return findConsecutiveDaysOff(allDays, holidays, optimizedDaysOff);
if (isWeekend(date) || isHoliday(date, holidays) || isDayOff(date, allDaysOffSet)) {
currentGroup.push(date);
} else {
if (currentGroup.length > 2) {
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff);
}
currentGroup = [];
}
}
}
// Check the last group at the end of the year
if (currentGroup.length > 2) {
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff);
}
return consecutiveDaysOff;
}
// Get all weekends for a specific year
@@ -143,7 +174,7 @@ function calculateChain(startDate, gapLength, allDaysOffSet, direction) {
}
// Select days off based on ranked gaps
function selectDaysOff(rankedGaps, daysOff, allDaysOffSet) {
function selectDaysOff(rankedGaps, daysOff, allDaysOffSet, year) {
const selectedDays = [];
while (daysOff > 0 && rankedGaps.length > 0) {
@@ -165,51 +196,26 @@ function selectDaysOff(rankedGaps, daysOff, allDaysOffSet) {
}
// Recalculate gaps and re-rank them after each assignment
const newGaps = findGaps(allDaysOffSet, new Date().getFullYear());
const newGaps = findGaps(allDaysOffSet, year);
rankedGaps = rankGapsByEfficiency(newGaps, allDaysOffSet);
}
return selectedDays;
}
// Find consecutive days off
function findConsecutiveDaysOff(allDays, holidays, optimizedDaysOff) {
let consecutiveDaysOff = [];
let currentGroup = [];
let includesHoliday = false;
allDays.forEach(date => {
if (isWeekend(date) || isHoliday(date, holidays) || isDayOff(date, new Set(optimizedDaysOff.map(d => dateKey(d))))) {
currentGroup.push(date);
if (isHoliday(date, holidays)) includesHoliday = true;
} else if (currentGroup.length > 0) {
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff, includesHoliday);
currentGroup = [];
includesHoliday = false;
}
});
if (currentGroup.length > 0) {
addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff, includesHoliday);
}
return consecutiveDaysOff;
}
// Add consecutive days off to the list
function addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff, includesHoliday) {
if (currentGroup.some(d => isDayOff(d, new Set(optimizedDaysOff.map(d => dateKey(d)))))) {
const startDate = currentGroup[0];
const endDate = currentGroup[currentGroup.length - 1];
const totalDays = daysBetween(startDate, endDate) + 1;
const usedDaysOff = currentGroup.filter(d => isDayOff(d, new Set(optimizedDaysOff.map(d => dateKey(d))))).length;
const message = `${usedDaysOff} days off -> ${totalDays} days`;
function addConsecutiveDaysOff(consecutiveDaysOff, currentGroup, optimizedDaysOff) {
const startDate = currentGroup[0];
const endDate = currentGroup[currentGroup.length - 1];
const totalDays = daysBetween(startDate, endDate) + 1;
const usedDaysOff = currentGroup.filter(d => isDayOff(d, new Set(optimizedDaysOff.map(d => dateKey(d))))).length;
if (totalDays > 2) {
consecutiveDaysOff.push({
startDate: formatDate(startDate),
endDate: formatDate(endDate),
includesHoliday,
message
startDate,
endDate,
usedDaysOff,
totalDays
});
}
}

View File

@@ -17,14 +17,8 @@
let optimizedDaysOff = [];
let consecutiveDaysOff = [];
let placeholder = "Country";
let isFirstClick = true;
let inputElement;
function handleYearChange(event) {
year = parseInt(event.target.value);
updateHolidays();
}
function handleCountryChange(event) {
const fullValue = event.target.value;
const countryCode = Object.keys(countriesList).find(code => countriesList[code] === fullValue);
@@ -43,7 +37,7 @@
tempSpan.style.whiteSpace = 'nowrap';
tempSpan.textContent = inputElement.value || inputElement.placeholder;
document.body.appendChild(tempSpan);
inputElement.style.width = `${tempSpan.offsetWidth + 30}px`;
inputElement.style.width = `${tempSpan.offsetWidth + 50}px`;
document.body.removeChild(tempSpan);
}
@@ -53,34 +47,15 @@
holidays = getHolidaysForYear(countryCode, year);
optimizedDaysOff = optimizeDaysOff(holidays, year, daysOff);
consecutiveDaysOff = calculateConsecutiveDaysOff(holidays, optimizedDaysOff, year);
} else {
holidays = [];
optimizedDaysOff = [];
consecutiveDaysOff = [];
}
}
function handleKeyDown(event) {
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;
}
console.log('Year:', year);
console.log('Holidays updated:', holidays);
console.log('Optimized Days Off:', optimizedDaysOff);
console.log('Consecutive Days Off:', consecutiveDaysOff);
}
function getFlagEmoji(countryCode) {
@@ -89,81 +64,98 @@
.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
}
function handleKeyDown(event) {
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
year++;
updateHolidays(); // Recalculate holidays for the new year
break;
case 'ArrowLeft':
event.preventDefault();
year--;
updateHolidays(); // Recalculate holidays for the new year
break;
case 'ArrowUp':
event.preventDefault();
daysOff++;
updateHolidays(); // Recalculate holidays with updated days off
break;
case 'ArrowDown':
event.preventDefault();
if (daysOff > 0) {
daysOff--;
updateHolidays(); // Recalculate holidays with updated days off
}
break;
}
}
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
adjustInputWidth(inputElement);
inputElement.addEventListener('input', () => adjustInputWidth(inputElement));
inputElement.addEventListener('input', () => {
adjustInputWidth(inputElement);
const countryCode = Object.keys(countriesList).find(code => countriesList[code] === inputElement.value);
});
inputElement.addEventListener('focus', () => {
inputElement.value = '';
adjustInputWidth(inputElement);
});
window.addEventListener('keydown', handleKeyDown);
});
updateHolidays();
console.log(consecutiveDaysOff);
</script>
<style>
header, footer {
.header {
max-width: 800px;
margin: 40px auto;
text-align: center;
color: #e0e0e0; /* Monochrome light text */
padding: 15px;
border-bottom: 1px solid #333; /* Subtle border for separation */
}
h1 {
.header h2 {
font-size: 2.5em; /* Slightly larger font size */
margin: 0;
font-size: 2em;
}
footer {
border-top: 1px solid #333;
font-size: 0.9em;
.header p {
font-size: 1.1em;
}
.content-box {
max-width: 900px;
margin: 20px auto;
padding: 10px 0;
background-color: #1e1e1e; /* Slightly lighter dark background for content boxes */
max-width: 1200px;
margin: 40px auto;
padding: 20px;
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;
font-size: 1em; /* Slightly smaller font size */
color: #e0e0e0; /* Light gray text */
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
margin-bottom: 40px;
}
input {
margin: 0 10px;
font-size: 0.9em; /* Slightly smaller font size */
font-size: 1em;
padding: 8px;
background-color: #2a2a2a; /* Darker gray for inputs */
color: #e0e0e0; /* Light text for inputs */
border: 1px solid #444;
background-color: transparent;
border: 1px solid #555;
border-radius: 5px;
transition: background-color 0.3s;
width: auto; /* Dynamic width based on content */
color: #fff;
transition: background-color 0.3s, color 0.3s;
width: auto;
}
input::hover {
background-color: #333; /* Slightly lighter on hover */
input:hover {
background-color: rgba(255, 255, 255, 0.1);
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin: 15px 0;
padding: 15px;
background-color: #2a2a2a;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
input:focus {
outline: 2px solid #61dafb;
}
.calendar-grid {
@@ -174,83 +166,40 @@
padding: 20px;
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
.calendar-grid {
grid-template-columns: repeat(2, 1fr); /* 2 columns on smaller screens */
grid-template-columns: repeat(2, 1fr); /* Adjust to 2 columns for medium screens */
gap: 10px;
padding: 10px;
}
}
@media (max-width: 600px) {
.calendar-grid {
grid-template-columns: repeat(2, 1fr); /* Allow 2 columns on smaller screens */
gap: 5px;
padding: 5px;
}
}
@media (max-width: 400px) {
.calendar-grid {
grid-template-columns: repeat(1, 1fr); /* Adjust to 1 column for very small screens */
}
}
.calendar-container {
width: 100%;
aspect-ratio: 1;
background-color: #2a2a2a; /* Dark gray for calendar */
color: #e0e0e0;
max-width: 300px;
background-color: #111;
color: #fff;
border-radius: 5px;
padding: 10px;
padding: 15px;
box-sizing: border-box;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.editable-input {
border: none;
border-bottom: 1px dotted #e0e0e0;
background: none;
color: inherit;
font-size: inherit;
font-family: inherit;
text-align: center;
margin: 0 10px;
outline: none;
transition: width 0.2s;
-webkit-appearance: none; /* Remove default styling */
-moz-appearance: none;
appearance: none;
color: #e0e0e0; /* Default text color */
}
.editable-input::placeholder {
color: #a0a0a0; /* Slightly grayer text for placeholder */
}
.arrow-controls {
display: inline-flex;
align-items: center;
}
button {
background-color: #2a2a2a;
border: 1px solid #444;
color: #e0e0e0;
font-size: 1em; /* Slightly smaller font size */
cursor: pointer;
padding: 5px 10px;
margin: 0 10px;
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: background-color 0.3s, color 0.3s, transform 0.1s;
}
button:hover {
background-color: #333;
color: #fff; /* Change color on hover */
}
button:active {
transform: translateY(2px); /* Simulate button press */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
button:focus {
outline: 2px solid #61dafb; /* Accessibility focus outline */
}
.bold {
font-weight: bold;
font-size: 1.2em;
}
.flag {
font-size: 2em; /* Adjust the size as needed */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: visible;
display: flex;
flex-direction: column;
}
.calendar-key {
@@ -258,9 +207,8 @@
justify-content: center;
align-items: center;
padding: 10px;
background-color: #1e1e1e;
border-radius: 5px;
color: #e0e0e0;
margin-bottom: 20px;
}
.key-item {
@@ -277,7 +225,7 @@
}
.color-box.weekend {
background-color: #333;
background-color: #585858; /* Muted gray/white */
}
.color-box.optimized {
@@ -287,18 +235,91 @@
.color-box.holiday {
background-color: #3b1e6e;
}
footer {
text-align: center;
padding: 20px;
color: #c5c6c7;
font-size: 0.9em;
}
footer a {
color: #66fcf1;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
.arrow-controls {
display: inline-flex;
align-items: center;
}
button {
background-color: #333;
border: 1px solid #444;
color: #fff;
font-size: 1em;
cursor: pointer;
padding: 5px 10px;
margin: 0 10px;
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: background-color 0.3s, color 0.3s, transform 0.1s;
}
button:hover {
background-color: #444;
}
button:active {
transform: translateY(2px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
button:focus {
outline: 2px solid #61dafb;
}
.bold {
font-weight: bold;
font-size: 1.2em;
}
.flag {
font-size: 2em;
}
.day {
aspect-ratio: 1;
text-align: center;
font-size: 0.7em; /* Adjust font size for smaller screens */
display: flex;
align-items: center;
justify-content: center;
color: #ddd;
background-color: #222;
position: relative;
}
</style>
<header>
<h1>Stretch My Time Off</h1>
</header>
<main>
<div class="header">
<h2>Stretch My Time Off</h2>
<p>
In {selectedCountry}, there are {holidays.length} public holidays in {year}.
<br />
Let's stretch your {daysOff} days off to {consecutiveDaysOff.reduce((total, group) => total + group.totalDays, 0)} vacation days.
</p>
</div>
<div class="content-box">
<p>
I live in
<span class="flag">{getFlagEmoji(Object.keys(countriesList).find(code => countriesList[code] === selectedCountry))}</span>
<input bind:this={inputElement} list="countries" class="editable-input bold" bind:value={selectedCountry} placeholder={placeholder} on:input={adjustInputWidth} on:focus={() => { inputElement.value = ''; adjustInputWidth(); }} on:change={handleCountryChange} aria-label="Select country" />
<span class="flag" style="vertical-align: middle;">{getFlagEmoji(Object.keys(countriesList).find(code => countriesList[code] === selectedCountry))}</span>
<input bind:this={inputElement} list="countries" class="editable-input bold" bind:value={selectedCountry} placeholder={placeholder} on:input={handleCountryChange} on:focus={() => { inputElement.value = ''; adjustInputWidth(); }} aria-label="Select country" />
and have
<span class="arrow-controls">
<button on:click={() => { daysOff++; updateHolidays(); }} aria-label="Increase days off"></button>
@@ -319,7 +340,7 @@
</datalist>
</div>
<div class="content-box">
<div class="content-box" id="calendar">
<div class="calendar-key">
<div class="key-item">
<span class="color-box weekend"></span> Weekend
@@ -334,7 +355,7 @@
<div class="calendar-grid">
{#each months as month}
<div class="calendar-container">
<CalendarMonth {year} {month} {holidays} {optimizedDaysOff} />
<CalendarMonth {year} {month} {holidays} {optimizedDaysOff} {consecutiveDaysOff} />
</div>
{/each}
</div>
@@ -342,5 +363,5 @@
</main>
<footer>
<p>© { new Date().getFullYear() } Stretch My Time Off. All rights reserved.</p>
<p>Made with <span style="color: red;">📅</span> by <a href="https://zach.ie" target="_blank">Zach</a></p>
</footer>