diff --git a/src/lib/CalendarMonth.svelte b/src/lib/CalendarMonth.svelte
index 326dc9a..a6df2a9 100644
--- a/src/lib/CalendarMonth.svelte
+++ b/src/lib/CalendarMonth.svelte
@@ -10,6 +10,8 @@
export let weekendDays: number[] = [6, 0];
export let startDate: Date = new Date(year, 0, 1);
export let isActive: boolean = true;
+ export let fixedDaysOff: Date[] = [];
+ export let onDayClick: ((date: Date) => void) | undefined = undefined;
// Function to determine the first day of the week based on locale
function getFirstDayOfWeek(locale: string): number {
@@ -59,6 +61,34 @@
);
}
+ function isFixedDayOff(day: number): boolean {
+ return fixedDaysOff.some(date =>
+ date.getFullYear() === year &&
+ date.getMonth() === month &&
+ date.getDate() === day
+ );
+ }
+
+ function getDayTooltip(day: number): string {
+ const date = new Date(year, month, day);
+ if (isWeekend(date)) {
+ return 'Weekend';
+ } else if (isFixedDayOff(day)) {
+ return 'Day off (fixed)';
+ } else if (isOptimizedDayOff(day)) {
+ return 'Day off (calculated)';
+ } else {
+ return 'Tap to select fixed day off';
+ }
+ }
+
+ function handleDayClick(day: number) {
+ const date = new Date(year, month, day);
+ const holiday = getHoliday(day);
+ if (isPastDate(day) || !onDayClick || isWeekend(date) || holiday) return;
+ onDayClick(date);
+ }
+
function getDominantMonth(period: { startDate: Date; endDate: Date }): number {
const startMonth = period.startDate.getMonth();
const endMonth = period.endDate.getMonth();
@@ -111,11 +141,20 @@
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as day}
{@const holiday = getHoliday(day)}
{@const pastDate = isPastDate(day)}
-
+ {@const fixedDay = isFixedDayOff(day)}
+ {@const optimizedDay = isOptimizedDayOff(day)}
+ {@const dayDate = new Date(year, month, day)}
+ {@const isWeekendDay = isWeekend(dayDate)}
+ {@const tooltipText = holiday ? holiday.name : getDayTooltip(day)}
+ {@const canClick = onDayClick && !pastDate && !isWeekendDay && !holiday}
+
handleDayClick(day)}
+ role={canClick ? 'button' : undefined}
+ tabindex={canClick ? 0 : undefined}
+ >
{day}
- {#if holiday}
-
- {/if}
+
{/each}
@@ -178,10 +217,21 @@
.optimized {
background-color: #4caf50;
}
+ .fixed {
+ background-color: #2e7d32;
+ border: 2px solid #66bb6a;
+ }
.holiday {
background-color: #3b1e6e;
+ }
+ .clickable {
cursor: pointer;
}
+ .clickable:hover {
+ opacity: 0.8;
+ transform: scale(1.05);
+ transition: transform 0.1s, opacity 0.1s;
+ }
.consecutive-day {
border: 1px solid rgba(255, 255, 255, 0.7);
}
diff --git a/src/lib/holidayUtils.test.ts b/src/lib/holidayUtils.test.ts
index 9e891f3..6ae5141 100644
--- a/src/lib/holidayUtils.test.ts
+++ b/src/lib/holidayUtils.test.ts
@@ -143,6 +143,21 @@ describe('holidayUtils', () => {
});
});
+ it('should not include fixed days off in optimized days', () => {
+ const fixedDaysOff = [
+ new Date(TEST_YEAR, 0, 5),
+ new Date(TEST_YEAR, 0, 10),
+ ];
+ const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const fixedDaysOffKeys = new Set(fixedDaysOff.map(d =>
+ `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
+ ));
+ result.forEach(date => {
+ const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ expect(fixedDaysOffKeys.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' },
@@ -158,6 +173,24 @@ describe('holidayUtils', () => {
expect(DEFAULT_WEEKENDS).not.toContain(date.getDay());
});
});
+
+ it('should not select days that are fixed days off, treating them like weekends/holidays', () => {
+ const fixedDaysOff = [
+ new Date(TEST_YEAR, 0, 5),
+ new Date(TEST_YEAR, 0, 7),
+ ];
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const fixedDaysOffSet = new Set(fixedDaysOff.map(d =>
+ `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
+ ));
+ result.forEach(date => {
+ const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ expect(fixedDaysOffSet.has(dateKey)).toBe(false);
+ });
+ });
});
describe('parameters', () => {
@@ -186,6 +219,25 @@ describe('holidayUtils', () => {
const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate);
expect(Array.isArray(result)).toBe(true);
});
+
+ it('should filter fixed days off by year and startDate', () => {
+ const fixedDaysOff = [
+ new Date(2023, 11, 31),
+ new Date(TEST_YEAR, 0, 5),
+ new Date(TEST_YEAR, 5, 15),
+ ];
+ const startDate = new Date(TEST_YEAR, 2, 1);
+ const result = optimizeDaysOff(mockHolidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, startDate, fixedDaysOff);
+ // Should not include fixed days off before startDate
+ const fixedDaysOffKeys = new Set(fixedDaysOff
+ .filter(d => d.getFullYear() === TEST_YEAR && d >= startDate)
+ .map(d => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
+ );
+ result.forEach(date => {
+ const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ expect(fixedDaysOffKeys.has(dateKey)).toBe(false);
+ });
+ });
});
describe('gap finding and prioritization', () => {
@@ -202,6 +254,30 @@ describe('holidayUtils', () => {
});
});
+ it('should exclude gaps longer than MAX_GAP_LENGTH (5) days', () => {
+ // Create a gap of 6 weekdays (should be excluded)
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
+ { date: new Date(TEST_YEAR, 0, 9), name: 'Tue' }, // 6 weekdays gap (Jan 2-8)
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 10);
+ // Should not fill the 6-day gap, but might find other smaller gaps
+ expect(Array.isArray(result)).toBe(true);
+ });
+
+ it('should include gaps exactly at MAX_GAP_LENGTH (5) days', () => {
+ // Create a gap of exactly 5 weekdays
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
+ { date: new Date(TEST_YEAR, 0, 8), name: 'Mon' }, // 5 weekdays gap (Jan 2-7)
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
+ expect(result.length).toBeGreaterThan(0);
+ 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' },
@@ -255,6 +331,42 @@ describe('holidayUtils', () => {
expect(Array.isArray(result)).toBe(true);
});
+ it('should prefer forward filling when forward chain is longer', () => {
+ // Create a scenario where forward chain is longer
+ // Holiday on Thursday, gap before it, weekend after
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 4), name: 'Thursday Holiday' },
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
+ expect(result.length).toBe(1);
+ // Should select a day that creates a longer consecutive period
+ // The exact day depends on the algorithm's analysis, but should be in a valid gap
+ expect(result[0].getFullYear()).toBe(TEST_YEAR);
+ expect(result[0].getMonth()).toBe(0);
+ expect(DEFAULT_WEEKENDS).not.toContain(result[0].getDay());
+ });
+
+ it('should prefer backward filling when backward chain is longer', () => {
+ // Create a scenario where backward chain is longer
+ // Weekend before, gap, holiday on Monday
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Monday Holiday' },
+ ];
+ // If we have a gap before Monday, backward might be better
+ // This depends on the specific day of week, but we can test the logic
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
+ expect(Array.isArray(result)).toBe(true);
+ });
+
+ it('should handle equal chain lengths by choosing direction with fewer usedDaysOff', () => {
+ // When chains are equal length, should prefer direction with fewer usedDaysOff
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 3), name: 'Wednesday Holiday' },
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 1);
+ expect(result.length).toBe(1);
+ });
+
it('should optimize to create longer consecutive periods', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 4), name: 'Holiday' },
@@ -513,6 +625,22 @@ describe('holidayUtils', () => {
});
});
+ it('should exclude periods that are only weekends', () => {
+ 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)) {
+ if (!DEFAULT_WEEKENDS.includes(d.getDay())) {
+ hasNonWeekend = true;
+ break;
+ }
+ }
+ expect(hasNonWeekend).toBe(true);
+ });
+ });
+
it('should handle groups that are only weekends correctly', () => {
const holidays: Array<{ date: Date; name: string }> = [];
const optimizedDays: Date[] = [];
@@ -551,6 +679,49 @@ describe('holidayUtils', () => {
const result = calculateConsecutiveDaysOff(mockHolidays, [], TEST_YEAR);
expect(Array.isArray(result)).toBe(true);
});
+
+ it('should include fixed days off in consecutive periods', () => {
+ const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
+ const fixedDaysOff = [new Date(TEST_YEAR, 0, 3)];
+ const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const periodWithFixed = result.find(period => {
+ const fixedDate = fixedDaysOff[0];
+ return period.startDate <= fixedDate && period.endDate >= fixedDate;
+ });
+ expect(periodWithFixed).toBeDefined();
+ });
+
+ it('should treat fixed days off as part of consecutive periods', () => {
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
+ const fixedDaysOff = [new Date(TEST_YEAR, 0, 3)];
+ const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const period = result.find(p =>
+ p.startDate <= holidays[0].date &&
+ p.endDate >= fixedDaysOff[0]
+ );
+ expect(period).toBeDefined();
+ if (period) {
+ expect(period.totalDays).toBeGreaterThanOrEqual(3);
+ }
+ });
+
+ it('should filter fixed days off by year and startDate', () => {
+ const optimizedDays = [new Date(TEST_YEAR, 5, 2)];
+ const fixedDaysOff = [
+ new Date(2023, 11, 31),
+ new Date(TEST_YEAR, 0, 5),
+ new Date(TEST_YEAR, 5, 15),
+ ];
+ const startDate = new Date(TEST_YEAR, 2, 1);
+ const result = calculateConsecutiveDaysOff(mockHolidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, startDate, fixedDaysOff);
+ // Should only include fixed days off from the correct year and after startDate
+ result.forEach(period => {
+ expect(period.startDate.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
+ });
+ });
});
describe('edge cases', () => {
@@ -669,6 +840,130 @@ describe('holidayUtils', () => {
expect(Array.isArray(optimizedDays)).toBe(true);
expect(Array.isArray(periods)).toBe(true);
});
+
+ it('should work with fixed days off: exclude from optimization, include in periods', () => {
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const fixedDaysOff = [
+ new Date(TEST_YEAR, 0, 5),
+ new Date(TEST_YEAR, 0, 10),
+ ];
+ const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const periods = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+
+ // Fixed days off should not be in optimized days
+ const fixedDaysOffKeys = new Set(fixedDaysOff.map(d =>
+ `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
+ ));
+ optimizedDays.forEach(date => {
+ const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ expect(fixedDaysOffKeys.has(dateKey)).toBe(false);
+ });
+
+ // Fixed days off should be included in consecutive periods
+ fixedDaysOff.forEach(fixedDay => {
+ const periodWithFixed = periods.find(period =>
+ period.startDate <= fixedDay && period.endDate >= fixedDay
+ );
+ expect(periodWithFixed).toBeDefined();
+ });
+ });
+
+ it('should prioritize fixed days off over calculated days off', () => {
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const fixedDaysOff = [new Date(TEST_YEAR, 0, 5)];
+ // Request optimization for a day that's already fixed
+ const optimizedDays = optimizeDaysOff(holidays, TEST_YEAR, 10, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+
+ // The fixed day should not appear in optimized days
+ const fixedDayKey = `${fixedDaysOff[0].getFullYear()}-${fixedDaysOff[0].getMonth()}-${fixedDaysOff[0].getDate()}`;
+ const hasFixedDay = optimizedDays.some(day => {
+ const dayKey = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
+ return dayKey === fixedDayKey;
+ });
+ expect(hasFixedDay).toBe(false);
+ });
+
+ it('should handle empty fixedDaysOff array the same as undefined', () => {
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const resultWithEmpty = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, []);
+ const resultWithUndefined = optimizeDaysOff(holidays, TEST_YEAR, 5);
+
+ expect(resultWithEmpty.length).toBe(resultWithUndefined.length);
+ });
+
+ it('should not count fixed days off in usedDaysOff calculation', () => {
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
+ const fixedDaysOff = [new Date(TEST_YEAR, 0, 3), new Date(TEST_YEAR, 0, 4)];
+ const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+
+ const period = result.find(p =>
+ p.startDate <= holidays[0].date &&
+ p.endDate >= fixedDaysOff[1]
+ );
+ expect(period).toBeDefined();
+ if (period) {
+ // usedDaysOff should only count optimized days, not fixed days
+ expect(period.usedDaysOff).toBe(1); // Only the one optimized day
+ expect(period.totalDays).toBeGreaterThanOrEqual(4); // But total days includes fixed days
+ }
+ });
+
+ it('should handle case where all weekdays are fixed days off', () => {
+ // Create fixed days off for all weekdays in January
+ const fixedDaysOff: Date[] = [];
+ for (let day = 1; day <= 31; day++) {
+ const date = new Date(TEST_YEAR, 0, day);
+ if (!DEFAULT_WEEKENDS.includes(date.getDay())) {
+ fixedDaysOff.push(date);
+ }
+ }
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+
+ // Should not try to optimize days that are already fixed
+ expect(Array.isArray(result)).toBe(true);
+ result.forEach(date => {
+ const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ const isFixed = fixedDaysOff.some(fd =>
+ `${fd.getFullYear()}-${fd.getMonth()}-${fd.getDate()}` === dateKey
+ );
+ expect(isFixed).toBe(false);
+ });
+ });
+
+ it('should extend consecutive periods with fixed days off', () => {
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Holiday' },
+ ];
+ const optimizedDays = [new Date(TEST_YEAR, 0, 2)];
+ const fixedDaysOff = [
+ new Date(TEST_YEAR, 0, 3),
+ new Date(TEST_YEAR, 0, 4),
+ new Date(TEST_YEAR, 0, 5),
+ ];
+ const result = calculateConsecutiveDaysOff(holidays, optimizedDays, TEST_YEAR, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+
+ const period = result.find(p =>
+ p.startDate <= holidays[0].date &&
+ p.endDate >= fixedDaysOff[2]
+ );
+ expect(period).toBeDefined();
+ if (period) {
+ // Should include all the fixed days in the period
+ expect(period.totalDays).toBeGreaterThanOrEqual(5);
+ }
+ });
});
describe('Edge cases and error handling', () => {
@@ -706,6 +1001,99 @@ describe('holidayUtils', () => {
});
});
+ it('should handle startDate on Dec 31', () => {
+ const startDate = new Date(TEST_YEAR, 11, 31);
+ const holidays: Array<{ date: Date; name: string }> = [];
+ 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);
+ expect(date.getMonth()).toBe(11);
+ expect(date.getDate()).toBe(31);
+ });
+ });
+
+ it('should handle fixed days off with startDate at end of year', () => {
+ const startDate = new Date(TEST_YEAR, 11, 15);
+ const fixedDaysOff = [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, fixedDaysOff);
+ const fixedDayKey = `${fixedDaysOff[0].getFullYear()}-${fixedDaysOff[0].getMonth()}-${fixedDaysOff[0].getDate()}`;
+ result.forEach(date => {
+ const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ expect(dateKey).not.toBe(fixedDayKey);
+ expect(date.getTime()).toBeGreaterThanOrEqual(startDate.getTime());
+ });
+ });
+
+ it('should handle fixed days off that are on weekends (should still be excluded)', () => {
+ // Create a fixed day off on a Saturday
+ const saturday = new Date(TEST_YEAR, 0, 6); // Jan 6, 2024 is a Saturday
+ if (saturday.getDay() === 6) {
+ const fixedDaysOff = [saturday];
+ const holidays: Array<{ date: Date; name: string }> = [];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ // Should not include the Saturday in optimized days (it's already a weekend)
+ const hasSaturday = result.some(d =>
+ d.getFullYear() === saturday.getFullYear() &&
+ d.getMonth() === saturday.getMonth() &&
+ d.getDate() === saturday.getDate()
+ );
+ expect(hasSaturday).toBe(false);
+ }
+ });
+
+ it('should handle fixed days off that are on holidays (should still be excluded)', () => {
+ const holidayDate = new Date(TEST_YEAR, 0, 1);
+ const fixedDaysOff = [holidayDate];
+ const holidays = [
+ { date: holidayDate, name: 'New Year' },
+ ];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const hasHoliday = result.some(d =>
+ d.getFullYear() === holidayDate.getFullYear() &&
+ d.getMonth() === holidayDate.getMonth() &&
+ d.getDate() === holidayDate.getDate()
+ );
+ expect(hasHoliday).toBe(false);
+ });
+
+ it('should handle case where no gaps are available (all days are off)', () => {
+ // Create holidays for all weekdays
+ const holidays: Array<{ date: Date; name: string }> = [];
+ for (let month = 0; month < 12; month++) {
+ const daysInMonth = new Date(TEST_YEAR, month + 1, 0).getDate();
+ for (let day = 1; day <= daysInMonth; day++) {
+ const date = new Date(TEST_YEAR, month, day);
+ if (!DEFAULT_WEEKENDS.includes(date.getDay())) {
+ holidays.push({ date, name: `Holiday ${month}-${day}` });
+ }
+ }
+ }
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5);
+ expect(result).toEqual([]);
+ });
+
+ it('should handle selectDaysOff when gap has some days already in allDaysOff', () => {
+ // Create a scenario where a gap has some days that are already off
+ const holidays = [
+ { date: new Date(TEST_YEAR, 0, 1), name: 'Mon' },
+ { date: new Date(TEST_YEAR, 0, 5), name: 'Fri' },
+ ];
+ const fixedDaysOff = [new Date(TEST_YEAR, 0, 3)]; // Wednesday in the gap
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ // Should skip the fixed day and fill other days in the gap
+ expect(Array.isArray(result)).toBe(true);
+ const hasFixedDay = result.some(d =>
+ d.getFullYear() === fixedDaysOff[0].getFullYear() &&
+ d.getMonth() === fixedDaysOff[0].getMonth() &&
+ d.getDate() === fixedDaysOff[0].getDate()
+ );
+ expect(hasFixedDay).toBe(false);
+ });
+
it('should handle holidays from previous year correctly', () => {
const holidays = [
{ date: new Date(2023, 11, 31), name: 'Old Year' },
@@ -762,7 +1150,7 @@ describe('holidayUtils', () => {
expect(hasDate2).toBe(false);
});
- it('should correctly identify holidays using dateKey', () => {
+ it('should correctly identify holidays using dateKey', () => {
const holidays = [
{ date: new Date(TEST_YEAR, 0, 15, 10, 30), name: 'Holiday' },
];
@@ -775,6 +1163,21 @@ describe('holidayUtils', () => {
expect(hasHolidayDate).toBe(false);
});
+ it('should correctly identify fixed days off using dateKey (ignoring time component)', () => {
+ const fixedDaysOff = [
+ new Date(TEST_YEAR, 0, 15, 10, 30), // Same day, different time
+ new Date(TEST_YEAR, 0, 15, 14, 0), // Same day, different time
+ ];
+ const holidays: Array<{ date: Date; name: string }> = [];
+ const result = optimizeDaysOff(holidays, TEST_YEAR, 5, DEFAULT_WEEKENDS, undefined, fixedDaysOff);
+ const hasFixedDay = result.some(d =>
+ d.getFullYear() === TEST_YEAR &&
+ d.getMonth() === 0 &&
+ d.getDate() === 15
+ );
+ expect(hasFixedDay).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 }> = [];
diff --git a/src/lib/holidayUtils.ts b/src/lib/holidayUtils.ts
index 3484cc1..683c1ad 100644
--- a/src/lib/holidayUtils.ts
+++ b/src/lib/holidayUtils.ts
@@ -31,11 +31,13 @@ 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], startDate?: Date): Date[] {
+export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOff: number, weekendDays: number[] = [0, 6], startDate?: Date, fixedDaysOff: Date[] = []): Date[] {
const effectiveStartDate = startDate || new Date(year, 0, 1);
const filteredHolidays = holidays.filter(h => h.date.getFullYear() === year && h.date >= effectiveStartDate);
+ const filteredFixedDaysOff = fixedDaysOff.filter(d => d.getFullYear() === year && d >= effectiveStartDate);
const allDaysOff = new Set([
...filteredHolidays.map(h => dateKey(h.date)),
+ ...filteredFixedDaysOff.map(d => dateKey(d)),
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
]);
@@ -44,14 +46,16 @@ export function optimizeDaysOff(holidays: { date: Date }[], year: number, daysOf
}
// Calculate periods of consecutive days off (weekends + holidays + PTO)
-export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6], startDate?: Date) {
+export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimizedDaysOff: Date[], year: number, weekendDays: number[] = [0, 6], startDate?: Date, fixedDaysOff: 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 filteredFixedDaysOff = fixedDaysOff.filter(d => d >= effectiveStartDate);
const allDaysOff = new Set([
...filteredHolidays.map(h => dateKey(h.date)),
...filteredOptimizedDaysOff.map(d => dateKey(d)),
+ ...filteredFixedDaysOff.map(d => dateKey(d)),
...getWeekends(year, weekendDays, effectiveStartDate).map(d => dateKey(d))
]);
@@ -63,14 +67,14 @@ export function calculateConsecutiveDaysOff(holidays: { date: Date }[], optimize
currentGroup.push(new Date(d));
} else if (currentGroup.length > 0) {
if (isValidConsecutiveGroup(currentGroup, weekendDays)) {
- consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
+ consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff, filteredFixedDaysOff));
}
currentGroup = [];
}
}
if (currentGroup.length > 0 && isValidConsecutiveGroup(currentGroup, weekendDays)) {
- consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff));
+ consecutiveDaysOff.push(createPeriod(currentGroup, filteredOptimizedDaysOff, filteredFixedDaysOff));
}
return consecutiveDaysOff;
@@ -193,7 +197,7 @@ function isValidConsecutiveGroup(group: Date[], weekendDays: number[]): boolean
}
// Create a period object from a group of consecutive days
-function createPeriod(group: Date[], optimizedDaysOff: Date[]) {
+function createPeriod(group: Date[], optimizedDaysOff: Date[], fixedDaysOff: Date[] = []) {
return {
startDate: group[0],
endDate: group[group.length - 1],
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 2f109d3..7fb6a0d 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -17,6 +17,7 @@
let daysOff: number = 0;
let optimizedDaysOff: Date[] = [];
let consecutiveDaysOff: Array<{ startDate: Date; endDate: Date; totalDays: number }> = [];
+ let fixedDaysOff: Date[] = [];
let showExcludedMonths: boolean = true;
let visibleMonths: number[] = [];
let countriesInput: HTMLInputElement | null = null;
@@ -35,6 +36,7 @@
let showHolidaysList: boolean = false;
let showWeekendSettings: boolean = false;
+ let showFixedDaysOffList: boolean = false;
let weekendDays: number[] = [];
// Start date state
@@ -48,9 +50,15 @@
updateHolidays();
}
- // Reactive: when year changes, load start date for that year
+ // Reactive: when fixedDaysOff changes, update calculations
+ $: if (fixedDaysOff) {
+ updateHolidays();
+ }
+
+ // Reactive: when year changes, load start date and fixed days off for that year
$: if (year !== undefined && year) {
startDate = getStartDate(year);
+ loadFixedDaysOff(year);
}
// Reactive: when startDate or year changes, update excluded months visibility
@@ -108,6 +116,7 @@
selectedStateCode = '';
}
startDate = getStartDate(year);
+ loadFixedDaysOff(year);
// showExcludedMonths will be set by reactive statement
updateHolidays();
});
@@ -166,8 +175,8 @@
hidden: isHolidayHidden(holiday)
}));
const visibleHolidays = holidays.filter(h => !h.hidden);
- optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate);
- consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate);
+ optimizedDaysOff = optimizeDaysOff(visibleHolidays, year, daysOff, weekendDays, startDate, fixedDaysOff);
+ consecutiveDaysOff = calculateConsecutiveDaysOff(visibleHolidays, optimizedDaysOff, year, weekendDays, startDate, fixedDaysOff);
} else {
holidays = [];
optimizedDaysOff = [];
@@ -175,6 +184,29 @@
}
}
+ function toggleFixedDayOff(date: Date) {
+ // Normalize date to remove time component
+ const normalizedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ const dateKeyStr = dateKey(normalizedDate);
+
+ // Check if date is already in fixedDaysOff
+ const existingIndex = fixedDaysOff.findIndex(d => dateKey(d) === dateKeyStr);
+
+ if (existingIndex >= 0) {
+ // Remove if already exists
+ fixedDaysOff = fixedDaysOff.filter((_, i) => i !== existingIndex);
+ } else {
+ // Add if doesn't exist
+ fixedDaysOff = [...fixedDaysOff, normalizedDate];
+ }
+
+ // Save to localStorage
+ saveFixedDaysOff(year);
+
+ // Update calculations
+ updateHolidays();
+ }
+
function resetToDefault() {
year = defaultYear;
selectedCountry = defaultCountry;
@@ -337,6 +369,42 @@
return `${year}-${month}-${day}`;
}
+ // Helper function to create a date key (same as in holidayUtils.ts)
+ function dateKey(date: Date): string {
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ }
+
+ // Load fixed days off for a given year from localStorage
+ function loadFixedDaysOff(year: number) {
+ try {
+ const stored = localStorage.getItem(`fixedDaysOff_${year}`);
+ if (stored) {
+ const dateStrings: string[] = JSON.parse(stored);
+ fixedDaysOff = dateStrings.map(dateStr => {
+ const [y, m, d] = dateStr.split('-').map(Number);
+ return new Date(y, m, d);
+ });
+ } else {
+ fixedDaysOff = [];
+ }
+ } catch (e) {
+ console.error('Error loading fixed days off:', e);
+ fixedDaysOff = [];
+ }
+ }
+
+ // Save fixed days off for a given year to localStorage
+ function saveFixedDaysOff(year: number) {
+ try {
+ const dateStrings = fixedDaysOff.map(date =>
+ `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
+ );
+ localStorage.setItem(`fixedDaysOff_${year}`, JSON.stringify(dateStrings));
+ } catch (e) {
+ console.error('Error saving fixed days off:', e);
+ }
+ }
+
// Format start date for display
function formatStartDate(date: Date): string {
const today = new Date();
@@ -1088,6 +1156,9 @@
Day Off
+ showFixedDaysOffList = !showFixedDaysOffList} class="edit-link">
+ (edit)
+
- {#if showHolidaysList || showWeekendSettings}
+ {#if showHolidaysList || showWeekendSettings || showFixedDaysOffList}
+ {#if showFixedDaysOffList}
+
+
Fixed Days Off
+ {#if fixedDaysOff.length > 0}
+
+ {:else}
+
+ Tap on a calendar day below to select it as a fixed day off
+
+ {/if}
+
+ {/if}
+
{#if showHolidaysList}
Public Holidays
@@ -1180,6 +1276,8 @@
consecutiveDaysOff={consecutiveDaysOff}
selectedCountryCode={selectedCountryCode}
weekendDays={weekendDays}
+ fixedDaysOff={fixedDaysOff}
+ onDayClick={toggleFixedDayOff}
/>
{/each}