From 5cb0b933392072ae64fb267feecd9e33ea22ee50 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sat, 17 Oct 2026 11:11:37 +0200
Subject: [PATCH] refactor(planning): give planning calculations one state
owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.
Refs #275
Co-Authored-By: Claude Opus 5.5
---
docs/planning-duration.md | 5 +-
.../calendar-year-planning.spec.ts | 54 ++--
.../components/calendar-year/calendar-year.ts | 295 +++++------------
.../components/user-input/user-input.spec.ts | 56 +++-
src/app/components/user-input/user-input.ts | 20 +-
.../planning-calculation.service.spec.ts | 301 ++++++++++++++++++
.../services/planning-calculation.service.ts | 221 +++++++++++++
.../planning-duration.service.spec.ts | 21 +-
src/app/services/planning-duration.service.ts | 29 +-
src/app/services/rule-migration.spec.ts | 4 +-
src/app/services/user-input-service.spec.ts | 2 -
src/app/services/user-input-service.ts | 22 +-
12 files changed, 708 insertions(+), 322 deletions(-)
create mode 100644 src/app/services/planning-calculation.service.spec.ts
create mode 100644 src/app/services/planning-calculation.service.ts
diff --git a/docs/planning-duration.md b/docs/planning-duration.md
index 698bedb..6271a2d 100644
--- a/docs/planning-duration.md
+++ b/docs/planning-duration.md
@@ -73,9 +73,8 @@ range is explicitly indicative, not a confidence interval or a promise.
## What is timed
-Manual requests capture `performance.now()` inside
-`UserInputService.requestComputation`, before updating the calculation trigger.
-The existing initial calculation falls back to the start of `startCalculation`.
+Every request, manual or initial, captures `performance.now()` inside
+`PlanningCalculationService.start`.
The endpoint is Angular's `afterNextRender` callback after the complete plan has
been projected into the calendar, its months and placements have been rendered,
and the loading overlay has been removed. This measures request scheduling,
diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts
index c7a4c99..a6a47ac 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -30,6 +30,7 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
+import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
@@ -474,7 +475,9 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const before = {
balances: structuredClone(input.vacationNumberSignal()),
- displayed: structuredClone(input.displayedPlanSignal()),
+ displayed: structuredClone(
+ TestBed.inject(PlanningCalculationService).displayedPlan(),
+ ),
declarations: structuredClone(input.declaredDaysSignal()),
rules: structuredClone(rules.rules),
};
@@ -506,9 +509,11 @@ describe('CalendarYear planning outcome boundary', () => {
];
expect(statistics()).toEqual(expectedStatistics);
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
- expect(input.planningOutcomeSignal()).toBe(outcome);
+ expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances);
- expect(input.displayedPlanSignal()).toEqual(before.displayed);
+ expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
+ before.displayed,
+ );
expect(input.declaredDaysSignal()).toEqual(before.declarations);
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
@@ -525,7 +530,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(statistics()).toEqual(expectedStatistics);
- expect(input.planningOutcomeSignal()).toBe(outcome);
+ expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
},
);
@@ -1047,7 +1052,6 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
- const input = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
@@ -1056,10 +1060,10 @@ describe('CalendarYear planning outcome boundary', () => {
activateWithKeyboard(root.querySelector('.statistics-button')!);
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
- input.requestComputation();
+ TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
- input.requestComputation();
+ TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
const otherControl = document.createElement('button');
otherControl.textContent = 'Another control';
@@ -1071,7 +1075,7 @@ describe('CalendarYear planning outcome boundary', () => {
oldOutcome.resolve(plan);
await fixture.whenStable();
fixture.detectChanges();
- expect(input.planningOutcomeSignal()).toBe(replacement);
+ expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
const opener = root.querySelector('.statistics-button');
if (replacement.kind === 'Plan') {
expect(opener).not.toBeNull();
@@ -1747,7 +1751,7 @@ describe('CalendarYear planning outcome boundary', () => {
},
]);
const duration = TestBed.inject(PlanningDurationService);
- const submitted = duration.request()!;
+ const submitted = TestBed.inject(PlanningCalculationService).request()!;
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: pending.promise,
@@ -1849,11 +1853,13 @@ describe('CalendarYear planning outcome boundary', () => {
}).compileComponents();
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-01', '2024-12-31');
- const input = TestBed.inject(UserInputService);
const duration = TestBed.inject(PlanningDurationService);
calendar.createPlanningCalculation.mockImplementation(() => ({
...calculation,
- snapshot: { ...calculation.snapshot, ...duration.request()! },
+ snapshot: {
+ ...calculation.snapshot,
+ ...TestBed.inject(PlanningCalculationService).request()!,
+ },
outcome:
calendar.createPlanningCalculation.mock.calls.length === 1
? old.promise
@@ -1863,7 +1869,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
now = 11_000;
settings.setPlanningDates('2024-01-01', '2026-12-31');
- input.requestComputation();
+ TestBed.inject(PlanningCalculationService).start();
now = 12_000; // Rendering/scheduling after the request is part of the wait.
fixture.detectChanges();
const initial = duration.estimate();
@@ -1885,7 +1891,10 @@ describe('CalendarYear planning outcome boundary', () => {
const revisitedOutcome = deferred();
calendar.createPlanningCalculation.mockReturnValueOnce({
...calculation,
- snapshot: { ...calculation.snapshot, ...duration.request()! },
+ snapshot: {
+ ...calculation.snapshot,
+ ...TestBed.inject(PlanningCalculationService).request()!,
+ },
outcome: revisitedOutcome.promise,
});
now = 631_000; // Returning to the calendar is a fresh initial calculation.
@@ -1947,7 +1956,7 @@ describe('CalendarYear planning outcome boundary', () => {
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();
+ TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -2051,7 +2060,7 @@ describe('CalendarYear planning outcome boundary', () => {
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
settings.setPlanningDates('2021-05-14', '2021-05-14');
- TestBed.inject(UserInputService).requestComputation();
+ TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
oldOutcome.resolve(plan);
@@ -2117,7 +2126,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(calculate).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
- input.requestComputation();
+ TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -2163,12 +2172,11 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
- const userInput = TestBed.inject(UserInputService);
const fixture: ComponentFixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
- expect(userInput.displayedPlanSignal()).toBeUndefined();
+ expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
});
it('keeps and summarizes a returned result', async () => {
@@ -2180,13 +2188,14 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
- const userInput = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
- expect(userInput.displayedPlanSignal()?.remainingBalances).toEqual(plan.remainingBalances);
+ expect(
+ TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
+ ).toEqual(plan.remainingBalances);
const summaryValues = Array.from(
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf,
).map((element) => element.textContent?.trim());
@@ -2538,7 +2547,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
- TestBed.inject(UserInputService).requestComputation();
+ TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -2959,7 +2968,6 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
- const userInput = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
@@ -2971,6 +2979,6 @@ describe('CalendarYear planning outcome boundary', () => {
if (outcome?.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('planner-error');
expect(fixture.componentInstance['isLoading']()).toBe(false);
- expect(userInput.displayedPlanSignal()).toBeUndefined();
+ expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
});
});
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 7ae5169..1a0fd9a 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -42,14 +42,12 @@ 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 { PlanningCalculationService } from '../../services/planning-calculation.service';
import {
CounterfactualAnalysis,
CounterfactualSelection,
LeaveType,
- DeclaredNonWorkingDay,
PlanExplanation,
- PlanningCalculationContext,
- PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
@@ -86,6 +84,7 @@ export class CalendarYear {
private readonly calendarService = inject(CalendarService);
private readonly settings = inject(CalendarSettingsService);
private readonly duration = inject(PlanningDurationService);
+ private readonly calculations = inject(PlanningCalculationService);
private readonly injector = inject(Injector);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject>(DateAdapter);
@@ -98,13 +97,13 @@ export class CalendarYear {
private statisticsDialog: MatDialogRef | null = null;
protected readonly translationService = inject(TranslationService);
- protected readonly isLoading = signal(false);
- protected readonly isCancelling = signal(false);
+ protected readonly isLoading = this.calculations.isCalculating;
+ protected readonly isCancelling = this.calculations.isCancelling;
private readonly resultsTitle = viewChild>('resultsTitle');
- protected readonly loadingProgress = signal(0);
- protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
- protected readonly planningExplanation = signal(null);
- protected readonly explanationError = signal(null);
+ protected readonly loadingProgress = this.calculations.progress;
+ protected readonly planningOutcome = this.calculations.outcome;
+ protected readonly planningExplanation = this.calculations.explanation;
+ protected readonly explanationError = this.calculations.explanationError;
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
private readonly planDetails = viewChild>('planDetails');
@@ -115,7 +114,7 @@ export class CalendarYear {
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal(null);
protected readonly liveMessage = signal('');
- protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
+ protected readonly planningHorizon = this.calculations.displayedHorizon;
protected readonly selectedDates = signal(new SelectedDates());
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
private readonly monthGrid = viewChild>('monthGrid');
@@ -141,21 +140,18 @@ export class CalendarYear {
}
return { dates, type: this.userInput.declaredTypeSignal() };
});
- private readonly resultDeclaredDays = signal([]);
protected readonly calendarHorizon = computed(() =>
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
);
protected readonly calendarDeclaredDays = computed>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
- : (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
+ : (this.calculations.accepted()?.declaredDays ?? []);
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
- private calculationVersion = 0;
private counterfactualVersion = 0;
- private activeCalculation: PlanningCalculationContext | null = null;
- private runningCalculation: PlanningCalculationContext | null = null;
+ private wasCancelling = false;
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -163,7 +159,7 @@ export class CalendarYear {
return this.translationService.tr;
}
- protected readonly displayedPlan = this.userInput.displayedPlanSignal;
+ protected readonly displayedPlan = this.calculations.displayedPlan;
protected readonly weekendConnectedHolidays = computed(() => {
const isWeekend = (date: string) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
@@ -339,185 +335,74 @@ export class CalendarYear {
});
});
effect(() => {
- this.userInput.computeTriggerSignal();
- untracked(() => this.startCalculation());
+ if (this.isLoading()) untracked(() => this.liveMessage.set(this.tr().loading));
});
- }
-
- private startCalculation(): void {
- 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.declarationEntry.set(false);
- if (!this.planningHorizon()) this.planningHorizon.set(horizon);
- 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({
- ...request,
- balances: request.balances.map((balance) => ({
- ...balance,
- label: this.translationService.getDayTypeLabel(balance.type),
- })),
- labels: Object.fromEntries(
- currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
- ),
- progress: (stage) => {
- if (version === this.calculationVersion)
- this.loadingProgress.set(this.stageProgress(stage));
- },
+ effect(() => {
+ const cancelling = this.isCancelling();
+ untracked(() => {
+ if (this.wasCancelling && !cancelling) this.resultsTitle()?.nativeElement.focus();
+ this.wasCancelling = cancelling;
});
- } catch (error) {
- this.resetCalculationState(horizon);
- this.planningHorizon.set(horizon);
- this.failCalculation(error, version);
- return;
- }
- this.runningCalculation = calculation;
- void calculation.outcome
- .then((outcome) => {
- if (version !== this.calculationVersion) return;
- this.resetCalculationState(horizon);
- this.activeCalculation = calculation;
- this.userInput.planningSnapshotSignal.set(calculation.snapshot);
- this.planningHorizon.set(horizon);
- this.applyOutcome(outcome);
- this.loadArtifacts(calculation);
- completed = outcome.kind === 'Plan' && !this.isCancelling();
- })
- .catch((error: unknown) => {
- if (version !== this.calculationVersion) return;
- this.resetCalculationState(horizon);
- this.activeCalculation = calculation;
- this.userInput.planningSnapshotSignal.set(calculation.snapshot);
- this.planningHorizon.set(horizon);
- this.applyOutcome({
- kind: 'PlanningIncomplete',
- completedStages: [],
- reasons: [
- {
- code: 'planner-error',
- message:
- error instanceof Error
- ? error.message
- : 'The planner failed unexpectedly.',
- },
- ],
- });
- this.loadArtifacts(calculation);
- })
- .finally(() => {
- if (version !== this.calculationVersion) return;
- this.runningCalculation = null;
- this.loadingProgress.set(100);
- this.isLoading.set(false);
- if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
- this.isCancelling.set(false);
- if (completed && !this.destroyRef.destroyed)
+ });
+ effect(() => {
+ const accepted = this.calculations.accepted();
+ if (!accepted) return;
+ untracked(() => {
+ this.closeStatistics();
+ this.selectedPlacementIndex.set(0);
+ this.planDetailsOpen.set(false);
+ this.clearCounterfactual();
+ const plan = this.displayedPlan();
+ this.selectedDates.set(
+ plan
+ ? this.calendarService.selectedDatesForPlan(plan)
+ : this.calendarService.getSelectedDatesForHorizon(accepted.horizon),
+ );
+ this.liveMessage.set(this.outcomeLabel(accepted.outcome));
+ if (accepted.successful && !this.destroyRef.destroyed)
afterNextRender(
() => {
- if (version === this.calculationVersion && !this.destroyRef.destroyed)
+ if (
+ this.calculations.accepted() === accepted &&
+ !this.destroyRef.destroyed &&
+ accepted.snapshot
+ )
this.duration.recordCompletion(
- calculation.snapshot,
- performance.now() - requestedAt,
+ accepted.snapshot,
+ performance.now() - accepted.requestedAt,
);
},
{ injector: this.injector },
);
});
+ });
+ effect(() => {
+ const explanation = this.calculations.explanation();
+ if (explanation?.kind !== 'PlanExplanation') return;
+ untracked(() => {
+ this.selectPlacement(0, false);
+ const outcome = this.planningOutcome();
+ this.liveMessage.set(
+ `${outcome ? this.outcomeLabel(outcome) : ''} ${this.placementAnnouncement()}`.trim(),
+ );
+ });
+ });
+ effect(() => {
+ const reason = this.explanationError();
+ if (reason?.code === 'explanation-error')
+ untracked(() =>
+ this.liveMessage.set(
+ `${this.tr().planning.explanationError} ${reason.message}`,
+ ),
+ );
+ });
+ this.calculations.start();
}
protected cancelCalculation(): void {
- if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
- this.isCancelling.set(true);
+ if (!this.isLoading() || this.isCancelling()) return;
+ this.calculations.cancel();
this.liveMessage.set(this.tr().planning.cancelling);
- this.runningCalculation.cancel();
- }
-
- private failCalculation(error: unknown, version: number): void {
- if (version !== this.calculationVersion) return;
- const reason = {
- code: 'planner-error',
- message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
- };
- this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
- this.explanationError.set(reason);
- this.loadingProgress.set(100);
- this.isLoading.set(false);
- }
-
- private resetCalculationState(horizon: { start: string; end: string }): void {
- this.closeStatistics();
- this.activeCalculation = null;
- this.userInput.planningSnapshotSignal.set(null);
- this.planningOutcome.set(null);
- this.planningExplanation.set(null);
- this.explanationError.set(null);
- this.selectedPlacementIndex.set(0);
- this.planDetailsOpen.set(false);
- this.clearCounterfactual();
- this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
- }
-
- private loadArtifacts(calculation: PlanningCalculationContext): void {
- void calculation.explanation
- .then((explanation) => {
- if (calculation !== this.activeCalculation) return;
- this.planningExplanation.set(explanation ?? null);
- if (explanation?.kind === 'PlanExplanation') {
- this.selectPlacement(0, false);
- this.liveMessage.set(
- `${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
- );
- }
- })
- .catch((error: unknown) => {
- if (calculation !== this.activeCalculation) return;
- const reason = {
- code: 'explanation-error',
- message: error instanceof Error ? error.message : String(error),
- };
- this.explanationError.set(reason);
- this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
- });
- void calculation.explanationError
- .then((reason) => {
- if (calculation === this.activeCalculation)
- this.explanationError.set(reason ?? null);
- })
- .catch((error: unknown) => {
- if (calculation !== this.activeCalculation) return;
- const reason = {
- code: 'explanation-error',
- message: error instanceof Error ? error.message : String(error),
- };
- this.explanationError.set(reason);
- this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
- });
- }
-
- private applyOutcome(outcome: PlanningOutcome): void {
- this.planningOutcome.set(outcome);
- this.liveMessage.set(this.outcomeLabel(outcome));
- const plan = this.displayedPlan();
- this.resultDeclaredDays.set(
- plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
- );
- if (plan) {
- this.planningHorizon.set(plan.horizon);
- this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
- return;
- }
- this.selectedDates.set(
- this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
- );
}
protected formatStatistic(value: number | null): string {
@@ -823,7 +708,7 @@ export class CalendarYear {
selection: CounterfactualSelection,
openDialog = false,
): Promise {
- const calculation = this.activeCalculation;
+ const calculation = this.calculations.accepted()?.calculation;
if (!calculation) return;
this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion;
@@ -838,7 +723,10 @@ export class CalendarYear {
);
try {
const result = await calculation.analyzeCounterfactual(selection);
- if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
+ if (
+ version !== this.counterfactualVersion ||
+ calculation !== this.calculations.accepted()?.calculation
+ )
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
@@ -849,7 +737,10 @@ export class CalendarYear {
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
- if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
+ if (
+ version !== this.counterfactualVersion ||
+ calculation !== this.calculations.accepted()?.calculation
+ )
return;
const message = error instanceof Error ? error.message : String(error);
this.counterfactualError.set(message);
@@ -914,10 +805,10 @@ export class CalendarYear {
}
protected ruleLabel(ruleId: string): string {
- const rule = this.activeCalculation?.snapshot.rules.find(
- (candidate) => candidate.id === ruleId,
- );
- return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
+ const rule = this.calculations
+ .snapshot()
+ ?.rules.find((candidate) => candidate.id === ruleId);
+ return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId;
}
protected balanceLabel(explanation: PlacementExplanation): string {
@@ -949,9 +840,9 @@ export class CalendarYear {
protected objectiveCopy(id: string) {
if (id.startsWith('optimization:')) {
const ruleId = id.slice('optimization:'.length);
- const rule = this.activeCalculation?.snapshot.rules.find(
- (candidate) => candidate.id === ruleId,
- );
+ const rule = this.calculations
+ .snapshot()
+ ?.rules.find((candidate) => candidate.id === ruleId);
const copy = this.tr().planning.explanation.objectives['optimization'];
return {
...copy,
@@ -969,9 +860,9 @@ export class CalendarYear {
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
- ? this.activeCalculation?.snapshot.rules.find(
- (candidate) => candidate.id === id.slice('preference:'.length),
- )
+ ? this.calculations
+ .snapshot()
+ ?.rules.find((candidate) => candidate.id === id.slice('preference:'.length))
: undefined;
return (
copy && {
@@ -1056,20 +947,4 @@ export class CalendarYear {
: this.tr().planning.cancelled;
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
}
-
- 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:') || stage.startsWith('optimization:')) return 50;
- if (stage === 'holiday-week-value') return 58;
- 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();
- }
}
diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts
index bf6ff3b..97f8e6f 100644
--- a/src/app/components/user-input/user-input.spec.ts
+++ b/src/app/components/user-input/user-input.spec.ts
@@ -7,12 +7,19 @@ import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
-import { vi } from 'vitest';
+import { vi, type MockInstance } from 'vitest';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
+import { CalendarService } from '../../services/calendar-service';
+import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
-import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
+import {
+ createPlanningCalculation,
+ type PlanningCalculationContext,
+ type PlanningOutcome,
+ PlanningRequest,
+} from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
@@ -21,6 +28,8 @@ describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture;
let userInputService: UserInputService;
+ let createCalculation: MockInstance;
+ let calculations: PlanningCalculationService;
beforeEach(async () => {
localStorage.clear();
@@ -45,6 +54,11 @@ describe('UserInput', () => {
fixture = TestBed.createComponent(UserInput);
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
+ calculations = TestBed.inject(PlanningCalculationService);
+ // Unless a test supplies a result, a started calculation stays pending.
+ createCalculation = vi
+ .spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
+ .mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
fixture.detectChanges();
await fixture.whenStable();
}
@@ -91,16 +105,25 @@ describe('UserInput', () => {
async function calculate(request: PlanningRequest): Promise {
const browserWindow = globalThis.window;
- let calculation: ReturnType;
+ let calculation: PlanningCalculationContext;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
- const outcome = await calculation.outcome;
- userInputService.planningSnapshotSignal.set(calculation.snapshot);
- userInputService.planningOutcomeSignal.set(outcome);
+ await show(calculation);
+ }
+
+ async function show(
+ calculation: PlanningCalculationContext,
+ outcome?: PlanningOutcome,
+ ): Promise {
+ const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
+ createCalculation.mockReturnValueOnce(shown);
+ calculations.start();
+ await shown.outcome;
+ await Promise.resolve();
fixture.detectChanges();
}
@@ -155,19 +178,18 @@ describe('UserInput', () => {
expect(card.textContent).toContain('Aucune action nécessaire');
expect(card.textContent).not.toContain('Disponible seulement');
expect(root.querySelector('.remaining-history')).toBeNull();
- const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
+ const outcome = JSON.stringify(calculations.outcome());
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
- expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
+ expect(JSON.stringify(calculations.outcome())).toBe(outcome);
- const plan = userInputService.displayedPlanSignal()!;
- userInputService.planningOutcomeSignal.set({
+ const plan = calculations.displayedPlan()!;
+ await show(calculations.accepted()!.calculation!, {
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
- fixture.detectChanges();
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
expect(card.textContent).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
@@ -185,7 +207,7 @@ describe('UserInput', () => {
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
fixture.detectChanges();
expect(estimateText()).not.toBe(initial);
- expect(userInputService.computeTriggerSignal()).toBe(0);
+ expect(createCalculation).not.toHaveBeenCalled();
});
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
@@ -218,7 +240,7 @@ describe('UserInput', () => {
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
- expect(userInputService.computeTriggerSignal()).toBe(0);
+ expect(createCalculation).not.toHaveBeenCalled();
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
@@ -233,11 +255,11 @@ describe('UserInput', () => {
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
- expect(userInputService.computeTriggerSignal()).toBe(0);
+ expect(createCalculation).not.toHaveBeenCalled();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
- expect(userInputService.computeTriggerSignal()).toBe(1);
+ expect(createCalculation).toHaveBeenCalledTimes(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
@@ -282,7 +304,7 @@ describe('UserInput', () => {
expect(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
- expect(userInputService.computeTriggerSignal()).toBe(0);
+ expect(createCalculation).not.toHaveBeenCalled();
});
it('should initialize with vacation days from service', () => {
@@ -518,7 +540,7 @@ describe('UserInput', () => {
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
- expect(userInputService.computeTriggerSignal()).toBe(1);
+ expect(createCalculation).toHaveBeenCalledTimes(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts
index 3ae0ef6..9cd2299 100644
--- a/src/app/components/user-input/user-input.ts
+++ b/src/app/components/user-input/user-input.ts
@@ -15,6 +15,7 @@ import { VacDay } from '../../services/calendar-service';
import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
+import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -45,6 +46,7 @@ export class UserInput {
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
+ private readonly calculations = inject(PlanningCalculationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject>(DateAdapter);
protected readonly planningDates = computed(() => {
@@ -72,22 +74,21 @@ export class UserInput {
}
protected readonly remainingDays = computed(() =>
- (this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
+ (this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
(total, balance) => total + balance.quantity,
0,
),
);
protected readonly hasAtRiskDays = computed(() =>
- this.userInputService
- .displayedPlanSignal()
+ this.calculations
+ .displayedPlan()
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
);
protected remainingDaysDetails = computed(() => {
- const plan = this.userInputService.displayedPlanSignal();
+ const plan = this.calculations.displayedPlan();
if (!plan) return [];
- const incomplete =
- this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
+ const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
@@ -147,10 +148,7 @@ export class UserInput {
}
private ruleName(id: string): string {
- return (
- this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
- ?.name ?? id
- );
+ return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
@@ -178,6 +176,6 @@ export class UserInput {
public triggerComputation() {
if (this.calendarSettings.planningHorizonError()) return;
- this.userInputService.requestComputation();
+ this.calculations.start();
}
}
diff --git a/src/app/services/planning-calculation.service.spec.ts b/src/app/services/planning-calculation.service.spec.ts
new file mode 100644
index 0000000..5fc0f25
--- /dev/null
+++ b/src/app/services/planning-calculation.service.spec.ts
@@ -0,0 +1,301 @@
+import { provideZonelessChangeDetection } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { vi } from 'vitest';
+import { CalendarService } from './calendar-service';
+import { CalendarSettingsService } from './calendar-settings-service';
+import type {
+ PlanOutcome,
+ PlanningCalculationContext,
+ PlanningExplanation,
+ PlanningOutcome,
+ PlanningReason,
+} from './leave-planner.service';
+import { PlanningCalculationService } from './planning-calculation.service';
+import { UserInputService } from './user-input-service';
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function planFor(start: string, end: string): PlanOutcome {
+ return {
+ kind: 'Plan',
+ horizon: { start, end },
+ declaredDays: [],
+ placements: [],
+ timeOffPeriods: [],
+ preferences: [],
+ remainingBalances: [],
+ completedStages: [],
+ } as unknown as PlanOutcome;
+}
+
+const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
+
+function fakeCalculation(id: string) {
+ const outcome = deferred();
+ const explanation = deferred();
+ const explanationError = deferred();
+ const cancel = vi.fn();
+ const context = {
+ id,
+ cancel,
+ snapshot: { rules: [], labels: {}, declaredDays: [] },
+ outcome: outcome.promise,
+ explanation: explanation.promise,
+ explanationError: explanationError.promise,
+ analyzeCounterfactual: vi.fn(),
+ } as unknown as PlanningCalculationContext;
+ return { context, outcome, explanation, explanationError, cancel };
+}
+
+type Fake = ReturnType;
+
+const flush = () => new Promise((resolve) => setTimeout(resolve));
+
+describe('PlanningCalculationService', () => {
+ let create: ReturnType;
+ let service: PlanningCalculationService;
+
+ function queue(...fakes: Fake[]): void {
+ for (const fake of fakes) create.mockReturnValueOnce(fake.context);
+ }
+
+ beforeEach(() => {
+ localStorage.clear();
+ create = vi.fn();
+ TestBed.configureTestingModule({
+ providers: [
+ provideZonelessChangeDetection(),
+ { provide: CalendarService, useValue: { createPlanningCalculation: create } },
+ ],
+ });
+ TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
+ service = TestBed.inject(PlanningCalculationService);
+ });
+
+ afterEach(() => {
+ TestBed.resetTestingModule();
+ localStorage.clear();
+ });
+
+ it('does not calculate until started and ignores input edits afterwards', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
+ expect(create).not.toHaveBeenCalled();
+ expect(service.accepted()).toBeNull();
+ service.start();
+ expect(create).toHaveBeenCalledTimes(1);
+ expect(service.isCalculating()).toBe(true);
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ const accepted = service.accepted()!;
+ expect(accepted.successful).toBe(true);
+ expect(service.isCalculating()).toBe(false);
+ TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
+ TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
+ await flush();
+ expect(create).toHaveBeenCalledTimes(1);
+ expect(service.accepted()).toBe(accepted);
+ expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
+ });
+
+ it('hands the submission translated labels and leaves declaration entry', () => {
+ queue(fakeCalculation('first'));
+ const input = TestBed.inject(UserInputService);
+ input.declarationEntrySignal.set(true);
+ service.start();
+ expect(input.declarationEntrySignal()).toBe(false);
+ const submission = create.mock.calls[0][0];
+ expect(submission.horizon).toEqual(service.request()!.horizon);
+ expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
+ expect(submission.labels).toBeDefined();
+ });
+
+ it('reports progress of the latest calculation only', () => {
+ queue(fakeCalculation('old'), fakeCalculation('latest'));
+ service.start();
+ const oldProgress = create.mock.calls[0][0].progress;
+ service.start();
+ const latestProgress = create.mock.calls[1][0].progress;
+ oldProgress('obligations');
+ expect(service.progress()).toBe(0);
+ latestProgress('at-risk');
+ expect(service.progress()).toBe(35);
+ latestProgress('unknown');
+ expect(service.progress()).toBe(35);
+ });
+
+ it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
+ const first = fakeCalculation('first');
+ const stale = fakeCalculation('stale');
+ const latest = fakeCalculation('latest');
+ queue(first, stale, latest);
+ service.start();
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ const previous = service.accepted()!;
+ service.start();
+ service.start();
+ expect(service.accepted()).toBe(previous);
+ expect(service.displayedPlan()).toBe(previous.outcome);
+ stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
+ await flush();
+ expect(service.accepted()).toBe(previous);
+ expect(service.isCalculating()).toBe(true);
+ latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
+ await flush();
+ expect(service.accepted()!.calculation).toBe(latest.context);
+ expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
+ expect(service.isCalculating()).toBe(false);
+ });
+
+ it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
+ const first = fakeCalculation('first');
+ const second = fakeCalculation('second');
+ queue(first, second);
+ service.start();
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ expect(service.outcome()?.kind).toBe('Plan');
+ expect(service.explanation()).toBeNull();
+ service.start();
+ second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ first.explanation.resolve(explained);
+ await flush();
+ expect(service.explanation()).toBeNull();
+ second.explanation.resolve(undefined);
+ second.explanationError.resolve(undefined);
+ await flush();
+ expect(service.outcome()?.kind).toBe('Plan');
+ });
+
+ it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
+ const first = fakeCalculation('first');
+ const second = fakeCalculation('second');
+ queue(first, second);
+ service.start();
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ service.start();
+ second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ first.explanationError.resolve({ code: 'old', message: 'old' });
+ await flush();
+ expect(service.explanationError()).toBeNull();
+ });
+
+ it('keeps the outcome when the explanation fails', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ service.start();
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ first.explanation.reject(new Error('no trace'));
+ first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
+ await flush();
+ expect(service.outcome()?.kind).toBe('Plan');
+ expect(service.explanationError()?.message).toBeTruthy();
+ expect(service.explanation()).toBeNull();
+ });
+
+ it('keeps the outcome when the explanation error promise rejects', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ service.start();
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ first.explanationError.reject(new Error('lost'));
+ await flush();
+ expect(service.outcome()?.kind).toBe('Plan');
+ expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
+ });
+
+ it('turns an outcome rejection into an accepted planner error', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ service.start();
+ first.outcome.reject(new Error('worker died'));
+ await flush();
+ const accepted = service.accepted()!;
+ expect(accepted.successful).toBe(false);
+ expect(accepted.outcome).toEqual({
+ kind: 'PlanningIncomplete',
+ completedStages: [],
+ reasons: [{ code: 'planner-error', message: 'worker died' }],
+ });
+ expect(service.isCalculating()).toBe(false);
+ });
+
+ it('turns a construction failure into an accepted planner error with the requested horizon', () => {
+ create.mockImplementation(() => {
+ throw new Error('cannot start');
+ });
+ service.start();
+ const reason = { code: 'planner-error', message: 'cannot start' };
+ expect(service.outcome()).toEqual({
+ kind: 'PlanningIncomplete',
+ completedStages: [],
+ reasons: [reason],
+ });
+ expect(service.explanationError()).toEqual(reason);
+ expect(service.accepted()!.calculation).toBeNull();
+ expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
+ expect(service.isCalculating()).toBe(false);
+ });
+
+ it('cancels cooperatively once, and not when nothing is running', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ service.cancel();
+ expect(first.cancel).not.toHaveBeenCalled();
+ service.start();
+ service.cancel();
+ service.cancel();
+ expect(first.cancel).toHaveBeenCalledTimes(1);
+ expect(service.isCancelling()).toBe(true);
+ first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
+ await flush();
+ expect(service.isCancelling()).toBe(false);
+ expect(service.accepted()!.successful).toBe(false);
+ });
+
+ it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ service.start();
+ service.cancel();
+ first.outcome.resolve({
+ kind: 'PlanningIncomplete',
+ completedStages: [],
+ reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
+ });
+ await flush();
+ expect(service.outcome()?.kind).toBe('PlanningIncomplete');
+ expect(service.displayedPlan()).toBeUndefined();
+ expect(service.accepted()!.successful).toBe(false);
+ });
+
+ it('shows the incumbent of a cancelled calculation', async () => {
+ const first = fakeCalculation('first');
+ queue(first);
+ const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
+ service.start();
+ service.cancel();
+ first.outcome.resolve({
+ kind: 'PlanningIncomplete',
+ incumbent,
+ completedStages: [],
+ reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
+ });
+ await flush();
+ expect(service.displayedPlan()).toBe(incumbent);
+ expect(service.accepted()!.successful).toBe(false);
+ });
+});
diff --git a/src/app/services/planning-calculation.service.ts b/src/app/services/planning-calculation.service.ts
new file mode 100644
index 0000000..e7871e5
--- /dev/null
+++ b/src/app/services/planning-calculation.service.ts
@@ -0,0 +1,221 @@
+import { computed, inject, Injectable, signal } from '@angular/core';
+import dayjs from 'dayjs';
+import { CalendarService } from './calendar-service';
+import { CalendarSettingsService } from './calendar-settings-service';
+import type {
+ DeclaredNonWorkingDay,
+ PlanningCalculationContext,
+ PlanningExplanation,
+ PlanningOutcome,
+ PlanningReason,
+ PlanningSnapshot,
+} from './leave-planner.service';
+import { RuleService } from './rule-service';
+import { TranslationService } from './translation-service';
+import { UserInputService } from './user-input-service';
+
+type Horizon = { start: string; end: string };
+
+interface PendingCalculation {
+ horizon: Horizon;
+ progress: number;
+ cancelling: boolean;
+}
+
+export interface AcceptedCalculation {
+ /** Null when the planner could not even be created. */
+ calculation: PlanningCalculationContext | null;
+ outcome: PlanningOutcome;
+ snapshot: PlanningSnapshot | null;
+ horizon: Horizon;
+ declaredDays: readonly DeclaredNonWorkingDay[];
+ requestedAt: number;
+ successful: boolean;
+}
+
+function stageProgress(stage: string, current: number): 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:') || stage.startsWith('optimization:')) return 50;
+ if (stage === 'holiday-week-value') return 58;
+ 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 current;
+}
+
+function plannerError(error: unknown): PlanningReason {
+ return {
+ code: 'planner-error',
+ message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
+ };
+}
+
+function explanationFailure(error: unknown): PlanningReason {
+ return {
+ code: 'explanation-error',
+ message: error instanceof Error ? error.message : String(error),
+ };
+}
+
+/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
+@Injectable({ providedIn: 'root' })
+export class PlanningCalculationService {
+ private readonly settings = inject(CalendarSettingsService);
+ private readonly userInput = inject(UserInputService);
+ private readonly rules = inject(RuleService);
+ private readonly calendar = inject(CalendarService);
+ private readonly translation = inject(TranslationService);
+
+ private version = 0;
+ private running: PlanningCalculationContext | null = null;
+ private readonly pendingState = signal(null);
+ private readonly acceptedState = signal(null);
+ private readonly explanationState = signal(null);
+ private readonly explanationErrorState = signal(null);
+
+ readonly request = computed(() => {
+ const horizon = this.settings.planningHorizon();
+ if (!horizon) return null;
+ return {
+ horizon,
+ declaredDays: this.userInput.declaredDaysSignal(),
+ balances: this.userInput.vacationNumberSignal().map((balance) => ({
+ id: balance.id,
+ type: balance.type,
+ quantity: balance.numberOfDays,
+ availableFrom: balance.availableDate
+ ? dayjs(balance.availableDate).format('YYYY-MM-DD')
+ : undefined,
+ expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
+ })),
+ rules: this.rules.rules,
+ };
+ });
+
+ readonly accepted = this.acceptedState.asReadonly();
+ readonly explanation = this.explanationState.asReadonly();
+ readonly explanationError = this.explanationErrorState.asReadonly();
+ readonly isCalculating = computed(() => this.pendingState() !== null);
+ readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
+ readonly progress = computed(() => this.pendingState()?.progress ?? 0);
+ readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
+ readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
+ readonly displayedPlan = computed(() => {
+ const outcome = this.outcome();
+ return outcome?.kind === 'Plan'
+ ? outcome
+ : outcome?.kind === 'PlanningIncomplete'
+ ? outcome.incumbent
+ : undefined;
+ });
+ readonly displayedHorizon = computed(
+ () => this.acceptedState()?.horizon ?? this.pendingState()?.horizon ?? null,
+ );
+
+ start(): void {
+ const request = this.request();
+ if (!request) return;
+ const requestedAt = performance.now();
+ const version = ++this.version;
+ this.userInput.declarationEntrySignal.set(false);
+ this.pendingState.set({ horizon: request.horizon, progress: 0, cancelling: false });
+ let calculation: PlanningCalculationContext;
+ try {
+ calculation = this.calendar.createPlanningCalculation({
+ ...request,
+ balances: request.balances.map((balance) => ({
+ ...balance,
+ label: this.translation.getDayTypeLabel(balance.type),
+ })),
+ labels: Object.fromEntries(
+ request.rules.map((rule) => [rule.id, rule.name ?? rule.id]),
+ ),
+ progress: (stage) => {
+ if (version === this.version)
+ this.pendingState.update(
+ (pending) =>
+ pending && {
+ ...pending,
+ progress: stageProgress(stage, pending.progress),
+ },
+ );
+ },
+ });
+ } catch (error) {
+ const reason = plannerError(error);
+ this.running = null;
+ this.accept(null, this.incomplete(reason), request.horizon, [], requestedAt);
+ this.explanationErrorState.set(reason);
+ return;
+ }
+ this.running = calculation;
+ const accept = (outcome: PlanningOutcome) => {
+ if (version !== this.version) return false;
+ this.running = null;
+ this.accept(calculation, outcome, request.horizon, undefined, requestedAt);
+ return true;
+ };
+ void calculation.outcome
+ .then((outcome) => accept(outcome))
+ .catch((error: unknown) => accept(this.incomplete(plannerError(error))))
+ .then((accepted) => {
+ if (accepted) this.loadArtifacts(calculation);
+ });
+ }
+
+ cancel(): void {
+ const pending = this.pendingState();
+ if (!pending || pending.cancelling || !this.running) return;
+ this.pendingState.set({ ...pending, cancelling: true });
+ this.running.cancel();
+ }
+
+ private incomplete(reason: PlanningReason): PlanningOutcome {
+ return { kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] };
+ }
+
+ private accept(
+ calculation: PlanningCalculationContext | null,
+ outcome: PlanningOutcome,
+ requestHorizon: Horizon,
+ declaredDays: readonly DeclaredNonWorkingDay[] | undefined,
+ requestedAt: number,
+ ): void {
+ const plan = outcome.kind === 'Plan' ? outcome : undefined;
+ const shown =
+ plan ?? (outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined);
+ const cancelling = this.pendingState()?.cancelling ?? false;
+ this.explanationState.set(null);
+ this.explanationErrorState.set(null);
+ this.pendingState.set(null);
+ this.acceptedState.set({
+ calculation,
+ outcome,
+ snapshot: calculation?.snapshot ?? null,
+ horizon: shown?.horizon ?? requestHorizon,
+ declaredDays:
+ shown?.declaredDays ?? declaredDays ?? calculation?.snapshot.declaredDays ?? [],
+ requestedAt,
+ successful: plan !== undefined && !cancelling,
+ });
+ }
+
+ private loadArtifacts(calculation: PlanningCalculationContext): void {
+ const current = () => this.acceptedState()?.calculation === calculation;
+ const fail = (error: unknown) => {
+ if (current()) this.explanationErrorState.set(explanationFailure(error));
+ };
+ calculation.explanation
+ .then((explanation) => current() && this.explanationState.set(explanation ?? null))
+ .catch(fail);
+ calculation.explanationError
+ .then((reason) => current() && this.explanationErrorState.set(reason ?? null))
+ .catch(fail);
+ }
+}
diff --git a/src/app/services/planning-duration.service.spec.ts b/src/app/services/planning-duration.service.spec.ts
index eabe86a..c4ed358 100644
--- a/src/app/services/planning-duration.service.spec.ts
+++ b/src/app/services/planning-duration.service.spec.ts
@@ -5,6 +5,7 @@ import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
+import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('Planning duration estimate', () => {
@@ -31,7 +32,8 @@ describe('Planning duration estimate', () => {
it('offers a coarse seconds range on the first launch without calculating', () => {
const duration = TestBed.inject(PlanningDurationService);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
- expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0);
+ expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
+ expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
expect(TestBed.inject(RuleService).rules).toEqual([]);
});
@@ -47,7 +49,7 @@ describe('Planning duration estimate', () => {
});
const duration = TestBed.inject(PlanningDurationService);
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
- duration.recordCompletion(duration.request()!, 20_000);
+ duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
@@ -81,19 +83,20 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(moreLeave);
settings.setPlanningDates('2026-12-31', '2024-01-01');
expect(duration.estimate()).toBeNull();
- expect(input.computeTriggerSignal()).toBe(0);
+ expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
+ expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
});
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
const duration = TestBed.inject(PlanningDurationService);
- const submitted = duration.request()!;
+ const submitted = TestBed.inject(PlanningCalculationService).request()!;
duration.recordCompletion(submitted, 120_000);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
});
it('calibrates against the submitted snapshot even when the form has since changed', () => {
const duration = TestBed.inject(PlanningDurationService);
- const submitted = duration.request()!;
+ const submitted = TestBed.inject(PlanningCalculationService).request()!;
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
duration.recordCompletion(submitted, 20_000);
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
@@ -114,14 +117,16 @@ describe('Planning duration estimate', () => {
placeType: SelectableDayType.CP,
});
const duration = TestBed.inject(PlanningDurationService);
- const calculation = createPlanningCalculation(duration.request()!);
+ const calculation = createPlanningCalculation(
+ TestBed.inject(PlanningCalculationService).request()!,
+ );
duration.recordCompletion(calculation.snapshot, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
const duration = TestBed.inject(PlanningDurationService);
- const submitted = duration.request()!;
+ const submitted = TestBed.inject(PlanningCalculationService).request()!;
for (const elapsed of [20_000, 25_000, 900_000])
duration.recordCompletion(submitted, elapsed);
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
@@ -136,7 +141,7 @@ describe('Planning duration estimate', () => {
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
const duration = TestBed.inject(PlanningDurationService);
const initial = duration.estimate();
- duration.recordCompletion(duration.request()!, elapsed);
+ duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
expect(duration.estimate()).toEqual(initial);
});
});
diff --git a/src/app/services/planning-duration.service.ts b/src/app/services/planning-duration.service.ts
index 83e22cd..e6192e2 100644
--- a/src/app/services/planning-duration.service.ts
+++ b/src/app/services/planning-duration.service.ts
@@ -1,9 +1,7 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
-import { CalendarSettingsService } from './calendar-settings-service';
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
-import { RuleService } from './rule-service';
-import { UserInputService } from './user-input-service';
+import { PlanningCalculationService } from './planning-calculation.service';
export interface PlanningDurationRange {
minimum: number;
@@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number {
@Injectable({ providedIn: 'root' })
export class PlanningDurationService {
- private readonly settings = inject(CalendarSettingsService);
- private readonly userInput = inject(UserInputService);
- private readonly rules = inject(RuleService);
+ private readonly calculations = inject(PlanningCalculationService);
private readonly calibration = signal([]);
- readonly request = computed(() => {
- const horizon = this.settings.planningHorizon();
- if (!horizon) return null;
- return {
- horizon,
- declaredDays: this.userInput.declaredDaysSignal(),
- balances: this.userInput.vacationNumberSignal().map((balance) => ({
- id: balance.id,
- type: balance.type,
- quantity: balance.numberOfDays,
- availableFrom: balance.availableDate
- ? dayjs(balance.availableDate).format('YYYY-MM-DD')
- : undefined,
- expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
- })),
- rules: this.rules.rules,
- };
- });
-
readonly estimate = computed(() => {
- const request = this.request();
+ const request = this.calculations.request();
if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
diff --git a/src/app/services/rule-migration.spec.ts b/src/app/services/rule-migration.spec.ts
index 5d46631..eb9a6e9 100644
--- a/src/app/services/rule-migration.spec.ts
+++ b/src/app/services/rule-migration.spec.ts
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { CalendarSettingsService } from './calendar-settings-service';
-import { PlanningDurationService } from './planning-duration.service';
+import { PlanningCalculationService } from './planning-calculation.service';
import { UserInputService } from './user-input-service';
import { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
@@ -260,7 +260,7 @@ describe('saved rule migration', () => {
expiryDate: new Date('2024-01-05'),
},
]);
- const request = TestBed.inject(PlanningDurationService).request()!;
+ const request = TestBed.inject(PlanningCalculationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
diff --git a/src/app/services/user-input-service.spec.ts b/src/app/services/user-input-service.spec.ts
index 703cfe2..f4e5260 100644
--- a/src/app/services/user-input-service.spec.ts
+++ b/src/app/services/user-input-service.spec.ts
@@ -46,7 +46,6 @@ describe('UserInputService', () => {
{ date: '2024-01-02', type: 'other' },
]);
expect(service.vacationNumberSignal()).toBe(balances);
- expect(service.computeTriggerSignal()).toBe(0);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
@@ -119,7 +118,6 @@ describe('UserInputService', () => {
]);
expect(save).toHaveBeenCalledTimes(3);
expect(service.vacationNumberSignal()).toBe(balances);
- expect(service.computeTriggerSignal()).toBe(0);
});
it('restores an empty list when saved declarations are unreadable', () => {
diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts
index 0fcb093..a5291d4 100644
--- a/src/app/services/user-input-service.ts
+++ b/src/app/services/user-input-service.ts
@@ -1,4 +1,4 @@
-import { computed, Injectable, signal } from '@angular/core';
+import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
@@ -6,7 +6,7 @@ import {
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
-import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
+import type { LeaveType } from './leave-planner.service';
@Injectable({
providedIn: 'root',
@@ -161,19 +161,6 @@ export class UserInputService {
readonly vacationNumberSignal = signal(
this.getSavedValues() ?? this.getInitialValues(),
);
- readonly planningOutcomeSignal = signal(null);
- readonly planningSnapshotSignal = signal(null);
- readonly displayedPlanSignal = computed(() => {
- const outcome = this.planningOutcomeSignal();
- return outcome?.kind === 'Plan'
- ? outcome
- : outcome?.kind === 'PlanningIncomplete'
- ? outcome.incumbent
- : undefined;
- });
- readonly computeTriggerSignal = signal(0);
- readonly computationRequestedAtMs = signal(null);
-
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
try {
@@ -193,9 +180,4 @@ export class UserInputService {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
-
- public requestComputation(): void {
- this.computationRequestedAtMs.set(performance.now());
- this.computeTriggerSignal.update((count) => count + 1);
- }
}