feat: implement rules engine and management panel for custom holiday optimization

This commit is contained in:
nessar committed 2026-05-30 15:00:07 +02:00
1 parent c0549de4dd
commit 08ee84e5c9
20 files changed
+3951 -16847

No files matched your search

+259 -1
View File
@@ -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);
});
});
});
+289 -181
View File
@@ -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>>();
+98
View File
@@ -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);
});
});
+139
View File
@@ -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;
});
}
}