refactor(planning): remove the original decision trace
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
This commit is contained in:
1 parent
ea4256107f
commit
d9d9befa12
43 files changed
+729
-8880
No files matched your search
@@ -30,17 +30,12 @@ import {
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import {
|
||||
CalendarMonth,
|
||||
CalendarDayStatus,
|
||||
DeclarationPreview,
|
||||
} from '../calendar-month/calendar-month';
|
||||
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
|
||||
import { addPlanningDays } from '../../services/planning-date';
|
||||
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
||||
import {
|
||||
CalendarService,
|
||||
calendarProjectionHorizon,
|
||||
DayType,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
@@ -50,10 +45,8 @@ import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||
import {
|
||||
applyReplayDeltas,
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
LeavePlacement,
|
||||
LeaveType,
|
||||
DeclaredNonWorkingDay,
|
||||
PlanExplanation,
|
||||
@@ -64,15 +57,9 @@ import {
|
||||
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',
|
||||
@@ -121,12 +108,8 @@ export class CalendarYear {
|
||||
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
|
||||
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
||||
protected readonly explanationError = signal<PlanningReason | null>(null);
|
||||
protected readonly planningTrace = this.userInput.planningTraceSignal;
|
||||
protected readonly traceLoading = signal(false);
|
||||
protected readonly traceResolved = this.userInput.traceResolvedSignal;
|
||||
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);
|
||||
@@ -134,18 +117,7 @@ export class CalendarYear {
|
||||
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());
|
||||
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
|
||||
@@ -179,21 +151,14 @@ export class CalendarYear {
|
||||
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
|
||||
const days = this.declarationEntry()
|
||||
? this.userInput.declaredDaysSignal()
|
||||
: (this.traceState()?.declaredDays ??
|
||||
this.displayedPlan()?.declaredDays ??
|
||||
this.resultDeclaredDays());
|
||||
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
|
||||
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
|
||||
});
|
||||
|
||||
private calculationVersion = 0;
|
||||
private counterfactualVersion = 0;
|
||||
private historicalEligibilityVersion = 0;
|
||||
private activeCalculation: PlanningCalculationContext | null = null;
|
||||
private runningCalculation: PlanningCalculationContext | null = null;
|
||||
private readonly traceDecisionIndices = new WeakMap<
|
||||
PlanningTrace,
|
||||
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
|
||||
>();
|
||||
private displayDateLocale = '';
|
||||
private displayDateFormatter?: Intl.DateTimeFormat;
|
||||
|
||||
@@ -245,28 +210,6 @@ export class CalendarYear {
|
||||
() => 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(),
|
||||
@@ -314,12 +257,9 @@ export class CalendarYear {
|
||||
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;
|
||||
// Compare restored alternatives against the displayed plan.
|
||||
const before = analysis.results.find((result) => result.date === analysis.placement.date)
|
||||
?.comparison?.after;
|
||||
return before
|
||||
? {
|
||||
before,
|
||||
@@ -337,10 +277,6 @@ export class CalendarYear {
|
||||
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
|
||||
);
|
||||
|
||||
protected readonly traceState = computed(() => {
|
||||
return this.selectedTraceTransition()?.[this.traceMoment()];
|
||||
});
|
||||
|
||||
protected readonly calendarSelectedDates = computed(() => {
|
||||
if (this.declarationEntry()) {
|
||||
const horizon = this.calendarHorizon();
|
||||
@@ -352,176 +288,22 @@ export class CalendarYear {
|
||||
})
|
||||
: new SelectedDates();
|
||||
}
|
||||
const state = this.traceState();
|
||||
return state
|
||||
? this.calendarService.selectedDatesForPlan({
|
||||
placements: [...state.placements],
|
||||
declaredDays: state.declaredDays,
|
||||
horizon: { ...state.horizon },
|
||||
})
|
||||
: this.selectedDates();
|
||||
|
||||
return 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,
|
||||
this.planDetailsOpen() ? 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.calendarHorizon();
|
||||
if (!horizon) return [];
|
||||
const plan = this.traceState() ?? this.displayedPlan();
|
||||
const plan = this.displayedPlan();
|
||||
const projectionHorizon =
|
||||
!this.declarationEntry() && plan
|
||||
? (calendarProjectionHorizon(plan) ?? horizon)
|
||||
@@ -555,7 +337,7 @@ export class CalendarYear {
|
||||
effect(() => {
|
||||
if (this.declarationEntry())
|
||||
untracked(() => {
|
||||
this.showFinalPlan();
|
||||
this.clearCounterfactual();
|
||||
this.liveMessage.set(this.tr().bookedDays.enter);
|
||||
});
|
||||
});
|
||||
@@ -670,8 +452,6 @@ export class CalendarYear {
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -683,20 +463,9 @@ export class CalendarYear {
|
||||
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([]);
|
||||
}
|
||||
@@ -736,44 +505,6 @@ export class CalendarYear {
|
||||
this.explanationError.set(reason);
|
||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
||||
});
|
||||
void calculation.trace
|
||||
.then((trace) => {
|
||||
if (calculation !== this.activeCalculation) 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 (calculation !== this.activeCalculation) return;
|
||||
this.planningTrace.set({
|
||||
status: 'failed',
|
||||
initialState: {
|
||||
calculationDate: calculation.snapshot.calculationDate,
|
||||
horizon: calculation.snapshot.horizon,
|
||||
...(calculation.snapshot.declaredDays
|
||||
? { declaredDays: calculation.snapshot.declaredDays }
|
||||
: {}),
|
||||
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 (calculation === this.activeCalculation) this.traceLoading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
private applyOutcome(outcome: PlanningOutcome): void {
|
||||
@@ -833,7 +564,6 @@ export class CalendarYear {
|
||||
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());
|
||||
@@ -863,19 +593,10 @@ export class CalendarYear {
|
||||
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();
|
||||
@@ -1064,7 +785,7 @@ export class CalendarYear {
|
||||
return;
|
||||
}
|
||||
const declaredType = this.calendarDeclaredDays()[date];
|
||||
if (declaredType && !this.traceReplay() && !this.counterfactual()) {
|
||||
if (declaredType && !this.counterfactual()) {
|
||||
this.liveMessage.set(
|
||||
`${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
|
||||
);
|
||||
@@ -1074,212 +795,34 @@ export class CalendarYear {
|
||||
this.invalidComparisonDate.set(false);
|
||||
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
|
||||
}
|
||||
if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
|
||||
if (!this.counterfactual() && !this.counterfactualLoading()) {
|
||||
const index = this.planExplanation()?.placements.findIndex(
|
||||
(explanation) => explanation.placement.date === date,
|
||||
);
|
||||
if (index !== undefined && index >= 0) {
|
||||
this.planDetailsOpen.set(true);
|
||||
this.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]));
|
||||
this.liveMessage.set(this.dateStatusAnnouncement(date));
|
||||
}
|
||||
}
|
||||
|
||||
private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string {
|
||||
private dateStatusAnnouncement(date: string): string {
|
||||
const result = this.counterfactual()?.results.find((result) => result.date === date);
|
||||
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,
|
||||
this.tr().planning.counterfactual,
|
||||
result ? this.tr().planning[result.status] : undefined,
|
||||
result?.reason ? this.reasonLabel(result.reason) : undefined,
|
||||
]
|
||||
.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(
|
||||
@@ -1295,12 +838,7 @@ export class CalendarYear {
|
||||
this.counterfactualLoading.set(true);
|
||||
this.invalidComparisonDate.set(false);
|
||||
if (openDialog) this.openComparisonDialog();
|
||||
const placement =
|
||||
typeof selection === 'string'
|
||||
? selection
|
||||
: 'placement' in selection
|
||||
? selection.placement
|
||||
: selection;
|
||||
const placement = selection;
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
|
||||
);
|
||||
@@ -1312,9 +850,7 @@ export class CalendarYear {
|
||||
this.counterfactual.set(result);
|
||||
if (openDialog || this.comparisonDialog)
|
||||
this.selectCalendarDate(
|
||||
this.traceReplay()
|
||||
? result.placement.date
|
||||
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
|
||||
this.nearbyComparisonDates()[0]?.date ?? result.placement.date,
|
||||
);
|
||||
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
||||
}
|
||||
@@ -1362,113 +898,6 @@ export class CalendarYear {
|
||||
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 => ({
|
||||
calculationDate: trace.initialState.calculationDate,
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
...(trace.initialState.declaredDays
|
||||
? { declaredDays: trace.initialState.declaredDays }
|
||||
: {}),
|
||||
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;
|
||||
@@ -1480,18 +909,6 @@ export class CalendarYear {
|
||||
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 (
|
||||
@@ -1627,22 +1044,10 @@ export class CalendarYear {
|
||||
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(':', ': ');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user