Files
vac-optimizer/src/app/components/calendar-year/calendar-year.ts
T
nessarandClaude Opus 5.5 5cb0b93339 refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.

Refs #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00

951 lines
33 KiB
TypeScript

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<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
private readonly comparisonTemplate =
viewChild.required<TemplateRef<unknown>>('dateComparisonDialog');
private comparisonDialog: MatDialogRef<unknown> | null = null;
private readonly statisticsTemplate =
viewChild.required<TemplateRef<unknown>>('planStatisticsDialog');
private statisticsDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling;
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('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<ElementRef<HTMLDetailsElement>>('planDetails');
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false);
protected readonly counterfactualError = signal<string | null>(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(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<ElementRef<HTMLElement>>('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<DeclarationPreview | null>(() => {
const drag = this.declarationDrag();
const horizon = this.calendarHorizon();
if (!drag || !horizon || !this.declarationEntry()) return null;
const dates = new Set<string>();
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<Readonly<Record<string, LeaveType>>>(() => {
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<string, { ruleId: string; label: string; quantity: number }>();
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<PlacementExplanation | undefined>(
() => 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<Date>): 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<HTMLButtonElement>('[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<string>): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
for (const button of this.monthGrid()?.nativeElement.querySelectorAll<HTMLElement>(
'[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<void> {
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;
}
}