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..d400415 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; + 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, - ) - ); -} diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 121b5c4..4f7829c 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,18 +40,26 @@ export class CalendarYear { console.log('Samedi Malin setting:', c); console.log('Optimizing vacations with data:', vacationData); this.isLoading.set(true); - // 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); + this.loadingProgress.set(0); + // 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); + })(); + }); }); } diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 5327327..d4a2088 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()) { + + + + + {{ 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..019a8a9 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; // 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; @@ -535,18 +536,43 @@ 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, 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(); + 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 +580,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 +589,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 +797,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 +814,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 +829,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, + }); }