feat(planning): explain and trace planning decisions

This commit is contained in:
nessar committed 2026-08-31 00:02:45 +02:00
1 parent 609b3dece9
commit d7a13b0b1e
24 files changed
+6073 -342

No files matched your search

+658 -110
View File
@@ -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);
}