feat: implement rules engine and management panel for custom holiday optimization
This commit is contained in:
1 parent
c0549de4dd
commit
08ee84e5c9
20 files changed
+3951
-16847
No files matched your search
@@ -1,7 +1,15 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service';
|
||||
import {
|
||||
CalendarService,
|
||||
DayType,
|
||||
SelectedDate,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
} from './calendar-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
|
||||
|
||||
describe('CalendarService', () => {
|
||||
let service: CalendarService;
|
||||
@@ -202,4 +210,254 @@ 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 = spyOn(selectedDates, 'applyTimeWindowRules');
|
||||
const applyIfThenRulesSpy = spyOn(selectedDates, 'applyIfThenRules');
|
||||
|
||||
await selectedDates.optimizeVacations(vacationDays, rules, calendarService);
|
||||
|
||||
expect(applyIfThenRulesSpy.calls.count()).toBe(2);
|
||||
expect(applyTimeWindowRulesSpy.calls.count()).toBe(1);
|
||||
|
||||
// First rule is IF_THEN
|
||||
expect(applyIfThenRulesSpy.calls.argsFor(0)[1][0].id).toBe('1');
|
||||
// Second rule is TIME_WINDOW
|
||||
expect(applyTimeWindowRulesSpy.calls.argsFor(0)[1][0].id).toBe('2');
|
||||
// Third rule is IF_THEN
|
||||
expect(applyIfThenRulesSpy.calls.argsFor(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
|
||||
spyOn(calendarService, 'monthsInAYearFromNow').and.returnValue([
|
||||
[
|
||||
[
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
spyOn(calendarService, 'monthsInAYearFromNow').and.returnValue([
|
||||
[
|
||||
[
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import Holidays, { HolidaysTypes } from 'date-holidays';
|
||||
import dayjs from 'dayjs';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import { CalendarSettingsService } from './calendar-settings-service';
|
||||
import { DateCacheService } from './date-cache.service';
|
||||
import { VacationRule, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
@@ -208,15 +208,17 @@ export enum SelectableDayType {
|
||||
CP = DayType.CP,
|
||||
RTT = DayType.RTT,
|
||||
OTHER = DayType.OTHER,
|
||||
ANY = 'any',
|
||||
}
|
||||
|
||||
export const tooltipTypeMapping: { [key in DayType]: string } = {
|
||||
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
|
||||
[DayType.WEEKEND]: 'Weekend',
|
||||
[DayType.CLOSED_DAY]: 'Jour Férié',
|
||||
[DayType.RTT]: 'RTT',
|
||||
[DayType.CP]: 'Congé Payé',
|
||||
[DayType.OTHER]: 'Autre',
|
||||
[DayType.TODAY]: "Aujourd'hui",
|
||||
[SelectableDayType.ANY]: "N'importe",
|
||||
};
|
||||
|
||||
export const monthNames: string[] = [
|
||||
@@ -545,16 +547,25 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
|
||||
async optimizeVacations(
|
||||
vacationDays: VacDay[],
|
||||
calendarSettingsService: CalendarSettingsService,
|
||||
rules: VacationRule[],
|
||||
calendarService: CalendarService,
|
||||
progressCallback?: (progress: number) => void,
|
||||
): Promise<VacDay[]> {
|
||||
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationDays);
|
||||
|
||||
// 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
|
||||
@@ -617,209 +628,306 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
return vacationDays;
|
||||
}
|
||||
|
||||
samediMalin(vacationDays: VacDay[]): void {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const nowTime = now.getTime();
|
||||
applyTimeWindowRules(
|
||||
vacationDays: VacDay[],
|
||||
rules: TimeWindowRule[],
|
||||
calendarService: CalendarService,
|
||||
): void {
|
||||
for (const rule of rules) {
|
||||
let daysToPlace = rule.placeDays;
|
||||
|
||||
const samedi_ferie = this._datesSelected.filter(
|
||||
(date) => date.type === DayType.CLOSED_DAY && date.range.start?.getDay() === 6,
|
||||
);
|
||||
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());
|
||||
|
||||
// Helper function to get available CP days that don't expire before a given date (ordered by expiry date)
|
||||
const getAvailableCpDaysForDate = (useByDate: Date): VacDay[] => {
|
||||
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();
|
||||
return vacationDays
|
||||
const availableDays = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
vd.type === SelectableDayType.CP &&
|
||||
(type === SelectableDayType.ANY || vd.type === type) &&
|
||||
vd.numberOfDays > 0 &&
|
||||
vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
};
|
||||
|
||||
// Helper function to get total available CP days that don't expire before a given date
|
||||
const getTotalCpDaysForDate = (useByDate: Date): number => {
|
||||
const useByTime = useByDate.getTime();
|
||||
return vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
vd.type === SelectableDayType.CP && vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
};
|
||||
|
||||
// Helper function to consume 2 CP days from the available pool for a specific date
|
||||
const consume2CpDaysForDate = (useByDate: Date): boolean => {
|
||||
let daysToConsume = 2;
|
||||
const cpDays = getAvailableCpDaysForDate(useByDate);
|
||||
for (const cpDay of cpDays) {
|
||||
for (const vd of availableDays) {
|
||||
if (daysToConsume === 0) break;
|
||||
const toConsume = Math.min(cpDay.numberOfDays, daysToConsume);
|
||||
cpDay.numberOfDays -= toConsume;
|
||||
daysToConsume -= toConsume;
|
||||
const take = Math.min(vd.numberOfDays, daysToConsume);
|
||||
vd.numberOfDays -= take;
|
||||
daysToConsume -= take;
|
||||
}
|
||||
return daysToConsume === 0;
|
||||
};
|
||||
|
||||
// Helper function to add 1 OTHER day as reward
|
||||
const addOtherDayReward = (): void => {
|
||||
// Find an existing OTHER VacDay or create a new one
|
||||
const otherDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
|
||||
if (otherDay) {
|
||||
otherDay.numberOfDays += 1;
|
||||
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 {
|
||||
// Create a new OTHER VacDay with a far future expiry date
|
||||
vacationDays.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.OTHER,
|
||||
numberOfDays: 1,
|
||||
expiryDate: new Date(now.getFullYear() + 10, 11, 31),
|
||||
type: type,
|
||||
numberOfDays: count,
|
||||
expiryDate: new Date(new Date().getFullYear() + 10, 11, 31),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const holiday of samedi_ferie) {
|
||||
if (!holiday.range.start) continue;
|
||||
|
||||
// Get the Saturday date (holiday) and the following Sunday
|
||||
const saturdayDate = new Date(holiday.range.start);
|
||||
const sundayDate = new Date(saturdayDate);
|
||||
sundayDate.setDate(saturdayDate.getDate() + 1);
|
||||
|
||||
// Calculate dates for all three strategies (2 CP days total each)
|
||||
// Strategy 1: 2 days before (Thursday + Friday)
|
||||
const twoDaysBefore1 = new Date(saturdayDate);
|
||||
twoDaysBefore1.setDate(saturdayDate.getDate() - 2);
|
||||
const twoDaysBefore2 = new Date(saturdayDate);
|
||||
twoDaysBefore2.setDate(saturdayDate.getDate() - 1);
|
||||
|
||||
// Strategy 2: 2 days after (Monday + Tuesday)
|
||||
const twoDaysAfter1 = new Date(sundayDate);
|
||||
twoDaysAfter1.setDate(sundayDate.getDate() + 1);
|
||||
const twoDaysAfter2 = new Date(sundayDate);
|
||||
twoDaysAfter2.setDate(sundayDate.getDate() + 2);
|
||||
|
||||
// Strategy 3: 1 day before + 1 day after (Friday + Monday)
|
||||
const oneDayBefore = new Date(saturdayDate);
|
||||
oneDayBefore.setDate(saturdayDate.getDate() - 1);
|
||||
const oneDayAfter = new Date(sundayDate);
|
||||
oneDayAfter.setDate(sundayDate.getDate() + 1);
|
||||
|
||||
// Check if we can apply each strategy (not in the past, not already selected, and have non-expired CP days)
|
||||
const canApplyTwoBeforeStrategy =
|
||||
twoDaysBefore1.getTime() >= nowTime &&
|
||||
!this.isSelected(twoDaysBefore1) &&
|
||||
!this.isSelected(twoDaysBefore2) &&
|
||||
getTotalCpDaysForDate(twoDaysBefore2) >= 2; // Check expiry against the latest date used
|
||||
|
||||
const canApplyTwoAfterStrategy =
|
||||
twoDaysAfter1.getTime() >= nowTime &&
|
||||
!this.isSelected(twoDaysAfter1) &&
|
||||
!this.isSelected(twoDaysAfter2) &&
|
||||
getTotalCpDaysForDate(twoDaysAfter2) >= 2; // Check expiry against the latest date used
|
||||
|
||||
const canApplyMixedStrategy =
|
||||
oneDayBefore.getTime() >= nowTime &&
|
||||
!this.isSelected(oneDayBefore) &&
|
||||
!this.isSelected(oneDayAfter) &&
|
||||
getTotalCpDaysForDate(oneDayAfter) >= 2; // Check expiry against the latest date used
|
||||
|
||||
// Test all applicable strategies and choose the one with the best heuristic score
|
||||
const strategies: { apply: () => void; heuristic: number }[] = [];
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
|
||||
if (canApplyTwoBeforeStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
|
||||
),
|
||||
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(),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(twoDaysBefore2);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
if (!isWeekend && !isHoliday) {
|
||||
result.push(new Date(current));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
if (canApplyTwoAfterStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(twoDaysAfter1, twoDaysAfter2)),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysAfter1, twoDaysAfter2),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(twoDaysAfter2);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
for (const rule of rules) {
|
||||
const matchingHolidays = this._datesSelected.filter(
|
||||
(date) =>
|
||||
date.type === DayType.CLOSED_DAY &&
|
||||
date.range.start?.getDay() === rule.dayOfWeek,
|
||||
);
|
||||
|
||||
if (canApplyMixedStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(oneDayBefore, oneDayBefore)),
|
||||
);
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(oneDayAfter, oneDayAfter)),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(oneDayBefore, oneDayBefore),
|
||||
),
|
||||
);
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(oneDayAfter, oneDayAfter),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(oneDayAfter);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
for (const holiday of matchingHolidays) {
|
||||
if (!holiday.range.start) continue;
|
||||
const holidayDate = new Date(holiday.range.start);
|
||||
|
||||
// Apply the strategy with the best heuristic score
|
||||
if (strategies.length > 0) {
|
||||
const bestStrategy = strategies.reduce((best, current) =>
|
||||
current.heuristic > best.heuristic ? current : best,
|
||||
);
|
||||
bestStrategy.apply();
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('CalendarSettingsService', () => {
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(false);
|
||||
expect(service.get('samediMalin')()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow updating showWeekNumbers', () => {
|
||||
@@ -26,20 +25,13 @@ describe('CalendarSettingsService', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow updating samediMalin', () => {
|
||||
service.get('samediMalin').set(false);
|
||||
expect(service.get('samediMalin')()).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide settings array for UI iteration', () => {
|
||||
const settingsArray = service.settingsArray;
|
||||
expect(settingsArray.length).toBe(2);
|
||||
expect(settingsArray.length).toBe(1);
|
||||
expect(settingsArray[0].id).toBe('showWeekNumbers');
|
||||
expect(settingsArray[1].id).toBe('samediMalin');
|
||||
});
|
||||
|
||||
it('should return correct descriptions', () => {
|
||||
expect(service.getDescription('showWeekNumbers')).toBe('Afficher les numéros de semaine');
|
||||
expect(service.getDescription('samediMalin')).toBe('Activer le Samedi Malin');
|
||||
});
|
||||
});
|
||||
@@ -20,13 +20,6 @@ export class CalendarSettingsService {
|
||||
description: 'Afficher les numéros de semaine',
|
||||
},
|
||||
],
|
||||
[
|
||||
'samediMalin',
|
||||
{
|
||||
defaultValue: true,
|
||||
description: 'Activer le Samedi Malin',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
private readonly signals = new Map<string, WritableSignal<boolean>>();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuleService, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
|
||||
describe('RuleService', () => {
|
||||
let service: RuleService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test to ensure a clean state
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(RuleService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with default rules', () => {
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(2);
|
||||
expect(rules[0].id).toBe('default-samedi-malin');
|
||||
expect(rules[1].id).toBe('default-christmas');
|
||||
});
|
||||
|
||||
it('should parse dates correctly for TimeWindowRules', () => {
|
||||
const rules = service.rules;
|
||||
const christmasRule = rules.find((r) => r.type === RuleType.TIME_WINDOW) as TimeWindowRule;
|
||||
expect(christmasRule).toBeDefined();
|
||||
expect(christmasRule.startDate instanceof Date).toBeTrue();
|
||||
expect(christmasRule.endDate instanceof Date).toBeTrue();
|
||||
});
|
||||
|
||||
it('should add a new rule', () => {
|
||||
const newRule: IfThenRule = {
|
||||
id: 'test-rule',
|
||||
name: 'Test Rule',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 1, // Monday
|
||||
placeDays: 1,
|
||||
placeType: SelectableDayType.RTT,
|
||||
placement: 'after',
|
||||
rewardDays: 0,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
};
|
||||
|
||||
service.addRule(newRule);
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(3);
|
||||
expect(rules.find((r) => r.id === 'test-rule')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update an existing rule', () => {
|
||||
const rules = service.rules;
|
||||
const targetRule = { ...rules[0] } as IfThenRule;
|
||||
targetRule.name = 'Updated Name';
|
||||
|
||||
service.updateRule(targetRule);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
const updated = updatedRules.find((r) => r.id === targetRule.id);
|
||||
expect(updated?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should delete a rule', () => {
|
||||
const rules = service.rules;
|
||||
const idToDelete = rules[0].id;
|
||||
|
||||
service.deleteRule(idToDelete);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
expect(updatedRules.length).toBe(1);
|
||||
expect(updatedRules.find((r) => r.id === idToDelete)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should toggle rule enabled state', () => {
|
||||
const targetId = service.rules[0].id;
|
||||
const initialState = service.rules[0].enabled;
|
||||
|
||||
service.toggleRule(targetId);
|
||||
|
||||
expect(service.rules.find((r) => r.id === targetId)?.enabled).toBe(!initialState);
|
||||
});
|
||||
|
||||
it('should reorder rules correctly', () => {
|
||||
const rulesBefore = service.rules;
|
||||
const firstRuleId = rulesBefore[0].id;
|
||||
const secondRuleId = rulesBefore[1].id;
|
||||
|
||||
// Move first rule to second position
|
||||
service.reorderRules(0, 1);
|
||||
|
||||
const rulesAfter = service.rules;
|
||||
expect(rulesAfter[0].id).toBe(secondRuleId);
|
||||
expect(rulesAfter[1].id).toBe(firstRuleId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, WritableSignal, Injector, inject } from '@angular/core';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
export enum RuleType {
|
||||
IF_THEN = 'if_then',
|
||||
TIME_WINDOW = 'time_window',
|
||||
}
|
||||
|
||||
export interface BaseRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
|
||||
|
||||
export interface IfThenRule extends BaseRule {
|
||||
type: RuleType.IF_THEN;
|
||||
dayOfWeek: number; // 0-6, where 0 is Sunday, 6 is Saturday
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement: PlacementStrategy;
|
||||
rewardDays: number;
|
||||
rewardType: SelectableDayType;
|
||||
}
|
||||
|
||||
export interface TimeWindowRule extends BaseRule {
|
||||
type: RuleType.TIME_WINDOW;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
}
|
||||
|
||||
export type VacationRule = IfThenRule | TimeWindowRule;
|
||||
|
||||
interface SerializedRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
dayOfWeek?: number;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement?: PlacementStrategy;
|
||||
rewardDays?: number;
|
||||
rewardType?: SelectableDayType;
|
||||
startDate?: string | Date;
|
||||
endDate?: string | Date;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RuleService {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
private readonly defaultRules: VacationRule[] = [
|
||||
{
|
||||
id: 'default-samedi-malin',
|
||||
name: 'Samedi Malin',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
},
|
||||
{
|
||||
id: 'default-christmas',
|
||||
name: 'Vacances de Noël',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
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,
|
||||
placeType: SelectableDayType.ANY,
|
||||
},
|
||||
];
|
||||
|
||||
private rawRulesSignal: WritableSignal<SerializedRule[]>;
|
||||
|
||||
constructor() {
|
||||
this.rawRulesSignal = storedSignal<SerializedRule[]>(
|
||||
'vacationRules',
|
||||
this.defaultRules as unknown as SerializedRule[],
|
||||
this.injector,
|
||||
);
|
||||
}
|
||||
|
||||
public get rules(): VacationRule[] {
|
||||
const raw = this.rawRulesSignal();
|
||||
return raw.map((rule) => {
|
||||
if (rule.type === RuleType.TIME_WINDOW) {
|
||||
return {
|
||||
...rule,
|
||||
startDate: new Date(rule.startDate as string),
|
||||
endDate: new Date(rule.endDate as string),
|
||||
} as TimeWindowRule;
|
||||
}
|
||||
return rule as IfThenRule;
|
||||
});
|
||||
}
|
||||
|
||||
public addRule(rule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) => [...rules, rule 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public deleteRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) => rules.filter((rule) => rule.id !== id));
|
||||
}
|
||||
|
||||
public toggleRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
rules.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)),
|
||||
);
|
||||
}
|
||||
|
||||
public reorderRules(previousIndex: number, currentIndex: number): void {
|
||||
this.rawRulesSignal.update((rules) => {
|
||||
const updatedRules = [...rules];
|
||||
const [movedRule] = updatedRules.splice(previousIndex, 1);
|
||||
updatedRules.splice(currentIndex, 0, movedRule);
|
||||
return updatedRules;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user