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
+3928 -16824

No files matched your search

+2149
View File
File diff suppressed because it is too large. Load diff
-16599
View File
File diff suppressed because it is too large. Load diff
+24 -24
View File
@@ -27,46 +27,46 @@
},
"private": true,
"dependencies": {
"@angular/animations": "21.2.10",
"@angular/cdk": "^21.2.8",
"@angular/common": "21.2.10",
"@angular/compiler": "21.2.10",
"@angular/core": "21.2.10",
"@angular/forms": "21.2.10",
"@angular/material": "^21.2.8",
"@angular/platform-browser": "21.2.10",
"@angular/platform-browser-dynamic": "21.2.10",
"@angular/platform-server": "21.2.10",
"@angular/router": "21.2.10",
"@angular/ssr": "21.2.8",
"date-holidays": "^3.27.0",
"dayjs": "^1.11.20",
"@angular/animations": "21.2.15",
"@angular/cdk": "^21.2.13",
"@angular/common": "21.2.15",
"@angular/compiler": "21.2.15",
"@angular/core": "21.2.15",
"@angular/forms": "21.2.15",
"@angular/material": "^21.2.13",
"@angular/platform-browser": "21.2.15",
"@angular/platform-browser-dynamic": "21.2.15",
"@angular/platform-server": "21.2.15",
"@angular/router": "21.2.15",
"@angular/ssr": "21.2.13",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@angular/build": "21.2.8",
"@angular/cli": "21.2.8",
"@angular/compiler-cli": "21.2.10",
"@angular/build": "21.2.13",
"@angular/cli": "21.2.13",
"@angular/compiler-cli": "21.2.15",
"@eslint/js": "^10.0.1",
"@types/express": "^5.0.6",
"@types/jasmine": "~6.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^25.6.0",
"eslint": "^10.2.1",
"@types/node": "^25.9.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.6",
"jasmine-core": "~6.2.0",
"jest": "^30.3.0",
"jest-preset-angular": "^16.1.4",
"jest": "^30.4.2",
"jest-preset-angular": "^16.1.5",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.2.0",
"prettier": "^3.8.3",
"typescript": "~5.9.3",
"typescript-eslint": "^8.59.1"
"typescript": "~6.0.3",
"typescript-eslint": "^8.60.0"
}
}
+41 -13
View File
@@ -9,14 +9,52 @@
.content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
align-items: flex-start;
justify-content: space-between;
width: 100%;
padding: 0 24px;
box-sizing: border-box;
}
@media (max-width: 768px) {
app-user-input {
display: block;
flex: 3;
min-width: 350px;
margin-right: 16px;
}
app-calendar-year {
display: block;
flex: 7;
min-width: 700px;
margin-left: 16px;
}
@media (max-width: 1024px) {
.content {
flex-direction: column;
align-items: stretch;
padding: 0 16px;
}
app-user-input,
app-calendar-year {
flex: none;
width: 100%;
min-width: 0;
margin: 0;
}
app-user-input {
margin-bottom: 24px;
}
.mat-divider-vertical {
display: none;
}
.mat-divider-horizontal {
display: block !important;
}
}
@@ -27,16 +65,6 @@
z-index: 1000;
}
@media (max-width: 768px) {
.mat-divider-vertical {
display: none;
}
.mat-divider-horizontal {
display: block !important;
}
}
.mat-divider-vertical {
height: 80vh;
width: 1px;
@@ -5,16 +5,11 @@
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
max-width: 65vw;
width: 100%;
max-width: 100%;
position: relative;
}
@media (max-width: 768px) {
.calendar-year {
max-width: 100vw;
}
}
.loading-overlay {
position: absolute;
top: 0;
@@ -5,8 +5,8 @@ import { CalendarMonth } from '../calendar-month/calendar-month';
import { CalendarService, SelectedDates } from '../../services/calendar-service';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { DateCacheService } from '../../services/date-cache.service';
import { RuleService } from '../../services/rule-service';
@Component({
selector: 'app-calendar-year',
@@ -18,7 +18,7 @@ export class CalendarYear {
private dateCache = inject(DateCacheService);
private userInput = inject(UserInputService);
private calendarService = inject(CalendarService);
private calendarSettingsService = inject(CalendarSettingsService);
private ruleService = inject(RuleService);
protected isLoading = signal(true);
protected loadingProgress = signal(0);
@@ -38,9 +38,11 @@ export class CalendarYear {
this.selectedDates = this.calendarService.getSelectedDatesForYear();
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const c = untracked(() => this.calendarSettingsService.get('samediMalin')());
console.log('Samedi Malin setting:', c);
const currentRules = untracked(() => this.ruleService.rules);
console.log('Optimizing vacations with data:', vacationData);
console.log('Using rules:', currentRules);
this.isLoading.set(true);
this.loadingProgress.set(0);
// Defer execution to allow UI to update
@@ -51,7 +53,7 @@ export class CalendarYear {
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
const remainingDays = await this.selectedDates.optimizeVacations(
vacationDaysCopy,
this.calendarSettingsService,
currentRules,
this.calendarService,
(progress: number) => {
this.loadingProgress.set(progress);
@@ -0,0 +1,376 @@
<mat-expansion-panel class="rules-panel-container mat-elevation-z2">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>rule</mat-icon>
Règles Personnalisées
</mat-panel-title>
<mat-panel-description> Gérer les règles d'optimisation </mat-panel-description>
</mat-expansion-panel-header>
<div class="priority-statement">
<mat-icon color="primary">info</mat-icon>
<span>L'ordre des règles définit leur priorité d'exécution (de haut en bas). Glissez-déposez pour réorganiser.</span>
</div>
<div class="rules-list" cdkDropList (cdkDropListDropped)="drop($event)">
@for (rule of rules; track rule.id; let i = $index) {
<div class="rule-item" cdkDrag [cdkDragDisabled]="editingRuleId() === rule.id" [class.editing]="editingRuleId() === rule.id">
@if (editingRuleId() !== rule.id) {
<div class="rule-drag-grip">
<mat-icon class="drag-handle-icon">drag_handle</mat-icon>
</div>
<div class="rule-display">
<div class="rule-header">
<mat-slide-toggle
[checked]="rule.enabled"
(change)="toggleRule(rule.id)"
color="primary"
>
<span class="rule-name"><span class="rule-number">{{ i + 1 }}.</span> {{ rule.name }}</span>
</mat-slide-toggle>
<div class="rule-actions">
<button mat-icon-button (click)="startEdit(rule, $event)">
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
<div class="rule-summary">
@if (rule.type === RuleType.IF_THEN) {
<span>
Si un jour férié tombe un
<strong>{{
daysOfWeek[rule.dayOfWeek > 0 ? rule.dayOfWeek - 1 : 6]
.label
}}</strong
>, placer <strong>{{ rule.placeDays }}</strong> jour(s)
<strong>{{ getDayTypeLabel(rule.placeType) }}</strong>
<strong>
{{
placementOptions.find((p) => p.value === rule.placement)
?.label | lowercase
}}</strong
>
et obtenir <strong>{{ rule.rewardDays }}</strong> jour(s)
<strong>{{ getDayTypeLabel(rule.rewardType) }}</strong> en
bonus.
</span>
} @else if (rule.type === RuleType.TIME_WINDOW) {
<span>
Placer <strong>{{ rule.placeDays }}</strong> jour(s)
<strong>{{ getDayTypeLabel(rule.placeType) }}</strong> entre
le <strong>{{ rule.startDate | date: 'dd/MM/yyyy' }}</strong> et
le <strong>{{ rule.endDate | date: 'dd/MM/yyyy' }}</strong
>.
</span>
}
</div>
</div>
} @else {
<div class="rule-editor">
<h4>Modifier la règle</h4>
<mat-form-field class="full-width">
<mat-label>Nom de la règle</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
@if ($any(editBuffer).type === RuleType.IF_THEN) {
<div class="editor-row">
<mat-form-field>
<mat-label>Jour férié tombe un</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).dayOfWeek">
@for (day of daysOfWeek; track day.value) {
<mat-option [value]="day.value">{{
day.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">Placer</span>
<mat-form-field class="small-input">
<mat-label>Jours</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>Position</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placement">
@for (opt of placementOptions; track opt.value) {
<mat-option [value]="opt.value">{{
opt.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">Obtenir en bonus</span>
<mat-form-field class="small-input">
<mat-label>Jours</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).rewardDays"
min="0"
/>
</mat-form-field>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).rewardType">
@for (type of rewardDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
} @else {
<div class="editor-row">
<mat-form-field>
<mat-label>Date de début</mat-label>
<input
matInput
[matDatepicker]="startPicker"
[(ngModel)]="$any(editBuffer).startDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="startPicker"
></mat-datepicker-toggle>
<mat-datepicker #startPicker></mat-datepicker>
</mat-form-field>
<mat-form-field>
<mat-label>Date de fin</mat-label>
<input
matInput
[matDatepicker]="endPicker"
[(ngModel)]="$any(editBuffer).endDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="endPicker"
></mat-datepicker-toggle>
<mat-datepicker #endPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">Placer</span>
<mat-form-field class="small-input">
<mat-label>Jours</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">Annuler</button>
<button mat-flat-button color="primary" (click)="saveEdit()">
Enregistrer
</button>
</div>
</div>
}
</div>
}
@if (isAddingNew()) {
<div class="rule-item editing">
<div class="rule-editor">
<h4>Ajouter une règle</h4>
<mat-form-field class="full-width">
<mat-label>Nom de la règle</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
@if (newRuleType() === RuleType.IF_THEN) {
<div class="editor-row">
<mat-form-field>
<mat-label>Jour férié tombe un</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).dayOfWeek">
@for (day of daysOfWeek; track day.value) {
<mat-option [value]="day.value">{{ day.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">Placer</span>
<mat-form-field class="small-input">
<mat-label>Jours</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>Position</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placement">
@for (opt of placementOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">Obtenir en bonus</span>
<mat-form-field class="small-input">
<mat-label>Jours</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).rewardDays"
min="0"
/>
</mat-form-field>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).rewardType">
@for (type of rewardDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
} @else {
<div class="editor-row">
<mat-form-field>
<mat-label>Date de début</mat-label>
<input
matInput
[matDatepicker]="startPicker"
[(ngModel)]="$any(editBuffer).startDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="startPicker"
></mat-datepicker-toggle>
<mat-datepicker #startPicker></mat-datepicker>
</mat-form-field>
<mat-form-field>
<mat-label>Date de fin</mat-label>
<input
matInput
[matDatepicker]="endPicker"
[(ngModel)]="$any(editBuffer).endDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="endPicker"
></mat-datepicker-toggle>
<mat-datepicker #endPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">Placer</span>
<mat-form-field class="small-input">
<mat-label>Jours</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">Annuler</button>
<button mat-flat-button color="primary" (click)="saveEdit()">
Ajouter
</button>
</div>
</div>
</div>
}
<div class="add-buttons" [class.hidden]="isAddingNew() || editingRuleId()">
<button mat-stroked-button (click)="startAddNew(RuleType.IF_THEN)">
<mat-icon>add</mat-icon> Nouvelle Règle "Si ... Alors"
</button>
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
<mat-icon>add</mat-icon> Nouvelle Période Imposée
</button>
</div>
</div>
</mat-expansion-panel>
@@ -0,0 +1,217 @@
.rules-panel-container {
margin-top: 1rem;
margin-bottom: 1rem;
border-radius: 12px;
overflow: hidden;
container-type: inline-size;
mat-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
@container (max-width: 480px) {
mat-panel-description {
display: none !important;
}
}
}
.rules-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
}
.rule-item {
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
transition: all 0.2s ease;
background: var(--mat-sys-surface-container);
&:not(.editing) {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&:hover:not(.editing) {
border-color: var(--mat-sys-outline);
}
&.editing {
border: 1px solid var(--mat-sys-primary);
}
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 8px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.rules-list.cdk-drop-list-dragging .rule-item:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.rule-display {
padding: 16px;
}
.priority-statement {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
border-radius: 8px;
font-size: 0.95rem;
margin-top: 8px;
margin-bottom: 8px;
mat-icon {
color: var(--mat-sys-primary);
flex-shrink: 0;
}
}
.rule-drag-grip {
display: flex;
justify-content: center;
align-items: center;
padding-top: 8px;
margin-bottom: -8px;
color: var(--mat-sys-outline);
opacity: 0.6;
cursor: grab;
transition: opacity 0.2s;
.rule-item:active & {
cursor: grabbing;
}
&:hover {
opacity: 1;
}
}
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.rule-name {
font-weight: 600;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: 6px;
.rule-number {
color: var(--mat-sys-primary);
font-weight: 700;
}
}
.rule-actions {
display: flex;
gap: 4px;
opacity: 0.6;
transition: opacity 0.2s;
.rule-item:hover & {
opacity: 1;
}
}
}
.rule-summary {
font-size: 0.95rem;
line-height: 1.5;
padding-left: 48px; /* Align with toggle switch text */
strong {
font-weight: 600;
}
}
.rule-editor {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background: var(--mat-sys-surface-container-high);
border-radius: 8px;
h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.editor-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
.action-text {
font-weight: 500;
margin-right: 4px;
}
mat-form-field {
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.small-input {
width: 100px;
}
}
.full-width {
width: 100%;
}
.editor-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
}
.add-buttons {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
&.hidden {
display: none;
}
button {
border-radius: 24px;
width: 100%;
}
}
@@ -0,0 +1,103 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { RulesPanel } from './rules-panel';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RuleService, RuleType } from '../../services/rule-service';
describe('RulesPanel', () => {
let component: RulesPanel;
let fixture: ComponentFixture<RulesPanel>;
beforeEach(async () => {
// Clear localStorage before testing
localStorage.clear();
await TestBed.configureTestingModule({
imports: [RulesPanel, BrowserAnimationsModule],
providers: [provideZonelessChangeDetection(), RuleService],
}).compileComponents();
fixture = TestBed.createComponent(RulesPanel);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display initial rules from service', () => {
expect(component.rules.length).toBe(2);
});
it('should start adding new If-Then rule', () => {
component.startAddNew(RuleType.IF_THEN);
expect(component['isAddingNew']()).toBeTrue();
expect(component['newRuleType']()).toBe(RuleType.IF_THEN);
expect(component['editBuffer'].type).toBe(RuleType.IF_THEN);
expect(component['editBuffer'].name).toBe('Nouvelle Règle');
});
it('should start adding new Time Window rule', () => {
component.startAddNew(RuleType.TIME_WINDOW);
expect(component['isAddingNew']()).toBeTrue();
expect(component['newRuleType']()).toBe(RuleType.TIME_WINDOW);
expect(component['editBuffer'].type).toBe(RuleType.TIME_WINDOW);
expect(component['editBuffer'].name).toBe('Nouvelle Période');
});
it('should cancel edit', () => {
component.startAddNew(RuleType.IF_THEN);
component.cancelEdit();
expect(component['isAddingNew']()).toBeFalse();
expect(component['editingRuleId']()).toBeNull();
expect(Object.keys(component['editBuffer']).length).toBe(0);
});
it('should save a new rule', () => {
const initialCount = component.rules.length;
component.startAddNew(RuleType.IF_THEN);
component['editBuffer'].name = 'Custom Test Rule';
component.saveEdit();
expect(component.rules.length).toBe(initialCount + 1);
expect(component.rules.some((r) => r.name === 'Custom Test Rule')).toBeTrue();
});
it('should update an existing rule', () => {
const targetRule = component.rules[0];
// Create a mock event with stopPropagation
const mockEvent = { stopPropagation: () => {} } as Event;
component.startEdit(targetRule, mockEvent);
component['editBuffer'].name = 'Updated Name';
component.saveEdit();
expect(component.rules.find((r) => r.id === targetRule.id)?.name).toBe('Updated Name');
});
it('should delete a rule', () => {
const targetId = component.rules[0].id;
const initialCount = component.rules.length;
const mockEvent = { stopPropagation: () => {} } as Event;
component.deleteRule(targetId, mockEvent);
expect(component.rules.length).toBe(initialCount - 1);
expect(component.rules.some((r) => r.id === targetId)).toBeFalse();
});
it('should toggle a rule', () => {
const targetRule = component.rules[0];
const initialState = targetRule.enabled;
component.toggleRule(targetRule.id);
expect(component.rules.find((r) => r.id === targetRule.id)?.enabled).toBe(!initialState);
});
});
@@ -0,0 +1,188 @@
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import {
RuleService,
RuleType,
IfThenRule,
TimeWindowRule,
VacationRule,
PlacementStrategy,
} from '../../services/rule-service';
import { SelectableDayType, tooltipTypeMapping, DayType } from '../../services/calendar-service';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { FormsModule } from '@angular/forms';
import { DatePipe, LowerCasePipe } from '@angular/common';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
NativeDateAdapter,
} from '@angular/material/core';
import { CdkDragDrop, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
class CustomDateAdapter extends NativeDateAdapter {
override format(date: Date, displayFormat: unknown): string {
if (displayFormat === 'input') {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
return super.format(date, displayFormat as object);
}
}
const MY_DATE_FORMATS = {
parse: { dateInput: 'input' },
display: {
dateInput: 'input',
monthYearLabel: { year: 'numeric', month: 'short' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' },
},
};
@Component({
selector: 'app-rules-panel',
imports: [
MatExpansionModule,
MatIconModule,
MatButtonModule,
MatSlideToggleModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
FormsModule,
DatePipe,
LowerCasePipe,
CdkDropList,
CdkDrag,
],
templateUrl: './rules-panel.html',
styleUrl: './rules-panel.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: DateAdapter, useClass: CustomDateAdapter },
{ provide: MAT_DATE_LOCALE, useValue: 'en-GB' },
{ provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS },
],
})
export class RulesPanel {
public ruleService = inject(RuleService);
protected readonly RuleType = RuleType;
protected readonly placeDayTypes = Object.values(SelectableDayType);
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly tooltipTypeMapping = tooltipTypeMapping;
protected readonly daysOfWeek = [
{ value: 1, label: 'Lundi' },
{ value: 2, label: 'Mardi' },
{ value: 3, label: 'Mercredi' },
{ value: 4, label: 'Jeudi' },
{ value: 5, label: 'Vendredi' },
{ value: 6, label: 'Samedi' },
{ value: 0, label: 'Dimanche' },
];
protected readonly placementOptions: { value: PlacementStrategy; label: string }[] = [
{ value: 'before', label: ' Avant' },
{ value: 'after', label: ' Après' },
{ value: 'around', label: ' Autour' },
{ value: 'auto', label: ' Auto (Meilleur choix)' },
];
protected editingRuleId = signal<string | null>(null);
protected isAddingNew = signal(false);
protected newRuleType = signal<RuleType>(RuleType.IF_THEN);
protected editBuffer: Partial<VacationRule> = {};
public get rules() {
return this.ruleService.rules;
}
public drop(event: CdkDragDrop<VacationRule[]>) {
if (event.previousIndex !== event.currentIndex) {
this.ruleService.reorderRules(event.previousIndex, event.currentIndex);
}
}
public toggleRule(id: string) {
this.ruleService.toggleRule(id);
}
public startEdit(rule: VacationRule, event: Event) {
event.stopPropagation();
this.editingRuleId.set(rule.id);
this.isAddingNew.set(false);
this.editBuffer = { ...rule };
}
public cancelEdit() {
this.editingRuleId.set(null);
this.isAddingNew.set(false);
this.editBuffer = {};
}
public saveEdit() {
if (this.editBuffer.id) {
this.ruleService.updateRule(this.editBuffer as VacationRule);
} else {
this.ruleService.addRule({
...this.editBuffer,
id: crypto.randomUUID(),
enabled: true,
} as VacationRule);
}
this.cancelEdit();
}
public deleteRule(id: string, event: Event) {
event.stopPropagation();
this.ruleService.deleteRule(id);
}
public startAddNew(type: RuleType) {
this.isAddingNew.set(true);
this.editingRuleId.set(null);
this.newRuleType.set(type);
if (type === RuleType.IF_THEN) {
this.editBuffer = {
name: 'Nouvelle Règle',
type: RuleType.IF_THEN,
dayOfWeek: 6,
placeDays: 2,
placeType: SelectableDayType.CP,
placement: 'auto',
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
} as Partial<IfThenRule>;
} else {
this.editBuffer = {
name: 'Nouvelle Période',
type: RuleType.TIME_WINDOW,
startDate: new Date(),
endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
placeDays: 5,
placeType: SelectableDayType.CP,
} as Partial<TimeWindowRule>;
}
}
protected getDayTypeLabel(type: string | undefined | null): string {
if (!type) return '';
return tooltipTypeMapping[type as unknown as DayType] || String(type);
}
}
@@ -24,6 +24,7 @@
</button>
<app-calendar-settings></app-calendar-settings>
</div>
<app-rules-panel></app-rules-panel>
@if (hasRemainingDays()) {
<mat-card class="mat-elevation-z2 warning-card">
<mat-card-content class="warning-content">
@@ -2,6 +2,7 @@ import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/c
import { MatInputModule } from '@angular/material/input';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import { RulesPanel } from '../rules-panel/rules-panel';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { DayInput } from '../userInput/day-input/day-input';
@@ -13,6 +14,7 @@ import { tooltipTypeMapping, DayType, VacDay } from '../../services/calendar-ser
imports: [
MatInputModule,
CalendarSettings,
RulesPanel,
MatCard,
MatCardContent,
MatIcon,
@@ -1,3 +1,9 @@
:host {
display: block;
container-type: inline-size;
width: 100%;
}
.card {
display: flex;
flex-direction: row;
@@ -18,3 +24,49 @@
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
@container (max-width: 480px) {
.card {
flex-wrap: wrap;
gap: 12px;
padding: 12px;
align-items: center;
}
.numberInput {
width: 70px;
flex-grow: 0;
}
.dayTypeInput {
flex-grow: 1;
width: 0;
min-width: 120px;
}
.dateInput {
width: 100%;
flex-grow: 1;
flex-basis: 100%;
margin-top: 4px;
}
button[matFab] {
width: 40px;
height: 40px;
min-width: 40px;
line-height: 40px;
::ng-deep .mat-mdc-button-touch-target {
width: 40px;
height: 40px;
}
mat-icon {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
}
}
@@ -85,7 +85,9 @@ export class DayInput {
public dayChange = output<VacDay>();
public deleteEntry = output<void>();
protected readonly selectableDayTypes = Object.values(SelectableDayType);
protected readonly selectableDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly tooltipTypeMapping = tooltipTypeMapping;
onInput(event: Event) {
+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);
});
});
});
+266 -158
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,
);
// Helper function to get available CP days that don't expire before a given date (ordered by expiry date)
const getAvailableCpDaysForDate = (useByDate: Date): VacDay[] => {
const useByTime = useByDate.getTime();
return vacationDays
const consumeDays = (count: number): boolean => {
let needed = count;
const availableDays = vacationDays
.filter(
(vd) =>
vd.type === SelectableDayType.CP &&
(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());
};
// 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) {
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);
// 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);
const N = rule.placeDays;
if (N <= 0) continue;
// 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);
const placementOptions: Date[][] = [];
// 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);
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);
}
// 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),
),
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(
DayType.CP,
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
dt.type,
new DateRange<Date>(dt.date, dt.date),
),
);
consume2CpDaysForDate(twoDaysBefore2);
addOtherDayReward();
}
consumeDays(N, rule.placeType, maxDate);
addRewardDays(rule.rewardDays, rule.rewardType);
},
heuristic: heuristic,
});
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
}
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();
}
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();
}
// 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();
} 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;
});
}
}