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