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:
1 parent
5c35a59805
commit
2edf2004f5
31 files changed
+2275
-304
No files matched your search
@@ -25,6 +25,8 @@ import {
|
||||
PlanningTrace,
|
||||
} from '../../services/leave-planner.service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { RuleService, RuleType } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
|
||||
@@ -182,6 +184,7 @@ function calculationFor(
|
||||
};
|
||||
return {
|
||||
id: 'test',
|
||||
cancel: vi.fn(),
|
||||
snapshot: {
|
||||
horizon: plan.horizon,
|
||||
balances: [],
|
||||
@@ -217,6 +220,7 @@ function calendarFor(
|
||||
calculation,
|
||||
calendar: {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||||
createPlanningCalculation: vi.fn(() => calculation),
|
||||
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
||||
@@ -397,6 +401,265 @@ function jointTrace(): PlanningTrace {
|
||||
}
|
||||
|
||||
describe('CalendarYear planning outcome boundary', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it.each([false, true])(
|
||||
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||||
async (withIncumbent) => {
|
||||
const pending = deferred<PlanningOutcome>();
|
||||
const delayedTrace = deferred<PlanningTrace | undefined>();
|
||||
const cancelled: PlanningIncompleteOutcome = {
|
||||
kind: 'PlanningIncomplete',
|
||||
incumbent: withIncumbent ? plan : undefined,
|
||||
completedStages: ['validation'],
|
||||
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
|
||||
};
|
||||
const { calendar, calculation } = calendarFor(
|
||||
cancelled,
|
||||
undefined,
|
||||
undefined,
|
||||
delayedTrace.promise,
|
||||
);
|
||||
calendar.createPlanningCalculation
|
||||
.mockReturnValueOnce({ ...calculation, outcome: pending.promise })
|
||||
.mockReturnValueOnce(calculationFor(plan, explanation));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
TestBed.inject(TranslationService).language.set('fr');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const cancel = root.querySelector<HTMLButtonElement>('.cancel-calculation');
|
||||
expect(cancel?.textContent).toContain('Annuler');
|
||||
activateWithKeyboard(cancel!);
|
||||
fixture.detectChanges();
|
||||
expect(calculation.cancel).toHaveBeenCalledOnce();
|
||||
expect(cancel?.disabled).toBe(true);
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||
pending.resolve(cancelled);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('.planning-status')?.textContent).toContain(
|
||||
withIncumbent ? 'Calcul annulé — plan partiel' : 'Calcul annulé',
|
||||
);
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
||||
TestBed.inject(UserInputService).requestComputation();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
delayedTrace.resolve({ ...jointTrace(), status: 'incomplete' });
|
||||
await fixture.whenStable();
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
|
||||
calculationFor(conflict),
|
||||
);
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('0099-05-14', '0099-05-14');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-14"]')?.disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-15"]')?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid balances', invalid],
|
||||
['conflict', conflict],
|
||||
[
|
||||
'cancellation without a plan',
|
||||
{ ...incomplete, reasons: [{ code: 'cancelled', message: 'Cancelled.' }] },
|
||||
],
|
||||
['worker error', new Error('Worker stopped.')],
|
||||
['startup error', new Error('Worker could not start.')],
|
||||
] satisfies [string, PlanningOutcome | Error][])(
|
||||
'uses the requested historical three-year calendar after %s',
|
||||
async (label, outcome) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
vi.spyOn(calendar, 'createPlanningCalculation').mockImplementation(() => {
|
||||
if (label === 'startup error') throw outcome;
|
||||
return outcome instanceof Error
|
||||
? { ...calculationFor(incomplete), outcome: Promise.reject(outcome) }
|
||||
: calculationFor(outcome);
|
||||
});
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-03-15', '2024-02-14');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(36);
|
||||
for (const date of ['2021-03-15', '2024-02-14'])
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
|
||||
).toBe(false);
|
||||
for (const date of ['2021-03-14', '2024-02-15'])
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
root
|
||||
.querySelector('button[data-date="2023-04-10"]')
|
||||
?.classList.contains('closed-day'),
|
||||
).toBe(true);
|
||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores an older outcome, explanation and trace after a new horizon is submitted', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const oldOutcome = deferred<PlanningOutcome>();
|
||||
const oldExplanation = deferred<PlanningExplanation | undefined>();
|
||||
const oldTrace = deferred<PlanningTrace | undefined>();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const calculate = vi
|
||||
.spyOn(calendar, 'createPlanningCalculation')
|
||||
.mockReturnValueOnce({
|
||||
...calculationFor(plan),
|
||||
outcome: oldOutcome.promise,
|
||||
explanation: oldExplanation.promise,
|
||||
trace: oldTrace.promise,
|
||||
})
|
||||
.mockReturnValueOnce(calculationFor(conflict));
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||
TestBed.inject(UserInputService).requestComputation();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
oldOutcome.resolve(plan);
|
||||
oldExplanation.resolve(explanation);
|
||||
oldTrace.resolve(jointTrace());
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(calculate).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance['planningOutcome']()).toBe(conflict);
|
||||
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
|
||||
expect(fixture.componentInstance['planningTrace']()).toBeNull();
|
||||
expect(fixture.componentInstance['planningHorizon']()).toEqual({
|
||||
start: '2021-05-14',
|
||||
end: '2021-05-15',
|
||||
});
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('captures selected dates with balances and rules only on an explicit calculation request', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const calculate = vi
|
||||
.spyOn(calendar, 'createPlanningCalculation')
|
||||
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||
.mockReturnValueOnce(calculationFor(conflict));
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
const rules = TestBed.inject(RuleService);
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(calculate).toHaveBeenCalledTimes(1);
|
||||
expect(calculate.mock.calls[0][0].horizon).toEqual(plan.horizon);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||
input.updateBalances(() => [
|
||||
{
|
||||
id: 'historical',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 1,
|
||||
availableDate: new Date(2021, 4, 14),
|
||||
expiryDate: new Date(2021, 4, 14),
|
||||
},
|
||||
]);
|
||||
rules.addRule({
|
||||
id: 'historical-rule',
|
||||
name: 'Historical rule',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
force: 'obligation',
|
||||
enabled: true,
|
||||
placeDays: 1,
|
||||
placeType: SelectableDayType.CP,
|
||||
startDate: new Date(2021, 4, 14),
|
||||
endDate: new Date(2021, 4, 14),
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(calculate).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||
input.requestComputation();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calculate).toHaveBeenCalledTimes(2);
|
||||
expect(calculate.mock.calls[1][0]).toMatchObject({
|
||||
horizon: { start: '2021-05-14', end: '2021-05-15' },
|
||||
balances: [
|
||||
{
|
||||
id: 'historical',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2021-05-14',
|
||||
expiryDate: '2021-05-14',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(calculate.mock.calls[1][0].rules).toContainEqual(
|
||||
expect.objectContaining({ id: 'historical-rule' }),
|
||||
);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||
'button[data-date="2021-05-14"]',
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('button[data-date="2021-05-13"]')
|
||||
?.classList.contains('closed-day'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['InvalidInput', invalid],
|
||||
['PlanningConflict', conflict],
|
||||
@@ -1472,6 +1735,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||||
const calendar = {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||
createPlanningCalculation: vi.fn(() => ({
|
||||
...calculationFor(plan),
|
||||
|
||||
@@ -14,14 +14,25 @@
|
||||
></mat-spinner>
|
||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||
</div>
|
||||
<p role="status">{{ tr().loading }}</p>
|
||||
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="cancel-calculation"
|
||||
[disabled]="isCancelling()"
|
||||
(click)="cancelCalculation()"
|
||||
>
|
||||
{{ tr().planning.cancel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<header class="results-header">
|
||||
<div class="results-heading">
|
||||
<h2 id="planning-results-title" tabindex="-1">{{ tr().planning.title }}</h2>
|
||||
<h2 #resultsTitle id="planning-results-title" tabindex="-1">
|
||||
{{ tr().planning.title }}
|
||||
</h2>
|
||||
@if (planningOutcome()?.kind === 'InvalidInput') {
|
||||
<p class="planning-status error" role="alert">
|
||||
<mat-icon aria-hidden="true">error</mat-icon>
|
||||
@@ -42,7 +53,7 @@
|
||||
<mat-icon aria-hidden="true">{{
|
||||
hasPlannerError() ? 'error' : 'warning'
|
||||
}}</mat-icon>
|
||||
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
|
||||
{{ outcomeLabel(planningOutcome()!) }}
|
||||
</p>
|
||||
} @else if (planningOutcome()?.kind === 'Plan') {
|
||||
<p class="planning-status success" role="status">
|
||||
|
||||
@@ -526,6 +526,9 @@ output {
|
||||
justify-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.cancel-calculation {
|
||||
min-height: 44px;
|
||||
}
|
||||
.spinner-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user