feat(planning): implement constraint-first leave planner
This commit is contained in:
1 parent
ba8dbe92d9
commit
7ceaf1a57a
32 files changed
+4842
-880
No files matched your search
@@ -7,6 +7,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
|
||||
import { SelectableDayType, VacDay } from '../../services/calendar-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-year',
|
||||
@@ -28,6 +30,30 @@ export class CalendarYear {
|
||||
protected isLoading = signal(true);
|
||||
|
||||
protected loadingProgress = signal(0);
|
||||
protected planningOutcome = signal<PlanningOutcome | null>(null);
|
||||
private calculationVersion = 0;
|
||||
|
||||
protected hasPlannerError(): boolean {
|
||||
const outcome = this.planningOutcome();
|
||||
return (
|
||||
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
|
||||
);
|
||||
}
|
||||
|
||||
private stageProgress(stage: string): number {
|
||||
if (stage === 'complete') return 100;
|
||||
if (stage === 'validation') return 5;
|
||||
if (stage === 'obligations') return 20;
|
||||
if (stage === 'at-risk') return 35;
|
||||
if (stage.startsWith('preference:')) return 50;
|
||||
if (stage === 'non-working-day-gain') return 65;
|
||||
if (stage === 'longest-work-sequence') return 72;
|
||||
if (stage === 'short-sequence-deficit') return 79;
|
||||
if (stage === 'supporting-placements') return 86;
|
||||
if (stage === 'expiry') return 93;
|
||||
if (stage === 'dates') return 98;
|
||||
return this.loadingProgress();
|
||||
}
|
||||
|
||||
// Use DateCacheService instead of creating Date objects
|
||||
private get currentMonth() {
|
||||
@@ -43,37 +69,90 @@ export class CalendarYear {
|
||||
this.userInput.computeTriggerSignal();
|
||||
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
this.planningOutcome.set(null);
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
|
||||
console.log('Optimizing vacations with data:', vacationData);
|
||||
console.log('Using rules:', currentRules);
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
const version = ++this.calculationVersion;
|
||||
// Defer execution to allow UI to update
|
||||
queueMicrotask(() => {
|
||||
void (async () => {
|
||||
if (this.selectedDates) {
|
||||
// Create a deep copy to avoid mutating the original signal data
|
||||
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
|
||||
const remainingDays = await this.selectedDates.optimizeVacations(
|
||||
vacationDaysCopy,
|
||||
currentRules,
|
||||
this.calendarService,
|
||||
(progress: number) => {
|
||||
this.loadingProgress.set(progress);
|
||||
try {
|
||||
const outcome = await this.calendarService.planLeave({
|
||||
horizon: this.calendarService.getDefaultPlanningHorizon(),
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
availableFrom: balance.availableDate,
|
||||
expiryDate: balance.expiryDate,
|
||||
})),
|
||||
rules: currentRules,
|
||||
progress: (stage) => {
|
||||
if (version === this.calculationVersion)
|
||||
this.loadingProgress.set(this.stageProgress(stage));
|
||||
},
|
||||
);
|
||||
// Update the remaining vacation days signal
|
||||
this.userInput.remainingVacationDaysSignal.set(remainingDays);
|
||||
});
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome(outcome);
|
||||
} catch (error) {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [
|
||||
{
|
||||
code: 'planner-error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The planner failed unexpectedly.',
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
if (version === this.calculationVersion) {
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
this.isLoading.set(false);
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private applyOutcome(outcome: PlanningOutcome): void {
|
||||
this.planningOutcome.set(outcome);
|
||||
const plan =
|
||||
outcome.kind === 'Plan'
|
||||
? outcome
|
||||
: outcome.kind === 'PlanningIncomplete'
|
||||
? outcome.incumbent
|
||||
: undefined;
|
||||
if (plan) {
|
||||
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
|
||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
||||
return;
|
||||
}
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
|
||||
return plan.remainingBalances.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type as SelectableDayType,
|
||||
numberOfDays: balance.quantity,
|
||||
availableDate: new Date(`${balance.availableFrom}T00:00:00`),
|
||||
expiryDate: new Date(`${balance.expiryDate}T00:00:00`),
|
||||
classification: balance.classification,
|
||||
reason: balance.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
protected selectedDates: SelectedDates = new SelectedDates();
|
||||
|
||||
getYear(monthIndex: number): number {
|
||||
|
||||
Reference in new issue
Block a user