The overlay percentage now rises linearly to 95% at the expected wait captured at Calculate, holds there, and shows 100% only when the planner completes. Beneath the status, one line names the current planning stage (with the rule name for rule stages) and another shows the approximate remaining time, or "Taking longer than expected" once the wait is exceeded, announced once through the live status. Cancel freezes the percentage and hides both lines. PlanningCalculationService reports the stage and submitted request instead of fixed per-stage percentages; PlanningDurationService owns the wait progress on a 250 ms tick. Closes #300 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
7.5 KiB
TypeScript
221 lines
7.5 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,
|
|
PlanningRequest,
|
|
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 };
|
|
|
|
export interface PendingCalculation {
|
|
id: number;
|
|
horizon: Horizon;
|
|
/** The configuration submitted at Calculate, unaffected by later form edits. */
|
|
request: PlanningRequest;
|
|
requestedAt: number;
|
|
/** The latest planning stage reported by the planner, or 'complete'. */
|
|
stage: string;
|
|
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 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, planning stages, 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 pending = this.pendingState.asReadonly();
|
|
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,
|
|
request,
|
|
requestedAt,
|
|
stage: 'preparation',
|
|
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.cancelling ? { ...pending, stage } : pending,
|
|
);
|
|
},
|
|
});
|
|
} 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);
|
|
}
|
|
}
|