- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations. - Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations. - Add cancellation handling in the worker message event. - Update planning-date.ts with new utility functions for date manipulation. - Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation. - Create documentation for the cancellation feature in planner-cancellation.md. - Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts. #57 #58
1312 lines
45 KiB
TypeScript
1312 lines
45 KiB
TypeScript
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<Date>>(DateAdapter);
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly comparisonTemplate =
|
|
viewChild.required<TemplateRef<unknown>>('dateComparisonDialog');
|
|
private comparisonDialog: MatDialogRef<unknown> | null = null;
|
|
protected readonly translationService = inject(TranslationService);
|
|
|
|
protected readonly isLoading = signal(false);
|
|
protected readonly isCancelling = signal(false);
|
|
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
|
protected readonly loadingProgress = signal(0);
|
|
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
|
|
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
|
protected readonly explanationError = signal<PlanningReason | null>(null);
|
|
protected readonly planningTrace = signal<PlanningTrace | null>(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<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 tracePhaseIndex = signal(0);
|
|
protected readonly traceDecisionIndex = signal(0);
|
|
protected readonly traceCandidatePage = signal(0);
|
|
protected readonly traceMoment = signal<TraceMoment>('before');
|
|
protected readonly traceReplay = signal(false);
|
|
protected readonly selectedTraceDate = signal<string | null>(null);
|
|
protected readonly liveMessage = signal('');
|
|
private readonly historicalIneligibility = signal<ReadonlyMap<string, PlanningReason>>(
|
|
new Map(),
|
|
);
|
|
protected readonly historicalEligibilityLoading = signal(false);
|
|
protected readonly historicalEligibilityError = signal<string | null>(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<string, { decision: PlanningTraceDecision; ordinal: number }>
|
|
>();
|
|
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<PlacementExplanation | undefined>(
|
|
() => 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<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;
|
|
// 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<Readonly<Record<string, CalendarDayStatus>>>(() => {
|
|
const statuses: Record<string, CalendarDayStatus> = {};
|
|
const trace = this.planningTrace();
|
|
const closedDayDates = new Set<string>();
|
|
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<string, number>();
|
|
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<string>();
|
|
const setChange = (
|
|
date: string | undefined,
|
|
change: NonNullable<CalendarDayStatus['change']>,
|
|
) => {
|
|
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<void> {
|
|
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>,
|
|
): 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<void> {
|
|
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<string, { decision: PlanningTraceDecision; ordinal: number }> {
|
|
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<CalendarDayStatus['change']>): 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,
|
|
}));
|
|
}
|
|
}
|