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 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-17 11:11:37 +02:00
1 parent b69b3c3690
commit 5cb0b93339
12 files changed
+708 -322

No files matched your search

@@ -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<PendingCalculation | null>(null);
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
private readonly explanationState = signal<PlanningExplanation | null>(null);
private readonly explanationErrorState = signal<PlanningReason | null>(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);
}
}