feat(planning): implement constraint-first leave planner

This commit is contained in:
nessar committed 2026-08-28 22:44:14 +02:00
1 parent ba8dbe92d9
commit 7ceaf1a57a
32 files changed
+4842 -880

No files matched your search

+15 -268
View File
@@ -1,16 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { provideZonelessChangeDetection } from '@angular/core';
import {
CalendarService,
DayType,
SelectedDate,
SelectedDates,
SelectableDayType,
VacDay,
} from './calendar-service';
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service';
import { DateRange } from '@angular/material/datepicker';
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
describe('CalendarService', () => {
let service: CalendarService;
@@ -26,6 +17,20 @@ describe('CalendarService', () => {
expect(service).toBeTruthy();
});
it('derives the planning horizon from the instant in Europe/Paris', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Pacific/Auckland';
try {
expect(service.getDefaultPlanningHorizon(new Date('2026-08-28T01:00:00Z'))).toEqual({
start: '2026-08-28',
end: '2027-08-28',
});
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
});
describe('getHolidays', () => {
it('should return an array of holidays', () => {
const holidays = service.getHolidays();
@@ -188,14 +193,6 @@ describe('SelectedDates', () => {
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
});
it('should compute heuristics', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const heuristic = selectedDates.computeHeuristics();
expect(typeof heuristic).toBe('number');
});
it('should return correct classes', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
@@ -211,254 +208,4 @@ describe('SelectedDates', () => {
const tooltip = selectedDates.getTooltip(date);
expect(tooltip).toBeTruthy();
});
describe('optimizeVacations and rules engine', () => {
let calendarService: CalendarService;
let selectedDates: SelectedDates;
beforeEach(() => {
calendarService = TestBed.inject(CalendarService);
selectedDates = new SelectedDates();
});
it('should execute rules sequentially in order', async () => {
const vacationDays: VacDay[] = [];
const rules: (TimeWindowRule | IfThenRule)[] = [
{ id: '1', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
{ id: '2', type: RuleType.TIME_WINDOW, enabled: true } as TimeWindowRule,
{ id: '3', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
];
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules');
await selectedDates.optimizeVacations(vacationDays, rules, calendarService);
expect(applyIfThenRulesSpy.mock.calls.length).toBe(2);
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
// First rule is IF_THEN
expect(applyIfThenRulesSpy.mock.calls[0][1][0].id).toBe('1');
// Second rule is TIME_WINDOW
expect(applyTimeWindowRulesSpy.mock.calls[0][1][0].id).toBe('2');
// Third rule is IF_THEN
expect(applyIfThenRulesSpy.mock.calls[1][1][0].id).toBe('3');
});
it('should apply time window rules', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date(futureYear + 1, 11, 31),
},
];
const rules: TimeWindowRule[] = [
{
id: 't1',
name: 'Time Window',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: new Date(futureYear, 11, 20),
endDate: new Date(futureYear, 11, 25),
placeDays: 3,
placeType: SelectableDayType.CP,
},
];
// Mock monthsInAYearFromNow to return a controlled range of days
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
[
[
new Date(futureYear, 11, 20),
new Date(futureYear, 11, 21),
new Date(futureYear, 11, 22),
new Date(futureYear, 11, 23),
new Date(futureYear, 11, 24),
],
],
]);
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
// Should have placed 3 days
const placedCount = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedCount).toBe(3);
// Should have consumed 3 days from the pool
expect(vacationDays[0].numberOfDays).toBe(2);
});
it('should apply if-then rules', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date(futureYear + 1, 11, 31),
},
];
const rules: IfThenRule[] = [
{
id: 'i1',
name: 'Samedi Malin',
type: RuleType.IF_THEN,
enabled: true,
dayOfWeek: 6, // Saturday
placeDays: 2,
placeType: SelectableDayType.CP,
placement: 'auto',
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
},
];
// Setup a mock holiday on a Saturday
// Use a date far in the future so it doesn't fail the "past date" check
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); // Dec 5 is usually Saturday or close, let's just make sure we get a Saturday
// To guarantee it's a Saturday for the test:
while (saturdayHoliday.getDay() !== 6) {
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
}
selectedDates._datesSelected.push(
new SelectedDate(
DayType.CLOSED_DAY,
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
),
);
selectedDates.applyIfThenRules(vacationDays, rules);
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedCP).toBe(2);
// Should have consumed 2 CP days
expect(vacationDays[0].numberOfDays).toBe(3);
// Should have added 1 OTHER reward day
const rewardDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
expect(rewardDay).toBeDefined();
expect(rewardDay?.numberOfDays).toBe(1);
});
it('should apply time window rules with ANY type', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 2,
expiryDate: new Date(futureYear, 11, 31),
},
{
id: '2',
type: SelectableDayType.RTT,
numberOfDays: 2,
expiryDate: new Date(futureYear - 1, 11, 31), // Expires earlier
},
];
const rules: TimeWindowRule[] = [
{
id: 'tw1',
name: 'Vacances',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: new Date(futureYear, 6, 1),
endDate: new Date(futureYear, 6, 15),
placeDays: 3,
placeType: SelectableDayType.ANY,
},
];
vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([
[
[
new Date(futureYear, 6, 1),
new Date(futureYear, 6, 2),
new Date(futureYear, 6, 3),
new Date(futureYear, 6, 4),
new Date(futureYear, 6, 5),
],
],
]);
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
(d) => d.type === DayType.RTT,
).length;
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedRTT).toBe(2);
expect(placedCP).toBe(1);
// Should have consumed 2 RTT and 1 CP
expect(vacationDays[0].numberOfDays).toBe(1); // CP remaining
expect(vacationDays[1].numberOfDays).toBe(0); // RTT remaining
});
it('should apply if-then rules with ANY type', () => {
const futureYear = new Date().getFullYear() + 2;
const vacationDays: VacDay[] = [
{
id: '1',
type: SelectableDayType.CP,
numberOfDays: 2,
expiryDate: new Date(futureYear + 2, 11, 31),
},
{
id: '2',
type: SelectableDayType.RTT,
numberOfDays: 2,
expiryDate: new Date(futureYear + 1, 11, 31), // Expires earlier
},
];
const rules: IfThenRule[] = [
{
id: 'i1',
name: 'Rule',
type: RuleType.IF_THEN,
enabled: true,
dayOfWeek: 6, // Saturday
placeDays: 3,
placeType: SelectableDayType.ANY,
placement: 'auto',
rewardDays: 0,
rewardType: SelectableDayType.CP,
},
];
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5);
while (saturdayHoliday.getDay() !== 6) {
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
}
selectedDates._datesSelected.push(
new SelectedDate(
DayType.CLOSED_DAY,
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
),
);
selectedDates.applyIfThenRules(vacationDays, rules);
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
const placedRTT = selectedDates._datesSelected.filter(
(d) => d.type === DayType.RTT,
).length;
const placedCP = selectedDates._datesSelected.filter(
(d) => d.type === DayType.CP,
).length;
expect(placedRTT).toBe(2);
expect(placedCP).toBe(1);
});
});
});
+51 -569
View File
@@ -5,7 +5,14 @@ import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
import { DateCacheService } from './date-cache.service';
import { VacationRule, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
import {
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningYears, planningDateInParis } from './planning-date';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
@@ -22,6 +29,29 @@ const allTypesOptions: HolidaysTypes.Options = {
export class CalendarService {
private dateCache = inject(DateCacheService);
readonly planLeave = planLeaveOperation;
getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } {
const start = planningDateInParis(now);
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: PlanOutcome): SelectedDates {
const selected = this.getSelectedDatesForYear();
for (const placement of plan.placements) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(
new Date(`${placement.date}T00:00:00`),
new Date(`${placement.date}T00:00:00`),
),
),
);
}
return selected;
}
// Cache for holiday timestamps
private holidayTimestampsCache: Set<number> | null = null;
private holidayCacheYear: number | null = null;
@@ -111,10 +141,17 @@ export class CalendarService {
// Logic to return holidays for the given year
const hd = new Holidays('FR', allTypesOptions);
const holidays = hd.getHolidays(currentYear, lang);
const holidaysPlusOne = hd.getHolidays(currentYear + 1, lang);
const holidays = hd
.getHolidays(currentYear, lang)
.filter((holiday) => holiday.type === 'public');
const holidaysPlusOne = hd
.getHolidays(currentYear + 1, lang)
.filter((holiday) => holiday.type === 'public');
holidays.push(...holidaysPlusOne);
const holidaysDate: Date[] = holidays.map((holiday) => new Date(holiday.date));
const holidaysDate: Date[] = holidays.map((holiday) => {
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
return dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date);
});
// Update cache
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
@@ -188,7 +225,11 @@ export type VacDay = {
id: string;
type: SelectableDayType;
numberOfDays: number;
/** Legacy balances omit this value; the planner then uses the horizon start. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
@@ -211,6 +252,12 @@ export enum SelectableDayType {
ANY = 'any',
}
const dayTypeForLeaveType: Record<LeaveType, DayType> = {
cp: DayType.CP,
rtt: DayType.RTT,
other: DayType.OTHER,
};
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
@@ -305,10 +352,6 @@ export class SelectedDates implements SelectedDateInterface {
private minDate: Date | null = null;
private maxDate: Date | null = null;
// Constant to indicate no valid strategy was found
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
@@ -470,565 +513,4 @@ export class SelectedDates implements SelectedDateInterface {
.filter((tooltip) => tooltip)
.join(' + ');
}
computeHeuristics(): number {
if (this.datesSelected.length === 0) {
return 0;
}
let totalScore = 0;
// Sort all dates by start date to find next closest dates
const sorted = [...this.datesSelected]
.filter((date) => date.type !== null)
.sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
const bStart = b.range.start?.getTime() ?? 0;
return aStart - bStart;
});
// PRIMARY: Optimize adjacency - reduce "little holes" between vacation periods
for (let i = 0; i < sorted.length - 1; i++) {
const current = sorted[i];
const next = sorted[i + 1];
// Get dates as UTC strings to remove timezone issues (handles DST properly)
const currentEndDate = new Date(current.range.end ?? new Date());
const nextStartDate = new Date(next.range.start ?? new Date());
// Convert to date-only strings (YYYY-MM-DD) to compare calendar days
const currentEndString = currentEndDate.toISOString().split('T')[0];
const nextStartString = nextStartDate.toISOString().split('T')[0];
// Parse back to dates at UTC midnight
const currentEndDateNormalized = new Date(currentEndString + 'T00:00:00Z');
const nextStartDateNormalized = new Date(nextStartString + 'T00:00:00Z');
const currentEndTime = currentEndDateNormalized.getTime();
const nextStartTime = nextStartDateNormalized.getTime();
const oneDayInMs = 24 * 60 * 60 * 1000;
// Check if adjacent (next day) - this is the best case!
if (nextStartTime - currentEndTime === oneDayInMs) {
totalScore += 15; // 50% increased bonus for adjacency; adjacency is highly valued in the scoring system
} else if (nextStartTime > currentEndTime) {
// Penalty based on days between the two dates
const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1;
// Ensure daysBetween is non-negative before taking sqrt
totalScore -= Math.sqrt(Math.max(0, daysBetween));
}
}
// SECONDARY: Penalize uneven monthly distribution to encourage spread
const monthCounts = new Map<number, number>(); // Use year*12+month as key to avoid strings
for (const selectedDate of this.datesSelected) {
if (!selectedDate.range.start || !selectedDate.range.end) continue;
// Count days in this selected date range
const daysInRange = selectedDate.getLengthInDays();
// For each day in the range, add it to the appropriate month
const currentDate = new Date(selectedDate.range.start);
for (let i = 0; i < daysInRange; i++) {
const monthKey = currentDate.getFullYear() * 12 + currentDate.getMonth();
monthCounts.set(monthKey, (monthCounts.get(monthKey) ?? 0) + 1);
currentDate.setDate(currentDate.getDate() + 1);
}
}
// Apply small penalty for uneven distribution (secondary priority)
for (const count of monthCounts.values()) {
totalScore -= count; // Smaller penalty than gap filling
}
return totalScore;
}
async optimizeVacations(
vacationDays: VacDay[],
rules: VacationRule[],
calendarService: CalendarService,
progressCallback?: (progress: number) => void,
): Promise<VacDay[]> {
// Calculate total days to process
const totalDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
let processedDays = 0;
// Execute custom rules sequentially in the exact order they appear in the list
for (const rule of rules) {
if (!rule.enabled) continue;
if (rule.type === RuleType.TIME_WINDOW) {
this.applyTimeWindowRules(vacationDays, [rule as TimeWindowRule], calendarService);
} else if (rule.type === RuleType.IF_THEN) {
this.applyIfThenRules(vacationDays, [rule as IfThenRule]);
}
}
// Helper function to update progress, throttled to every 5%
let lastReportedProgress = 0;
const PROGRESS_STEP = 5; // percent
const updateProgress = async () => {
if (progressCallback && totalDays > 0) {
const progress = Math.round((processedDays / totalDays) * 100);
if (progress - lastReportedProgress >= PROGRESS_STEP || progress === 100) {
progressCallback(progress);
lastReportedProgress = progress;
// Allow UI to update with a microtask
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
};
await updateProgress();
// Track VacDays that can't find valid placements
const exhaustedVacDayIds = new Set<string>();
// Process vacation days ordered by expiration date (earliest first)
while (vacationDays.some((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))) {
// Sort by expiration date to use days that expire first, excluding exhausted ones
const sortedVacDays = [...vacationDays]
.filter((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
if (sortedVacDays.length === 0) {
break;
}
const vacDayToUse = sortedVacDays[0];
const result = this.lookForVacation(vacDayToUse, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
// Mark this VacDay as exhausted and try other vacation days
exhaustedVacDayIds.add(vacDayToUse.id);
continue;
}
vacDayToUse.numberOfDays -= 1;
processedDays++;
await updateProgress();
}
// Final progress update (100%)
if (progressCallback) {
progressCallback(100);
}
// Inform user if not all days were placed
const remainingDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
if (remainingDays > 0) {
console.warn(
`Optimization stopped: ${remainingDays} of ${totalDays} vacation days remain unplaced. ` +
`Too many days have equivalent placement quality - please place remaining days manually.`,
);
}
return vacationDays;
}
applyTimeWindowRules(
vacationDays: VacDay[],
rules: TimeWindowRule[],
calendarService: CalendarService,
): void {
for (const rule of rules) {
let daysToPlace = rule.placeDays;
const consumeDays = (count: number): boolean => {
let needed = count;
const availableDays = vacationDays
.filter(
(vd) =>
(rule.placeType === SelectableDayType.ANY ||
vd.type === rule.placeType) &&
vd.numberOfDays > 0,
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
for (const vd of availableDays) {
if (needed === 0) break;
const take = Math.min(vd.numberOfDays, needed);
vd.numberOfDays -= take;
needed -= take;
}
return needed === 0;
};
const totalAvailable = vacationDays
.filter(
(vd) => rule.placeType === SelectableDayType.ANY || vd.type === rule.placeType,
)
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
if (totalAvailable < daysToPlace) {
console.warn(
`Insufficient ${rule.placeType} days for Time Window Rule "${rule.name}". Needs ${daysToPlace}, has ${totalAvailable}. Placing ${totalAvailable} days instead.`,
);
daysToPlace = totalAvailable;
}
if (daysToPlace <= 0) continue;
const getNextAvailableType = (): DayType => {
const sorted = vacationDays
.filter(
(vd) =>
(rule.placeType === SelectableDayType.ANY ||
vd.type === rule.placeType) &&
vd.numberOfDays > 0,
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
return sorted.length > 0
? (sorted[0].type as unknown as DayType)
: ((rule.placeType === SelectableDayType.ANY
? SelectableDayType.CP
: rule.placeType) as unknown as DayType);
};
const candidates: Date[] = [];
const daysOfYear = calendarService.monthsInAYearFromNow();
const start = new Date(rule.startDate).getTime();
const end = new Date(rule.endDate).getTime();
for (const month of daysOfYear) {
for (const week of month) {
for (const day of week) {
if (day) {
const dayTime = day.getTime();
if (dayTime >= start && dayTime <= end && !this.isSelected(day)) {
candidates.push(day);
}
}
}
}
}
for (let i = 0; i < daysToPlace; i++) {
if (candidates.length === 0) break;
const currentPlaceType = getNextAvailableType();
let bestCandidate: Date | null = null;
let bestHeuristic = -Infinity;
let bestCandidateIndex = -1;
for (let j = 0; j < candidates.length; j++) {
const day = candidates[j];
if (this.isSelected(day)) continue;
const tempDatesBackup = [...this._datesSelected];
this._datesSelected.push(
new SelectedDate(currentPlaceType, new DateRange<Date>(day, day)),
);
this.datesSelected = this._datesSelected.slice();
this.grouping();
const heuristic = this.computeHeuristics();
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
if (heuristic > bestHeuristic) {
bestHeuristic = heuristic;
bestCandidate = day;
bestCandidateIndex = j;
}
}
if (bestCandidate) {
this.push(
new SelectedDate(
currentPlaceType,
new DateRange<Date>(bestCandidate, bestCandidate),
),
);
consumeDays(1);
candidates.splice(bestCandidateIndex, 1);
}
}
}
}
applyIfThenRules(vacationDays: VacDay[], rules: IfThenRule[]): void {
const nowTime = new Date().setHours(0, 0, 0, 0);
const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => {
let daysToConsume = count;
const useByTime = useByDate.getTime();
const availableDays = vacationDays
.filter(
(vd) =>
(type === SelectableDayType.ANY || vd.type === type) &&
vd.numberOfDays > 0 &&
vd.expiryDate.getTime() >= useByTime,
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
for (const vd of availableDays) {
if (daysToConsume === 0) break;
const take = Math.min(vd.numberOfDays, daysToConsume);
vd.numberOfDays -= take;
daysToConsume -= take;
}
return daysToConsume === 0;
};
const checkAvailableDays = (
count: number,
type: SelectableDayType,
useByDate: Date,
): boolean => {
const useByTime = useByDate.getTime();
const total = vacationDays
.filter(
(vd) =>
(type === SelectableDayType.ANY || vd.type === type) &&
vd.expiryDate.getTime() >= useByTime,
)
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
return total >= count;
};
const addRewardDays = (count: number, type: SelectableDayType): void => {
if (count <= 0) return;
const existing = vacationDays.find((vd) => vd.type === type);
if (existing) {
existing.numberOfDays += count;
} else {
vacationDays.push({
id: crypto.randomUUID(),
type: type,
numberOfDays: count,
expiryDate: new Date(new Date().getFullYear() + 10, 11, 31),
});
}
};
const getWorkingDays = (startDate: Date, direction: 1 | -1, count: number): Date[] => {
const result: Date[] = [];
const current = new Date(startDate);
while (result.length < count) {
current.setDate(current.getDate() + direction);
const isWeekend = current.getDay() === 0 || current.getDay() === 6;
const isHoliday = this._datesSelected.some(
(d) =>
d.type === DayType.CLOSED_DAY &&
d.range.start?.getTime() === current.getTime(),
);
if (!isWeekend && !isHoliday) {
result.push(new Date(current));
}
}
return result;
};
for (const rule of rules) {
const matchingHolidays = this._datesSelected.filter(
(date) =>
date.type === DayType.CLOSED_DAY &&
date.range.start?.getDay() === rule.dayOfWeek,
);
for (const holiday of matchingHolidays) {
if (!holiday.range.start) continue;
const holidayDate = new Date(holiday.range.start);
const N = rule.placeDays;
if (N <= 0) continue;
const placementOptions: Date[][] = [];
if (rule.placement === 'before' || rule.placement === 'auto') {
placementOptions.push(getWorkingDays(holidayDate, -1, N));
}
if (rule.placement === 'after' || rule.placement === 'auto') {
placementOptions.push(getWorkingDays(holidayDate, 1, N));
}
if (rule.placement === 'around' || rule.placement === 'auto') {
const beforeCount = Math.ceil(N / 2);
const afterCount = Math.floor(N / 2);
const aroundDates = [
...getWorkingDays(holidayDate, -1, beforeCount),
...getWorkingDays(holidayDate, 1, afterCount),
];
placementOptions.push(aroundDates);
}
const strategies: { apply: () => void; heuristic: number }[] = [];
const tempDatesBackup = [...this._datesSelected];
for (const dates of placementOptions) {
const allValid = dates.every(
(d) => d.getTime() >= nowTime && !this.isSelected(d),
);
if (!allValid) continue;
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
if (!checkAvailableDays(N, rule.placeType, maxDate)) continue;
const simulatedDays = vacationDays.map((vd) => ({ ...vd }));
const datesWithTypes: { date: Date; type: DayType }[] = [];
for (const d of dates) {
const sorted = simulatedDays
.filter(
(vd) =>
(rule.placeType === SelectableDayType.ANY ||
vd.type === rule.placeType) &&
vd.numberOfDays > 0 &&
vd.expiryDate.getTime() >= maxDate.getTime(),
)
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
let chosenType = (rule.placeType === SelectableDayType.ANY
? SelectableDayType.CP
: rule.placeType) as unknown as DayType;
if (sorted.length > 0) {
sorted[0].numberOfDays--;
chosenType = sorted[0].type as unknown as DayType;
}
datesWithTypes.push({ date: d, type: chosenType });
}
for (const dt of datesWithTypes) {
this.push(new SelectedDate(dt.type, new DateRange<Date>(dt.date, dt.date)));
}
const heuristic = this.computeHeuristics();
strategies.push({
apply: () => {
this._datesSelected = [...tempDatesBackup];
for (const dt of datesWithTypes) {
this.push(
new SelectedDate(
dt.type,
new DateRange<Date>(dt.date, dt.date),
),
);
}
consumeDays(N, rule.placeType, maxDate);
addRewardDays(rule.rewardDays, rule.rewardType);
},
heuristic: heuristic,
});
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
}
if (strategies.length > 0) {
const bestStrategy = strategies.reduce((best, current) =>
current.heuristic > best.heuristic ? current : best,
);
bestStrategy.apply();
} else {
console.warn(
`Could not apply If-Then Rule "${rule.name}" for holiday on ${holidayDate.toLocaleDateString()}. Candidates invalid or insufficient days.`,
);
}
}
}
}
lookForVacation(
vacDay: VacDay,
calendarService: CalendarService,
): { apply: () => void; heuristic: number } {
const daysOfYear = calendarService.monthsInAYearFromNow();
const now = new Date();
now.setHours(0, 0, 0, 0);
const nowTime = now.getTime();
const expiryTime = vacDay.expiryDate.getTime();
// Preprocess: filter out past days, already selected days, and days after expiry date
const candidateDays: Date[] = [];
for (const month of daysOfYear) {
for (const week of month) {
for (const day of week) {
if (day) {
day.setHours(0, 0, 0, 0);
const dayTime = day.getTime();
if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) {
candidateDays.push(day);
}
}
}
}
}
// Early exit if no candidates
if (candidateDays.length === 0) {
return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC };
}
const vacType = vacDay.type as unknown as DayType;
const tempDatesBackup = [...this._datesSelected];
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
let bestHeuristic = -Infinity;
let daysWithBestHeuristic = 0;
// Evaluate each candidate day
for (const day of candidateDays) {
// Reset to backup state
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Mark this day as vacation (reuse the day object since it's already normalized)
this.datesSelected.push(new SelectedDate(vacType, new DateRange<Date>(day, day)));
const heuristic = this.computeHeuristics();
// Track only if better than current best
if (heuristic > bestHeuristic) {
bestHeuristic = heuristic;
daysWithBestHeuristic = 1;
const capturedDay = day;
bestStrategy = {
apply: () => {
this._datesSelected = [...tempDatesBackup];
this.push(
new SelectedDate(
vacType,
new DateRange<Date>(capturedDay, capturedDay),
),
);
},
heuristic: heuristic,
value: capturedDay,
};
} else if (heuristic === bestHeuristic) {
daysWithBestHeuristic++;
}
}
// Check if too many days have the same best heuristic after evaluating all days
if (daysWithBestHeuristic > SelectedDates.MAX_DAYS_WITH_SAME_HEURISTIC) {
// Restore backup before stopping
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Stop optimization - let user decide manually
return {
apply: () => {},
heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC,
};
}
// Restore backup before returning
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Apply best strategy if found
if (bestStrategy) {
bestStrategy.apply();
return bestStrategy;
}
return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC };
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
import type {
InvalidInputOutcome,
PlanOutcome,
PlanningIncompleteOutcome,
PlanningOutcome,
PlanningRequest,
} from './leave-planner.core';
export type {
IfThenPlanningRule,
InvalidInputOutcome,
LeaveBalanceInput,
LeavePlacement,
LeaveSelector,
LeaveType,
PlanOutcome,
PlacementMode,
PlanningConflictOutcome,
PlanningDate,
PlanningHorizon,
PlanningIncompleteOutcome,
PlanningMetrics,
PlanningOutcome,
PlanningReason,
PlanningRequest,
PlanningRule,
PreferenceResult,
RemainingBalance,
RemainingClassification,
RuleForce,
TimeOffPeriod,
TimeWindowPlanningRule,
} from './leave-planner.core';
const MAX_PLANNING_MS = 10_000;
interface UntrustedRecord {
horizon?: unknown;
start?: unknown;
end?: unknown;
balances?: unknown;
rules?: unknown;
deadlineMs?: unknown;
progress?: unknown;
id?: unknown;
name?: unknown;
type?: unknown;
quantity?: unknown;
availableFrom?: unknown;
expiryDate?: unknown;
enabled?: unknown;
force?: unknown;
startDate?: unknown;
endDate?: unknown;
placeDays?: unknown;
placeType?: unknown;
dayOfWeek?: unknown;
placement?: unknown;
rewardDays?: unknown;
rewardType?: unknown;
}
interface PlanningSnapshot {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
deadlineMs?: unknown;
progress?: unknown;
}
function asRecord(value: unknown): UntrustedRecord | undefined {
return typeof value === 'object' && value !== null ? (value as UntrustedRecord) : undefined;
}
function copyDate(value: unknown): unknown {
return value instanceof Date ? new Date(value.getTime()) : value;
}
function snapshotRequest(request: unknown): PlanningSnapshot {
const source = asRecord(request) ?? {};
const horizon = asRecord(source.horizon);
const balances = Array.isArray(source.balances)
? source.balances.map((balance) => {
const value = asRecord(balance);
return {
id: value?.id,
type: value?.type,
quantity: value?.quantity,
availableFrom:
value?.availableFrom === undefined
? undefined
: copyDate(value.availableFrom),
expiryDate: copyDate(value?.expiryDate),
};
})
: source.balances;
const rules = Array.isArray(source.rules)
? source.rules.map((rule) => {
const value = asRecord(rule);
const base = {
id: value?.id,
name: value?.name,
type: value?.type,
enabled: value?.enabled,
force: value?.force,
};
if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') {
return {
...base,
startDate: copyDate(value.startDate),
endDate: copyDate(value.endDate),
placeDays: value.placeDays,
placeType: value.placeType,
};
}
return {
...base,
dayOfWeek: value?.dayOfWeek,
placeDays: value?.placeDays,
placeType: value?.placeType,
placement: value?.placement,
rewardDays: value?.rewardDays,
rewardType: value?.rewardType,
};
})
: source.rules;
return {
horizon: horizon
? {
start: copyDate(horizon.start),
end: copyDate(horizon.end),
}
: undefined,
balances,
rules,
deadlineMs: source.deadlineMs,
progress: typeof source.progress === 'function' ? source.progress : undefined,
};
}
function deadlineFor(request: PlanningSnapshot): number {
const configured = typeof request.deadlineMs === 'number' ? request.deadlineMs : undefined;
return Math.min(Math.max(0, configured ?? MAX_PLANNING_MS), MAX_PLANNING_MS);
}
function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undefined {
const deadline = request.deadlineMs;
if (
deadline !== undefined &&
(typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline < 0)
)
return {
kind: 'InvalidInput',
reasons: [
{
code: 'deadline',
message: 'The planning deadline must be a finite non-negative number.',
},
],
};
return undefined;
}
type PlannerWorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'error'; message: string };
function interruptedOutcome(
checkpoint: PlanOutcome | undefined,
deadline: number,
reasonCode = 'deadline',
message = `Planning stopped after ${deadline}ms before all quality stages completed.`,
): PlanningIncompleteOutcome {
return {
kind: 'PlanningIncomplete',
incumbent: checkpoint,
completedStages: checkpoint?.completedStages ?? [],
lastCompletedStage: checkpoint?.lastCompletedStage,
reasons: [{ code: reasonCode, message }],
};
}
function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise<PlanningOutcome> {
return new Promise((resolve) => {
let worker: Worker;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch {
resolve(
interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
),
);
return;
}
let settled = false;
let checkpoint: PlanOutcome | undefined;
const finish = (outcome: PlanningOutcome) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
resolve(outcome);
};
const timeout = setTimeout(
() => finish(interruptedOutcome(checkpoint, deadline)),
Math.max(0, deadline - 1),
);
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
if (message.type === 'progress') {
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
return;
}
if (message.type === 'checkpoint') {
checkpoint = message.plan;
return;
}
if (message.type === 'result') {
finish(message.outcome);
return;
}
finish(interruptedOutcome(checkpoint, deadline, 'worker-error', message.message));
};
worker.onerror = () =>
finish(
interruptedOutcome(
checkpoint,
deadline,
'worker-error',
'Planning worker failed before returning an outcome.',
),
);
try {
worker.postMessage({
type: 'plan',
request: { ...snapshot, deadlineMs: deadline, progress: undefined },
});
} catch {
finish(
interruptedOutcome(
checkpoint,
deadline,
'worker-error',
'Planning input could not be sent to the isolated worker.',
),
);
}
});
}
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
const snapshot = snapshotRequest(request);
const deadlineError = invalidDeadline(snapshot);
if (deadlineError) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return deadlineError;
}
const deadline = deadlineFor(snapshot);
if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') return planInWorker(snapshot, deadline);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const core = await import('./leave-planner.core');
const validation = core.invalidInputFor(snapshot);
if (validation) return validation;
return interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
);
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const core = await import('./leave-planner.core');
return core.calculatePlanningInternal(snapshot);
}
+45
View File
@@ -0,0 +1,45 @@
/// <reference lib="webworker" />
import type { PlanningOutcome, PlanOutcome } from './leave-planner.service';
import { calculatePlanningInternal } from './leave-planner.core';
type WorkerPlanningRequest = {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest = {
type: 'plan';
request: WorkerPlanningRequest;
};
type WorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'error'; message: string };
const worker = self as DedicatedWorkerGlobalScope;
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
if (event.data?.type !== 'plan') return;
try {
const outcome = calculatePlanningInternal(
{
...event.data.request,
progress: (stage: string) =>
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse),
},
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse),
);
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
} catch (error) {
worker.postMessage({
type: 'error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
}
};
+18
View File
@@ -0,0 +1,18 @@
export function planningDateInParis(value: Date): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Paris',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function addPlanningYears(key: string, years: number): string {
const date = new Date(`${key}T00:00:00Z`);
const month = date.getUTCMonth();
date.setUTCFullYear(date.getUTCFullYear() + years);
if (date.getUTCMonth() !== month) date.setUTCDate(0);
return date.toISOString().slice(0, 10);
}
+37
View File
@@ -31,6 +31,43 @@ describe('RuleService', () => {
expect(christmasRule.endDate instanceof Date).toBe(true);
});
it('migrates legacy force values without changing rule order or dates', () => {
TestBed.resetTestingModule();
localStorage.setItem(
'vacationRules',
JSON.stringify([
{
id: 'legacy-first',
name: 'First',
type: RuleType.TIME_WINDOW,
enabled: true,
startDate: '2024-02-01',
endDate: '2024-02-05',
placeDays: 1,
placeType: SelectableDayType.CP,
},
{
id: 'legacy-second',
name: 'Second',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'obligation',
startDate: '2024-03-01',
endDate: '2024-03-02',
placeDays: 1,
placeType: SelectableDayType.RTT,
},
]),
);
TestBed.configureTestingModule({});
service = TestBed.inject(RuleService);
expect(service.rules.map((rule) => rule.id)).toEqual(['legacy-first', 'legacy-second']);
expect(service.rules[0].force).toBe('preference');
const first = service.rules[0] as TimeWindowRule;
expect(first.startDate.toISOString().slice(0, 10)).toBe('2024-02-01');
});
it('should add a new rule', () => {
const newRule: IfThenRule = {
id: 'test-rule',
+19 -3
View File
@@ -7,11 +7,15 @@ export enum RuleType {
TIME_WINDOW = 'time_window',
}
export type RuleForce = 'obligation' | 'preference';
export interface BaseRule {
id: string;
name: string;
type: RuleType;
enabled: boolean;
/** Missing force is a legacy preference and is migrated on read. */
force?: RuleForce;
}
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
@@ -41,6 +45,7 @@ interface SerializedRule {
name: string;
type: RuleType;
enabled: boolean;
force?: RuleForce;
dayOfWeek?: number;
placeDays: number;
placeType: SelectableDayType;
@@ -63,6 +68,7 @@ export class RuleService {
name: 'Samedi Malin',
type: RuleType.IF_THEN,
enabled: true,
force: 'preference',
dayOfWeek: 6, // Saturday
placeDays: 2,
placeType: SelectableDayType.CP,
@@ -75,6 +81,7 @@ export class RuleService {
name: 'Vacances de Noël',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'preference',
startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20
endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year
placeDays: 5,
@@ -98,22 +105,31 @@ export class RuleService {
if (rule.type === RuleType.TIME_WINDOW) {
return {
...rule,
force: rule.force ?? 'preference',
startDate: new Date(rule.startDate as string),
endDate: new Date(rule.endDate as string),
} as TimeWindowRule;
}
return rule as IfThenRule;
return { ...rule, force: rule.force ?? 'preference' } as IfThenRule;
});
}
public addRule(rule: VacationRule): void {
this.rawRulesSignal.update((rules) => [...rules, rule as unknown as SerializedRule]);
this.rawRulesSignal.update((rules) => [
...rules,
{ ...rule, force: rule.force ?? 'preference' } as unknown as SerializedRule,
]);
}
public updateRule(updatedRule: VacationRule): void {
this.rawRulesSignal.update((rules) =>
rules.map((rule) =>
rule.id === updatedRule.id ? (updatedRule as unknown as SerializedRule) : rule,
rule.id === updatedRule.id
? ({
...updatedRule,
force: updatedRule.force ?? 'preference',
} as unknown as SerializedRule)
: rule,
),
);
}
+30
View File
@@ -13,10 +13,22 @@ export interface AppTranslations {
calculate: string;
remainingDaysWarning: string;
remainingDaysManualPlace: string;
deleteEntry: string;
chooseDate: string;
availableDate: string;
daySuffix: string;
daysSuffix: string;
expires: string;
remainingClassification: {
unavoidableExpiry: string;
atRisk: string;
deferred: string;
};
remainingReason: {
unavoidableExpiry: string;
atRisk: string;
deferred: string;
};
};
dayTypes: {
weekend: string;
@@ -41,6 +53,7 @@ export interface AppTranslations {
ifThenTemplate: string;
timeWindowTemplate: string;
editRule: string;
deleteRule: string;
addRule: string;
place: string;
ruleName: string;
@@ -49,6 +62,9 @@ export interface AppTranslations {
type: string;
position: string;
getBonus: string;
force: string;
obligation: string;
preference: string;
startDate: string;
endDate: string;
cancel: string;
@@ -75,6 +91,20 @@ export interface AppTranslations {
auto: string;
};
};
planning: {
invalidInput: string;
conflict: string;
incomplete: string;
error: string;
complete: string;
};
benchmark: {
title: string;
instructions: string;
run: string;
running: string;
copy: string;
};
loading: string;
weekdaysShort: string[];
}
+5
View File
@@ -10,11 +10,13 @@ export class UserInputService {
}
private getInitialValues(): VacDay[] {
const availableDate = new Date();
return [
{
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: this.getRandomNumber(),
availableDate,
// today + 365 days
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
@@ -22,12 +24,14 @@ export class UserInputService {
id: crypto.randomUUID(),
type: SelectableDayType.RTT,
numberOfDays: this.getRandomNumber(),
availableDate: new Date(availableDate),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.OTHER,
numberOfDays: this.getRandomNumber(),
availableDate: new Date(availableDate),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
];
@@ -38,6 +42,7 @@ export class UserInputService {
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: 1,
availableDate: new Date(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
};
}