fix(planning): complete explanation and trace behavior (#46)

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent 57a39c81ce
commit c9dbe82b79
10 files changed
+925 -140

No files matched your search

@@ -6,6 +6,7 @@ import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-mon
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
CalendarService,
DayType,
SelectedDates,
SelectableDayType,
VacDay,
@@ -61,7 +62,7 @@ export class CalendarYear {
protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
protected readonly planDetailsOpen = signal(true);
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false);
@@ -71,6 +72,7 @@ export class CalendarYear {
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('');
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
@@ -146,14 +148,23 @@ export class CalendarYear {
protected readonly currentPlacementDate = computed(() => {
if (this.counterfactual())
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null;
if (this.traceReplay())
return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null;
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : 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 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(
`${day.getFullYear()}-${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],
@@ -173,16 +184,13 @@ export class CalendarYear {
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] : []))
for (const date of Object.keys(statuses)) {
const reason = this.nonWorkingDateReason(date, closedDayDates);
if (reason)
statuses[date] = {
kind: candidate.status,
label: this.traceStatusLabel(candidate.status),
reason: candidate.reason.message,
kind: 'ineligible',
label: this.traceStatusLabel('ineligible'),
reason,
};
}
const explicitPriorities = new Map<string, number>();
@@ -234,20 +242,39 @@ export class CalendarYear {
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),
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 ?? [])
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: result.status,
label:
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
reason: result.reason?.message,
historicalLabel: result.historicalStatus
? this.traceStatusLabel(result.historicalStatus)
: undefined,
hypotheticalReason: result.reason?.message,
};
}
return statuses;
});
@@ -357,13 +384,14 @@ export class CalendarYear {
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.planDetailsOpen.set(true);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -372,6 +400,8 @@ export class CalendarYear {
void calculation.explanation.then((explanation) => {
if (version !== this.calculationVersion) return;
this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen())
this.selectPlacement(0);
});
void calculation.explanationError.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
@@ -435,6 +465,7 @@ export class CalendarYear {
protected togglePlanDetails(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) this.selectPlacement(this.selectedPlacementIndex());
else this.clearCounterfactual();
@@ -464,12 +495,36 @@ export class CalendarYear {
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(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`);
this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
}
}
private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string {
return [
date,
status?.label,
status?.reason,
status?.hypotheticalLabel
? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}`
: undefined,
status?.hypotheticalReason
? `${this.tr().planning.reason}: ${status.hypotheticalReason}`
: undefined,
status?.changeLabel,
]
.filter(Boolean)
.join('. ');
}
protected selectTracePhase(index: number): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last));
@@ -478,6 +533,7 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.selectedTraceDate.set(null);
this.clearCounterfactual();
this.liveMessage.set(
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
@@ -505,6 +561,7 @@ export class CalendarYear {
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.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
@@ -535,11 +592,15 @@ export class CalendarYear {
this.clearCounterfactual();
this.traceMoment.set(moment);
this.traceReplay.set(true);
this.liveMessage.set(
`${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
);
}
protected showFinalPlan(): void {
this.traceReplay.set(false);
this.clearCounterfactual();
this.liveMessage.set(this.tr().planning.finalPlan);
}
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
@@ -547,9 +608,20 @@ export class CalendarYear {
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 });
}
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 async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
const calculation = this.activeCalculation;
if (!calculation) return;