feat: enhance loading spinner with progress indication and add warnin… (#49)
* feat: enhance loading spinner with progress indication and add warning for unplaced vacation days * Update src/app/services/calendar-service.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/app/components/calendar-year/calendar-year.scss Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/app/components/user-input/user-input.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 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> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
8 files changed
+157
-37
No files matched your search
@@ -2,7 +2,10 @@
|
|||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<div class="loading-overlay">
|
<div class="loading-overlay">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<mat-spinner></mat-spinner>
|
<div class="spinner-container">
|
||||||
|
<mat-spinner [mode]="'determinate'" [value]="loadingProgress()"></mat-spinner>
|
||||||
|
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||||
|
</div>
|
||||||
<p>Calcul en cours...</p>
|
<p>Calcul en cours...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
.card {
|
||||||
margin: 16px;
|
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,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,7 @@ export class CalendarYear {
|
|||||||
private calendarSettingsService = inject(CalendarSettingsService);
|
private calendarSettingsService = inject(CalendarSettingsService);
|
||||||
|
|
||||||
protected isLoading = signal(true);
|
protected isLoading = signal(true);
|
||||||
|
protected loadingProgress = signal(0);
|
||||||
|
|
||||||
// Use DateCacheService instead of creating Date objects
|
// Use DateCacheService instead of creating Date objects
|
||||||
private get currentMonth() {
|
private get currentMonth() {
|
||||||
@@ -39,18 +40,26 @@ export class CalendarYear {
|
|||||||
console.log('Samedi Malin setting:', c);
|
console.log('Samedi Malin setting:', c);
|
||||||
console.log('Optimizing vacations with data:', vacationData);
|
console.log('Optimizing vacations with data:', vacationData);
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
// Simulate server-side calculation with a small delay
|
this.loadingProgress.set(0);
|
||||||
setTimeout(() => {
|
// Defer execution to allow UI to update
|
||||||
if (this.selectedDates) {
|
queueMicrotask(() => {
|
||||||
// Create a copy to avoid mutating the original signal data
|
void (async () => {
|
||||||
this.selectedDates.optimizeVacations(
|
if (this.selectedDates) {
|
||||||
{ ...vacationData },
|
// Create a copy to avoid mutating the original signal data
|
||||||
this.calendarSettingsService,
|
const remainingDays = await this.selectedDates.optimizeVacations(
|
||||||
this.calendarService,
|
{ ...vacationData },
|
||||||
);
|
this.calendarSettingsService,
|
||||||
}
|
this.calendarService,
|
||||||
this.isLoading.set(false);
|
(progress: number) => {
|
||||||
}, 1);
|
this.loadingProgress.set(progress);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Update the remaining vacation days signal
|
||||||
|
this.userInput.remainingVacationDaysSignal.set(remainingDays);
|
||||||
|
}
|
||||||
|
this.isLoading.set(false);
|
||||||
|
})();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,16 @@
|
|||||||
<input matInput type="number" max="365" min="0" formControlName="Others" />
|
<input matInput type="number" max="365" min="0" formControlName="Others" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
|
@if (hasRemainingDays()) {
|
||||||
|
<mat-card class="mat-elevation-z2 warning-card">
|
||||||
|
<mat-card-content class="warning-content">
|
||||||
|
<mat-icon color="warn" aria-hidden="true">warning</mat-icon>
|
||||||
|
<span>
|
||||||
|
{{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances non
|
||||||
|
placé{{ remainingDays() > 1 ? 's' : '' }}. Veuillez les placer manuellement.
|
||||||
|
</span>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
<app-calendar-settings></app-calendar-settings>
|
<app-calendar-settings></app-calendar-settings>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,6 +13,16 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warning-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
|||||||
@@ -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 { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
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 { VacationsNumber } from '../../services/calendar-service';
|
||||||
import { debounceTime, takeUntil, Subject } from 'rxjs';
|
import { debounceTime, takeUntil, Subject } from 'rxjs';
|
||||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||||
|
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-input',
|
selector: 'app-user-input',
|
||||||
@@ -15,7 +17,9 @@ import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
|||||||
MatInputModule,
|
MatInputModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
CalendarSettings,
|
CalendarSettings,
|
||||||
CalendarSettings,
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatIcon,
|
||||||
],
|
],
|
||||||
templateUrl: './user-input.html',
|
templateUrl: './user-input.html',
|
||||||
styleUrl: './user-input.scss',
|
styleUrl: './user-input.scss',
|
||||||
@@ -44,6 +48,13 @@ export class UserInput implements OnInit, OnDestroy {
|
|||||||
Others: new FormControl<number | null>(null),
|
Others: new FormControl<number | null>(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected remainingDays = computed(() => {
|
||||||
|
const remaining = this.userInputService.remainingVacationDaysSignal();
|
||||||
|
return remaining.cp + remaining.rtt + remaining.other;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.userInputForm.valueChanges
|
this.userInputForm.valueChanges
|
||||||
.pipe(debounceTime(1500), takeUntil(this.destroy$))
|
.pipe(debounceTime(1500), takeUntil(this.destroy$))
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
|
|
||||||
// Constant to indicate no valid strategy was found
|
// Constant to indicate no valid strategy was found
|
||||||
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
|
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
|
||||||
|
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
|
||||||
|
|
||||||
update(): void {
|
update(): void {
|
||||||
this.datesSelected = this._datesSelected.slice();
|
this.datesSelected = this._datesSelected.slice();
|
||||||
@@ -501,7 +502,7 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
|
|
||||||
// Check if adjacent (next day) - this is the best case!
|
// Check if adjacent (next day) - this is the best case!
|
||||||
if (nextStartTime - currentEndTime === oneDayInMs) {
|
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) {
|
} else if (nextStartTime > currentEndTime) {
|
||||||
// Penalty based on days between the two dates
|
// Penalty based on days between the two dates
|
||||||
const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1;
|
const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1;
|
||||||
@@ -535,18 +536,43 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
return totalScore;
|
return totalScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
optimizeVacations(
|
async optimizeVacations(
|
||||||
vacationsNumber: VacationsNumber,
|
vacationsNumber: VacationsNumber,
|
||||||
calendarSettingsService: CalendarSettingsService,
|
calendarSettingsService: CalendarSettingsService,
|
||||||
calendarService: CalendarService,
|
calendarService: CalendarService,
|
||||||
): void {
|
progressCallback?: (progress: number) => void,
|
||||||
|
): Promise<VacationsNumber> {
|
||||||
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationsNumber);
|
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) {
|
while (vacationsNumber.cp > 0) {
|
||||||
const result = this.lookForVacation(DayType.CP, calendarService);
|
const result = this.lookForVacation(DayType.CP, calendarService);
|
||||||
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
|
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
vacationsNumber.cp -= 1;
|
vacationsNumber.cp -= 1;
|
||||||
|
processedDays++;
|
||||||
|
await updateProgress();
|
||||||
}
|
}
|
||||||
while (vacationsNumber.rtt > 0) {
|
while (vacationsNumber.rtt > 0) {
|
||||||
const result = this.lookForVacation(DayType.RTT, calendarService);
|
const result = this.lookForVacation(DayType.RTT, calendarService);
|
||||||
@@ -554,6 +580,8 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
vacationsNumber.rtt -= 1;
|
vacationsNumber.rtt -= 1;
|
||||||
|
processedDays++;
|
||||||
|
await updateProgress();
|
||||||
}
|
}
|
||||||
while (vacationsNumber.other > 0) {
|
while (vacationsNumber.other > 0) {
|
||||||
const result = this.lookForVacation(DayType.OTHER, calendarService);
|
const result = this.lookForVacation(DayType.OTHER, calendarService);
|
||||||
@@ -561,7 +589,25 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
vacationsNumber.other -= 1;
|
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 {
|
samediMalin(vacationsNumber: VacationsNumber): void {
|
||||||
@@ -751,6 +797,7 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
const tempDatesBackup = [...this._datesSelected];
|
const tempDatesBackup = [...this._datesSelected];
|
||||||
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
|
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
|
||||||
let bestHeuristic = -Infinity;
|
let bestHeuristic = -Infinity;
|
||||||
|
let daysWithBestHeuristic = 0;
|
||||||
|
|
||||||
// Evaluate each candidate day
|
// Evaluate each candidate day
|
||||||
for (const day of candidateDays) {
|
for (const day of candidateDays) {
|
||||||
@@ -767,6 +814,7 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
// Track only if better than current best
|
// Track only if better than current best
|
||||||
if (heuristic > bestHeuristic) {
|
if (heuristic > bestHeuristic) {
|
||||||
bestHeuristic = heuristic;
|
bestHeuristic = heuristic;
|
||||||
|
daysWithBestHeuristic = 1;
|
||||||
const capturedDay = day;
|
const capturedDay = day;
|
||||||
bestStrategy = {
|
bestStrategy = {
|
||||||
apply: () => {
|
apply: () => {
|
||||||
@@ -781,9 +829,25 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
heuristic: heuristic,
|
heuristic: heuristic,
|
||||||
value: capturedDay,
|
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
|
// Restore backup before returning
|
||||||
this._datesSelected = [...tempDatesBackup];
|
this._datesSelected = [...tempDatesBackup];
|
||||||
this.datesSelected = this._datesSelected.slice();
|
this.datesSelected = this._datesSelected.slice();
|
||||||
|
|||||||
@@ -18,4 +18,9 @@ export class UserInputService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly vacationNumberSignal = signal<VacationsNumber>(this.getInitialValues());
|
readonly vacationNumberSignal = signal<VacationsNumber>(this.getInitialValues());
|
||||||
|
readonly remainingVacationDaysSignal = signal<VacationsNumber>({
|
||||||
|
cp: 0,
|
||||||
|
rtt: 0,
|
||||||
|
other: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user