feat(planning): explain and trace planning decisions
This commit is contained in:
1 parent
609b3dece9
commit
d7a13b0b1e
24 files changed
+6073
-342
No files matched your search
@@ -1,16 +1,37 @@
|
||||
import { Component, computed, effect, signal, inject, untracked } from '@angular/core';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
|
||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||
import { CalendarMonth } from '../calendar-month/calendar-month';
|
||||
import { CalendarService, SelectedDates } from '../../services/calendar-service';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
|
||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
import {
|
||||
CalendarService,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
} from '../../services/calendar-service';
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
|
||||
import { SelectableDayType, VacDay } from '../../services/calendar-service';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import {
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
LeavePlacement,
|
||||
PlanExplanation,
|
||||
PlanOutcome,
|
||||
PlanningCalculationContext,
|
||||
PlanningExplanation,
|
||||
PlanningObjective,
|
||||
PlanningOutcome,
|
||||
PlanningReason,
|
||||
PlanningTrace,
|
||||
PlanningTraceCandidate,
|
||||
PlanningTraceDecision,
|
||||
PlanningTraceState,
|
||||
} from '../../services/leave-planner.service';
|
||||
|
||||
type PlacementExplanation = PlanExplanation['placements'][number];
|
||||
type TraceMoment = 'before' | 'after';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-year',
|
||||
@@ -26,21 +47,42 @@ import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
styleUrl: './calendar-year.scss',
|
||||
})
|
||||
export class CalendarYear {
|
||||
private dateCache = inject(DateCacheService);
|
||||
private userInput = inject(UserInputService);
|
||||
private calendarService = inject(CalendarService);
|
||||
private ruleService = inject(RuleService);
|
||||
private readonly userInput = inject(UserInputService);
|
||||
private readonly calendarService = inject(CalendarService);
|
||||
private readonly ruleService = inject(RuleService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
protected readonly isLoading = signal(true);
|
||||
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 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 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 liveMessage = signal('');
|
||||
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
|
||||
protected readonly selectedDates = signal(new SelectedDates());
|
||||
|
||||
private calculationVersion = 0;
|
||||
private counterfactualVersion = 0;
|
||||
private activeCalculation: PlanningCalculationContext | null = null;
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
protected isLoading = signal(true);
|
||||
|
||||
protected loadingProgress = signal(0);
|
||||
protected planningOutcome = signal<PlanningOutcome | null>(null);
|
||||
protected displayedPlan = computed(() => {
|
||||
protected readonly displayedPlan = computed(() => {
|
||||
const outcome = this.planningOutcome();
|
||||
return outcome?.kind === 'Plan'
|
||||
? outcome
|
||||
@@ -48,7 +90,557 @@ export class CalendarYear {
|
||||
? outcome.incumbent
|
||||
: undefined;
|
||||
});
|
||||
private calculationVersion = 0;
|
||||
|
||||
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 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 traceState = computed(() => {
|
||||
if (!this.traceReplay()) return undefined;
|
||||
return this.replayTraceState(
|
||||
this.planningTrace(),
|
||||
this.selectedTraceDecision()?.id,
|
||||
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.counterfactual())
|
||||
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
|
||||
if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null;
|
||||
return this.selectedExplanation()?.placement.date ?? null;
|
||||
});
|
||||
|
||||
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
|
||||
const statuses: Record<string, CalendarDayStatus> = {};
|
||||
const trace = this.planningTrace();
|
||||
if (!this.counterfactual() && 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 candidate of trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) => decision.candidates),
|
||||
)) {
|
||||
if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working')
|
||||
continue;
|
||||
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : []))
|
||||
statuses[date] = {
|
||||
kind: candidate.status,
|
||||
label: this.traceStatusLabel(candidate.status),
|
||||
reason: candidate.reason.message,
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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.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 before = this.replayTraceState(trace, decision.id, 'before');
|
||||
const after = this.replayTraceState(trace, decision.id, 'after');
|
||||
for (const placement of [
|
||||
...(before?.placements ?? []),
|
||||
...(after?.placements ?? []),
|
||||
]) {
|
||||
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
for (const result of this.counterfactual()?.results ?? [])
|
||||
statuses[result.date] = {
|
||||
kind: result.status,
|
||||
label:
|
||||
result.status === 'eligible'
|
||||
? this.tr().planning.eligible
|
||||
: this.tr().planning.ineligible,
|
||||
reason: result.reason?.message,
|
||||
historicalLabel: result.historicalStatus
|
||||
? this.traceStatusLabel(result.historicalStatus)
|
||||
: undefined,
|
||||
};
|
||||
return statuses;
|
||||
});
|
||||
|
||||
protected readonly months = computed(() => {
|
||||
const horizon = this.planningHorizon();
|
||||
if (!horizon) return [];
|
||||
const start = new Date(`${horizon.start}T00:00:00`);
|
||||
const last = new Date(`${horizon.end}T00:00:00`);
|
||||
last.setDate(last.getDate() - 1);
|
||||
const result: { monthIndex: number; year: number }[] = [];
|
||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
while (cursor <= last) {
|
||||
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
this.resetCalculationState();
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
const horizon = this.calendarService.getDefaultPlanningHorizon();
|
||||
this.planningHorizon.set(horizon);
|
||||
const version = ++this.calculationVersion;
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
queueMicrotask(() => {
|
||||
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,
|
||||
expiryDate: balance.expiryDate,
|
||||
})),
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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(): 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.clearCounterfactual();
|
||||
this.tracePhaseIndex.set(0);
|
||||
this.traceDecisionIndex.set(0);
|
||||
this.traceCandidatePage.set(0);
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(false);
|
||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
||||
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' && explanation.placements.length)
|
||||
this.selectPlacement(0);
|
||||
});
|
||||
void calculation.explanationError.then((reason) => {
|
||||
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
|
||||
});
|
||||
void calculation.trace
|
||||
.then((trace) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.planningTrace.set(trace ?? null);
|
||||
this.traceResolved.set(true);
|
||||
})
|
||||
.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);
|
||||
})
|
||||
.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.getSelectedDatesForYear());
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
protected selectPlacement(index: number): 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.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
|
||||
);
|
||||
void this.analyzeSelection(explanation.placements[selected].placement);
|
||||
}
|
||||
|
||||
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
|
||||
const current = this.selectedPlacementIndex();
|
||||
this.selectPlacement(
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
}
|
||||
|
||||
protected selectCalendarDate(date: string): void {
|
||||
if (!this.traceReplay()) {
|
||||
const index = this.planExplanation()?.placements.findIndex(
|
||||
(explanation) => explanation.placement.date === date,
|
||||
);
|
||||
if (index !== undefined && index >= 0) {
|
||||
this.selectPlacement(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.counterfactual()?.results.some((result) => result.date === date)) {
|
||||
this.selectedCounterfactualDate.set(date);
|
||||
this.liveMessage.set(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
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.clearCounterfactual();
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
|
||||
);
|
||||
}
|
||||
|
||||
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
|
||||
const current = this.tracePhaseIndex();
|
||||
this.selectTracePhase(
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
}
|
||||
|
||||
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.clearCounterfactual();
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
|
||||
);
|
||||
}
|
||||
|
||||
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
|
||||
const current = this.traceDecisionIndex();
|
||||
this.selectTraceDecision(
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
}
|
||||
|
||||
protected stepTraceCandidatePage(direction: -1 | 1): void {
|
||||
this.traceCandidatePage.update((page) =>
|
||||
Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
protected showTraceMoment(moment: TraceMoment): void {
|
||||
this.clearCounterfactual();
|
||||
this.traceMoment.set(moment);
|
||||
this.traceReplay.set(true);
|
||||
}
|
||||
|
||||
protected showFinalPlan(): void {
|
||||
this.traceReplay.set(false);
|
||||
this.clearCounterfactual();
|
||||
}
|
||||
|
||||
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
|
||||
const decision = this.selectedTraceDecision();
|
||||
if (!candidate.placement || !decision) return;
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(true);
|
||||
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
|
||||
}
|
||||
|
||||
private async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
|
||||
const calculation = this.activeCalculation;
|
||||
if (!calculation) return;
|
||||
const version = ++this.counterfactualVersion;
|
||||
this.counterfactual.set(null);
|
||||
this.counterfactualError.set(null);
|
||||
this.counterfactualLoading.set(true);
|
||||
try {
|
||||
const result = await calculation.analyzeCounterfactual(selection);
|
||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
||||
return;
|
||||
if (result.kind === 'Counterfactual') {
|
||||
this.counterfactual.set(result);
|
||||
this.selectedCounterfactualDate.set(result.placement.date);
|
||||
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
||||
}
|
||||
} catch (error) {
|
||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
||||
return;
|
||||
this.counterfactualError.set(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private clearCounterfactual(): void {
|
||||
this.counterfactualVersion++;
|
||||
this.counterfactual.set(null);
|
||||
this.selectedCounterfactualDate.set(null);
|
||||
this.counterfactualLoading.set(false);
|
||||
this.counterfactualError.set(null);
|
||||
}
|
||||
|
||||
private replayTraceState(
|
||||
trace: PlanningTrace | null,
|
||||
decisionId: string | undefined,
|
||||
moment: TraceMoment,
|
||||
): PlanningTraceState | undefined {
|
||||
if (!trace || !decisionId) return undefined;
|
||||
const balances = trace.initialState.balances.map((balance) => ({ ...balance }));
|
||||
const placements = new Map(
|
||||
trace.initialState.placements.map((placement) => [placement.date, { ...placement }]),
|
||||
);
|
||||
const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
}));
|
||||
const snapshot = (): PlanningTraceState => ({
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
balances,
|
||||
placements: [...placements.values()],
|
||||
earnedActivations,
|
||||
});
|
||||
for (const decision of trace.phases.flatMap((phase) => phase.decisions)) {
|
||||
if (decision.id === decisionId && moment === 'before') return snapshot();
|
||||
this.applyTraceDecision(decision, placements, balances, earnedActivations);
|
||||
if (decision.id === decisionId) return snapshot();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private applyTraceDecision(
|
||||
decision: PlanningTraceDecision,
|
||||
placements: Map<string, LeavePlacement>,
|
||||
balances: PlanningTraceState['balances'][number][],
|
||||
earnedActivations: Array<{
|
||||
id: string;
|
||||
ruleId: string;
|
||||
occurrence: string;
|
||||
dates: string[];
|
||||
}>,
|
||||
): void {
|
||||
for (const delta of decision.deltas) {
|
||||
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
|
||||
if (delta.placement)
|
||||
placements.set(delta.placement.date, {
|
||||
...delta.placement,
|
||||
ruleIds: [...delta.placement.ruleIds],
|
||||
});
|
||||
const balanceIndex = balances.findIndex(
|
||||
(candidate) => candidate.id === delta.balanceId,
|
||||
);
|
||||
if (balanceIndex >= 0 && delta.remainingAfter !== undefined)
|
||||
balances[balanceIndex] = {
|
||||
...balances[balanceIndex],
|
||||
remaining: delta.remainingAfter,
|
||||
};
|
||||
else if (balanceIndex < 0 && delta.balance) balances.push({ ...delta.balance });
|
||||
if (
|
||||
delta.activation &&
|
||||
!earnedActivations.some((activation) => activation.id === delta.activation!.id)
|
||||
)
|
||||
earnedActivations.push({
|
||||
...delta.activation,
|
||||
dates: [...delta.activation.dates],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected hasPlannerError(): boolean {
|
||||
const outcome = this.planningOutcome();
|
||||
@@ -57,6 +649,53 @@ export class CalendarYear {
|
||||
);
|
||||
}
|
||||
|
||||
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 objective.label ?? objective.id;
|
||||
}
|
||||
|
||||
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 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(':', ': ');
|
||||
}
|
||||
|
||||
private 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;
|
||||
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;
|
||||
@@ -73,87 +712,6 @@ export class CalendarYear {
|
||||
return this.loadingProgress();
|
||||
}
|
||||
|
||||
// Use DateCacheService instead of creating Date objects
|
||||
private get currentMonth() {
|
||||
return this.dateCache.getCurrentMonth();
|
||||
}
|
||||
|
||||
private get currentYear() {
|
||||
return this.dateCache.getCurrentYear();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
this.planningOutcome.set(null);
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
const version = ++this.calculationVersion;
|
||||
// Defer execution to allow UI to update
|
||||
queueMicrotask(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const outcome = await this.calendarService.planLeave({
|
||||
horizon: this.calendarService.getDefaultPlanningHorizon(),
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
availableFrom: balance.availableDate,
|
||||
expiryDate: balance.expiryDate,
|
||||
})),
|
||||
rules: currentRules,
|
||||
progress: (stage) => {
|
||||
if (version === this.calculationVersion)
|
||||
this.loadingProgress.set(this.stageProgress(stage));
|
||||
},
|
||||
});
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome(outcome);
|
||||
} catch (error) {
|
||||
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) {
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private applyOutcome(outcome: PlanningOutcome): void {
|
||||
this.planningOutcome.set(outcome);
|
||||
const plan = this.displayedPlan();
|
||||
if (plan) {
|
||||
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
|
||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
||||
return;
|
||||
}
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
|
||||
return plan.remainingBalances.map((balance) => ({
|
||||
id: balance.id,
|
||||
@@ -165,14 +723,4 @@ export class CalendarYear {
|
||||
reason: balance.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
protected selectedDates: SelectedDates = new SelectedDates();
|
||||
|
||||
getYear(monthIndex: number): number {
|
||||
const offset = this.currentMonth <= monthIndex ? 0 : 1;
|
||||
return this.currentYear + offset;
|
||||
}
|
||||
|
||||
// months starting from actual date - cache this array
|
||||
protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12);
|
||||
}
|
||||
Reference in new issue
Block a user