Merge pull request #20 from zachd/feat-calendar-start-date-c6a9d

Add start dates
This commit is contained in:
Zachary
2025-11-10 00:38:37 +01:00
committed by GitHub
7 changed files with 2425 additions and 157 deletions

1236
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,21 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@vitest/ui": "^4.0.8",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.3"
"vite": "^5.0.3",
"vitest": "^4.0.8"
},
"dependencies": {
"date-holidays": "^3.23.12",

View File

@@ -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));
</script>
<div class="calendar">
<div class="calendar {isActive ? '' : 'excluded-month'}">
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
{#each orderedDayInitials as dayInitial}
@@ -101,7 +110,8 @@
{/each}
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
{@const holiday = getHoliday(day)}
<div class="day {isWeekend(new Date(year, month, day)) ? 'weekend' : ''} {holiday ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''}">
{@const pastDate = isPastDate(day)}
<div class="day {isWeekend(new Date(year, month, day)) ? 'weekend' : ''} {holiday ? 'holiday' : ''} {isOptimizedDayOff(day) ? 'optimized' : ''} {isConsecutiveDayOff(day) ? 'consecutive-day' : ''} {pastDate ? 'past-date' : ''}">
<span class={holiday?.hidden ? 'strikethrough' : ''}>{day}</span>
{#if holiday}
<Tooltip text={holiday.name} />
@@ -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;
}
</style>

View File

@@ -0,0 +1,781 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { getHolidaysForYear, optimizeDaysOff, calculateConsecutiveDaysOff } from './holidayUtils';
// Test constants
const TEST_YEAR = 2024;
const DEFAULT_WEEKENDS = [0, 6]; // Sunday, Saturday
const CUSTOM_WEEKENDS = [5, 6]; // Friday, Saturday
// Mock browser APIs
const mockNavigator = {
languages: ['en', 'en-US']
};
const mockIntlDateTimeFormat = vi.fn(() => ({
resolvedOptions: () => ({ timeZone: 'America/New_York' })
}));
describe('holidayUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('navigator', mockNavigator);
vi.stubGlobal('Intl', {
...Intl,
DateTimeFormat: mockIntlDateTimeFormat
});
});
describe('getHolidaysForYear', () => {
it('should return holidays for a given year and country', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR);
expect(holidays).toBeDefined();
expect(Array.isArray(holidays)).toBe(true);
expect(holidays.length).toBeGreaterThan(0);
});
it('should filter only public holidays', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR);
holidays.forEach(holiday => {
expect(holiday).toHaveProperty('date');
expect(holiday).toHaveProperty('name');
expect(holiday.date).toBeInstanceOf(Date);
expect(typeof holiday.name).toBe('string');
});
});
it('should handle state codes', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR, 'CA');
expect(holidays).toBeDefined();
expect(Array.isArray(holidays)).toBe(true);
});
it('should return holidays sorted by date', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR);
for (let i = 1; i < holidays.length; i++) {
const prev = holidays[i - 1].date.getTime();
const curr = holidays[i].date.getTime();
expect(curr).toBeGreaterThanOrEqual(prev);
}
});
it('should handle different countries', () => {
const usHolidays = getHolidaysForYear('US', TEST_YEAR);
const gbHolidays = getHolidaysForYear('GB', TEST_YEAR);
expect(usHolidays.length).toBeGreaterThan(0);
expect(gbHolidays.length).toBeGreaterThan(0);
expect(usHolidays.length).not.toBe(gbHolidays.length);
});
it('should expand multi-day holidays correctly', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR);
const dateKeys = new Set(holidays.map(h =>
`${h.date.getFullYear()}-${h.date.getMonth()}-${h.date.getDate()}`
));
expect(holidays.length).toBeGreaterThanOrEqual(dateKeys.size);
});
it('should sort holidays by date first, then by name', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR);
for (let i = 1; i < holidays.length; i++) {
const prev = holidays[i - 1];
const curr = holidays[i];
const prevTime = prev.date.getTime();
const currTime = curr.date.getTime();
if (prevTime === currTime) {
expect(curr.name.localeCompare(prev.name)).toBeGreaterThanOrEqual(0);
} else {
expect(currTime).toBeGreaterThan(prevTime);
}
}
});
});
describe('optimizeDaysOff', () => {
const mockHolidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'New Year' },
{ date: new Date(TEST_YEAR, 6, 4), name: 'Independence Day' },
];
describe('basic functionality', () => {
it('should return an array of dates', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 5);
expect(Array.isArray(result)).toBe(true);
result.forEach(date => {
expect(date).toBeInstanceOf(Date);
});
});
it('should return at most the requested number of days', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 5);
expect(result.length).toBeLessThanOrEqual(5);
});
it('should handle zero days off', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 0);
expect(result).toEqual([]);
});
it('should handle more days off than available gaps', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 1000);
expect(result.length).toBeGreaterThan(0);
expect(result.length).toBeLessThan(1000);
});
});
describe('exclusion rules', () => {
it('should not include weekends in optimized days', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 10);
result.forEach(date => {
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
it('should not include holidays in optimized days', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 10);
const holidayKeys = new Set(mockHolidays.map(h =>
`${h.date.getFullYear()}-${h.date.getMonth()}-${h.date.getDate()}`
));
result.forEach(date => {
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
expect(holidayKeys.has(dateKey)).toBe(false);
});
});
it('should not select days that are already holidays or weekends', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
{ date: new Date(TEST_YEAR, 0, 3), name: 'Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 10);
const holidaySet = new Set(holidays.map(h =>
`${h.date.getFullYear()}-${h.date.getMonth()}-${h.date.getDate()}`
));
result.forEach(date => {
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
expect(holidaySet.has(dateKey)).toBe(false);
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
});
describe('parameters', () => {
it('should respect startDate parameter', () => {
const startDate = new Date(TEST_YEAR, 5, 1);
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
result.forEach(date => {
expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
});
});
it('should handle custom weekend days', () => {
const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 5, CUSTOM_WEEKENDS);
result.forEach(date => {
expect(CUSTOM_WEEKENDS).not.toContain(date.getDay());
});
});
it('should filter holidays by year and startDate', () => {
const holidays = [
{ date: new Date(2023, 11, 31), name: 'Old Year' },
{ date: new Date(TEST_YEAR, 0, 1), name: 'New Year' },
{ date: new Date(TEST_YEAR, 5, 15), name: 'Mid Year' },
];
const startDate = new Date(TEST_YEAR, 2, 1);
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
expect(Array.isArray(result)).toBe(true);
});
});
describe('gap finding and prioritization', () => {
it('should only find gaps of MAX_GAP_LENGTH (5) days or less', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
{ date: new Date(TEST_YEAR, 0, 3), name: 'Wed' },
{ date: new Date(TEST_YEAR, 0, 10), name: 'Wed' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 10);
expect(Array.isArray(result)).toBe(true);
result.forEach(date => {
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
it('should find and fill gaps of 1-5 days', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
{ date: new Date(TEST_YEAR, 0, 3), name: 'Wed Holiday' },
{ date: new Date(TEST_YEAR, 0, 11), name: 'Thu Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 10);
expect(result.length).toBeGreaterThan(0);
result.forEach(date => {
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
it('should prioritize gaps that create longer consecutive periods', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 4), name: 'Thursday Holiday' },
{ date: new Date(TEST_YEAR, 0, 8), name: 'Monday Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
expect(result.length).toBe(1);
});
it('should prioritize smaller gaps when they create longer chains', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 4), name: 'Thu Holiday' },
{ date: new Date(TEST_YEAR, 0, 9), name: 'Tue Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
expect(result.length).toBe(1);
expect(result[0].getDate()).toBe(5);
});
it('should handle multiple gaps and select most efficient ones first', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
{ date: new Date(TEST_YEAR, 0, 4), name: 'Thu' },
{ date: new Date(TEST_YEAR, 0, 8), name: 'Mon' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 3);
expect(result.length).toBeLessThanOrEqual(3);
result.forEach(date => {
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
it('should handle backward vs forward chain calculation', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 5), name: 'Friday Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
expect(Array.isArray(result)).toBe(true);
});
it('should optimize to create longer consecutive periods', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 4), name: 'Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
expect(result.length).toBeGreaterThan(0);
});
});
describe('edge cases', () => {
it('should handle partial gap filling when daysOff is less than gap length', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
{ date: new Date(TEST_YEAR, 0, 8), name: 'Mon' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 2);
expect(result.length).toBe(2);
result.forEach(date => {
expect(date.getDate()).toBeGreaterThanOrEqual(2);
expect(date.getDate()).toBeLessThanOrEqual(6);
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
it('should handle multiple gaps when daysOff exceeds single gap capacity', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
{ date: new Date(TEST_YEAR, 0, 3), name: 'Wed' },
{ date: new Date(TEST_YEAR, 0, 5), name: 'Fri' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 3);
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result.length).toBeLessThanOrEqual(3);
});
it('should handle optimization with no available gaps', () => {
const holidays = Array.from({ length: 365 }, (_, i) => {
const date = new Date(TEST_YEAR, 0, 1);
date.setDate(date.getDate() + i);
if (date.getDay() !== 0 && date.getDay() !== 6) {
return { date, name: `Holiday ${i}` };
}
return null;
}).filter(Boolean) as Array<{ date: Date; name: string }>;
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
expect(result).toEqual([]);
});
it('should handle gaps at the start of the year', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 5), name: 'Holiday' },
];
const startDate = new Date(TEST_YEAR, 0, 1);
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
expect(Array.isArray(result)).toBe(true);
});
it('should handle gaps at the end of the year', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 11, 25), name: 'Christmas' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
result.forEach(date => {
expect(date.getFullYear()).toBe(TEST_YEAR);
expect(date.getMonth()).toBeLessThanOrEqual(11);
});
});
it('should handle gaps that span year boundaries correctly', () => {
const startDate = new Date(TEST_YEAR, 11, 20);
const holidays = [
{ date: new Date(TEST_YEAR, 11, 25), name: 'Christmas' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
result.forEach(date => {
expect(date.getFullYear()).toBe(TEST_YEAR);
expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
});
});
});
});
describe('calculateConsecutiveDaysOff', () => {
const mockHolidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'New Year' },
{ date: new Date(TEST_YEAR, 0, 15), name: 'Holiday' },
];
describe('basic functionality', () => {
it('should return an array of periods', () => {
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR);
expect(Array.isArray(result)).toBe(true);
});
it('should calculate periods with correct structure', () => {
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
expect(period).toHaveProperty('startDate');
expect(period).toHaveProperty('endDate');
expect(period).toHaveProperty('totalDays');
expect(period).toHaveProperty('usedDaysOff');
expect(period.startDate).toBeInstanceOf(Date);
expect(period.endDate).toBeInstanceOf(Date);
expect(typeof period.totalDays).toBe('number');
expect(typeof period.usedDaysOff).toBe('number');
});
});
it('should include holidays in consecutive periods', () => {
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR);
const hasPeriodWithHoliday = result.some(period => {
const holidayDate = mockHolidays[0].date;
return period.startDate <= holidayDate && period.endDate >= holidayDate;
});
expect(hasPeriodWithHoliday).toBe(true);
});
});
describe('calculations', () => {
it('should calculate totalDays correctly', () => {
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
const calculatedDays = Math.round(
(period.endDate.getTime() - period.startDate.getTime()) / (1000 * 60 * 60 * 24)
) + 1;
expect(period.totalDays).toBe(calculatedDays);
});
});
it('should count usedDaysOff correctly', () => {
const optimizedDays = [
new Date(TEST_YEAR, 0, 2),
new Date(TEST_YEAR, 0, 3),
];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
expect(period.usedDaysOff).toBeGreaterThanOrEqual(0);
const daysInPeriod = optimizedDays.filter(day =>
day >= period.startDate && day <= period.endDate
).length;
expect(period.usedDaysOff).toBeLessThanOrEqual(daysInPeriod);
});
});
it('should correctly count usedDaysOff in periods', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
];
const optimizedDays = [
new Date(TEST_YEAR, 0, 2),
new Date(TEST_YEAR, 0, 3),
];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
const periodWithOptimized = result.find(period =>
optimizedDays.some(day => day >= period.startDate && day <= period.endDate)
);
if (periodWithOptimized) {
const daysInPeriod = optimizedDays.filter(day =>
day >= periodWithOptimized.startDate && day <= periodWithOptimized.endDate
).length;
expect(periodWithOptimized.usedDaysOff).toBe(daysInPeriod);
}
});
});
describe('validation rules', () => {
it('should not include periods that are only weekends', () => {
const optimizedDays: Date[] = [];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
let allWeekends = true;
for (let d = new Date(period.startDate); d <= period.endDate; d.setDate(d.getDate() + 1)) {
const dayOfWeek = d.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
allWeekends = false;
break;
}
}
expect(allWeekends).toBe(false);
});
});
it('should exclude single-day periods', () => {
const holidays: Array<{ date: Date; name: string }> = [];
const optimizedDays: Date[] = [];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
expect(period.totalDays).toBeGreaterThanOrEqual(2);
});
});
it('should handle groups that are only weekends correctly', () => {
const holidays: Array<{ date: Date; name: string }> = [];
const optimizedDays: Date[] = [];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
let hasNonWeekend = false;
for (let d = new Date(period.startDate); d <= period.endDate; d.setDate(d.getDate() + 1)) {
const dayOfWeek = d.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
hasNonWeekend = true;
break;
}
}
expect(hasNonWeekend).toBe(true);
});
});
});
describe('parameters', () => {
it('should respect startDate parameter', () => {
const startDate = new Date(TEST_YEAR, 5, 1);
const optimizedDays = [new Date(TEST_YEAR, 5, 2)];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, startDate);
result.forEach(period => {
expect(period.startDate.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
});
});
it('should handle custom weekend days', () => {
const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR, CUSTOM_WEEKENDS);
expect(Array.isArray(result)).toBe(true);
});
it('should handle empty optimized days', () => {
const result = calculateConsecutiveDaysOff(mockHolidays, [], TEST_YEAR);
expect(Array.isArray(result)).toBe(true);
});
});
describe('edge cases', () => {
it('should handle periods spanning multiple months', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 31), name: 'End of Jan' },
];
const optimizedDays = [new Date(TEST_YEAR, 1, 1)];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
expect(Array.isArray(result)).toBe(true);
});
it('should handle periods that start exactly at startDate', () => {
const startDate = new Date(TEST_YEAR, 5, 1);
const holidays = [
{ date: new Date(TEST_YEAR, 5, 1), name: 'Start Holiday' },
];
const optimizedDays = [new Date(TEST_YEAR, 5, 3)];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, startDate);
if (result.length > 0) {
expect(result[0].startDate.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
}
});
it('should handle periods that end exactly at year end (Dec 31)', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 11, 30), name: 'Dec 30' },
];
const optimizedDays = [new Date(TEST_YEAR, 11, 31)];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
const periodAtYearEnd = result.find(period =>
period.endDate.getMonth() === 11 && period.endDate.getDate() === 31
);
if (periodAtYearEnd) {
expect(periodAtYearEnd.endDate.getFullYear()).toBe(TEST_YEAR);
}
});
it('should correctly handle overlapping optimized days and holidays', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
];
const optimizedDays = [
new Date(TEST_YEAR, 0, 2),
new Date(TEST_YEAR, 0, 3),
];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
const period = result.find(p =>
p.startDate <= holidays[0].date && p.endDate >= optimizedDays[1]
);
if (period) {
expect(period.usedDaysOff).toBe(2);
}
});
it('should handle consecutive periods separated by work days', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
{ date: new Date(TEST_YEAR, 0, 4), name: 'Thu' },
];
const optimizedDays = [
new Date(TEST_YEAR, 0, 2),
new Date(TEST_YEAR, 0, 5),
];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
expect(result.length).toBeGreaterThanOrEqual(1);
result.forEach(period => {
expect(period.totalDays).toBeGreaterThanOrEqual(2);
});
});
});
});
describe('Integration tests', () => {
it('should work together: get holidays, optimize, and calculate periods', () => {
const holidays = getHolidaysForYear('US', TEST_YEAR);
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 10);
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
expect(holidays.length).toBeGreaterThan(0);
expect(optimizedDays.length).toBeLessThanOrEqual(10);
expect(Array.isArray(periods)).toBe(true);
periods.forEach(period => {
expect(period.totalDays).toBeGreaterThanOrEqual(2);
expect(period.usedDaysOff).toBeGreaterThanOrEqual(0);
expect(period.startDate <= period.endDate).toBe(true);
});
});
it('should optimize efficiently to maximize consecutive days', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 4), name: 'Holiday' },
];
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 1);
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
if (periods.length > 0) {
const hasOptimizedDay = periods.some(period =>
optimizedDays.some(day =>
day >= period.startDate && day <= period.endDate
)
);
expect(hasOptimizedDay).toBe(true);
}
});
it('should handle edge case: all days are holidays or weekends', () => {
const holidays = Array.from({ length: 50 }, (_, i) => ({
date: new Date(TEST_YEAR, 0, i + 1),
name: `Holiday ${i + 1}`
}));
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 5);
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
expect(Array.isArray(optimizedDays)).toBe(true);
expect(Array.isArray(periods)).toBe(true);
});
});
describe('Edge cases and error handling', () => {
it('should handle year with no holidays gracefully', () => {
const result = optimizeDaysOff([], TEST_YEAR, 5);
expect(Array.isArray(result)).toBe(true);
});
it('should handle invalid country codes gracefully', () => {
try {
const holidays = getHolidaysForYear('XX', TEST_YEAR);
expect(Array.isArray(holidays)).toBe(true);
} catch (e) {
expect(e).toBeDefined();
}
});
it('should handle dates at year boundaries', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 11, 31), name: 'New Year Eve' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
expect(Array.isArray(result)).toBe(true);
});
it('should handle startDate at end of year', () => {
const startDate = new Date(TEST_YEAR, 11, 15);
const holidays = [
{ date: new Date(TEST_YEAR, 11, 25), name: 'Christmas' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
result.forEach(date => {
expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
expect(date.getFullYear()).toBe(TEST_YEAR);
});
});
it('should handle holidays from previous year correctly', () => {
const holidays = [
{ date: new Date(2023, 11, 31), name: 'Old Year' },
{ date: new Date(TEST_YEAR, 0, 1), name: 'New Year' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
expect(Array.isArray(result)).toBe(true);
});
it('should handle leap year correctly', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 1, 29), name: 'Leap Day' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
expect(Array.isArray(result)).toBe(true);
});
});
describe('Private function behavior (tested indirectly)', () => {
it('should correctly identify weekend days', () => {
const holidays: Array<{ date: Date; name: string }> = [];
const result = optimizeDaysOff(holidays, TEST_YEAR, 10);
result.forEach(date => {
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
it('should correctly calculate days between dates', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Start' },
];
const optimizedDays = [new Date(TEST_YEAR, 0, 5)];
const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
result.forEach(period => {
const calculated = Math.round(
(period.endDate.getTime() - period.startDate.getTime()) / (1000 * 60 * 60 * 24)
) + 1;
expect(period.totalDays).toBe(calculated);
});
});
it('should generate consistent date keys', () => {
const date1 = new Date(TEST_YEAR, 0, 15);
const date2 = new Date(TEST_YEAR, 0, 15);
const holidays = [
{ date: date1, name: 'Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
const hasDate2 = result.some(d =>
d.getFullYear() === date2.getFullYear() &&
d.getMonth() === date2.getMonth() &&
d.getDate() === date2.getDate()
);
expect(hasDate2).toBe(false);
});
it('should correctly identify holidays using dateKey', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 15, 10, 30), name: 'Holiday' },
];
const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
const hasHolidayDate = result.some(d =>
d.getFullYear() === TEST_YEAR &&
d.getMonth() === 0 &&
d.getDate() === 15
);
expect(hasHolidayDate).toBe(false);
});
it('should correctly get weekends for the year with startDate', () => {
const startDate = new Date(TEST_YEAR, 5, 1);
const holidays: Array<{ date: Date; name: string }> = [];
const result = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, startDate);
result.forEach(date => {
expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
});
describe('Complex scenarios and real-world cases', () => {
it('should handle a typical year with multiple holidays and weekends', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'New Year' },
{ date: new Date(TEST_YEAR, 4, 27), name: 'Memorial Day' },
{ date: new Date(TEST_YEAR, 6, 4), name: 'Independence Day' },
{ date: new Date(TEST_YEAR, 8, 2), name: 'Labor Day' },
{ date: new Date(TEST_YEAR, 10, 28), name: 'Thanksgiving' },
{ date: new Date(TEST_YEAR, 11, 25), name: 'Christmas' },
];
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 10);
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
expect(optimizedDays.length).toBeLessThanOrEqual(10);
expect(periods.length).toBeGreaterThan(0);
periods.forEach(period => {
expect(period.totalDays).toBeGreaterThanOrEqual(2);
expect(period.startDate <= period.endDate).toBe(true);
expect(period.usedDaysOff).toBeGreaterThanOrEqual(0);
});
});
it('should maximize consecutive days off efficiently', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 4), name: 'Thu' },
{ date: new Date(TEST_YEAR, 0, 8), name: 'Mon' },
];
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 1);
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR);
const periodWithOptimized = periods.find(p =>
optimizedDays.some(day => day >= p.startDate && day <= p.endDate)
);
expect(periodWithOptimized).toBeDefined();
if (periodWithOptimized) {
expect(periodWithOptimized.totalDays).toBeGreaterThanOrEqual(4);
}
});
it('should handle non-standard weekend configurations', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
];
const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 5, CUSTOM_WEEKENDS);
const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, CUSTOM_WEEKENDS);
optimizedDays.forEach(date => {
expect(CUSTOM_WEEKENDS).not.toContain(date.getDay());
});
expect(Array.isArray(periods)).toBe(true);
});
});
});

View File

@@ -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<string>, year: number, weekendDays: number[]) {
function findGaps(allDaysOff: Set<string>, 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) {

View File

@@ -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()]}&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);
@@ -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;
}
</style>
<main>
@@ -689,15 +1051,27 @@
<span class="bold">{daysOff}</span>
<button on:click={() => { daysOff++; updateHolidays(); }} aria-label="Increase days off"></button>
</span>
days&nbsp;off in
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 || daysOff !== defaultDaysOff}
<a href="#" on:click|preventDefault={resetToDefault} class="reset-link">Reset to my country</a>
{#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">
@@ -781,13 +1155,27 @@
</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 months as month}
{#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}
@@ -798,6 +1186,41 @@
</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>

View File

@@ -2,5 +2,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
plugins: [sveltekit()],
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}']
}
});