Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot] ed6b18b2a3 Initial plan 2025-11-06 23:19:09 +00:00
nessar da31c9c753 Refactor get method in CalendarSettingsService to throw an error if the setting ID doesn't exist 2025-11-07 00:12:02 +01:00
nessar bda3db2aa8 Skip initial execution in storedSignal effect to prevent unnecessary localStorage writes 2025-11-07 00:04:08 +01:00
nessar 7ffa57748b Enhance storedSignal function to accept an Injector for improved context handling in localStorage persistence 2025-11-06 23:59:26 +01:00
Théophyl HoguetandCopilot 275512d443 Update src/app/services/calendar-settings-service.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-06 23:58:42 +01:00
nessar 3eb35854d4 Update typescript-eslint to version 8.46.3 and enhance error handling in storedSignal function 2025-11-06 23:49:14 +01:00
nessar b793019ba7 Refactor calendar settings service to utilize a new storedSignal implementation for localStorage persistence and restore the stored-signal utility 2025-11-06 23:40:18 +01:00
Théophyl HoguetandCopilot d70aef4962 Update src/app/services/calendar-settings-service.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-06 23:33:47 +01:00
nessar 22993a02c5 Refactor calendar settings service to use a definitions map for settings and update related components to access settings via a getter method 2025-11-06 22:22:04 +01:00
nessar 2a60fef8ff Merge remote-tracking branch 'origin/main' into THoguet/issue5 2025-11-06 21:25:23 +01:00
nessar 3fce4c03b5 enregistrer les calendar settings dans le localstorage
Fixes #5
2025-11-06 21:23:55 +01:00
21 changed files with 15368 additions and 15847 deletions

No files matched your search

+3 -3
View File
@@ -27,15 +27,15 @@ jobs:
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended,security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v3
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -52,14 +52,14 @@ jobs:
cache-to: type=gha,mode=max
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.33.1
uses: aquasecurity/trivy-action@0.27.0
with:
image-ref: ghcr.io/thoguet/vac-optimizer@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
+10 -10
View File
@@ -14,13 +14,13 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci --legacy-peer-deps
run: npm ci
- name: Check code formatting with Prettier
run: npx prettier --check "src/**/*.{ts,html,scss,css,json}"
@@ -35,13 +35,13 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci --legacy-peer-deps
run: npm ci
- name: Run TypeScript compiler
run: npx tsc --noEmit
@@ -53,19 +53,19 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci --legacy-peer-deps
run: npm ci
- name: Run unit tests
run: npm test -- --watch=false --browsers=ChromeHeadless --code-coverage
- name: Upload coverage reports
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v4
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -79,13 +79,13 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci --legacy-peer-deps
run: npm ci
- name: Build production bundle
run: npm run build -- --configuration production
@@ -98,7 +98,7 @@ jobs:
echo "Build completed successfully"
- name: Upload build artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: dist/
+2 -2
View File
@@ -1,5 +1,5 @@
# Stage 1: Build the Angular application
FROM node:25-alpine AS builder
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
@@ -17,7 +17,7 @@ COPY . .
RUN npm run build
# Stage 2: Production image
FROM node:25-alpine AS production
FROM node:20-alpine AS production
# Set working directory
WORKDIR /app
+15137 -15134
View File
File diff suppressed because it is too large. Load diff
+68 -69
View File
@@ -1,71 +1,70 @@
{
"name": "vac-optimizer",
"version": "1.5.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:vac-optimizer": "node dist/vac-optimizer/server/server.mjs",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,html,scss,css,json}\""
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/animations": "^21.0.1",
"@angular/cdk": "^21.0.1",
"@angular/common": "^21.0.1",
"@angular/compiler": "^21.0.1",
"@angular/core": "^21.0.1",
"@angular/forms": "^21.0.1",
"@angular/material": "^21.0.1",
"@angular/platform-browser": "^21.0.1",
"@angular/platform-server": "^21.0.1",
"@angular/router": "^21.0.1",
"@angular/ssr": "^21.0.1",
"date-holidays": "^3.26.2",
"dayjs": "^1.11.19",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.1",
"@angular/cli": "^21.0.1",
"@angular/compiler-cli": "^21.0.1",
"@eslint/js": "^9.39.1",
"@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"jasmine-core": "~5.12.1",
"jest": "^30.2.0",
"jest-preset-angular": "^15.0.3",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.6.2",
"typescript": "~5.9.2",
"typescript-eslint": "^8.46.4"
}
"name": "vac-optimizer",
"version": "1.5.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:vac-optimizer": "node dist/vac-optimizer/server/server.mjs",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,html,scss,css,json}\""
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/cdk": "^20.2.10",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.7",
"@angular/forms": "^20.3.0",
"@angular/material": "^20.2.10",
"@angular/platform-browser": "^20.3.0",
"@angular/platform-server": "^20.3.0",
"@angular/router": "^20.3.0",
"@angular/ssr": "^20.3.7",
"date-holidays": "^3.26.2",
"dayjs": "^1.11.19",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^20.3.7",
"@angular/cli": "^20.3.7",
"@angular/compiler-cli": "^20.3.0",
"@eslint/js": "^9.17.0",
"@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.0",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"jasmine-core": "~5.12.1",
"jest": "^30.2.0",
"jest-preset-angular": "^15.0.3",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.6.2",
"typescript": "~5.9.2",
"typescript-eslint": "^8.46.3"
}
}
-2
View File
@@ -11,7 +11,6 @@ import {
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
providers: [
@@ -19,6 +18,5 @@ export const appConfig: ApplicationConfig = {
provideZonelessChangeDetection(),
provideRouter(routes),
provideClientHydration(withEventReplay(), withIncrementalHydration()),
provideAnimationsAsync(),
],
};
@@ -38,6 +38,7 @@
class="day number"
[matTooltip]="selectedDates.getTooltip(day)"
[matTooltipShowDelay]="500"
[matTooltipTouchGestures]="'on'"
>
{{ day.getDate() }}
</p>
@@ -2,10 +2,7 @@
@if (isLoading()) {
<div class="loading-overlay">
<div class="loading-spinner">
<div class="spinner-container">
<mat-spinner [mode]="'determinate'" [value]="loadingProgress()"></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div>
</div>
<mat-spinner></mat-spinner>
<p>Calcul en cours...</p>
</div>
</div>
@@ -51,32 +51,25 @@
}
}
.spinner-container {
position: relative;
display: inline-block;
::ng-deep .mat-mdc-progress-spinner {
// Override Material spinner size
width: 100px;
height: 100px;
}
.spinner-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
font-weight: bold;
color: #333;
pointer-events: none;
@media (prefers-color-scheme: dark) {
color: #ddd;
}
}
}
.card {
margin: 16px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:root {
@include mat.progress-spinner-overrides(
(
size: 0.5rem,
active-indicator-width: 0.25rem,
)
);
}
@@ -3,14 +3,14 @@ import { UserInputService } from '../../services/user-input-service';
import { MatCard, MatCardContent } from '@angular/material/card';
import { CalendarMonth } from '../calendar-month/calendar-month';
import { CalendarService, SelectedDates } from '../../services/calendar-service';
import { CommonModule } from '@angular/common';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { DateCacheService } from '../../services/date-cache.service';
@Component({
selector: 'app-calendar-year',
imports: [MatCard, CalendarMonth, MatCardContent, MatProgressSpinner],
imports: [MatCard, CalendarMonth, MatCardContent, CommonModule, MatProgressSpinner],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
})
@@ -21,7 +21,6 @@ export class CalendarYear {
private calendarSettingsService = inject(CalendarSettingsService);
protected isLoading = signal(true);
protected loadingProgress = signal(0);
// Use DateCacheService instead of creating Date objects
private get currentMonth() {
@@ -40,27 +39,18 @@ export class CalendarYear {
console.log('Samedi Malin setting:', c);
console.log('Optimizing vacations with data:', vacationData);
this.isLoading.set(true);
this.loadingProgress.set(0);
// Defer execution to allow UI to update
queueMicrotask(() => {
void (async () => {
if (this.selectedDates) {
// Create a deep copy to avoid mutating the original signal data
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
const remainingDays = await this.selectedDates.optimizeVacations(
vacationDaysCopy,
this.calendarSettingsService,
this.calendarService,
(progress: number) => {
this.loadingProgress.set(progress);
},
);
// Update the remaining vacation days signal
this.userInput.remainingVacationDaysSignal.set(remainingDays);
}
this.isLoading.set(false);
})();
});
// Simulate server-side calculation with a small delay
setTimeout(() => {
if (this.selectedDates) {
// Create a copy to avoid mutating the original signal data
this.selectedDates.optimizeVacations(
{ ...vacationData },
this.calendarSettingsService,
this.calendarService,
);
}
this.isLoading.set(false);
}, 1);
});
}
+15 -37
View File
@@ -1,40 +1,18 @@
<div class="user-input">
<p>Optimisez vos jours de vacances en fonction des jours fériés et des week-ends !</p>
<div class="listOfDays">
@for (day of daysOff; track day.id) {
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
<app-day-input [day]="day" (dayChange)="updateDay($index, $event)" (deleteEntry)="removeFromList($index)"></app-day-input>
</div>
}
<mat-card class="add-day-card mat-elevation-z2" (click)="addDay()">
<button matFab aria-label="Add entry">
<mat-icon>add</mat-icon>
</button>
<h3>Ajouter un jour de vacances</h3>
</mat-card>
</div>
<div class="action-buttons">
<button matFab extended (click)="triggerComputation()">
<mat-icon>calculate</mat-icon>
Calculer
</button>
<app-calendar-settings></app-calendar-settings>
</div>
@if (hasRemainingDays()) {
<mat-card class="mat-elevation-z2 warning-card">
<mat-card-content class="warning-content">
<mat-icon color="warn">warning</mat-icon>
<div class="warning">
<span>
{{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances
non placé{{ remainingDays() > 1 ? 's' : '' }}:
</span>
@for (day of remainingDaysDetails(); track $index) {
<span class="remaining-day-detail">- {{ day }}</span>
}
<span> Veuillez les placer manuellement. </span>
</div>
</mat-card-content>
</mat-card>
}
<form [formGroup]="userInputForm">
<mat-form-field appearance="fill">
<mat-label>Combien de CP par an ?</mat-label>
<input matInput type="number" max="365" min="0" formControlName="CP" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Combien de jours de RTT par an ?</mat-label>
<input matInput type="number" max="365" min="0" formControlName="RTT" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Autres jours</mat-label>
<input matInput type="number" max="365" min="0" formControlName="Others" />
</mat-form-field>
</form>
<app-calendar-settings></app-calendar-settings>
</div>
@@ -13,91 +13,9 @@
font-size: 0.875rem;
margin-top: 0.25rem;
}
.warning-card {
margin-bottom: 1rem;
}
.warning-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
}
.listOfDays {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.day-input-wrapper {
animation: slide-fade-in 200ms ease-out;
@starting-style {
opacity: 0;
transform: translateX(-20px);
}
}
.day-enter {
animation: slide-fade-in 200ms ease-out;
}
.day-leave {
opacity: 0;
transform: translateX(20px);
transition:
opacity 200ms ease-in,
transform 200ms ease-in;
}
@keyframes slide-fade-in {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
form {
display: flex;
flex-direction: column;
}
.add-day-card {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px;
gap: 1rem;
margin-bottom: 1rem;
cursor: pointer;
}
.add-day-card h3 {
flex-grow: 1;
margin: 0;
font-weight: normal;
}
.action-buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
}
.warning {
display: flex;
flex-direction: column;
}
:host {
max-width: 100%;
}
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { UserInput } from './user-input';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
describe('UserInput', () => {
let component: UserInput;
@@ -10,7 +9,7 @@ describe('UserInput', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserInput],
providers: [provideZonelessChangeDetection(), provideAnimationsAsync()],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
fixture = TestBed.createComponent(UserInput);
+46 -56
View File
@@ -1,78 +1,68 @@
import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, OnDestroy, effect } from '@angular/core';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { UserInputService } from '../../services/user-input-service';
import { VacationsNumber } from '../../services/calendar-service';
import { debounceTime, takeUntil, Subject } from 'rxjs';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { DayInput } from '../userInput/day-input/day-input';
import { MatButtonModule } from '@angular/material/button';
import { tooltipTypeMapping, DayType, VacDay } from '../../services/calendar-service';
@Component({
selector: 'app-user-input',
imports: [
MatFormField,
MatLabel,
MatInputModule,
ReactiveFormsModule,
CalendarSettings,
CalendarSettings,
MatCard,
MatCardContent,
MatIcon,
MatButtonModule,
DayInput,
],
templateUrl: './user-input.html',
styleUrl: './user-input.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserInput {
private userInputService: UserInputService = inject(UserInputService);
export class UserInput implements OnInit, OnDestroy {
constructor(private userInputService: UserInputService) {
// Watch for signal changes and update form
effect(() => {
const vacationData = this.userInputService.vacationNumberSignal();
this.userInputForm.patchValue(
{
CP: vacationData.cp,
RTT: vacationData.rtt,
Others: vacationData.other,
},
{ emitEvent: false },
);
});
}
protected remainingDays = computed(() => {
const remaining = this.userInputService.remainingVacationDaysSignal();
return remaining.reduce((acc, day) => acc + day.numberOfDays, 0);
private destroy$ = new Subject<void>();
userInputForm: FormGroup = new FormGroup({
CP: new FormControl<number | null>(null),
RTT: new FormControl<number | null>(null),
Others: new FormControl<number | null>(null),
});
protected remainingDaysDetails = computed(() => {
const remaining = this.userInputService.remainingVacationDaysSignal();
return remaining
.filter((day) => day.numberOfDays > 0)
.map((day) => {
const typeName = tooltipTypeMapping[day.type as unknown as DayType];
const expiryDate = day.expiryDate.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${day.numberOfDays} ${typeName} (exp. ${expiryDate})`;
});
});
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
protected get daysOff() {
return this.userInputService.vacationNumberSignal();
ngOnInit() {
this.userInputForm.valueChanges
.pipe(debounceTime(1500), takeUntil(this.destroy$))
.subscribe(() => this.submitForm());
}
public addDay() {
this.userInputService.vacationNumberSignal.update((days) => [
...days,
this.userInputService.createVacationDay(),
]);
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
public removeFromList(index: number) {
this.userInputService.vacationNumberSignal.update((days) =>
days.filter((_, i) => i !== index),
);
}
public updateDay(index: number, updatedDay: VacDay) {
this.userInputService.vacationNumberSignal.update((days) =>
days.map((day, i) => (i === index ? updatedDay : day)),
);
}
public triggerComputation() {
// Trigger signal update by setting a new array reference
this.userInputService.vacationNumberSignal.update((days) => [...days]);
private submitForm() {
if (this.userInputForm.valid) {
const vacationData: VacationsNumber = {
cp: this.userInputForm.get('CP')?.value || 0,
rtt: this.userInputForm.get('RTT')?.value || 0,
other: this.userInputForm.get('Others')?.value || 0,
};
this.userInputService.vacationNumberSignal.set(vacationData);
}
}
}
@@ -1,36 +0,0 @@
<!-- <mat-list-item role="listitem"> -->
<mat-card class="card">
<mat-form-field class="numberInput">
<input
matInput
type="number"
min="0"
[value]="day().numberOfDays"
(input)="onInput($event)"
/>
</mat-form-field>
<mat-form-field class="dayTypeInput">
<mat-select (valueChange)="onTypeChange($event)" [value]="day().type">
<mat-option></mat-option>
@for (dayType of selectableDayTypes; track $index) {
<mat-option [value]="dayType">{{ tooltipTypeMapping[dayType] }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="dateInput">
<mat-label>Choose a date</mat-label>
<input
matInput
type="datetime"
[matDatepicker]="picker"
[value]="day().expiryDate"
(dateChange)="addEvent($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<button matFab aria-label="Delete entry" (click)="deleteEntry.emit()">
<mat-icon>close</mat-icon>
</button>
</mat-card>
<!-- </mat-list-item> -->
@@ -1,21 +0,0 @@
.card {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
gap: 16px;
flex-wrap: wrap;
}
.dateInput {
flex-grow: 1;
}
.numberInput {
width: 80px;
}
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
@@ -1,33 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { DayInput } from './day-input';
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
describe('DayInput', () => {
let component: DayInput;
let fixture: ComponentFixture<DayInput>;
const mockVacDay: VacDay = {
id: 'test-id',
type: SelectableDayType.CP,
numberOfDays: 5,
expiryDate: new Date('2025-12-31'),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DayInput],
providers: [provideAnimationsAsync()],
}).compileComponents();
fixture = TestBed.createComponent(DayInput);
component = fixture.componentInstance;
fixture.componentRef.setInput('day', mockVacDay);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,103 +0,0 @@
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
import { SelectableDayType, tooltipTypeMapping, VacDay } from '../../../services/calendar-service';
import { MatCard } from '@angular/material/card';
import { MatOption, MatSelect } from '@angular/material/select';
import { MatInput, MatInputModule, MatLabel } from '@angular/material/input';
import {
MatDatepicker,
MatDatepickerInput,
MatDatepickerInputEvent,
MatDatepickerToggle,
} from '@angular/material/datepicker';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
NativeDateAdapter,
} from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
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);
}
override parse(value: string): Date | null {
if (!value) return null;
const parts = value.split('/');
if (parts.length === 3) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1;
const year = parseInt(parts[2], 10);
if (!isNaN(day) && !isNaN(month) && !isNaN(year)) {
return new Date(year, month, day);
}
}
return null;
}
}
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-day-input',
imports: [
MatIconModule,
MatCard,
MatSelect,
MatOption,
MatInput,
MatDatepicker,
MatLabel,
MatDatepickerToggle,
MatDatepickerInput,
MatInputModule,
MatButtonModule,
],
templateUrl: './day-input.html',
styleUrl: './day-input.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 DayInput {
public day = input.required<VacDay>();
public dayChange = output<VacDay>();
public deleteEntry = output<void>();
protected readonly selectableDayTypes = Object.values(SelectableDayType);
protected readonly tooltipTypeMapping = tooltipTypeMapping;
onInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value);
this.dayChange.emit({ ...this.day(), numberOfDays: value });
}
onTypeChange(type: SelectableDayType) {
this.dayChange.emit({ ...this.day(), type });
}
addEvent(event: MatDatepickerInputEvent<Date>) {
this.dayChange.emit({ ...this.day(), expiryDate: event.value! });
}
}
+43 -169
View File
@@ -184,11 +184,10 @@ export class CalendarService {
}
}
export type VacDay = {
id: string;
type: SelectableDayType;
numberOfDays: number;
expiryDate: Date;
export type VacationsNumber = {
rtt: number;
cp: number;
other: number;
};
export type Week = (Date | null)[];
@@ -204,12 +203,6 @@ export enum DayType {
TODAY = 'today',
}
export enum SelectableDayType {
CP = DayType.CP,
RTT = DayType.RTT,
OTHER = DayType.OTHER,
}
export const tooltipTypeMapping: { [key in DayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
@@ -305,7 +298,6 @@ export class SelectedDates implements SelectedDateInterface {
// Constant to indicate no valid strategy was found
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
update(): void {
this.datesSelected = this._datesSelected.slice();
@@ -509,7 +501,7 @@ export class SelectedDates implements SelectedDateInterface {
// Check if adjacent (next day) - this is the best case!
if (nextStartTime - currentEndTime === oneDayInMs) {
totalScore += 15; // 50% increased bonus for adjacency; adjacency is highly valued in the scoring system
totalScore += 10; // Big bonus for adjacency
} else if (nextStartTime > currentEndTime) {
// Penalty based on days between the two dates
const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1;
@@ -543,81 +535,36 @@ export class SelectedDates implements SelectedDateInterface {
return totalScore;
}
async optimizeVacations(
vacationDays: VacDay[],
optimizeVacations(
vacationsNumber: VacationsNumber,
calendarSettingsService: CalendarSettingsService,
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;
// Helper function to update progress, throttled to every 5%
let lastReportedProgress = 0;
const PROGRESS_STEP = 5; // percent
const updateProgress = async () => {
if (progressCallback && totalDays > 0) {
const progress = Math.round((processedDays / totalDays) * 100);
if (progress - lastReportedProgress >= PROGRESS_STEP || progress === 100) {
progressCallback(progress);
lastReportedProgress = progress;
// Allow UI to update with a microtask
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
};
await updateProgress();
// Track VacDays that can't find valid placements
const exhaustedVacDayIds = new Set<string>();
// Process vacation days ordered by expiration date (earliest first)
while (vacationDays.some((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))) {
// Sort by expiration date to use days that expire first, excluding exhausted ones
const sortedVacDays = [...vacationDays]
.filter((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
if (sortedVacDays.length === 0) {
): void {
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationsNumber);
while (vacationsNumber.cp > 0) {
const result = this.lookForVacation(DayType.CP, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
break;
}
const vacDayToUse = sortedVacDays[0];
const result = this.lookForVacation(vacDayToUse, calendarService);
vacationsNumber.cp -= 1;
}
while (vacationsNumber.rtt > 0) {
const result = this.lookForVacation(DayType.RTT, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
// Mark this VacDay as exhausted and try other vacation days
exhaustedVacDayIds.add(vacDayToUse.id);
continue;
break;
}
vacDayToUse.numberOfDays -= 1;
processedDays++;
await updateProgress();
vacationsNumber.rtt -= 1;
}
// Final progress update (100%)
if (progressCallback) {
progressCallback(100);
while (vacationsNumber.other > 0) {
const result = this.lookForVacation(DayType.OTHER, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
break;
}
vacationsNumber.other -= 1;
}
// Inform user if not all days were placed
const remainingDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
if (remainingDays > 0) {
console.warn(
`Optimization stopped: ${remainingDays} of ${totalDays} vacation days remain unplaced. ` +
`Too many days have equivalent placement quality - please place remaining days manually.`,
);
}
return vacationDays;
}
samediMalin(vacationDays: VacDay[]): void {
samediMalin(vacationsNumber: VacationsNumber): void {
const now = new Date();
now.setHours(0, 0, 0, 0);
const nowTime = now.getTime();
@@ -626,63 +573,14 @@ export class SelectedDates implements SelectedDateInterface {
(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
.filter(
(vd) =>
vd.type === SelectableDayType.CP &&
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) {
if (daysToConsume === 0) break;
const toConsume = Math.min(cpDay.numberOfDays, daysToConsume);
cpDay.numberOfDays -= toConsume;
daysToConsume -= toConsume;
}
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;
} 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),
});
}
};
for (const holiday of samedi_ferie) {
if (!holiday.range.start) continue;
// Skip if no CP days available
if (vacationsNumber.cp < 2) {
continue;
}
// Get the Saturday date (holiday) and the following Sunday
const saturdayDate = new Date(holiday.range.start);
const sundayDate = new Date(saturdayDate);
@@ -707,24 +605,21 @@ export class SelectedDates implements SelectedDateInterface {
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)
// Check if we can apply each strategy (and not in the past)
const canApplyTwoBeforeStrategy =
twoDaysBefore1.getTime() >= nowTime &&
!this.isSelected(twoDaysBefore1) &&
!this.isSelected(twoDaysBefore2) &&
getTotalCpDaysForDate(twoDaysBefore2) >= 2; // Check expiry against the latest date used
!this.isSelected(twoDaysBefore2);
const canApplyTwoAfterStrategy =
twoDaysAfter1.getTime() >= nowTime &&
!this.isSelected(twoDaysAfter1) &&
!this.isSelected(twoDaysAfter2) &&
getTotalCpDaysForDate(twoDaysAfter2) >= 2; // Check expiry against the latest date used
!this.isSelected(twoDaysAfter2);
const canApplyMixedStrategy =
oneDayBefore.getTime() >= nowTime &&
!this.isSelected(oneDayBefore) &&
!this.isSelected(oneDayAfter) &&
getTotalCpDaysForDate(oneDayAfter) >= 2; // Check expiry against the latest date used
!this.isSelected(oneDayAfter);
// Test all applicable strategies and choose the one with the best heuristic score
const strategies: { apply: () => void; heuristic: number }[] = [];
@@ -747,8 +642,8 @@ export class SelectedDates implements SelectedDateInterface {
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
),
);
consume2CpDaysForDate(twoDaysBefore2);
addOtherDayReward();
vacationsNumber.cp -= 2;
vacationsNumber.other += 1; // Reward: gain 1 other day
},
heuristic: heuristic,
});
@@ -771,8 +666,8 @@ export class SelectedDates implements SelectedDateInterface {
new DateRange<Date>(twoDaysAfter1, twoDaysAfter2),
),
);
consume2CpDaysForDate(twoDaysAfter2);
addOtherDayReward();
vacationsNumber.cp -= 2;
vacationsNumber.other += 1; // Reward: gain 1 other day
},
heuristic: heuristic,
});
@@ -804,8 +699,8 @@ export class SelectedDates implements SelectedDateInterface {
new DateRange<Date>(oneDayAfter, oneDayAfter),
),
);
consume2CpDaysForDate(oneDayAfter);
addOtherDayReward();
vacationsNumber.cp -= 2;
vacationsNumber.other += 1; // Reward: gain 1 other day
},
heuristic: heuristic,
});
@@ -825,24 +720,22 @@ export class SelectedDates implements SelectedDateInterface {
}
lookForVacation(
vacDay: VacDay,
vacType: DayType,
calendarService: CalendarService,
): { apply: () => void; heuristic: number } {
const daysOfYear = calendarService.monthsInAYearFromNow();
const now = new Date();
now.setHours(0, 0, 0, 0);
const nowTime = now.getTime();
const expiryTime = vacDay.expiryDate.getTime();
// Preprocess: filter out past days, already selected days, and days after expiry date
// Preprocess: filter out past days and already selected days in one pass
const candidateDays: Date[] = [];
for (const month of daysOfYear) {
for (const week of month) {
for (const day of week) {
if (day) {
day.setHours(0, 0, 0, 0);
const dayTime = day.getTime();
if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) {
if (day.getTime() >= nowTime && !this.isSelected(day)) {
candidateDays.push(day);
}
}
@@ -855,11 +748,9 @@ export class SelectedDates implements SelectedDateInterface {
return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC };
}
const vacType = vacDay.type as unknown as DayType;
const tempDatesBackup = [...this._datesSelected];
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
let bestHeuristic = -Infinity;
let daysWithBestHeuristic = 0;
// Evaluate each candidate day
for (const day of candidateDays) {
@@ -876,7 +767,6 @@ export class SelectedDates implements SelectedDateInterface {
// Track only if better than current best
if (heuristic > bestHeuristic) {
bestHeuristic = heuristic;
daysWithBestHeuristic = 1;
const capturedDay = day;
bestStrategy = {
apply: () => {
@@ -891,25 +781,9 @@ export class SelectedDates implements SelectedDateInterface {
heuristic: heuristic,
value: capturedDay,
};
} else if (heuristic === bestHeuristic) {
daysWithBestHeuristic++;
}
}
// Check if too many days have the same best heuristic after evaluating all days
if (daysWithBestHeuristic > SelectedDates.MAX_DAYS_WITH_SAME_HEURISTIC) {
// Restore backup before stopping
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
this.grouping();
// Stop optimization - let user decide manually
return {
apply: () => {},
heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC,
};
}
// Restore backup before returning
this._datesSelected = [...tempDatesBackup];
this.datesSelected = this._datesSelected.slice();
+6 -32
View File
@@ -1,5 +1,5 @@
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import { VacationsNumber } from './calendar-service';
@Injectable({
providedIn: 'root',
@@ -9,39 +9,13 @@ export class UserInputService {
return Math.floor(Math.random() * 9) + 1;
}
private getInitialValues(): VacDay[] {
return [
{
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: this.getRandomNumber(),
// today + 365 days
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.RTT,
numberOfDays: this.getRandomNumber(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
{
id: crypto.randomUUID(),
type: SelectableDayType.OTHER,
numberOfDays: this.getRandomNumber(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
},
];
}
public createVacationDay(): VacDay {
private getInitialValues(): VacationsNumber {
return {
id: crypto.randomUUID(),
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
cp: this.getRandomNumber(),
rtt: this.getRandomNumber(),
other: this.getRandomNumber(),
};
}
readonly vacationNumberSignal = signal<VacDay[]>(this.getInitialValues());
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly vacationNumberSignal = signal<VacationsNumber>(this.getInitialValues());
}