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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user