From 5cd45b86c83cb8a89fcf142ac5134a4d7963d737 Mon Sep 17 00:00:00 2001 From: TO Date: Sat, 29 Nov 2025 21:09:21 +0100 Subject: [PATCH] feat: enhance vacation optimization logic and UI components --- .../components/calendar-year/calendar-year.ts | 5 +- src/app/components/user-input/user-input.html | 22 ++- src/app/components/user-input/user-input.scss | 18 +++ src/app/components/user-input/user-input.ts | 23 ++- .../userInput/day-input/day-input.scss | 1 + src/app/services/calendar-service.ts | 147 ++++++++++++------ src/main.server.ts | 3 +- 7 files changed, 163 insertions(+), 56 deletions(-) diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 428e837..06a5b72 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -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) => { diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 8ef99d9..c2630a2 100644 --- a/src/app/components/user-input/user-input.html +++ b/src/app/components/user-input/user-input.html @@ -13,16 +13,28 @@

Ajouter un jour de vacances

+
+ + +
@if (hasRemainingDays()) { warning - - {{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances non - placé{{ remainingDays() > 1 ? 's' : '' }}. Veuillez les placer manuellement. - +
+ + {{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances + non placé{{ remainingDays() > 1 ? 's' : '' }}: + + @for (day of remainingDaysDetails(); track $index) { + - {{ day }} + } + 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 27bfce1..fdc74e7 100644 --- a/src/app/components/user-input/user-input.scss +++ b/src/app/components/user-input/user-input.scss @@ -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%; +} diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts index dbbbf71..8db3317 100644 --- a/src/app/components/user-input/user-input.ts +++ b/src/app/components/user-input/user-input.ts @@ -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]); + } } diff --git a/src/app/components/userInput/day-input/day-input.scss b/src/app/components/userInput/day-input/day-input.scss index 65f078e..416fac2 100644 --- a/src/app/components/userInput/day-input/day-input.scss +++ b/src/app/components/userInput/day-input/day-input.scss @@ -5,6 +5,7 @@ justify-content: space-between; padding: 16px; gap: 16px; + flex-wrap: wrap; } .dateInput { diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index dbdb1c3..64873b6 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -544,15 +544,15 @@ export class SelectedDates implements SelectedDateInterface { } async optimizeVacations( - vacationsNumber: any, + vacationDays: VacDay[], calendarSettingsService: CalendarSettingsService, calendarService: CalendarService, progressCallback?: (progress: number) => void, - ): Promise { - if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationsNumber); + ): Promise { + 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(); + + // 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(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(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(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; diff --git a/src/main.server.ts b/src/main.server.ts index 0eeaae4..9b1aba7 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -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;