feat(planning): complete verified explanations and trace delivery (#46)

Implement plan explanations, original decision traces, calendar replay, and worker counterfactual analysis. Preserve incomplete checkpoints and conflict reasons, with accessible navigation and localized comparison details.

Keep planning outcomes independent of tracing and bound worker delivery with receiver acknowledgements. Retain complete candidate history while releasing temporary buffers and benchmark references.

Validated with 201 tests, app/spec type checks, lint, production build, baseline outcome comparisons, and repeated loaded browser measurements. Document performance and remaining limits.
This commit is contained in:
nessar committed 2026-10-03 16:26:56 +02:00
1 parent 0ce33cc9d6
commit 01c87d1e44
20 files changed
+7625 -1001

No files matched your search

@@ -56,7 +56,7 @@
[disabled]="!isInHorizon(day)"
[matTooltip]="dayTooltip"
[matTooltipShowDelay]="500"
[attr.aria-label]="getDayAriaLabel(day)"
[attr.aria-label]="getDayAriaLabel(day, dayTooltip)"
[attr.aria-current]="dateKey === currentDate ? 'date' : null"
(click)="dateSelected.emit(dateKey)"
>
@@ -1,4 +1,12 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
inject,
} from '@angular/core';
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
import { MatTooltip } from '@angular/material/tooltip';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
@@ -20,6 +28,7 @@ export interface CalendarDayStatus {
imports: [MatTooltip],
templateUrl: './calendar-month.html',
styleUrl: './calendar-month.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CalendarMonth implements OnInit {
protected calendarSettingsService = inject(CalendarSettingsService);
@@ -44,6 +53,8 @@ export class CalendarMonth implements OnInit {
currentMonth: Date = new Date();
weeksInAMonth: Month = [];
private dayFormatterLocale = '';
private dayFormatter?: Intl.DateTimeFormat;
@Input({ required: true })
selectedDates!: SelectedDates;
@@ -121,16 +132,18 @@ export class CalendarMonth implements OnInit {
: '~×';
}
getDayAriaLabel(day: Date): string {
const parts = [
day.toLocaleDateString(this.translationService.locale(), {
getDayAriaLabel(day: Date, tooltip: string | null = this.getDayTooltip(day)): string {
const locale = this.translationService.locale();
if (!this.dayFormatter || locale !== this.dayFormatterLocale) {
this.dayFormatterLocale = locale;
this.dayFormatter = new Intl.DateTimeFormat(locale, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
];
const tooltip = this.getDayTooltip(day);
});
}
const parts = [this.dayFormatter.format(day)];
if (tooltip) parts.push(tooltip);
if (!this.isInHorizon(day))
parts.push(this.translationService.tr().planning.outsideHorizon);
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,7 @@
<div class="calendar-year">
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage() }}</p>
<p class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{{ liveMessage() }}
</p>
@if (isLoading()) {
<div class="loading-overlay">
@@ -12,7 +14,7 @@
></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div>
</div>
<p>{{ tr().loading }}</p>
<p role="status">{{ tr().loading }}</p>
</div>
</div>
}
@@ -170,7 +172,7 @@
<button
type="button"
data-step="first"
[disabled]="selectedPlacementIndex() === 0"
[attr.aria-disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.first"
(click)="stepPlacement('first')"
>
@@ -179,20 +181,20 @@
<button
type="button"
data-step="previous"
[disabled]="selectedPlacementIndex() === 0"
[attr.aria-disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.previous"
(click)="stepPlacement('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output>
<output aria-current="step" [attr.aria-label]="placementAnnouncement()">
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
{{ tr().planning.of }} {{ planExplanation()!.placements.length }}
</output>
<button
type="button"
data-step="next"
[disabled]="
[attr.aria-disabled]="
selectedPlacementIndex() ===
planExplanation()!.placements.length - 1
"
@@ -204,7 +206,7 @@
<button
type="button"
data-step="last"
[disabled]="
[attr.aria-disabled]="
selectedPlacementIndex() ===
planExplanation()!.placements.length - 1
"
@@ -283,7 +285,32 @@
}
</p>
}
@if (hasAtRiskContribution(selected)) {
<p>{{ tr().planning.explanation.atRiskContribution }}</p>
}
@if (selected.supporting) {
<p>{{ tr().planning.details.supporting }}</p>
}
<p>
<strong>{{ tr().planning.remaining }}:</strong>
{{ selected.remainingQuantity }} {{ tr().userInput.daysSuffix }}
@if (selected.remainingBalance; as remaining) {
·
{{
tr().planning.details.remainingReasons[remaining.classification]
}}
}
</p>
</div>
@let decisiveObjective =
selected.firstDecisiveObjective ??
selected.comparison.firstDecisiveObjective;
@if (decisiveObjective) {
<p class="decisive-summary">
<strong>{{ tr().planning.details.firstDifference }}:</strong>
{{ objectiveCopy(decisiveObjective).label }}
</p>
}
<section class="day-impact" aria-labelledby="impact-title">
<h4 id="impact-title">{{ tr().planning.explanation.impact }}</h4>
<p>{{ tr().planning.explanation.impactHelp }}</p>
@@ -302,9 +329,14 @@
</thead>
<tbody>
@for (change of placementChanges(); track change.id) {
<tr>
<tr [class.decisive-row]="change.id === decisiveObjective">
<th scope="row">
{{ change.label }}<small>{{ change.help }}</small>
@if (change.id === decisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
}
</th>
<td>{{ change.before }}</td>
<td>{{ change.after }}</td>
@@ -358,9 +390,6 @@
}}
</p>
}
@if (selected.supporting) {
<p>{{ tr().planning.details.supporting }}</p>
}
</section>
<ng-container
[ngTemplateOutlet]="calculationTable"
@@ -376,7 +405,11 @@
<button type="button" class="compare-dates-button" (click)="compareDates()">
<mat-icon aria-hidden="true">compare_arrows</mat-icon
>{{ tr().planning.compareDates }}
>{{
counterfactualError()
? tr().planning.retryCounterfactual
: tr().planning.compareDates
}}
</button>
</section>
</details>
@@ -408,7 +441,7 @@
<button
type="button"
data-phase-step="first"
[disabled]="tracePhaseIndex() === 0"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.firstPhase"
(click)="stepTracePhase('first')"
>
@@ -417,20 +450,24 @@
<button
type="button"
data-phase-step="previous"
[disabled]="tracePhaseIndex() === 0"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.previousPhase"
(click)="stepTracePhase('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="tracePhaseAnnouncement()"
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
{{ trace.phases.length }}</output
>
<button
type="button"
data-phase-step="next"
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.nextPhase"
(click)="stepTracePhase('next')"
>
@@ -439,7 +476,9 @@
<button
type="button"
data-phase-step="last"
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.lastPhase"
(click)="stepTracePhase('last')"
>
@@ -466,7 +505,7 @@
<nav [attr.aria-label]="tr().planning.decisions">
<button
type="button"
[disabled]="traceDecisionIndex() === 0"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.firstDecision"
(click)="stepTraceDecision('first')"
>
@@ -474,20 +513,22 @@
</button>
<button
type="button"
[disabled]="traceDecisionIndex() === 0"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.previousDecision"
(click)="stepTraceDecision('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="traceDecisionAnnouncement()"
>{{ tr().planning.decision }}
{{ traceDecisionIndex() + 1 }} {{ tr().planning.of }}
{{ phase.decisions.length }}</output
>
<button
type="button"
[disabled]="
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.nextDecision"
@@ -497,7 +538,7 @@
</button>
<button
type="button"
[disabled]="
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.lastDecision"
@@ -539,6 +580,23 @@
{{ tr().planning.finalPlan }}
</button>
</div>
@if (historicalEligibilityLoading()) {
<p role="status">
{{ tr().planning.historicalEligibilityLoading }}
</p>
}
@if (historicalEligibilityError(); as error) {
<p role="alert">
{{ tr().planning.historicalEligibilityError }}
{{ error }}
<button
type="button"
(click)="retryHistoricalEligibility()"
>
{{ tr().planning.retryHistoricalEligibility }}
</button>
</p>
}
<p>
<strong>{{ tr().planning.dates }}:</strong>
{{
@@ -562,13 +620,17 @@
>
<button
type="button"
[disabled]="traceCandidatePage() === 0"
[attr.aria-disabled]="
traceCandidatePage() === 0
"
[attr.aria-label]="
tr().planning.previousCandidates
"
(click)="stepTraceCandidatePage(-1)"
>
◀
<mat-icon aria-hidden="true"
>chevron_left</mat-icon
>
</button>
<output
>{{ tr().planning.page }}
@@ -578,14 +640,16 @@
>
<button
type="button"
[disabled]="
[attr.aria-disabled]="
traceCandidatePage() ===
traceCandidatePages() - 1
"
[attr.aria-label]="tr().planning.nextCandidates"
(click)="stepTraceCandidatePage(1)"
>
▶
<mat-icon aria-hidden="true"
>chevron_right</mat-icon
>
</button>
</nav>
}
@@ -624,6 +688,100 @@
@if (candidate.reason) {
<p>{{ candidate.reason.message }}</p>
}
@if (candidate.technicalComparison?.length) {
<section class="technical-comparison">
<h5>
{{
tr().planning.details
.traceStageTitle
}}
</h5>
<p class="calculation-intro">
{{
tr().planning.details
.traceStageHelp
}}
</p>
<table class="impact-table">
<thead>
<tr>
<th scope="col">
{{
tr().planning
.explanation
.criterion
}}
</th>
<th scope="col">
{{
tr().planning.before
}}
</th>
<th scope="col">
{{
tr().planning.after
}}
</th>
</tr>
</thead>
<tbody>
@for (
comparator of candidate.technicalComparison;
track $index
) {
<tr
[class.decisive-row]="
comparator.name ===
candidate.firstDecisiveComparator
"
>
<th scope="row">
<code>{{
comparator.name
}}</code>
<small>{{
comparator.direction ===
'maximize'
? tr()
.planning
.details
.higher
: tr()
.planning
.details
.lower
}}</small>
@if (
comparator.name ===
candidate.firstDecisiveComparator
) {
<span
class="decisive-label"
>{{
tr()
.planning
.details
.firstDifference
}}</span
>
}
</th>
<td>
{{
comparator.before
}}
</td>
<td>
{{
comparator.after
}}
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (candidate.comparison; as comparison) {
<div class="calculation-content">
<ng-container
@@ -636,6 +794,10 @@
tr().planning.before,
afterLabel: tr().planning.after,
audit: comparison,
traceEffect:
!!candidate
.technicalComparison
?.length,
}"
/>
</div>
@@ -687,6 +849,21 @@
{{ tr().planning.placements }},
{{ state.earnedActivations.length }}
{{ tr().planning.activations }}
@for (balance of state.balances; track balance.id) {
<span class="trace-state-balance"
>{{ balance.id }}: {{ balance.remaining }}
{{ tr().planning.balanceRemaining }}
<small
>{{ tr().planning.details.available }}
{{ displayDate(balance.availableFrom) }}
·
{{ tr().planning.details.expires }}
{{
displayDate(balance.expiryDate)
}}</small
></span
>
}
</p>
}
}
@@ -697,7 +874,7 @@
}
</div>
} @else if (traceResolved()) {
<p>{{ tr().planning.traceUnavailable }}</p>
<p role="status">{{ tr().planning.traceUnavailable }}</p>
}
</details>
}
@@ -711,6 +888,16 @@
</button>
</div>
}
@if (counterfactualLoading()) {
<p class="counterfactual-progress" aria-hidden="true">
{{ tr().planning.counterfactualLoading }}
</p>
}
@if (counterfactualError()) {
<div class="counterfactual-inline-error" role="alert">
<p>{{ tr().planning.counterfactualError }} {{ counterfactualError() }}</p>
</div>
}
<p class="calendar-help">
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
</p>
@@ -828,10 +1015,23 @@
let-beforeLabel="beforeLabel"
let-afterLabel="afterLabel"
let-audit="audit"
let-traceEffect="traceEffect"
>
<section class="criteria-detail">
<h4>{{ tr().planning.details.criteria }}</h4>
<p class="calculation-intro">{{ tr().planning.details.criteriaHelp }}</p>
<h4>
{{
traceEffect
? tr().planning.details.traceImpactTitle
: tr().planning.details.criteria
}}
</h4>
<p class="calculation-intro">
{{
traceEffect
? tr().planning.details.traceImpactHelp
: tr().planning.details.criteriaHelp
}}
</p>
<table class="impact-table criteria-table">
<thead>
<tr>
@@ -849,7 +1049,7 @@
@if (objective.numeric) {
<tr
[class.decisive-row]="
objective.id === comparison.firstDecisiveObjective
!traceEffect && objective.id === comparison.firstDecisiveObjective
"
>
<th scope="row">
@@ -862,7 +1062,10 @@
? tr().planning.details.higher
: tr().planning.details.lower
}}</small>
@if (objective.id === comparison.firstDecisiveObjective) {
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
@@ -886,7 +1089,10 @@
@if (!objective.numeric) {
<li>
{{ objective.label }}
@if (objective.id === comparison.firstDecisiveObjective) {
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
@@ -940,7 +1146,9 @@
<p>{{ tr().planning.counterfactualLoading }}</p>
</div>
} @else if (counterfactualError()) {
<p class="inline-error" role="alert">{{ tr().planning.counterfactualError }}</p>
<div class="counterfactual-inline-error" role="alert">
<p>{{ tr().planning.counterfactualError }} {{ counterfactualError() }}</p>
</div>
} @else if (counterfactual(); as analysis) {
<div class="comparison-date-pair">
<div class="original-date">
@@ -1106,6 +1314,12 @@
)
}}
</p>
@if (dateStatuses()[result.date]?.reason; as historicalReason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ historicalReason }}
</p>
}
@if (dateComparison(); as comparison) {
<ng-container
[ngTemplateOutlet]="calculationTable"
@@ -1118,6 +1332,7 @@
? tr().planning.explanation.withCandidate
: tr().planning.explanation.alternative,
audit: result,
traceEffect: traceReplay(),
}"
/>
} @else {
@@ -1136,11 +1351,17 @@
</mat-dialog-content>
<footer class="comparison-footer">
<span>{{ tr().planning.comparison.preview }}</span>
<button type="button" class="comparison-done" (click)="clearCounterfactual()">
<button
type="button"
class="comparison-done"
(click)="counterfactualError() ? retryCounterfactual() : clearCounterfactual()"
>
{{
traceReplay()
? tr().planning.comparison.backToTrace
: tr().planning.comparison.backToPlan
counterfactualError()
? tr().planning.retryCounterfactual
: traceReplay()
? tr().planning.comparison.backToTrace
: tr().planning.comparison.backToPlan
}}
</button>
</footer>
@@ -67,6 +67,10 @@ button {
opacity: 0.45;
cursor: not-allowed;
}
&[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
}
summary {
@@ -353,12 +357,27 @@ output {
margin: 0;
}
.inline-error,
.counterfactual-inline-error,
.conflict-explanation {
padding: 0.75rem;
border-inline-start: 4px solid var(--ui-error-text);
background: var(--ui-error-bg);
color: var(--ui-error-text);
}
.counterfactual-inline-error {
display: grid;
justify-items: start;
gap: 0.75rem;
}
.counterfactual-progress {
color: var(--ui-text-secondary);
font-size: 0.875rem;
}
.decisive-summary {
padding: 0.75rem;
border-inline-start: 3px solid var(--mat-sys-primary);
background: var(--ui-panel-bg);
}
.incomplete-explanation,
.incomplete-incumbent {
padding: 1rem;
@@ -461,6 +480,8 @@ output {
}
small {
color: var(--ui-text-secondary);
flex-shrink: 0;
white-space: nowrap;
}
}
.trace-phase-stepper {
+374 -138
View File
@@ -33,6 +33,7 @@ import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import {
applyReplayDeltas,
CounterfactualAnalysis,
CounterfactualSelection,
LeavePlacement,
@@ -52,6 +53,7 @@ import {
type PlacementExplanation = PlanExplanation['placements'][number];
type TraceMoment = 'before' | 'after';
type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
@Component({
selector: 'app-calendar-year',
@@ -110,12 +112,24 @@ export class CalendarYear {
protected readonly traceReplay = signal(false);
protected readonly selectedTraceDate = signal<string | null>(null);
protected readonly liveMessage = signal('');
private readonly historicalIneligibility = signal<ReadonlyMap<string, PlanningReason>>(
new Map(),
);
protected readonly historicalEligibilityLoading = signal(false);
protected readonly historicalEligibilityError = signal<string | null>(null);
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
private calculationVersion = 0;
private counterfactualVersion = 0;
private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private readonly traceDecisionIndices = new WeakMap<
PlanningTrace,
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
>();
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
get tr() {
return this.translationService.tr;
@@ -146,6 +160,11 @@ export class CalendarYear {
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;
@@ -227,12 +246,7 @@ export class CalendarYear {
);
protected readonly traceState = computed(() => {
if (!this.traceReplay()) return undefined;
return this.replayTraceState(
this.planningTrace(),
this.selectedTraceDecision()?.id,
this.traceMoment(),
);
return this.selectedTraceTransition()?.[this.traceMoment()];
});
protected readonly calendarSelectedDates = computed(() => {
@@ -250,7 +264,7 @@ export class CalendarYear {
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
if (this.traceReplay())
return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null;
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
return this.selectedExplanation()?.placement.date ?? null;
});
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
@@ -307,6 +321,14 @@ export class CalendarYear {
}
}
}
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>();
@@ -325,7 +347,7 @@ export class CalendarYear {
changeLabel: this.traceChangeLabel(change),
};
};
for (const delta of decision.deltas) {
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') {
@@ -333,11 +355,10 @@ export class CalendarYear {
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');
const transition = this.selectedTraceTransition();
for (const placement of [
...(before?.placements ?? []),
...(after?.placements ?? []),
...(transition?.before.placements ?? []),
...(transition?.after.placements ?? []),
]) {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
}
@@ -351,6 +372,9 @@ export class CalendarYear {
...status,
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: status?.reason,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
@@ -367,7 +391,11 @@ export class CalendarYear {
statuses[result.date] = {
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: historicalStatus === 'ineligible'
? nonWorkingReason
: undefined,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
@@ -381,11 +409,18 @@ export class CalendarYear {
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`);
let first = new Date(`${horizon.start}T00:00:00`);
let last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
for (const period of this.displayedPlan()?.timeOffPeriods ?? []) {
for (const date of period.nonWorkingDates) {
const contextDate = new Date(`${date}T00:00:00`);
if (date < horizon.start && contextDate < first) first = contextDate;
else if (date >= horizon.end && contextDate > last) last = contextDate;
}
}
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
@@ -406,6 +441,7 @@ export class CalendarYear {
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
queueMicrotask(() => {
let calculation: PlanningCalculationContext;
try {
@@ -494,26 +530,57 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
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' && this.planDetailsOpen())
this.selectPlacement(0);
});
void calculation.explanationError.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
});
void calculation.explanation
.then((explanation) => {
if (version !== this.calculationVersion) return;
this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation') {
this.planDetailsOpen.set(true);
this.selectPlacement(0, false, false);
this.liveMessage.set(
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
);
}
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
void calculation.explanationError
.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
void calculation.trace
.then((trace) => {
if (version !== this.calculationVersion) return;
this.planningTrace.set(trace ?? null);
this.traceResolved.set(true);
this.liveMessage.set(
trace?.status === 'failed'
? this.tr().planning.traceUnavailable
: this.tr().planning.traceReady,
);
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
@@ -533,6 +600,7 @@ export class CalendarYear {
},
});
this.traceResolved.set(true);
this.liveMessage.set(this.tr().planning.traceUnavailable);
})
.finally(() => {
if (version === this.calculationVersion) this.traceLoading.set(false);
@@ -553,7 +621,7 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]);
}
protected selectPlacement(index: number): void {
protected selectPlacement(index: number, analyze = true, announce = true): void {
const explanation = this.planExplanation();
if (!explanation?.placements.length) return;
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
@@ -561,14 +629,27 @@ export class CalendarYear {
this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
this.liveMessage.set(
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
);
if (announce) this.liveMessage.set(this.placementAnnouncement());
if (analyze) void this.analyzeSelection(explanation.placements[selected].placement);
}
protected compareDates(): void {
const placement = this.selectedExplanation()?.placement;
if (placement) void this.analyzeSelection(placement);
if (!placement) return;
this.openComparisonDialog();
if (this.counterfactual()) {
if (this.selectedCounterfactualDate() === placement.date) {
const alternative = this.nearbyComparisonDates()[0];
if (alternative) this.selectCalendarDate(alternative.date);
}
} else if (!this.counterfactualLoading()) {
void this.analyzeSelection(placement, true);
}
}
protected retryCounterfactual(): void {
if (this.counterfactualSelection)
void this.analyzeSelection(this.counterfactualSelection, !!this.comparisonDialog);
}
protected togglePlanDetails(event: Event): void {
@@ -592,15 +673,15 @@ export class CalendarYear {
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex();
this.selectPlacement(
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1,
);
: current + 1;
if (target !== current) this.selectPlacement(target);
}
protected selectCalendarDate(date: string): void {
@@ -616,10 +697,6 @@ export class CalendarYear {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
this.planDetails()
?.nativeElement.querySelector('summary')
?.focus({ preventScroll: true });
return;
}
}
@@ -661,25 +738,24 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.clearCounterfactual();
this.liveMessage.set(
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
);
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();
this.selectTracePhase(
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1,
);
: current + 1;
if (target !== current) this.selectTracePhase(target);
}
protected selectTraceDecision(index: number): void {
@@ -691,28 +767,94 @@ export class CalendarYear {
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}`,
);
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();
this.selectTraceDecision(
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1,
);
: current + 1;
if (target !== current) this.selectTraceDecision(target);
}
protected stepTraceCandidatePage(direction: -1 | 1): void {
this.traceCandidatePage.update((page) =>
Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)),
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()}`,
);
}
@@ -720,13 +862,13 @@ 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}`,
);
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);
}
@@ -737,7 +879,10 @@ export class CalendarYear {
this.traceMoment.set('before');
this.traceReplay.set(true);
this.selectedTraceDate.set(candidate.placement.date);
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
void this.analyzeSelection(
{ decisionId: decision.id, placement: candidate.placement },
true,
);
}
private nonWorkingDateReason(
@@ -750,59 +895,81 @@ export class CalendarYear {
return undefined;
}
private async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
private counterfactualSelection: CounterfactualSelection | null = null;
private async analyzeSelection(
selection: CounterfactualSelection,
openDialog = false,
): Promise<void> {
const calculation = this.activeCalculation;
if (!calculation) return;
this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion;
this.counterfactual.set(null);
this.counterfactualError.set(null);
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (!this.comparisonDialog) {
const dialog = this.dialog.open(this.comparisonTemplate(), {
width: '48rem',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: 'calc(100dvh - 2rem)',
autoFocus: 'first-heading',
ariaLabelledBy: 'counterfactual-title',
ariaDescribedBy: 'comparison-intro',
});
this.comparisonDialog = dialog;
dialog.beforeClosed().subscribe(() => {
if (this.comparisonDialog === dialog) {
this.comparisonDialog = null;
this.clearCounterfactual();
}
});
}
if (openDialog) this.openComparisonDialog();
const placement =
typeof selection === 'string'
? selection
: 'placement' in selection
? selection.placement
: selection;
this.liveMessage.set(
`${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
);
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
);
if (openDialog || this.comparisonDialog)
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
this.counterfactualError.set(error instanceof Error ? error.message : String(error));
const message = error instanceof Error ? error.message : String(error);
this.counterfactualError.set(message);
this.liveMessage.set(`${this.tr().planning.counterfactualError} ${message}`);
} finally {
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
}
}
private openComparisonDialog(): void {
if (this.comparisonDialog) return;
const dialog = this.dialog.open(this.comparisonTemplate(), {
width: '48rem',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: 'calc(100dvh - 2rem)',
autoFocus: 'first-heading',
ariaLabelledBy: 'counterfactual-title',
ariaDescribedBy: 'comparison-intro',
});
this.comparisonDialog = dialog;
dialog.beforeClosed().subscribe(() => {
if (this.comparisonDialog === dialog) {
this.comparisonDialog = null;
this.clearCounterfactual();
}
});
}
protected clearCounterfactual(): void {
const dialog = this.comparisonDialog;
this.comparisonDialog = null;
dialog?.close();
this.counterfactualVersion++;
this.counterfactual.set(null);
this.counterfactualSelection = null;
this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false);
this.counterfactualError.set(null);
@@ -810,70 +977,130 @@ export class CalendarYear {
this.comparisonDateInput.set(null);
}
private replayTraceState(
private replayTraceTransition(
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],
}));
decision: PlanningTraceDecision | undefined,
): TraceTransition | undefined {
if (!trace || !decision) return undefined;
const state = {
balances: trace.initialState.balances.map((balance) => ({ ...balance })),
placements: new Map(
trace.initialState.placements.map((placement) => [
placement.date,
{ ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
};
const snapshot = (): PlanningTraceState => ({
horizon: { ...trace.initialState.horizon },
balances,
placements: [...placements.values()],
earnedActivations,
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],
})),
});
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();
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 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],
});
private getTraceDecisionIndex(
trace: PlanningTrace,
): ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }> {
let index = this.traceDecisionIndices.get(trace);
if (!index) {
const decisions = new Map<
string,
{ decision: PlanningTraceDecision; ordinal: number }
>();
let ordinal = 0;
for (const phase of trace.phases)
for (const decision of phase.decisions)
decisions.set(decision.id, { decision, ordinal: ordinal++ });
index = decisions;
this.traceDecisionIndices.set(trace, index);
}
return index;
}
protected placementAnnouncement(): string {
const selected = this.selectedExplanation();
const count = this.planExplanation()?.placements.length ?? 0;
if (!selected || !count) return '';
return `${this.tr().planning.placement} ${this.selectedPlacementIndex() + 1} ${this.tr().planning.of} ${count}: ${this.displayDate(selected.placement.date)}`;
}
protected hasAtRiskContribution(explanation: PlacementExplanation): boolean {
return explanation.contributions.some((contribution) => contribution.kind === 'at-risk');
}
protected tracePhaseAnnouncement(): string {
const phases = this.planningTrace()?.phases ?? [];
const phase = phases[this.tracePhaseIndex()];
return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`;
}
protected traceDecisionAnnouncement(): string {
const decisions = this.selectedTracePhase()?.decisions ?? [];
const decision = decisions[this.traceDecisionIndex()];
return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`;
}
protected hasPlannerError(): boolean {
@@ -974,10 +1201,15 @@ export class CalendarYear {
}
protected displayDate(value: string): string {
return new Intl.DateTimeFormat(this.translationService.locale(), {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
const locale = this.translationService.locale();
if (!this.displayDateFormatter || locale !== this.displayDateLocale) {
this.displayDateLocale = locale;
this.displayDateFormatter = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeZone: 'UTC',
});
}
return this.displayDateFormatter.format(new Date(value));
}
protected planningDate(value: string | Date | undefined): string {
@@ -989,6 +1221,10 @@ export class CalendarYear {
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];
}