Merge pull request #20 from zachd/feat-calendar-start-date-c6a9d
Add start dates
This commit is contained in:
Generated
+1128
-108
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -7,16 +7,21 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"@vitest/ui": "^4.0.8",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^5.0.3"
|
"vite": "^5.0.3",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-holidays": "^3.23.12",
|
"date-holidays": "^3.23.12",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
export let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }>;
|
export let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }>;
|
||||||
export let selectedCountryCode: string;
|
export let selectedCountryCode: string;
|
||||||
export let weekendDays: number[] = [6, 0];
|
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 to determine the first day of the week based on locale
|
||||||
function getFirstDayOfWeek(locale: string): number {
|
function getFirstDayOfWeek(locale: string): number {
|
||||||
@@ -84,12 +86,19 @@
|
|||||||
return weekendDays.includes(date.getDay());
|
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'];
|
const dayInitials = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||||
|
|
||||||
$: orderedDayInitials = dayInitials.slice(firstDayOfWeek).concat(dayInitials.slice(0, firstDayOfWeek));
|
$: orderedDayInitials = dayInitials.slice(firstDayOfWeek).concat(dayInitials.slice(0, firstDayOfWeek));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="calendar">
|
<div class="calendar {isActive ? '' : 'excluded-month'}">
|
||||||
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
|
<div class="month-name">{new Date(year, month).toLocaleString('default', { month: 'long' })}</div>
|
||||||
|
|
||||||
{#each orderedDayInitials as dayInitial}
|
{#each orderedDayInitials as dayInitial}
|
||||||
@@ -101,7 +110,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
|
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
|
||||||
{@const holiday = getHoliday(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>
|
<span class={holiday?.hidden ? 'strikethrough' : ''}>{day}</span>
|
||||||
{#if holiday}
|
{#if holiday}
|
||||||
<Tooltip text={holiday.name} />
|
<Tooltip text={holiday.name} />
|
||||||
@@ -137,6 +147,14 @@
|
|||||||
color: #c5c6c7;
|
color: #c5c6c7;
|
||||||
font-size: 0.6em;
|
font-size: 0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excluded-month .month-name {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-month .day-initial {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
.day {
|
.day {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -202,4 +220,12 @@
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.past-date {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-date span {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+24
-16
@@ -31,49 +31,56 @@ export function getHolidaysForYear(countryCode: string, year: number, stateCode?
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find optimal placement of PTO days to maximize consecutive time off
|
// 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([
|
const allDaysOff = new Set([
|
||||||
...holidays.filter(h => h.date.getFullYear() === year).map(h => dateKey(h.date)),
|
...filteredHolidays.map(h => dateKey(h.date)),
|
||||||
...getWeekends(year, weekendDays).map(d => dateKey(d))
|
...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);
|
return selectDaysOff(rankGapsByEfficiency(gaps, allDaysOff, weekendDays), daysOff, allDaysOff, weekendDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate periods of consecutive days off (weekends + holidays + PTO)
|
// 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([
|
const allDaysOff = new Set([
|
||||||
...holidays.map(h => dateKey(h.date)),
|
...filteredHolidays.map(h => dateKey(h.date)),
|
||||||
...optimizedDaysOff.map(d => dateKey(d)),
|
...filteredOptimizedDaysOff.map(d => dateKey(d)),
|
||||||
...getWeekends(year, weekendDays).map(d => dateKey(d))
|
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const consecutiveDaysOff = [];
|
const consecutiveDaysOff = [];
|
||||||
let currentGroup = [];
|
let currentGroup = [];
|
||||||
|
|
||||||
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 (isWeekend(d, weekendDays) || isHoliday(d, holidays) || allDaysOff.has(dateKey(d))) {
|
if (isWeekend(d, weekendDays) || isHoliday(d, filteredHolidays) || allDaysOff.has(dateKey(d))) {
|
||||||
currentGroup.push(new Date(d));
|
currentGroup.push(new Date(d));
|
||||||
} else if (currentGroup.length > 0) {
|
} else if (currentGroup.length > 0) {
|
||||||
if (isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
if (isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
||||||
consecutiveDaysOff.push(createPeriod(currentGroup, optimizedDaysOff));
|
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
|
||||||
}
|
}
|
||||||
currentGroup = [];
|
currentGroup = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
|
||||||
consecutiveDaysOff.push(createPeriod(currentGroup, optimizedDaysOff));
|
consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
|
||||||
}
|
}
|
||||||
|
|
||||||
return consecutiveDaysOff;
|
return consecutiveDaysOff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all weekend days for a year
|
// 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 = [];
|
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)) {
|
if (d.getMonth() === d.getMonth() && isWeekend(d, weekendDays)) {
|
||||||
weekends.push(new Date(d));
|
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
|
// 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 = [];
|
const gaps = [];
|
||||||
let gapStart = null;
|
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 (!allDaysOff.has(dateKey(d)) && !isWeekend(d, weekendDays)) {
|
||||||
if (!gapStart) gapStart = new Date(d);
|
if (!gapStart) gapStart = new Date(d);
|
||||||
} else if (gapStart) {
|
} else if (gapStart) {
|
||||||
|
|||||||
+451
-28
@@ -17,6 +17,8 @@
|
|||||||
let daysOff: number = 0;
|
let daysOff: number = 0;
|
||||||
let optimizedDaysOff: Date[] = [];
|
let optimizedDaysOff: Date[] = [];
|
||||||
let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }> = [];
|
let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }> = [];
|
||||||
|
let showExcludedMonths: boolean = true;
|
||||||
|
let visibleMonths: number[] = [];
|
||||||
let countriesInput: HTMLInputElement | null = null;
|
let countriesInput: HTMLInputElement | null = null;
|
||||||
let statesInput: HTMLInputElement | null = null;
|
let statesInput: HTMLInputElement | null = null;
|
||||||
let showHowItWorks: boolean = false;
|
let showHowItWorks: boolean = false;
|
||||||
@@ -35,12 +37,27 @@
|
|||||||
let showWeekendSettings: boolean = false;
|
let showWeekendSettings: boolean = false;
|
||||||
let weekendDays: number[] = [];
|
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) || '';
|
$: selectedCountryCode = Object.keys(countriesList).find(code => countriesList[code] === selectedCountry) || '';
|
||||||
|
|
||||||
$: if (selectedCountryCode || selectedStateCode || daysOff || year) {
|
$: if (selectedCountryCode || selectedStateCode || daysOff || year || startDate) {
|
||||||
updateHolidays();
|
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) {
|
$: if (daysOff) {
|
||||||
localStorage.setItem('daysOff', daysOff.toString());
|
localStorage.setItem('daysOff', daysOff.toString());
|
||||||
}
|
}
|
||||||
@@ -59,8 +76,11 @@
|
|||||||
const stateName = target.value;
|
const stateName = target.value;
|
||||||
selectedStateCode = Object.keys(statesList).find(code => statesList[code] === stateName) || '';
|
selectedStateCode = Object.keys(statesList).find(code => statesList[code] === stateName) || '';
|
||||||
selectedState = stateName;
|
selectedState = stateName;
|
||||||
localStorage.setItem('selectedState', selectedState);
|
// Save state per country
|
||||||
localStorage.setItem('selectedStateCode', selectedStateCode);
|
if (selectedCountryCode) {
|
||||||
|
localStorage.setItem(`selectedState_${selectedCountryCode}`, selectedState);
|
||||||
|
localStorage.setItem(`selectedStateCode_${selectedCountryCode}`, selectedStateCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -73,14 +93,22 @@
|
|||||||
const storedYear = localStorage.getItem('year');
|
const storedYear = localStorage.getItem('year');
|
||||||
const storedCountry = localStorage.getItem('selectedCountry');
|
const storedCountry = localStorage.getItem('selectedCountry');
|
||||||
const storedDaysOff = localStorage.getItem('daysOff');
|
const storedDaysOff = localStorage.getItem('daysOff');
|
||||||
const storedState = localStorage.getItem('selectedState');
|
|
||||||
const storedStateCode = localStorage.getItem('selectedStateCode');
|
|
||||||
|
|
||||||
year = storedYear ? parseInt(storedYear, 10) : defaultYear;
|
year = storedYear ? parseInt(storedYear, 10) : defaultYear;
|
||||||
selectedCountry = storedCountry || defaultCountry;
|
selectedCountry = storedCountry || defaultCountry;
|
||||||
daysOff = storedDaysOff ? parseInt(storedDaysOff, 10) : defaultDaysOff;
|
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();
|
updateHolidays();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,14 +141,17 @@
|
|||||||
function handleCountryChange(event: Event) {
|
function handleCountryChange(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const fullValue = target.value;
|
const fullValue = target.value;
|
||||||
if (selectedCountryCode) {
|
selectedCountry = fullValue;
|
||||||
daysOff = ptoData[selectedCountryCode] || 0;
|
// Get the country code for the new country (selectedCountryCode will update reactively)
|
||||||
selectedState = ''; // Reset state
|
const newCountryCode = Object.keys(countriesList).find(code => countriesList[code] === fullValue) || '';
|
||||||
selectedStateCode = ''; // Reset state code
|
if (newCountryCode) {
|
||||||
updateStatesList(selectedCountryCode); // Update states list for the new country
|
// 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('selectedCountry', selectedCountry);
|
||||||
localStorage.setItem('selectedState', selectedState);
|
|
||||||
localStorage.setItem('selectedStateCode', selectedStateCode);
|
|
||||||
localStorage.setItem('daysOff', daysOff.toString());
|
localStorage.setItem('daysOff', daysOff.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,8 +166,8 @@
|
|||||||
hidden: isHolidayHidden(holiday)
|
hidden: isHolidayHidden(holiday)
|
||||||
}));
|
}));
|
||||||
const visibleHolidays = holidays.filter(h => !h.hidden);
|
const visibleHolidays = holidays.filter(h => !h.hidden);
|
||||||
optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays);
|
optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate);
|
||||||
consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays);
|
consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate);
|
||||||
} else {
|
} else {
|
||||||
holidays = [];
|
holidays = [];
|
||||||
optimizedDaysOff = [];
|
optimizedDaysOff = [];
|
||||||
@@ -147,14 +178,18 @@
|
|||||||
function resetToDefault() {
|
function resetToDefault() {
|
||||||
year = defaultYear;
|
year = defaultYear;
|
||||||
selectedCountry = defaultCountry;
|
selectedCountry = defaultCountry;
|
||||||
selectedState = '';
|
const defaultCountryCode = Object.keys(countriesList).find(code => countriesList[code] === defaultCountry) || '';
|
||||||
selectedStateCode = '';
|
// Load state for default country
|
||||||
daysOff = defaultDaysOff;
|
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('year', year.toString());
|
||||||
localStorage.setItem('selectedCountry', selectedCountry);
|
localStorage.setItem('selectedCountry', selectedCountry);
|
||||||
localStorage.setItem('selectedState', selectedState);
|
|
||||||
localStorage.setItem('selectedStateCode', selectedStateCode);
|
|
||||||
localStorage.setItem('daysOff', daysOff.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@@ -252,6 +287,156 @@
|
|||||||
|
|
||||||
$: visibleHolidaysCount = holidays.filter(h => !h.hidden).length;
|
$: visibleHolidaysCount = holidays.filter(h => !h.hidden).length;
|
||||||
|
|
||||||
|
// Get start date for a given year from localStorage
|
||||||
|
function getStartDate(year: number): Date {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('startDates');
|
||||||
|
if (stored) {
|
||||||
|
const startDates: string[] = JSON.parse(stored);
|
||||||
|
// Find date that matches the year (extract year from date string)
|
||||||
|
const dateStr = startDates.find(date => {
|
||||||
|
const dateYear = parseInt(date.split('-')[0] || date.split('T')[0].split('-')[0]);
|
||||||
|
return dateYear === year;
|
||||||
|
});
|
||||||
|
if (dateStr) {
|
||||||
|
// Parse date string - handle both YYYY-MM-DD and ISO format
|
||||||
|
const parsed = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
|
||||||
|
const [y, m, d] = parsed.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading start date:', e);
|
||||||
|
}
|
||||||
|
return new Date(year, 0, 1); // Default to Jan 1st
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save start date for a given year to localStorage
|
||||||
|
function saveStartDate(year: number, date: Date) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('startDates');
|
||||||
|
let startDates: string[] = stored ? JSON.parse(stored) : [];
|
||||||
|
// Remove existing date for this year (extract year from date string)
|
||||||
|
startDates = startDates.filter(dateStr => {
|
||||||
|
const dateYear = parseInt(dateStr.split('-')[0] || dateStr.split('T')[0].split('-')[0]);
|
||||||
|
return dateYear !== year;
|
||||||
|
});
|
||||||
|
// Add new date
|
||||||
|
startDates.push(formatDateForInput(date));
|
||||||
|
localStorage.setItem('startDates', JSON.stringify(startDates));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error saving start date:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date as YYYY-MM-DD for date input (no timezone conversion)
|
||||||
|
function formatDateForInput(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format start date for display
|
||||||
|
function formatStartDate(date: Date): string {
|
||||||
|
const today = new Date();
|
||||||
|
if (date.getTime() === new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() && date.getFullYear() === year) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const day = date.getDate();
|
||||||
|
const suffix = getDaySuffix(day);
|
||||||
|
return `${monthNames[date.getMonth()]} ${day}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get day suffix (st, nd, rd, th)
|
||||||
|
function getDaySuffix(day: number): string {
|
||||||
|
if (day > 3 && day < 21) return 'th';
|
||||||
|
switch (day % 10) {
|
||||||
|
case 1: return 'st';
|
||||||
|
case 2: return 'nd';
|
||||||
|
case 3: return 'rd';
|
||||||
|
default: return 'th';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle start date change
|
||||||
|
function handleStartDateChange(newDate: Date) {
|
||||||
|
startDate = newDate;
|
||||||
|
saveStartDate(year, newDate);
|
||||||
|
updateHolidays();
|
||||||
|
// showExcludedMonths will be updated by reactive statement
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle date picker input change (auto-save)
|
||||||
|
function handleDatePickerChange() {
|
||||||
|
if (datePickerValue) {
|
||||||
|
// Parse YYYY-MM-DD format in local time (consistent with getStartDate)
|
||||||
|
const [y, m, d] = datePickerValue.split('-').map(Number);
|
||||||
|
const newDate = new Date(y, m - 1, d);
|
||||||
|
handleStartDateChange(newDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set start date to today
|
||||||
|
function setStartDateToToday() {
|
||||||
|
const today = new Date();
|
||||||
|
if (today.getFullYear() === year) {
|
||||||
|
handleStartDateChange(new Date(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||||
|
showDatePicker = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset start date to Jan 1st
|
||||||
|
function resetStartDateToJan1() {
|
||||||
|
const jan1st = new Date(year, 0, 1);
|
||||||
|
handleStartDateChange(jan1st);
|
||||||
|
showDatePicker = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if today is in the current year
|
||||||
|
function isTodayInYear(): boolean {
|
||||||
|
const today = new Date();
|
||||||
|
return today.getFullYear() === year;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Get start date normalized to the current year
|
||||||
|
function getStartDateInYear(): Date {
|
||||||
|
return new Date(year, startDate.getMonth(), startDate.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a month is active (not entirely before the start date)
|
||||||
|
function isMonthActive(monthIndex: number): boolean {
|
||||||
|
const startDateInYear = getStartDateInYear();
|
||||||
|
const startMonth = startDateInYear.getMonth();
|
||||||
|
|
||||||
|
// Month is active if:
|
||||||
|
// 1. The start date falls within this month (same month), OR
|
||||||
|
// 2. The month starts on or after the start date (later month)
|
||||||
|
// This means only months entirely before the start date's month are excluded
|
||||||
|
return monthIndex >= startMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if start date is Jan 1st
|
||||||
|
function isStartDateJan1st(): boolean {
|
||||||
|
const startDateInYear = getStartDateInYear();
|
||||||
|
return startDateInYear.getTime() === new Date(year, 0, 1).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any excluded months (months entirely before the start date)
|
||||||
|
function hasExcludedMonths(): boolean {
|
||||||
|
return months.some(month => !isMonthActive(month));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter months based on showExcludedMonths setting
|
||||||
|
// Explicitly depend on startDate, year, and showExcludedMonths to ensure proper reactivity
|
||||||
|
// Use a computed value that depends on all relevant variables
|
||||||
|
$: visibleMonths = (startDate && year !== undefined && year)
|
||||||
|
? (showExcludedMonths
|
||||||
|
? months
|
||||||
|
: months.filter(month => isMonthActive(month)))
|
||||||
|
: [];
|
||||||
|
|
||||||
function toggleWeekendDay(dayNumber: number) {
|
function toggleWeekendDay(dayNumber: number) {
|
||||||
if (weekendDays.includes(dayNumber)) {
|
if (weekendDays.includes(dayNumber)) {
|
||||||
weekendDays = weekendDays.filter(d => d !== dayNumber);
|
weekendDays = weekendDays.filter(d => d !== dayNumber);
|
||||||
@@ -318,7 +503,7 @@
|
|||||||
|
|
||||||
.content-box p {
|
.content-box p {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 2;
|
line-height: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
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 {
|
.calendar-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
@@ -557,7 +765,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content-box button {
|
.content-box button {
|
||||||
margin-left: 10px;
|
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -641,6 +848,161 @@
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: #fff;
|
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>
|
</style>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -689,15 +1051,27 @@
|
|||||||
<span class="bold">{daysOff}</span>
|
<span class="bold">{daysOff}</span>
|
||||||
<button on:click={() => { daysOff++; updateHolidays(); }} aria-label="Increase days off">▲</button>
|
<button on:click={() => { daysOff++; updateHolidays(); }} aria-label="Increase days off">▲</button>
|
||||||
</span>
|
</span>
|
||||||
days off in
|
days off from
|
||||||
|
<a href="#" on:click|preventDefault={() => { showDatePicker = true; datePickerValue = formatDateForInput(startDate); }} class="bold start-date-link">
|
||||||
|
{@html formatStartDate(startDate)}
|
||||||
|
</a>
|
||||||
|
until the end of
|
||||||
<span class="arrow-controls">
|
<span class="arrow-controls">
|
||||||
<button on:click={() => { year--; updateHolidays(); }} aria-label="Previous year">◀</button>
|
<button on:click={() => { year--; updateHolidays(); }} aria-label="Previous year">◀</button>
|
||||||
<span class="bold">{year}</span>
|
<span class="bold">{year}</span>
|
||||||
<button on:click={() => { year++; updateHolidays(); }} aria-label="Next year">▶</button>
|
<button on:click={() => { year++; updateHolidays(); }} aria-label="Next year">▶</button>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{#if year !== defaultYear || selectedCountry !== defaultCountry || daysOff !== defaultDaysOff}
|
{#if year !== defaultYear || selectedCountry !== defaultCountry}
|
||||||
<a href="#" on:click|preventDefault={resetToDefault} class="reset-link">Reset to my country</a>
|
{@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}
|
{/if}
|
||||||
|
|
||||||
<datalist id="countries">
|
<datalist id="countries">
|
||||||
@@ -781,13 +1155,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="calendar-grid">
|
||||||
{#each months as month}
|
{#each visibleMonths as month}
|
||||||
<div class="calendar-container">
|
<div class="calendar-container">
|
||||||
<CalendarMonth
|
<CalendarMonth
|
||||||
year={year}
|
year={year}
|
||||||
month={month}
|
month={month}
|
||||||
holidays={holidays}
|
holidays={holidays}
|
||||||
|
startDate={startDate}
|
||||||
|
isActive={isMonthActive(month)}
|
||||||
optimizedDaysOff={optimizedDaysOff}
|
optimizedDaysOff={optimizedDaysOff}
|
||||||
consecutiveDaysOff={consecutiveDaysOff}
|
consecutiveDaysOff={consecutiveDaysOff}
|
||||||
selectedCountryCode={selectedCountryCode}
|
selectedCountryCode={selectedCountryCode}
|
||||||
@@ -798,6 +1186,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<button type="button" class="toggle-text" on:click={toggleHowItWorks}>
|
||||||
{showHowItWorks ? 'Hide Details' : 'How does this work?'}
|
{showHowItWorks ? 'Hide Details' : 'How does this work?'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+6
-1
@@ -2,5 +2,10 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user