Re-mounting the calendar no longer records a duration sample or announces the previous result, explanation or rejected explanation. Starting a calculation during a cancellation announces loading and keeps focus where it is. Only rejected explanation deliveries are announced, as before. Replacement tests now replace a real plan. Refs #275, #274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
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 {
|
|
id: number;
|
|
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);
|
|
private readonly explanationRejectedState = signal(false);
|
|
|
|
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 explanationRejected = this.explanationRejectedState.asReadonly();
|
|
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
|
|
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({
|
|
id: version,
|
|
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.explanationRejectedState.set(false);
|
|
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()) return;
|
|
this.explanationRejectedState.set(true);
|
|
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);
|
|
}
|
|
}
|