feat: enhance vacation optimization logic and UI components

This commit is contained in:
nessar committed 2025-11-29 21:09:21 +01:00
1 parent a9c7e51d36
commit 5cd45b86c8
7 files changed
+163 -56

No files matched your search

@@ -45,9 +45,10 @@ export class CalendarYear {
queueMicrotask(() => {
void (async () => {
if (this.selectedDates) {
// Create a copy to avoid mutating the original signal data
// Create a deep copy to avoid mutating the original signal data
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
const remainingDays = await this.selectedDates.optimizeVacations(
{ ...vacationData },
vacationDaysCopy,
this.calendarSettingsService,
this.calendarService,
(progress: number) => {
+17 -5
View File
@@ -13,16 +13,28 @@
<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>
<span>
{{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances non
placé{{ remainingDays() > 1 ? 's' : '' }}. Veuillez les placer manuellement.
</span>
<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>
}
<app-calendar-settings></app-calendar-settings>
</div>
@@ -83,3 +83,21 @@ form {
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%;
}
+22 -1
View File
@@ -15,6 +15,7 @@ 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 } from '../../services/calendar-service';
@Component({
selector: 'app-user-input',
@@ -39,7 +40,22 @@ export class UserInput implements OnInit, OnDestroy {
protected remainingDays = computed(() => {
const remaining = this.userInputService.remainingVacationDaysSignal();
return remaining.length;
return remaining.reduce((acc, day) => acc + day.numberOfDays, 0);
});
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);
@@ -58,4 +74,9 @@ export class UserInput implements OnInit, OnDestroy {
public removeFromList(index: number) {
this.daysOff.splice(index, 1);
}
public triggerComputation() {
// Trigger signal update by setting a new array reference
this.userInputService.vacationNumberSignal.set([...this.daysOff]);
}
}
@@ -5,6 +5,7 @@
justify-content: space-between;
padding: 16px;
gap: 16px;
flex-wrap: wrap;
}
.dateInput {
+101 -46
View File
@@ -544,15 +544,15 @@ export class SelectedDates implements SelectedDateInterface {
}
async optimizeVacations(
vacationsNumber: any,
vacationDays: VacDay[],
calendarSettingsService: CalendarSettingsService,
calendarService: CalendarService,
progressCallback?: (progress: number) => void,
): Promise<any> {
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationsNumber);
): Promise<VacDay[]> {
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationDays);
const initialVacations = { ...vacationsNumber };
const totalDays = initialVacations.cp + initialVacations.rtt + initialVacations.other;
// 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%
@@ -572,30 +572,30 @@ export class SelectedDates implements SelectedDateInterface {
await updateProgress();
while (vacationsNumber.cp > 0) {
const result = this.lookForVacation(DayType.CP, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
// 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) {
break;
}
vacationsNumber.cp -= 1;
processedDays++;
await updateProgress();
}
while (vacationsNumber.rtt > 0) {
const result = this.lookForVacation(DayType.RTT, calendarService);
const vacDayToUse = sortedVacDays[0];
const result = this.lookForVacation(vacDayToUse, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
break;
// Mark this VacDay as exhausted and try other vacation days
exhaustedVacDayIds.add(vacDayToUse.id);
continue;
}
vacationsNumber.rtt -= 1;
processedDays++;
await updateProgress();
}
while (vacationsNumber.other > 0) {
const result = this.lookForVacation(DayType.OTHER, calendarService);
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
break;
}
vacationsNumber.other -= 1;
vacDayToUse.numberOfDays -= 1;
processedDays++;
await updateProgress();
}
@@ -606,7 +606,7 @@ export class SelectedDates implements SelectedDateInterface {
}
// Inform user if not all days were placed
const remainingDays = vacationsNumber.cp + vacationsNumber.rtt + vacationsNumber.other;
const remainingDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
if (remainingDays > 0) {
console.warn(
`Optimization stopped: ${remainingDays} of ${totalDays} vacation days remain unplaced. ` +
@@ -614,10 +614,10 @@ export class SelectedDates implements SelectedDateInterface {
);
}
return vacationsNumber;
return vacationDays;
}
samediMalin(vacationsNumber: any): void {
samediMalin(vacationDays: VacDay[]): void {
const now = new Date();
now.setHours(0, 0, 0, 0);
const nowTime = now.getTime();
@@ -626,14 +626,63 @@ 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);
@@ -658,21 +707,24 @@ export class SelectedDates implements SelectedDateInterface {
const oneDayAfter = new Date(sundayDate);
oneDayAfter.setDate(sundayDate.getDate() + 1);
// Check if we can apply each strategy (and not in the past)
// 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);
!this.isSelected(twoDaysBefore2) &&
getTotalCpDaysForDate(twoDaysBefore2) >= 2; // Check expiry against the latest date used
const canApplyTwoAfterStrategy =
twoDaysAfter1.getTime() >= nowTime &&
!this.isSelected(twoDaysAfter1) &&
!this.isSelected(twoDaysAfter2);
!this.isSelected(twoDaysAfter2) &&
getTotalCpDaysForDate(twoDaysAfter2) >= 2; // Check expiry against the latest date used
const canApplyMixedStrategy =
oneDayBefore.getTime() >= nowTime &&
!this.isSelected(oneDayBefore) &&
!this.isSelected(oneDayAfter);
!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 }[] = [];
@@ -695,8 +747,8 @@ export class SelectedDates implements SelectedDateInterface {
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
),
);
vacationsNumber.cp -= 2;
vacationsNumber.other += 1; // Reward: gain 1 other day
consume2CpDaysForDate(twoDaysBefore2);
addOtherDayReward();
},
heuristic: heuristic,
});
@@ -719,8 +771,8 @@ export class SelectedDates implements SelectedDateInterface {
new DateRange<Date>(twoDaysAfter1, twoDaysAfter2),
),
);
vacationsNumber.cp -= 2;
vacationsNumber.other += 1; // Reward: gain 1 other day
consume2CpDaysForDate(twoDaysAfter2);
addOtherDayReward();
},
heuristic: heuristic,
});
@@ -752,8 +804,8 @@ export class SelectedDates implements SelectedDateInterface {
new DateRange<Date>(oneDayAfter, oneDayAfter),
),
);
vacationsNumber.cp -= 2;
vacationsNumber.other += 1; // Reward: gain 1 other day
consume2CpDaysForDate(oneDayAfter);
addOtherDayReward();
},
heuristic: heuristic,
});
@@ -773,22 +825,24 @@ export class SelectedDates implements SelectedDateInterface {
}
lookForVacation(
vacType: DayType,
vacDay: VacDay,
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 and already selected days in one pass
// Preprocess: filter out past days, already selected days, and days after expiry date
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);
if (day.getTime() >= nowTime && !this.isSelected(day)) {
const dayTime = day.getTime();
if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) {
candidateDays.push(day);
}
}
@@ -801,6 +855,7 @@ 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;
+1 -2
View File
@@ -1,8 +1,7 @@
import { provideZoneChangeDetection } from "@angular/core";
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, {...config, providers: [provideZoneChangeDetection(), ...config.providers]}, context);
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
export default bootstrap;