import { afterNextRender, Component, computed, DestroyRef, effect, ElementRef, inject, Injector, 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 { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle, } from '@angular/material/dialog'; import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month'; import { addPlanningDays } from '../../services/planning-date'; import { isWorkingPlanningDate } from '../../services/declared-non-working-days'; import { CalendarService, calendarProjectionHorizon, SelectedDates, } from '../../services/calendar-service'; import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { PlanningDurationService } from '../../services/planning-duration.service'; import { PlanningCalculationService } from '../../services/planning-calculation.service'; import { CounterfactualAnalysis, CounterfactualSelection, LeaveType, PlanExplanation, PlanningObjective, PlanningObjectiveVector, PlanningOutcome, PlanningReason, } from '../../services/leave-planner.service'; type PlacementExplanation = PlanExplanation['placements'][number]; @Component({ selector: 'app-calendar-year', imports: [ JsonPipe, NgTemplateOutlet, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle, 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 settings = inject(CalendarSettingsService); private readonly duration = inject(PlanningDurationService); private readonly calculations = inject(PlanningCalculationService); private readonly injector = inject(Injector); 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; private readonly statisticsTemplate = viewChild.required>('planStatisticsDialog'); private statisticsDialog: MatDialogRef | null = null; protected readonly translationService = inject(TranslationService); protected readonly isLoading = this.calculations.isCalculating; protected readonly isCancelling = this.calculations.isCancelling; private readonly resultsTitle = viewChild>('resultsTitle'); protected readonly loadingProgress = this.calculations.progress; protected readonly planningOutcome = this.calculations.outcome; protected readonly planningExplanation = this.calculations.explanation; protected readonly explanationError = this.calculations.explanationError; protected readonly selectedPlacementIndex = signal(0); protected readonly planDetailsOpen = 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 liveMessage = signal(''); protected readonly planningHorizon = this.calculations.displayedHorizon; protected readonly selectedDates = signal(new SelectedDates()); protected readonly declarationEntry = this.userInput.declarationEntrySignal; private readonly monthGrid = viewChild>('monthGrid'); private readonly declarationDrag = signal<{ pointerId: number; start: string; end: string; } | null>(null); private suppressDeclarationClick = false; private declarationEvents?: AbortController; private declarationScrollFrame?: number; private declarationPointer?: PointerEvent; protected readonly declarationPreview = computed(() => { const drag = this.declarationDrag(); const horizon = this.calendarHorizon(); if (!drag || !horizon || !this.declarationEntry()) return null; const dates = new Set(); const start = drag.start < drag.end ? drag.start : drag.end; const end = drag.start > drag.end ? drag.start : drag.end; for (let date = start; date <= end; date = addPlanningDays(date, 1)) { if (date >= horizon.start && date < horizon.end && isWorkingPlanningDate(date)) dates.add(date); } return { dates, type: this.userInput.declaredTypeSignal() }; }); protected readonly calendarHorizon = computed(() => this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(), ); protected readonly calendarDeclaredDays = computed>>(() => { const days = this.declarationEntry() ? this.userInput.declaredDaysSignal() : (this.calculations.accepted()?.declaredDays ?? []); return Object.fromEntries(days.map(({ date, type }) => [date, type])); }); private counterfactualVersion = 0; private wasCancelling = false; private displayDateLocale = ''; private displayDateFormatter?: Intl.DateTimeFormat; get tr() { return this.translationService.tr; } protected readonly displayedPlan = this.calculations.displayedPlan; protected readonly weekendConnectedHolidays = computed(() => { const isWeekend = (date: string) => { const day = new Date(`${date}T00:00:00Z`).getUTCDay(); return day === 0 || day === 6; }; // Weekday natural non-working dates are public holidays; declarations are separate. return new Set( (this.displayedPlan()?.timeOffPeriods ?? []).flatMap(({ nonWorkingDates }) => nonWorkingDates.some(isWeekend) ? nonWorkingDates.filter((date) => !isWeekend(date)) : [], ), ).size; }); protected readonly earnedLeave = computed(() => { const byRule = new Map(); for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) { const entitlement = contribution.earnedEntitlement; if (!entitlement?.added || entitlement.quantity <= 0) continue; const entry = byRule.get(contribution.ruleId) ?? { ruleId: contribution.ruleId, label: contribution.label, quantity: 0, }; entry.quantity += entitlement.quantity; byRule.set(entry.ruleId, entry); } const contributions = [...byRule.values()]; return { total: contributions.reduce((total, contribution) => total + contribution.quantity, 0), contributions, }; }); 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 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; // Compare restored alternatives against the displayed plan. const 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 calendarSelectedDates = computed(() => { if (this.declarationEntry()) { const horizon = this.calendarHorizon(); return horizon ? this.calendarService.selectedDatesForPlan({ placements: [], horizon, declaredDays: this.userInput.declaredDaysSignal(), }) : new SelectedDates(); } return this.selectedDates(); }); protected readonly currentPlacementDate = computed(() => { return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null; }); protected readonly explainedPeriod = computed(() => this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined, ); protected readonly months = computed(() => { const horizon = this.calendarHorizon(); if (!horizon) return []; const plan = this.displayedPlan(); const projectionHorizon = !this.declarationEntry() && plan ? (calendarProjectionHorizon(plan) ?? horizon) : horizon; const first = new Date(`${projectionHorizon.start}T00:00:00`); const last = new Date(`${projectionHorizon.end}T00:00:00`); last.setDate(last.getDate() - 1); 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.closeStatistics(); this.clearCounterfactual(); this.cancelDeclarationRange(); }); effect(() => { this.declarationEntry(); this.settings.planningHorizon(); this.userInput.declaredTypeSignal(); untracked(() => this.cancelDeclarationRange()); }); effect(() => { if (this.declarationEntry()) untracked(() => { this.clearCounterfactual(); this.liveMessage.set(this.tr().bookedDays.enter); }); }); effect(() => { if (this.isLoading()) untracked(() => this.liveMessage.set(this.tr().loading)); }); effect(() => { const cancelling = this.isCancelling(); untracked(() => { if (this.wasCancelling && !cancelling) this.resultsTitle()?.nativeElement.focus(); this.wasCancelling = cancelling; }); }); effect(() => { const accepted = this.calculations.accepted(); if (!accepted) return; untracked(() => { this.closeStatistics(); this.selectedPlacementIndex.set(0); this.planDetailsOpen.set(false); this.clearCounterfactual(); const plan = this.displayedPlan(); this.selectedDates.set( plan ? this.calendarService.selectedDatesForPlan(plan) : this.calendarService.getSelectedDatesForHorizon(accepted.horizon), ); this.liveMessage.set(this.outcomeLabel(accepted.outcome)); if (accepted.successful && !this.destroyRef.destroyed) afterNextRender( () => { if ( this.calculations.accepted() === accepted && !this.destroyRef.destroyed && accepted.snapshot ) this.duration.recordCompletion( accepted.snapshot, performance.now() - accepted.requestedAt, ); }, { injector: this.injector }, ); }); }); effect(() => { const explanation = this.calculations.explanation(); if (explanation?.kind !== 'PlanExplanation') return; untracked(() => { this.selectPlacement(0, false); const outcome = this.planningOutcome(); this.liveMessage.set( `${outcome ? this.outcomeLabel(outcome) : ''} ${this.placementAnnouncement()}`.trim(), ); }); }); effect(() => { const reason = this.explanationError(); if (reason?.code === 'explanation-error') untracked(() => this.liveMessage.set( `${this.tr().planning.explanationError} ${reason.message}`, ), ); }); this.calculations.start(); } protected cancelCalculation(): void { if (!this.isLoading() || this.isCancelling()) return; this.calculations.cancel(); this.liveMessage.set(this.tr().planning.cancelling); } protected formatStatistic(value: number | null): string { return value === null ? '—' : value.toLocaleString(this.translationService.locale()); } protected openStatistics(): void { if (!this.displayedPlan() || this.statisticsDialog) return; const dialog = this.dialog.open(this.statisticsTemplate(), { width: '40rem', maxWidth: 'calc(100vw - 2rem)', maxHeight: 'calc(100dvh - 2rem)', autoFocus: 'first-heading', ariaLabelledBy: 'plan-statistics-title', ariaDescribedBy: 'plan-statistics-help', }); this.statisticsDialog = dialog; dialog.beforeClosed().subscribe(() => { if (this.statisticsDialog === dialog) this.statisticsDialog = null; }); dialog.afterClosed().subscribe(() => { if ( !this.destroyRef.destroyed && !this.displayedPlan() && document.activeElement === document.body ) this.resultsTitle()?.nativeElement.focus(); }); } private closeStatistics(): void { this.statisticsDialog?.close(); this.statisticsDialog = null; } 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.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.selectPlacement(this.selectedPlacementIndex()); } else this.clearCounterfactual(); } 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); } private declarationButton(target: EventTarget | null): HTMLButtonElement | null { const button = target instanceof Element ? target.closest('[data-date]') : null; return button && this.monthGrid()?.nativeElement.contains(button) ? button : null; } protected startDeclarationRange(event: PointerEvent): void { if (!this.declarationEntry() || event.button !== 0 || !event.isPrimary) return; this.suppressDeclarationClick = false; // Keep native touch scrolling and single-day taps available on the calendar. if (event.pointerType === 'touch') return; const button = this.declarationButton(event.target); const date = button?.dataset['date']; if (!date || button?.disabled) return; this.declarationDrag.set({ pointerId: event.pointerId, start: date, end: date, }); const grid = this.monthGrid()!.nativeElement; this.declarationEvents = new AbortController(); const options = { signal: this.declarationEvents.signal }; grid.ownerDocument.addEventListener( 'pointermove', (e) => this.moveDeclarationRange(e), options, ); grid.ownerDocument.addEventListener( 'pointerup', (e) => this.finishDeclarationRange(e), options, ); grid.ownerDocument.addEventListener( 'pointercancel', (e) => this.cancelDeclarationRange(e), options, ); grid.ownerDocument.addEventListener( 'keydown', (e) => { if (e.key === 'Escape') this.cancelDeclarationRange(); }, options, ); window.addEventListener('blur', () => this.cancelDeclarationRange(), options); grid.addEventListener('lostpointercapture', (e) => this.cancelDeclarationRange(e), options); grid.setPointerCapture?.(event.pointerId); button?.focus({ preventScroll: true }); } protected moveDeclarationRange(event: PointerEvent): void { const drag = this.declarationDrag(); if (!drag || event.pointerId !== drag.pointerId) return; if (!(event.buttons & 1)) { this.cancelDeclarationRange(); return; } this.declarationPointer = event; this.updateDeclarationEndpoint(event); this.scrollDeclarationRange(); } private updateDeclarationEndpoint(event: PointerEvent): void { const drag = this.declarationDrag(); if (!drag) return; const grid = this.monthGrid()?.nativeElement; const target = grid?.ownerDocument.elementFromPoint?.(event.clientX, event.clientY) ?? event.target; const date = this.declarationButton(target)?.dataset['date']; if (date && date !== drag.end) this.declarationDrag.set({ ...drag, end: date }); } private scrollDeclarationRange(): void { if (this.declarationScrollFrame !== undefined) return; let lastFrame = 0; const scroll = (now: number) => { this.declarationScrollFrame = undefined; const pointer = this.declarationPointer; if (!pointer || !this.declarationDrag()) return; const direction = pointer.clientY < 48 ? -1 : pointer.clientY > window.innerHeight - 48 ? 1 : 0; if (!direction || pointer.clientX < 0 || pointer.clientX > window.innerWidth) return; const before = window.scrollY; window.scrollBy(0, direction * (lastFrame ? Math.min(now - lastFrame, 32) : 16) * 0.5); lastFrame = now; this.updateDeclarationEndpoint(pointer); if (window.scrollY !== before) this.declarationScrollFrame = requestAnimationFrame(scroll); }; this.declarationScrollFrame = requestAnimationFrame(scroll); } protected finishDeclarationRange(event: PointerEvent): void { const drag = this.declarationDrag(); if (!drag || event.pointerId !== drag.pointerId || event.button !== 0) return; this.updateDeclarationEndpoint(event); const preview = this.declarationPreview(); if (!preview) return; const dates = [...preview.dates]; const previous = this.calendarDeclaredDays(); this.userInput.toggleDeclaredDays(dates, preview.type); const next = this.calendarDeclaredDays(); const added = dates.filter((date) => next[date]).length; const removed = dates.filter((date) => previous[date] && !next[date]).length; const messages: string[] = []; if (added && preview.type) messages.push( this.tr() .bookedDays.rangeSaved.replace('{count}', String(added)) .replace('{type}', this.translationService.getDayTypeLabel(preview.type)), ); if (removed || !added) messages.push(this.tr().bookedDays.rangeRemoved.replace('{count}', String(removed))); this.liveMessage.set(messages.join(' ')); this.cancelDeclarationRange(); this.animateDeclaration(preview.dates); } protected cancelDeclarationRange(event?: PointerEvent): void { const drag = this.declarationDrag(); if (!drag || (event && event.pointerId !== drag.pointerId)) return; this.declarationDrag.set(null); this.suppressDeclarationClick = true; this.declarationEvents?.abort(); this.declarationEvents = undefined; if (this.declarationScrollFrame !== undefined) cancelAnimationFrame(this.declarationScrollFrame); this.declarationScrollFrame = undefined; this.declarationPointer = undefined; const grid = this.monthGrid()?.nativeElement; if (grid?.hasPointerCapture?.(drag.pointerId)) grid.releasePointerCapture(drag.pointerId); } protected selectDeclaredDate(event: MouseEvent): void { if (!this.declarationEntry() || (event.detail > 0 && this.suppressDeclarationClick)) return; const button = this.declarationButton(event.target); const date = button?.dataset['date']; if (date && !button?.disabled) { this.selectCalendarDate(date); this.animateDeclaration(new Set([date])); } } private animateDeclaration(dates: ReadonlySet): void { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; for (const button of this.monthGrid()?.nativeElement.querySelectorAll( '[data-date]', ) ?? []) { if (!dates.has(button.dataset['date']!)) continue; button.animate?.( [ { transform: 'scale(1.025, 0.97)' }, { transform: 'scale(0.99, 1.015)' }, { transform: 'scale(1)' }, ], { duration: 240, easing: 'ease-out' }, ); } } protected selectCalendarDate(date: string): void { if (this.declarationEntry()) { const horizon = this.calendarHorizon(); if (!horizon || date < horizon.start || date >= horizon.end) return; const selectedType = this.userInput.declaredTypeSignal(); if (selectedType) this.userInput.toggleDeclaredDay(date, selectedType); else this.userInput.toggleDeclaredDays([date], null); const type = this.calendarDeclaredDays()[date]; this.liveMessage.set( `${this.displayDate(date)}. ${type ? `${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(type)}). ${this.tr().bookedDays.fixed}` : this.tr().bookedDays.removed}`, ); return; } const declaredType = this.calendarDeclaredDays()[date]; if (declaredType && !this.counterfactual()) { this.liveMessage.set( `${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`, ); return; } if (this.counterfactual()?.results.some((result) => result.date === date)) { this.invalidComparisonDate.set(false); this.comparisonDateInput.set(new Date(`${date}T00:00:00`)); } if (!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.selectPlacement(index); return; } } if (this.counterfactual()?.results.some((result) => result.date === date)) { this.selectedCounterfactualDate.set(date); this.liveMessage.set(this.dateStatusAnnouncement(date)); } } private dateStatusAnnouncement(date: string): string { const result = this.counterfactual()?.results.find((result) => result.date === date); return [ date, this.tr().planning.counterfactual, result ? this.tr().planning[result.status] : undefined, result?.reason ? this.reasonLabel(result.reason) : undefined, ] .filter(Boolean) .join('. '); } private counterfactualSelection: CounterfactualSelection | null = null; private async analyzeSelection( selection: CounterfactualSelection, openDialog = false, ): Promise { const calculation = this.calculations.accepted()?.calculation; 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 = 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.calculations.accepted()?.calculation ) return; if (result.kind === 'Counterfactual') { this.counterfactual.set(result); if (openDialog || this.comparisonDialog) this.selectCalendarDate( this.nearbyComparisonDates()[0]?.date ?? result.placement.date, ); this.liveMessage.set(this.tr().planning.counterfactualReady); } } catch (error) { if ( version !== this.counterfactualVersion || calculation !== this.calculations.accepted()?.calculation ) 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); } 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 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.calculations .snapshot() ?.rules.find((candidate) => candidate.id === ruleId); return rule?.name ?? this.calculations.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) { if (id.startsWith('optimization:')) { const ruleId = id.slice('optimization:'.length); const rule = this.calculations .snapshot() ?.rules.find((candidate) => candidate.id === ruleId); const copy = this.tr().planning.explanation.objectives['optimization']; return { ...copy, label: copy.label .replace('{rule}', this.ruleLabel(ruleId)) .replace( '{summary}', rule && 'configuration' in rule ? this.translationService.optimizationSummary(rule.configuration) : '', ), }; } const key = id.startsWith('preference:') ? 'preference' : id; const copy = this.tr().planning.explanation.objectives[key]; const rule = key === 'preference' ? this.calculations .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 reasonLabel(reason: PlanningReason): string { return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message; } 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; } }