feat(planner): implement cancellation for planning calculations

- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations.
- Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations.
- Add cancellation handling in the worker message event.
- Update planning-date.ts with new utility functions for date manipulation.
- Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation.
- Create documentation for the cancellation feature in planner-cancellation.md.
- Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts.

#57 #58
This commit is contained in:
nessar committed 2026-10-05 15:34:07 +02:00
1 parent 5c35a59805
commit 2edf2004f5
31 files changed
+2275 -304

No files matched your search

+103 -74
View File
@@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
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';
@@ -32,6 +33,7 @@ import {
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 {
applyReplayDeltas,
CounterfactualAnalysis,
@@ -64,6 +66,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatButton,
MatCard,
CalendarMonth,
MatCardContent,
@@ -78,6 +81,7 @@ 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 dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
@@ -86,7 +90,9 @@ export class CalendarYear {
private comparisonDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(true);
protected readonly isLoading = signal(false);
protected readonly isCancelling = signal(false);
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
protected readonly loadingProgress = signal(0);
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
@@ -201,7 +207,7 @@ export class CalendarYear {
// Material returns local midnight; converting to UTC can change the calendar day.
const date = selected
? [
selected.getFullYear(),
String(selected.getFullYear()).padStart(4, '0'),
String(selected.getMonth() + 1).padStart(2, '0'),
String(selected.getDate()).padStart(2, '0'),
].join('-')
@@ -283,7 +289,7 @@ export class CalendarYear {
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
const day = selected.range.start;
closedDayDates.add(
`${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
);
}
if (this.traceReplay() && trace) {
@@ -428,7 +434,8 @@ export class CalendarYear {
}
}
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
@@ -441,74 +448,87 @@ export class CalendarYear {
this.destroyRef.onDestroy(() => this.clearCounterfactual());
effect(() => {
this.userInput.computeTriggerSignal();
this.resetCalculationState();
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
const horizon = this.calendarService.getDefaultPlanningHorizon();
this.planningHorizon.set(horizon);
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
queueMicrotask(() => {
let calculation: PlanningCalculationContext;
try {
calculation = this.calendarService.createPlanningCalculation({
horizon,
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
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]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
} catch (error) {
this.failCalculation(error, version);
return;
}
this.activeCalculation = calculation;
this.loadArtifacts(calculation, version);
void calculation.outcome
.then((outcome) => {
if (version === this.calculationVersion) this.applyOutcome(outcome);
})
.catch((error: unknown) => {
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) return;
this.loadingProgress.set(100);
this.isLoading.set(false);
});
});
untracked(() => this.startCalculation());
});
}
private startCalculation(): void {
const horizon = this.calendarSettings.planningHorizon();
if (!horizon) return;
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;
try {
calculation = this.calendarService.createPlanningCalculation({
horizon,
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
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]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
} catch (error) {
this.failCalculation(error, version);
return;
}
this.activeCalculation = calculation;
this.loadArtifacts(calculation, version);
void calculation.outcome
.then((outcome) => {
if (version === this.calculationVersion) this.applyOutcome(outcome);
})
.catch((error: unknown) => {
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) return;
this.loadingProgress.set(100);
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
this.isCancelling.set(false);
});
}
protected cancelCalculation(): void {
if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return;
this.isCancelling.set(true);
this.liveMessage.set(this.tr().planning.cancelling);
this.activeCalculation.cancel();
}
private failCalculation(error: unknown, version: number): void {
if (version !== this.calculationVersion) return;
const reason = {
@@ -523,7 +543,7 @@ export class CalendarYear {
this.isLoading.set(false);
}
private resetCalculationState(): void {
private resetCalculationState(horizon: { start: string; end: string }): void {
this.activeCalculation = null;
this.planningOutcome.set(null);
this.planningExplanation.set(null);
@@ -542,7 +562,7 @@ export class CalendarYear {
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -626,7 +646,9 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.selectedDates.set(
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
);
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -1114,7 +1136,10 @@ export class CalendarYear {
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
return (
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
outcome?.kind === 'PlanningIncomplete' &&
outcome.reasons.some((reason) =>
['planner-error', 'worker-error', 'worker-unavailable'].includes(reason.code),
)
);
}
@@ -1245,10 +1270,14 @@ export class CalendarYear {
return value.replaceAll('-', ' ').replaceAll(':', ': ');
}
private outcomeLabel(outcome: PlanningOutcome): string {
protected outcomeLabel(outcome: PlanningOutcome): string {
if (outcome.kind === 'Plan') return this.tr().planning.complete;
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
if (outcome.reasons.some((reason) => reason.code === 'user-cancelled'))
return outcome.incumbent
? this.tr().planning.cancelledPartial
: this.tr().planning.cancelled;
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
}