From 5ab29ef7700cb45e321469e2b5f69678a5761fff Mon Sep 17 00:00:00 2001 From: TO Date: Tue, 18 Nov 2025 00:14:01 +0100 Subject: [PATCH 1/6] feat: enhance loading spinner with progress indication and add warning for unplaced vacation days --- .../calendar-year/calendar-year.html | 5 +- .../calendar-year/calendar-year.scss | 45 +++++++------ .../components/calendar-year/calendar-year.ts | 11 +++- src/app/components/user-input/user-input.html | 11 ++++ src/app/components/user-input/user-input.scss | 10 +++ src/app/components/user-input/user-input.ts | 15 ++++- src/app/services/calendar-service.ts | 65 ++++++++++++++++++- src/app/services/user-input-service.ts | 5 ++ 8 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 6d75550..a62a2a6 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -2,7 +2,10 @@ @if (isLoading()) {
- +
+ +
{{ loadingProgress() }}%
+

Calcul en cours...

diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index 2309a59..53a1e65 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -51,25 +51,32 @@ } } +.spinner-container { + position: relative; + display: inline-block; + + ::ng-deep .mat-mdc-progress-spinner { + // Override Material spinner size + width: 100px !important; + height: 100px !important; + } + + .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, - ) - ); -} diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 121b5c4..2923d38 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -21,6 +21,7 @@ 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() { @@ -39,15 +40,21 @@ export class CalendarYear { console.log('Samedi Malin setting:', c); console.log('Optimizing vacations with data:', vacationData); this.isLoading.set(true); + this.loadingProgress.set(0); // Simulate server-side calculation with a small delay - setTimeout(() => { + setTimeout(async () => { if (this.selectedDates) { // Create a copy to avoid mutating the original signal data - this.selectedDates.optimizeVacations( + const remainingDays = await this.selectedDates.optimizeVacations( { ...vacationData }, 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); }, 1); diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 5327327..7a7a50f 100644 --- a/src/app/components/user-input/user-input.html +++ b/src/app/components/user-input/user-input.html @@ -14,5 +14,16 @@ + @if (hasRemainingDays()) { + + + warning + + {{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances non + placé{{ remainingDays() > 1 ? 's' : '' }}. Veuillez les placer manuellement. + + + + } diff --git a/src/app/components/user-input/user-input.scss b/src/app/components/user-input/user-input.scss index c2ac840..739410f 100644 --- a/src/app/components/user-input/user-input.scss +++ b/src/app/components/user-input/user-input.scss @@ -13,6 +13,16 @@ font-size: 0.875rem; margin-top: 0.25rem; } + + .warning-card { + margin-bottom: 1rem; + } + + .warning-content { + display: flex; + align-items: center; + gap: 0.5rem; + } } form { diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts index dd29741..38ca38e 100644 --- a/src/app/components/user-input/user-input.ts +++ b/src/app/components/user-input/user-input.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, effect } from '@angular/core'; +import { Component, OnInit, OnDestroy, effect, computed } from '@angular/core'; import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; @@ -6,6 +6,8 @@ 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'; @Component({ selector: 'app-user-input', @@ -15,7 +17,9 @@ import { CalendarSettings } from '../calendar-settings/calendar-settings'; MatInputModule, ReactiveFormsModule, CalendarSettings, - CalendarSettings, + MatCard, + MatCardContent, + MatIcon, ], templateUrl: './user-input.html', styleUrl: './user-input.scss', @@ -44,6 +48,13 @@ export class UserInput implements OnInit, OnDestroy { Others: new FormControl(null), }); + protected remainingDays = computed(() => { + const remaining = this.userInputService.remainingVacationDaysSignal(); + return remaining.cp + remaining.rtt + remaining.other; + }); + + protected hasRemainingDays = computed(() => this.remainingDays() > 0); + ngOnInit() { this.userInputForm.valueChanges .pipe(debounceTime(1500), takeUntil(this.destroy$)) diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index f4a3141..0fc9d53 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -298,6 +298,7 @@ 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(); @@ -501,7 +502,7 @@ export class SelectedDates implements SelectedDateInterface { // Check if adjacent (next day) - this is the best case! if (nextStartTime - currentEndTime === oneDayInMs) { - totalScore += 10; // Big bonus for adjacency + totalScore += 15; // Big bonus for adjacency } else if (nextStartTime > currentEndTime) { // Penalty based on days between the two dates const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1; @@ -535,18 +536,38 @@ export class SelectedDates implements SelectedDateInterface { return totalScore; } - optimizeVacations( + async optimizeVacations( vacationsNumber: VacationsNumber, calendarSettingsService: CalendarSettingsService, calendarService: CalendarService, - ): void { + progressCallback?: (progress: number) => void, + ): Promise { if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationsNumber); + + const initialVacations = { ...vacationsNumber }; + const totalDays = initialVacations.cp + initialVacations.rtt + initialVacations.other; + let processedDays = 0; + + // Helper function to update progress + const updateProgress = async () => { + if (progressCallback && totalDays > 0) { + const progress = Math.round((processedDays / totalDays) * 100); + progressCallback(progress); + // Allow UI to update with a microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }; + + await updateProgress(); + while (vacationsNumber.cp > 0) { const result = this.lookForVacation(DayType.CP, calendarService); if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) { break; } vacationsNumber.cp -= 1; + processedDays++; + await updateProgress(); } while (vacationsNumber.rtt > 0) { const result = this.lookForVacation(DayType.RTT, calendarService); @@ -554,6 +575,8 @@ export class SelectedDates implements SelectedDateInterface { break; } vacationsNumber.rtt -= 1; + processedDays++; + await updateProgress(); } while (vacationsNumber.other > 0) { const result = this.lookForVacation(DayType.OTHER, calendarService); @@ -561,7 +584,25 @@ export class SelectedDates implements SelectedDateInterface { break; } vacationsNumber.other -= 1; + processedDays++; + await updateProgress(); } + + // Final progress update (100%) + if (progressCallback) { + progressCallback(100); + } + + // Inform user if not all days were placed + const remainingDays = vacationsNumber.cp + vacationsNumber.rtt + vacationsNumber.other; + 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 vacationsNumber; } samediMalin(vacationsNumber: VacationsNumber): void { @@ -751,6 +792,7 @@ export class SelectedDates implements SelectedDateInterface { 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) { @@ -767,6 +809,7 @@ export class SelectedDates implements SelectedDateInterface { // Track only if better than current best if (heuristic > bestHeuristic) { bestHeuristic = heuristic; + daysWithBestHeuristic = 1; const capturedDay = day; bestStrategy = { apply: () => { @@ -781,9 +824,25 @@ 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(); diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts index d9aa2f8..50a8dbb 100644 --- a/src/app/services/user-input-service.ts +++ b/src/app/services/user-input-service.ts @@ -18,4 +18,9 @@ export class UserInputService { } readonly vacationNumberSignal = signal(this.getInitialValues()); + readonly remainingVacationDaysSignal = signal({ + cp: 0, + rtt: 0, + other: 0, + }); } -- 2.54.0 From b97e9486e8d5d266fee6bd7cc908f6189fb6edec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophyl=20Hoguet?= <61246905+THoguet@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:18:10 +0100 Subject: [PATCH 2/6] Update src/app/services/calendar-service.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/services/calendar-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index 0fc9d53..f95f40d 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -502,7 +502,7 @@ export class SelectedDates implements SelectedDateInterface { // Check if adjacent (next day) - this is the best case! if (nextStartTime - currentEndTime === oneDayInMs) { - totalScore += 15; // Big bonus for adjacency + totalScore += 15; // 50% increased bonus for adjacency; adjacency is highly valued in the scoring system } else if (nextStartTime > currentEndTime) { // Penalty based on days between the two dates const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1; -- 2.54.0 From df1b8a509d2461e5086889d58dc8b58202166666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophyl=20Hoguet?= <61246905+THoguet@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:18:19 +0100 Subject: [PATCH 3/6] Update src/app/components/calendar-year/calendar-year.scss Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/components/calendar-year/calendar-year.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index 53a1e65..d400415 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -57,8 +57,8 @@ ::ng-deep .mat-mdc-progress-spinner { // Override Material spinner size - width: 100px !important; - height: 100px !important; + width: 100px; + height: 100px; } .spinner-text { -- 2.54.0 From 3fab4c4922c14fb3b30c29801100cac634008827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophyl=20Hoguet?= <61246905+THoguet@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:21:40 +0100 Subject: [PATCH 4/6] Update src/app/components/user-input/user-input.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/components/user-input/user-input.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 7a7a50f..d4a2088 100644 --- a/src/app/components/user-input/user-input.html +++ b/src/app/components/user-input/user-input.html @@ -17,7 +17,7 @@ @if (hasRemainingDays()) { - warning + {{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances non placé{{ remainingDays() > 1 ? 's' : '' }}. Veuillez les placer manuellement. -- 2.54.0 From 1f3789d82b9a23c27a3838dcb6bc5e7de36fb79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophyl=20Hoguet?= <61246905+THoguet@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:23:50 +0100 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/services/calendar-service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index f95f40d..019a8a9 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -548,13 +548,18 @@ export class SelectedDates implements SelectedDateInterface { const totalDays = initialVacations.cp + initialVacations.rtt + initialVacations.other; let processedDays = 0; - // Helper function to update progress + // 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); - progressCallback(progress); - // Allow UI to update with a microtask - await new Promise((resolve) => setTimeout(resolve, 0)); + 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)); + } } }; -- 2.54.0 From 54c2160f0dd293f8c1c78342e155a65d891041fd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:26:18 +0100 Subject: [PATCH 6/6] Fix setTimeout async anti-pattern in calendar-year component (#50) * Initial plan * fix: replace setTimeout async anti-pattern with queueMicrotask and void Co-authored-by: THoguet <61246905+THoguet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: THoguet <61246905+THoguet@users.noreply.github.com> --- .../components/calendar-year/calendar-year.ts | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 2923d38..4f7829c 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -41,23 +41,25 @@ export class CalendarYear { console.log('Optimizing vacations with data:', vacationData); this.isLoading.set(true); this.loadingProgress.set(0); - // Simulate server-side calculation with a small delay - setTimeout(async () => { - if (this.selectedDates) { - // Create a copy to avoid mutating the original signal data - const remainingDays = await this.selectedDates.optimizeVacations( - { ...vacationData }, - 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); - }, 1); + // Defer execution to allow UI to update + queueMicrotask(() => { + void (async () => { + if (this.selectedDates) { + // Create a copy to avoid mutating the original signal data + const remainingDays = await this.selectedDates.optimizeVacations( + { ...vacationData }, + 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); + })(); + }); }); } -- 2.54.0