feat(planning): implement constraint-first leave planner
This commit is contained in:
1 parent
ba8dbe92d9
commit
7ceaf1a57a
32 files changed
+4842
-880
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user