import { Component, computed, DestroyRef, effect, ElementRef, inject, signal, TemplateRef, untracked, viewChild, } from '@angular/core'; import { JsonPipe, NgTemplateOutlet } from '@angular/common'; import { MatCard, MatCardContent } from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButton } from '@angular/material/button'; import dayjs from 'dayjs'; import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month'; import { CalendarService, DayType, SelectedDates, SelectableDayType, VacDay, } from '../../services/calendar-service'; import { RuleService } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { applyReplayDeltas, CounterfactualAnalysis, CounterfactualSelection, LeavePlacement, PlanExplanation, PlanOutcome, PlanningCalculationContext, PlanningExplanation, PlanningObjective, PlanningObjectiveVector, PlanningOutcome, PlanningReason, PlanningTrace, PlanningTraceCandidate, PlanningTraceDecision, PlanningTraceState, } from '../../services/leave-planner.service'; type PlacementExplanation = PlanExplanation['placements'][number]; type TraceMoment = 'before' | 'after'; type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>; @Component({ selector: 'app-calendar-year', imports: [ JsonPipe, NgTemplateOutlet, MatDialogContent, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButton, MatCard, CalendarMonth, MatCardContent, MatProgressSpinner, MatIcon, ], templateUrl: './calendar-year.html', styleUrl: './calendar-year.scss', providers: DAY_FIRST_DATE_PROVIDERS, }) export class CalendarYear { private readonly userInput = inject(UserInputService); private readonly calendarService = inject(CalendarService); private readonly ruleService = inject(RuleService); private readonly calendarSettings = inject(CalendarSettingsService); private readonly dialog = inject(MatDialog); private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); private readonly comparisonTemplate = viewChild.required>('dateComparisonDialog'); private comparisonDialog: MatDialogRef | null = null; protected readonly translationService = inject(TranslationService); protected readonly isLoading = signal(false); protected readonly isCancelling = signal(false); private readonly resultsTitle = viewChild>('resultsTitle'); protected readonly loadingProgress = signal(0); protected readonly planningOutcome = signal(null); protected readonly planningExplanation = signal(null); protected readonly explanationError = signal(null); protected readonly planningTrace = signal(null); protected readonly traceLoading = signal(false); protected readonly traceResolved = signal(false); protected readonly selectedPlacementIndex = signal(0); protected readonly planDetailsOpen = signal(false); protected readonly traceOpen = signal(false); private readonly planDetails = viewChild>('planDetails'); protected readonly counterfactual = signal(null); protected readonly selectedCounterfactualDate = signal(null); protected readonly counterfactualLoading = signal(false); protected readonly counterfactualError = signal(null); protected readonly invalidComparisonDate = signal(false); protected readonly comparisonDateInput = signal(null); protected readonly tracePhaseIndex = signal(0); protected readonly traceDecisionIndex = signal(0); protected readonly traceCandidatePage = signal(0); protected readonly traceMoment = signal('before'); protected readonly traceReplay = signal(false); protected readonly selectedTraceDate = signal(null); protected readonly liveMessage = signal(''); private readonly historicalIneligibility = signal>( new Map(), ); protected readonly historicalEligibilityLoading = signal(false); protected readonly historicalEligibilityError = signal(null); protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null); protected readonly selectedDates = signal(new SelectedDates()); private calculationVersion = 0; private counterfactualVersion = 0; private historicalEligibilityVersion = 0; private activeCalculation: PlanningCalculationContext | null = null; private readonly traceDecisionIndices = new WeakMap< PlanningTrace, ReadonlyMap >(); private displayDateLocale = ''; private displayDateFormatter?: Intl.DateTimeFormat; get tr() { return this.translationService.tr; } protected readonly displayedPlan = computed(() => { const outcome = this.planningOutcome(); return outcome?.kind === 'Plan' ? outcome : outcome?.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined; }); protected readonly planExplanation = computed(() => { const explanation = this.planningExplanation(); return explanation?.kind === 'PlanExplanation' ? explanation : undefined; }); protected readonly selectedExplanation = computed( () => this.planExplanation()?.placements[this.selectedPlacementIndex()], ); protected readonly selectedTracePhase = computed( () => this.planningTrace()?.phases[this.tracePhaseIndex()], ); protected readonly selectedTraceDecision = computed( () => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()], ); protected readonly selectedTraceTransition = computed(() => this.traceReplay() ? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision()) : undefined, ); protected readonly visibleTraceCandidates = computed(() => { const start = this.traceCandidatePage() * 50; return this.selectedTraceDecision()?.candidates.slice(start, start + 50) ?? []; }); protected readonly traceCandidatePages = computed(() => Math.max(1, Math.ceil((this.selectedTraceDecision()?.candidates.length ?? 0) / 50)), ); protected readonly selectedCounterfactualResult = computed(() => this.counterfactual()?.results.find( (result) => result.date === this.selectedCounterfactualDate(), ), ); protected readonly nearbyComparisonDates = computed(() => { const analysis = this.counterfactual(); if (!analysis) return []; const original = Date.parse(analysis.placement.date); return analysis.results .filter( (result) => result.status === 'eligible' && result.date !== analysis.placement.date, ) .sort( (a, b) => Math.abs(Date.parse(a.date) - original) - Math.abs(Date.parse(b.date) - original) || a.date.localeCompare(b.date), ) .slice(0, 3); }); protected changeComparisonDate(event: MatDatepickerInputEvent): void { const selected = event.value; // Material returns local midnight; converting to UTC can change the calendar day. const date = selected ? [ String(selected.getFullYear()).padStart(4, '0'), String(selected.getMonth() + 1).padStart(2, '0'), String(selected.getDate()).padStart(2, '0'), ].join('-') : null; const valid = !!this.counterfactual()?.results.some((result) => result.date === date); this.invalidComparisonDate.set(!!(event.targetElement as HTMLInputElement).value && !valid); if (valid && date) this.selectCalendarDate(date); else this.selectedCounterfactualDate.set(null); } protected readonly placementChanges = computed(() => { const comparison = this.selectedExplanation()?.comparison; return this.numericChanges(comparison?.before, comparison?.after); }); protected readonly dateComparison = computed(() => { const analysis = this.counterfactual(); const comparison = this.selectedCounterfactualResult()?.comparison; if (!analysis || !comparison) return undefined; // Final-plan alternatives share a removed-day baseline. Compare their restored // plans; a trace candidate instead uses the exact state before its decision. const before = this.traceReplay() ? comparison.before : analysis.results.find((result) => result.date === analysis.placement.date)?.comparison ?.after; return before ? { before, after: comparison.after, firstDecisiveObjective: comparison.after.objectives.find( (objective) => before.objectives.find((item) => item.id === objective.id)?.value !== objective.value, )?.id, } : undefined; }); protected readonly dateChanges = computed(() => this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after), ); protected readonly traceState = computed(() => { return this.selectedTraceTransition()?.[this.traceMoment()]; }); protected readonly calendarSelectedDates = computed(() => { const state = this.traceState(); return state ? this.calendarService.selectedDatesForPlan({ placements: [...state.placements], horizon: { ...state.horizon }, }) : this.selectedDates(); }); protected readonly currentPlacementDate = computed(() => { if (this.traceReplay()) return ( this.selectedCounterfactualDate() ?? this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null ); return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null; }); protected readonly explainedPeriod = computed(() => this.planDetailsOpen() && !this.traceReplay() ? this.selectedExplanation()?.timeOffPeriod : undefined, ); protected readonly dateStatuses = computed>>(() => { const statuses: Record = {}; const trace = this.planningTrace(); const closedDayDates = new Set(); for (const selected of this.selectedDates().datesSelected) { if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue; const day = selected.range.start; closedDayDates.add( `${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`, ); } if (this.traceReplay() && trace) { const priority = new Map([ ['ineligible', 0], ['not-examined', 1], ['pruned', 2], ['evaluated-rejected', 3], ['chosen', 4], ]); for ( let timestamp = Date.parse(`${trace.initialState.horizon.start}T00:00:00Z`), end = Date.parse(`${trace.initialState.horizon.end}T00:00:00Z`); timestamp < end; timestamp += 86_400_000 ) { const date = new Date(timestamp).toISOString().slice(0, 10); statuses[date] = { kind: 'not-examined', label: this.traceStatusLabel('not-examined'), }; } for (const date of Object.keys(statuses)) { const reason = this.nonWorkingDateReason(date, closedDayDates); if (reason) statuses[date] = { kind: 'ineligible', label: this.traceStatusLabel('ineligible'), reason, }; } const explicitPriorities = new Map(); for (const candidate of this.selectedTraceDecision()?.candidates ?? []) { for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) { const candidatePriority = priority.get(candidate.status)!; if (candidatePriority > (explicitPriorities.get(date) ?? -1)) { statuses[date] = { kind: candidate.status, label: this.traceStatusLabel(candidate.status), reason: candidate.reason?.message, }; explicitPriorities.set(date, candidatePriority); } } } for (const [date, reason] of this.historicalIneligibility()) { if (statuses[date]?.kind !== 'not-examined') continue; statuses[date] = { kind: 'ineligible', label: this.traceStatusLabel('ineligible'), reason: this.reasonLabel(reason), }; } const decision = this.selectedTraceDecision(); if (decision) { const changedDates = new Set(); const setChange = ( date: string | undefined, change: NonNullable, ) => { if (!date) return; changedDates.add(date); statuses[date] = { ...(statuses[date] ?? { kind: 'not-examined', label: this.traceStatusLabel('not-examined'), }), change, changeLabel: this.traceChangeLabel(change), }; }; for (const delta of decision.branch?.deltas ?? decision.deltas) { if (delta.kind === 'placement-added') setChange(delta.date, 'added'); else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed'); else if (delta.kind === 'placement-moved') { setChange(delta.fromDate, 'moved-from'); setChange(delta.toDate, 'moved-to'); } else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared'); } const transition = this.selectedTraceTransition(); for (const placement of [ ...(transition?.before.placements ?? []), ...(transition?.after.placements ?? []), ]) { if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged'); } } for (const result of this.counterfactual()?.results ?? []) { const status = statuses[result.date]; const historicalStatus = result.historicalStatus ?? (status && status.kind !== 'eligible' ? status.kind : 'not-examined'); statuses[result.date] = { ...status, kind: historicalStatus, label: this.traceStatusLabel(historicalStatus), reason: result.historicalReason ? this.reasonLabel(result.historicalReason) : status?.reason, hypotheticalLabel: result.status === 'eligible' ? this.tr().planning.eligible : this.tr().planning.ineligible, hypotheticalReason: result.reason?.message, }; } return statuses; } for (const result of this.counterfactual()?.results ?? []) { const nonWorkingReason = this.nonWorkingDateReason(result.date, closedDayDates); const historicalStatus = result.historicalStatus ?? (nonWorkingReason ? 'ineligible' : 'not-examined'); statuses[result.date] = { kind: historicalStatus, label: this.traceStatusLabel(historicalStatus), reason: result.historicalReason ? this.reasonLabel(result.historicalReason) : historicalStatus === 'ineligible' ? nonWorkingReason : undefined, hypotheticalLabel: result.status === 'eligible' ? this.tr().planning.eligible : this.tr().planning.ineligible, hypotheticalReason: result.reason?.message, }; } return statuses; }); protected readonly months = computed(() => { const horizon = this.planningHorizon(); if (!horizon) return []; let first = new Date(`${horizon.start}T00:00:00`); let last = new Date(`${horizon.end}T00:00:00`); last.setDate(last.getDate() - 1); for (const period of this.displayedPlan()?.timeOffPeriods ?? []) { for (const date of period.nonWorkingDates) { const contextDate = new Date(`${date}T00:00:00`); if (date < horizon.start && contextDate < first) first = contextDate; else if (date >= horizon.end && contextDate > last) last = contextDate; } } const result: { monthIndex: number; year: number }[] = []; const cursor = new Date(first); cursor.setDate(1); while (cursor <= last) { result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); cursor.setMonth(cursor.getMonth() + 1); } return result; }); constructor() { effect(() => this.dateAdapter.setLocale(this.translationService.locale())); this.destroyRef.onDestroy(() => this.clearCounterfactual()); effect(() => { this.userInput.computeTriggerSignal(); untracked(() => this.startCalculation()); }); } private startCalculation(): void { const horizon = this.calendarSettings.planningHorizon(); if (!horizon) return; this.planningHorizon.set(horizon); this.resetCalculationState(horizon); const vacationData = this.userInput.vacationNumberSignal(); const currentRules = this.ruleService.rules; 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; try { calculation = this.calendarService.createPlanningCalculation({ horizon, balances: vacationData.map((balance) => ({ id: balance.id, type: balance.type, quantity: balance.numberOfDays, label: this.translationService.getDayTypeLabel(balance.type), availableFrom: balance.availableDate ? dayjs(balance.availableDate).format('YYYY-MM-DD') : undefined, expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'), })), rules: currentRules, labels: Object.fromEntries( currentRules.map((rule) => [rule.id, rule.name ?? rule.id]), ), progress: (stage) => { if (version === this.calculationVersion) this.loadingProgress.set(this.stageProgress(stage)); }, }); } catch (error) { this.failCalculation(error, version); return; } this.activeCalculation = calculation; this.loadArtifacts(calculation, version); void calculation.outcome .then((outcome) => { if (version === this.calculationVersion) this.applyOutcome(outcome); }) .catch((error: unknown) => { if (version !== this.calculationVersion) return; this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [ { code: 'planner-error', message: error instanceof Error ? error.message : 'The planner failed unexpectedly.', }, ], }); }) .finally(() => { if (version !== this.calculationVersion) return; this.loadingProgress.set(100); this.isLoading.set(false); if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus(); this.isCancelling.set(false); }); } protected cancelCalculation(): void { if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return; this.isCancelling.set(true); this.liveMessage.set(this.tr().planning.cancelling); this.activeCalculation.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.traceLoading.set(false); this.traceResolved.set(true); this.loadingProgress.set(100); this.isLoading.set(false); } private resetCalculationState(horizon: { start: string; end: string }): void { this.activeCalculation = null; this.planningOutcome.set(null); this.planningExplanation.set(null); this.explanationError.set(null); this.planningTrace.set(null); this.traceLoading.set(true); this.traceResolved.set(false); this.selectedPlacementIndex.set(0); this.planDetailsOpen.set(false); this.traceOpen.set(false); this.clearCounterfactual(); this.tracePhaseIndex.set(0); this.traceDecisionIndex.set(0); this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(false); this.clearHistoricalEligibility(); this.selectedTraceDate.set(null); this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon)); this.userInput.remainingVacationDaysSignal.set([]); } private loadArtifacts(calculation: PlanningCalculationContext, version: number): void { void calculation.explanation .then((explanation) => { if (version !== this.calculationVersion) 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 (version !== this.calculationVersion) 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 (version === this.calculationVersion) this.explanationError.set(reason ?? null); }) .catch((error: unknown) => { if (version !== this.calculationVersion) 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.trace .then((trace) => { if (version !== this.calculationVersion) return; this.planningTrace.set(trace ?? null); this.traceResolved.set(true); this.liveMessage.set( trace?.status === 'failed' ? this.tr().planning.traceUnavailable : this.tr().planning.traceReady, ); }) .catch((error: unknown) => { if (version !== this.calculationVersion) return; this.planningTrace.set({ status: 'failed', initialState: { horizon: calculation.snapshot.horizon, balances: [], placements: [], earnedActivations: [], }, phases: [], completedStages: [], error: { code: 'trace-error', message: error instanceof Error ? error.message : String(error), }, }); this.traceResolved.set(true); this.liveMessage.set(this.tr().planning.traceUnavailable); }) .finally(() => { if (version === this.calculationVersion) this.traceLoading.set(false); }); } private applyOutcome(outcome: PlanningOutcome): void { this.planningOutcome.set(outcome); this.liveMessage.set(this.outcomeLabel(outcome)); const plan = this.displayedPlan(); if (plan) { this.planningHorizon.set(plan.horizon); this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan)); this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan)); return; } this.selectedDates.set( this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!), ); this.userInput.remainingVacationDaysSignal.set([]); } protected selectPlacement(index: number, announce = true): void { const explanation = this.planExplanation(); if (!explanation?.placements.length) return; const selected = Math.max(0, Math.min(index, explanation.placements.length - 1)); this.selectedPlacementIndex.set(selected); this.traceReplay.set(false); this.clearCounterfactual(); this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date); if (announce) this.liveMessage.set(this.placementAnnouncement()); } protected compareDates(): void { const placement = this.selectedExplanation()?.placement; if (!placement) return; this.openComparisonDialog(); if (this.counterfactual()) { if (this.selectedCounterfactualDate() === placement.date) { const alternative = this.nearbyComparisonDates()[0]; if (alternative) this.selectCalendarDate(alternative.date); } } else if (!this.counterfactualLoading()) { void this.analyzeSelection(placement, true); } } protected retryCounterfactual(): void { if (this.counterfactualSelection) void this.analyzeSelection(this.counterfactualSelection, !!this.comparisonDialog); } protected togglePlanDetails(event: Event): void { const open = (event.currentTarget as HTMLDetailsElement).open; if (open === this.planDetailsOpen()) return; this.planDetailsOpen.set(open); if (open) { this.traceOpen.set(false); this.selectPlacement(this.selectedPlacementIndex()); } else this.clearCounterfactual(); } protected toggleTrace(event: Event): void { const open = (event.currentTarget as HTMLDetailsElement).open; if (open === this.traceOpen()) return; this.traceOpen.set(open); if (open) this.planDetailsOpen.set(false); this.showFinalPlan(); } protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void { const last = (this.planExplanation()?.placements.length ?? 1) - 1; const current = this.selectedPlacementIndex(); const target = step === 'first' ? 0 : step === 'last' ? last : step === 'previous' ? current - 1 : current + 1; if (target !== current) this.selectPlacement(target); } protected selectCalendarDate(date: string): void { if (this.counterfactual()?.results.some((result) => result.date === date)) { this.invalidComparisonDate.set(false); this.comparisonDateInput.set(new Date(`${date}T00:00:00`)); } if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) { const index = this.planExplanation()?.placements.findIndex( (explanation) => explanation.placement.date === date, ); if (index !== undefined && index >= 0) { this.planDetailsOpen.set(true); this.traceOpen.set(false); this.selectPlacement(index); return; } } if (this.traceReplay()) { this.selectedTraceDate.set(date); if (this.counterfactual()?.results.some((result) => result.date === date)) this.selectedCounterfactualDate.set(date); this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date])); return; } if (this.counterfactual()?.results.some((result) => result.date === date)) { this.selectedCounterfactualDate.set(date); this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date])); } } private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string { return [ date, status?.label, status?.reason, status?.hypotheticalLabel ? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}` : undefined, status?.hypotheticalReason ? `${this.tr().planning.reason}: ${status.hypotheticalReason}` : undefined, status?.changeLabel, ] .filter(Boolean) .join('. '); } protected selectTracePhase(index: number): void { const last = (this.planningTrace()?.phases.length ?? 1) - 1; index = Math.max(0, Math.min(index, last)); this.tracePhaseIndex.set(index); this.traceDecisionIndex.set(0); this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(false); this.clearHistoricalEligibility(); this.selectedTraceDate.set(null); this.clearCounterfactual(); this.liveMessage.set(this.tracePhaseAnnouncement()); } protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void { const last = (this.planningTrace()?.phases.length ?? 1) - 1; const current = this.tracePhaseIndex(); const target = step === 'first' ? 0 : step === 'last' ? last : step === 'previous' ? current - 1 : current + 1; if (target !== current) this.selectTracePhase(target); } protected selectTraceDecision(index: number): void { const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1; index = Math.max(0, Math.min(index, last)); this.traceDecisionIndex.set(index); this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(true); this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null); this.clearCounterfactual(); this.liveMessage.set(this.traceDecisionAnnouncement()); void this.loadHistoricalEligibility(); } private selectedDecisionPlacement(decision: PlanningTraceDecision): LeavePlacement | undefined { const placements = [ ...decision.candidates.flatMap((candidate) => candidate.placement ? [candidate.placement] : [], ), ...(decision.branch?.deltas ?? decision.deltas).flatMap((delta) => delta.placement ? [delta.placement] : [], ), ]; const selectedDate = this.selectedTraceDate() ?? decision.dates[0]; return placements.find((placement) => placement.date === selectedDate) ?? placements[0]; } private clearHistoricalEligibility(): void { this.historicalEligibilityVersion++; this.historicalIneligibility.set(new Map()); this.historicalEligibilityLoading.set(false); this.historicalEligibilityError.set(null); } private async loadHistoricalEligibility(): Promise { const version = ++this.historicalEligibilityVersion; this.historicalIneligibility.set(new Map()); this.historicalEligibilityLoading.set(false); this.historicalEligibilityError.set(null); const calculation = this.activeCalculation; const decision = this.selectedTraceDecision(); const placement = decision && this.selectedDecisionPlacement(decision); if (!this.traceReplay() || !calculation || !decision || !placement) return; this.historicalEligibilityLoading.set(true); this.liveMessage.set(this.tr().planning.historicalEligibilityLoading); try { const reasons = await calculation.historicalEligibility({ decisionId: decision.id, placement, }); if ( version !== this.historicalEligibilityVersion || calculation !== this.activeCalculation ) return; this.historicalIneligibility.set( new Map(reasons.map(({ date, reason }) => [date, reason])), ); this.liveMessage.set(this.tr().planning.historicalEligibilityReady); } catch (error) { if (version !== this.historicalEligibilityVersion) return; this.historicalIneligibility.set(new Map()); const message = error instanceof Error ? error.message : String(error); this.historicalEligibilityError.set(message); this.liveMessage.set(`${this.tr().planning.historicalEligibilityError} ${message}`); } finally { if (version === this.historicalEligibilityVersion) this.historicalEligibilityLoading.set(false); } } protected retryHistoricalEligibility(): void { void this.loadHistoricalEligibility(); } protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void { const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1; const current = this.traceDecisionIndex(); const target = step === 'first' ? 0 : step === 'last' ? last : step === 'previous' ? current - 1 : current + 1; if (target !== current) this.selectTraceDecision(target); } protected stepTraceCandidatePage(direction: -1 | 1): void { const page = Math.max( 0, Math.min(this.traceCandidatePage() + direction, this.traceCandidatePages() - 1), ); if (page === this.traceCandidatePage()) return; this.traceCandidatePage.set(page); this.liveMessage.set( `${this.tr().planning.page} ${this.traceCandidatePage() + 1} ${this.tr().planning.of} ${this.traceCandidatePages()}`, ); } protected showTraceMoment(moment: TraceMoment): void { this.clearCounterfactual(); this.traceMoment.set(moment); this.traceReplay.set(true); this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`); void this.loadHistoricalEligibility(); } protected showFinalPlan(): void { this.traceReplay.set(false); this.clearHistoricalEligibility(); this.clearCounterfactual(); this.liveMessage.set(this.tr().planning.finalPlan); } protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void { const decision = this.selectedTraceDecision(); if (!candidate.placement || !decision) return; this.traceMoment.set('before'); this.traceReplay.set(true); this.selectedTraceDate.set(candidate.placement.date); void this.analyzeSelection( { decisionId: decision.id, placement: candidate.placement }, true, ); } private nonWorkingDateReason( date: string, closedDayDates: ReadonlySet, ): string | undefined { const day = new Date(`${date}T00:00:00`); if (day.getDay() === 0 || day.getDay() === 6) return this.tr().dayTypes.weekend; if (closedDayDates.has(date)) return this.tr().dayTypes.closedDay; return undefined; } private counterfactualSelection: CounterfactualSelection | null = null; private async analyzeSelection( selection: CounterfactualSelection, openDialog = false, ): Promise { const calculation = this.activeCalculation; if (!calculation) return; this.counterfactualSelection = selection; const version = ++this.counterfactualVersion; this.counterfactual.set(null); this.counterfactualError.set(null); this.counterfactualLoading.set(true); this.invalidComparisonDate.set(false); if (openDialog) this.openComparisonDialog(); const placement = typeof selection === 'string' ? selection : 'placement' in selection ? selection.placement : selection; this.liveMessage.set( `${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`, ); try { const result = await calculation.analyzeCounterfactual(selection); if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) return; if (result.kind === 'Counterfactual') { this.counterfactual.set(result); if (openDialog || this.comparisonDialog) this.selectCalendarDate( this.traceReplay() ? result.placement.date : (this.nearbyComparisonDates()[0]?.date ?? result.placement.date), ); this.liveMessage.set(this.tr().planning.counterfactualReady); } } catch (error) { if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) return; const message = error instanceof Error ? error.message : String(error); this.counterfactualError.set(message); this.liveMessage.set(`${this.tr().planning.counterfactualError} ${message}`); } finally { if (version === this.counterfactualVersion) this.counterfactualLoading.set(false); } } private openComparisonDialog(): void { if (this.comparisonDialog) return; const dialog = this.dialog.open(this.comparisonTemplate(), { width: '48rem', maxWidth: 'calc(100vw - 2rem)', maxHeight: 'calc(100dvh - 2rem)', autoFocus: 'first-heading', ariaLabelledBy: 'counterfactual-title', ariaDescribedBy: 'comparison-intro', }); this.comparisonDialog = dialog; dialog.beforeClosed().subscribe(() => { if (this.comparisonDialog === dialog) { this.comparisonDialog = null; this.clearCounterfactual(); } }); } protected clearCounterfactual(): void { const dialog = this.comparisonDialog; this.comparisonDialog = null; dialog?.close(); this.counterfactualVersion++; this.counterfactual.set(null); this.counterfactualSelection = null; this.selectedCounterfactualDate.set(null); this.counterfactualLoading.set(false); this.counterfactualError.set(null); this.invalidComparisonDate.set(false); this.comparisonDateInput.set(null); } private replayTraceTransition( trace: PlanningTrace | null, decision: PlanningTraceDecision | undefined, ): TraceTransition | undefined { if (!trace || !decision) return undefined; const state = { balances: trace.initialState.balances.map((balance) => ({ ...balance })), placements: new Map( trace.initialState.placements.map((placement) => [ placement.date, { ...placement, ruleIds: [...placement.ruleIds] }, ]), ), earnedActivations: trace.initialState.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates], })), }; const snapshot = (): PlanningTraceState => ({ horizon: { ...trace.initialState.horizon }, balances: state.balances.map((balance) => ({ ...balance })), placements: [...state.placements.values()].map((placement) => ({ ...placement, ruleIds: [...placement.ruleIds], })), earnedActivations: state.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates], })), }); if (decision.branch) { const decisionIndex = this.getTraceDecisionIndex(trace); const target = decisionIndex.get(decision.id); if (!target) return undefined; const branchAncestors: PlanningTraceDecision[] = []; const visited = new Set([decision.id]); let current = decision; let committedParentOrdinal: number | undefined; while (current.branch) { branchAncestors.unshift(current); const parentId = current.branch.parentDecisionId; if (!parentId) break; if (visited.has(parentId)) return undefined; visited.add(parentId); const parent = decisionIndex.get(parentId); if (!parent) return undefined; if (!parent.decision.branch) { committedParentOrdinal = parent.ordinal; break; } current = parent.decision; } const branchRoot = branchAncestors[0]; const branchRootIndex = branchRoot && decisionIndex.get(branchRoot.id)?.ordinal; if (branchRootIndex === undefined || target.ordinal < branchRootIndex) return undefined; const replayThrough = committedParentOrdinal ?? branchRootIndex - 1; if (replayThrough >= branchRootIndex) return undefined; let ordinal = 0; for (const phase of trace.phases) { for (const prior of phase.decisions) { if (ordinal++ > replayThrough) break; applyReplayDeltas(state, prior.deltas); } if (ordinal > replayThrough) break; } for (const ancestor of branchAncestors.slice(0, -1)) applyReplayDeltas(state, ancestor.branch!.deltas); const before = snapshot(); applyReplayDeltas(state, decision.branch.deltas); return { before, after: snapshot() }; } for (const phase of trace.phases) { for (const prior of phase.decisions) { if (prior.id === decision.id) { const before = snapshot(); applyReplayDeltas(state, prior.deltas); return { before, after: snapshot() }; } applyReplayDeltas(state, prior.deltas); } } return undefined; } private getTraceDecisionIndex( trace: PlanningTrace, ): ReadonlyMap { let index = this.traceDecisionIndices.get(trace); if (!index) { const decisions = new Map< string, { decision: PlanningTraceDecision; ordinal: number } >(); let ordinal = 0; for (const phase of trace.phases) for (const decision of phase.decisions) decisions.set(decision.id, { decision, ordinal: ordinal++ }); index = decisions; this.traceDecisionIndices.set(trace, index); } return index; } protected placementAnnouncement(): string { const selected = this.selectedExplanation(); const count = this.planExplanation()?.placements.length ?? 0; if (!selected || !count) return ''; return `${this.tr().planning.placement} ${this.selectedPlacementIndex() + 1} ${this.tr().planning.of} ${count}: ${this.displayDate(selected.placement.date)}`; } protected hasAtRiskContribution(explanation: PlacementExplanation): boolean { return explanation.contributions.some((contribution) => contribution.kind === 'at-risk'); } protected tracePhaseAnnouncement(): string { const phases = this.planningTrace()?.phases ?? []; const phase = phases[this.tracePhaseIndex()]; return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`; } protected traceDecisionAnnouncement(): string { const decisions = this.selectedTracePhase()?.decisions ?? []; const decision = decisions[this.traceDecisionIndex()]; return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`; } protected hasPlannerError(): boolean { const outcome = this.planningOutcome(); return ( outcome?.kind === 'PlanningIncomplete' && outcome.reasons.some((reason) => ['planner-error', 'worker-error', 'worker-unavailable'].includes(reason.code), ) ); } 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; } protected balanceLabel(explanation: PlacementExplanation): string { return ( explanation.balance?.name ?? explanation.balance?.label ?? explanation.placement.balanceId ); } protected objectiveLabel(objective: PlanningObjective): string { return this.objectiveCopy(objective.id)?.label ?? objective.label ?? objective.id; } protected objectiveRows(before: PlanningObjectiveVector, after: PlanningObjectiveVector) { return after.objectives.map((objective) => ({ ...objective, label: this.objectiveLabel(objective), help: this.objectiveCopy(objective.id)?.help, before: before.objectives.find((item) => item.id === objective.id)?.value, numeric: typeof objective.value === 'number', })); } protected hasTieBreaks(vector: PlanningObjectiveVector): boolean { return vector.objectives.some((objective) => typeof objective.value === 'string'); } protected objectiveCopy(id: string) { const key = id.startsWith('preference:') ? 'preference' : id; const copy = this.tr().planning.explanation.objectives[key]; const rule = key === 'preference' ? this.activeCalculation?.snapshot.rules.find( (candidate) => candidate.id === id.slice('preference:'.length), ) : undefined; return ( copy && { ...copy, label: copy.label.replace('{rule}', this.ruleLabel(id.slice('preference:'.length))), help: (rule && this.tr().planning.explanation.preferenceHelp[rule.type.toLowerCase()]) ?? copy.help, } ); } protected phaseLabel(name: string): string { return ( this.tr().planning.explanation.stages[name] ?? this.objectiveCopy(name)?.label ?? this.formatIdentifier(name) ); } protected leaveUsed(explanation: PlacementExplanation): string { return this.tr().planning.explanation.leaveUsed.replace( '{balance}', this.balanceLabel(explanation), ); } private numericChanges(before?: PlanningObjectiveVector, after?: PlanningObjectiveVector) { return (after?.objectives ?? []).flatMap((objective) => { const previous = before?.objectives.find((item) => item.id === objective.id)?.value; if ( typeof previous !== 'number' || typeof objective.value !== 'number' || previous === objective.value ) return []; return [ { id: objective.id, label: this.objectiveLabel(objective), help: this.objectiveCopy(objective.id)?.help, before: previous, after: objective.value, }, ]; }); } protected displayDate(value: string): string { const locale = this.translationService.locale(); if (!this.displayDateFormatter || locale !== this.displayDateLocale) { this.displayDateLocale = locale; this.displayDateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC', }); } return this.displayDateFormatter.format(new Date(value)); } protected planningDate(value: string | Date | undefined): string { if (!value) return ''; return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10); } protected traceStatusLabel(status: PlanningTraceCandidate['status']): string { return this.tr().planning.traceStatuses[status]; } protected reasonLabel(reason: PlanningReason): string { return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message; } protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string { return this.tr().planning.tracePhaseStatuses[status]; } protected traceChangeLabel(change: NonNullable): string { return this.tr().planning.traceChanges[change]; } protected formatIdentifier(value: string): string { return value.replaceAll('-', ' ').replaceAll(':', ': '); } protected outcomeLabel(outcome: PlanningOutcome): string { if (outcome.kind === 'Plan') return this.tr().planning.complete; if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput; if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict; if (outcome.reasons.some((reason) => reason.code === 'user-cancelled')) return outcome.incumbent ? this.tr().planning.cancelledPartial : 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:')) 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(); } private toLegacyBalances(plan: PlanOutcome): VacDay[] { return plan.remainingBalances.map((balance) => ({ id: balance.id, type: balance.type as SelectableDayType, numberOfDays: balance.quantity, availableDate: new Date(`${balance.availableFrom}T00:00:00`), expiryDate: new Date(`${balance.expiryDate}T00:00:00`), classification: balance.classification, reason: balance.reason, })); } }