feat: add PlanningDurationService for estimating planning duration

- Implemented PlanningDurationService to provide an indicative wait time before calculation based on user input and calendar settings.
- Introduced duration estimation logic that considers horizon dates, balances, and rules without triggering a calculation.
- Enhanced user input component to display estimated duration using the new service.
- Updated translation files to include new duration estimate strings in multiple languages.
- Added tests for PlanningDurationService to ensure accurate duration estimation and calibration.
- Refactored leave planner service to remove hardcoded deadlines and improve worker responsiveness.
This commit is contained in:
nessar committed 2026-10-05 17:06:17 +02:00
1 parent 2edf2004f5
commit bc8570d8ca
29 files changed
+1064 -123

No files matched your search

@@ -1,10 +1,12 @@
import {
afterNextRender,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
Injector,
signal,
TemplateRef,
untracked,
@@ -18,7 +20,6 @@ import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import dayjs from 'dayjs';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
@@ -30,10 +31,9 @@ import {
SelectableDayType,
VacDay,
} from '../../services/calendar-service';
import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import {
applyReplayDeltas,
CounterfactualAnalysis,
@@ -80,8 +80,8 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
export class CalendarYear {
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly ruleService = inject(RuleService);
private readonly calendarSettings = inject(CalendarSettingsService);
private readonly duration = inject(PlanningDurationService);
private readonly injector = inject(Injector);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
@@ -453,32 +453,27 @@ export class CalendarYear {
}
private startCalculation(): void {
const horizon = this.calendarSettings.planningHorizon();
if (!horizon) return;
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
this.userInput.computationRequestedAtMs.set(null);
const request = this.duration.request();
if (!request) return;
const { horizon, rules: currentRules } = request;
this.planningHorizon.set(horizon);
this.resetCalculationState(horizon);
const vacationData = this.userInput.vacationNumberSignal();
const currentRules = this.ruleService.rules;
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.isCancelling.set(false);
this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
let calculation: PlanningCalculationContext;
let completed = false;
try {
calculation = this.calendarService.createPlanningCalculation({
horizon,
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
...request,
balances: request.balances.map((balance) => ({
...balance,
label: this.translationService.getDayTypeLabel(balance.type),
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: currentRules,
labels: Object.fromEntries(
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
@@ -495,7 +490,9 @@ export class CalendarYear {
this.loadArtifacts(calculation, version);
void calculation.outcome
.then((outcome) => {
if (version === this.calculationVersion) this.applyOutcome(outcome);
if (version !== this.calculationVersion) return;
this.applyOutcome(outcome);
completed = outcome.kind === 'Plan' && !this.isCancelling();
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
@@ -519,6 +516,17 @@ export class CalendarYear {
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
this.isCancelling.set(false);
if (completed && !this.destroyRef.destroyed)
afterNextRender(
() => {
if (version === this.calculationVersion && !this.destroyRef.destroyed)
this.duration.recordCompletion(
calculation.snapshot,
performance.now() - requestedAt,
);
},
{ injector: this.injector },
);
});
}