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

@@ -7,6 +7,7 @@ import {
} from './leave-planner-benchmark';
describe('leave planner benchmark protocol', () => {
// Warm-up and two repetitions include complete trace delivery after planning.
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
const scenarios = benchmarkScenarios();
expect(scenarios).toHaveLength(5);
@@ -62,6 +63,11 @@ describe('leave planner benchmark protocol', () => {
expect(loaded[1].deterministic).toBe('yes');
expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].traceDeliveryMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].planningWorkMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].planningWorkMs).toBeLessThanOrEqual(loaded[0].planningMs);
expect(loaded[0].traceCaptureMs).toBeGreaterThan(0);
expect(loaded[0].traceMaterializationMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].maxUiDelayMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].wallMs).toBeGreaterThanOrEqual(
loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs,
);
@@ -70,9 +76,10 @@ describe('leave planner benchmark protocol', () => {
const report = benchmarkReport(measurements);
expect(report.split('\n')).toHaveLength(11);
expect(report).toContain('planning_ms\ttrace_delivery_ms');
expect(report).toContain('planning_work_ms\ttrace_capture_ms');
expect(report).toContain('\tbaseline\t');
} finally {
vi.stubGlobal('window', browserWindow);
}
}, 30_000);
}, 90_000);
});
+85 -45
View File
@@ -16,6 +16,11 @@ export interface BenchmarkMeasurement {
preparationMs: number;
planningMs: number;
traceDeliveryMs: number;
maxUiDelayMs: number;
planningWorkMs?: number;
traceCaptureMs?: number;
traceMaterializationMs?: number;
traceTransferMs?: number;
wallMs: number;
outcome: PlanningOutcome['kind'];
lastCompletedStage?: string;
@@ -167,63 +172,93 @@ function resultSummary(outcome: PlanningOutcome): {
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
const scenarios = benchmarkScenarios();
for (const scenario of scenarios) {
const calculation = createPlanningCalculation(structuredClone(scenario.request));
await calculation.outcome;
await calculation.trace;
}
for (const scenario of scenarios) await warmUpScenario(scenario);
const measurements: BenchmarkMeasurement[] = [];
for (const scenario of scenarios) {
let baseline: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) {
const wallStart = performance.now();
const preparationStart = performance.now();
const request = structuredClone(scenario.request);
const preparationMs = performance.now() - preparationStart;
const calculationStart = performance.now();
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
const planningMs = performance.now() - calculationStart;
const traceStart = performance.now();
const trace = await calculation.trace;
const traceDeliveryMs = performance.now() - traceStart;
const summary = resultSummary(outcome);
const stable = JSON.stringify(outcome);
const deterministic =
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
const traceCandidates =
trace?.phases.reduce(
(total, phase) =>
total +
phase.decisions.reduce(
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
0,
),
0,
) ?? 0;
const wallMs = performance.now() - wallStart;
measurements.push({
scenario: scenario.name,
repetition,
preparationMs,
planningMs,
traceDeliveryMs,
wallMs,
...summary,
deterministic,
traceStatus: trace?.status ?? 'not-produced',
traceCandidates,
});
baseline ??= stable;
const result = await measureRepetition(scenario, repetition, baseline);
measurements.push(result.measurement);
baseline ??= result.stable;
}
}
return measurements;
}
async function warmUpScenario(scenario: BenchmarkScenario): Promise<void> {
const calculation = createPlanningCalculation(structuredClone(scenario.request));
await calculation.outcome;
await calculation.trace;
}
async function measureRepetition(
scenario: BenchmarkScenario,
repetition: number,
baseline: string | undefined,
): Promise<{ measurement: BenchmarkMeasurement; stable: string }> {
const wallStart = performance.now();
let lastTick = wallStart;
let maxUiDelayMs = 0;
const measureDelay = () => {
const current = performance.now();
maxUiDelayMs = Math.max(maxUiDelayMs, current - lastTick - 50);
lastTick = current;
};
const heartbeat = setInterval(measureDelay, 50);
try {
const preparationStart = performance.now();
const request = structuredClone(scenario.request);
const preparationMs = performance.now() - preparationStart;
const calculationStart = performance.now();
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
const planningMs = performance.now() - calculationStart;
const traceStart = performance.now();
const trace = await calculation.trace;
const traceDeliveryMs = performance.now() - traceStart;
const timings = await calculation.timings;
const summary = resultSummary(outcome);
const stable = JSON.stringify(outcome);
const deterministic =
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
const traceCandidates =
trace?.phases.reduce(
(total, phase) =>
total +
phase.decisions.reduce(
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
0,
),
0,
) ?? 0;
const wallMs = performance.now() - wallStart;
measureDelay();
return {
measurement: {
scenario: scenario.name,
repetition,
preparationMs,
planningMs,
traceDeliveryMs,
maxUiDelayMs,
...timings,
wallMs,
...summary,
deterministic,
traceStatus: trace?.status ?? 'not-produced',
traceCandidates,
},
stable,
};
} finally {
clearInterval(heartbeat);
}
}
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
const header =
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates';
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms';
return [
header,
...measurements.map((item) =>
@@ -240,6 +275,11 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
item.deterministic,
item.traceStatus,
item.traceCandidates,
item.maxUiDelayMs.toFixed(1),
item.planningWorkMs?.toFixed(1) ?? '',
item.traceCaptureMs?.toFixed(1) ?? '',
item.traceMaterializationMs?.toFixed(1) ?? '',
item.traceTransferMs?.toFixed(1) ?? '',
].join('\t'),
),
].join('\n');
@@ -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];
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals());
describe('quality calculation trace', () => {
it('separates source effects from the actual incumbent comparison', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
availableFrom: '2024-01-01',
expiryDate: '2024-01-31',
},
],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
if (outcome.kind !== 'Plan' || !trace) return;
const phase = trace.phases.find((item) => item.name === 'non-working-day-gain');
const jan3Decision = phase?.decisions.find((decision) =>
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'),
);
const jan2Decision = phase?.decisions.find((decision) =>
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'),
);
const jan5 = jan3Decision?.candidates.find(
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05',
);
const jan8 = jan3Decision?.candidates.find(
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08',
);
expect(jan2Decision).toBeDefined();
expect(jan3Decision).toBeDefined();
expect(jan3Decision).not.toBe(jan2Decision);
expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']);
expect(jan5?.status).toBe('chosen');
expect(jan8?.status).toBe('evaluated-rejected');
expect(jan3Decision?.deltas).toContainEqual(
expect.objectContaining({
kind: 'placement-moved',
fromDate: '2024-01-03',
toDate: '2024-01-05',
}),
);
const sourceValue = (
candidate: typeof jan5,
state: 'before' | 'after',
objective: string,
) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value;
expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3);
expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5);
expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20);
expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18);
expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3);
expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5);
expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20);
expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17);
expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain');
expect(jan8?.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'non-working-day-gain',
direction: 'maximize',
before: 5,
after: 5,
}),
]),
);
expect(
jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'),
).toBe(false);
expect(jan8?.firstDecisiveComparator).toBeUndefined();
expect(jan8?.reason?.message).toContain('did not improve the current best result');
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+360 -38
View File
@@ -4,13 +4,20 @@ import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceUpdate,
PlanningSnapshot,
PlanningTraceState,
TraceCandidateStatus,
} from './leave-planner.service';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
explainIncompleteCheckpoint,
historicalCounterfactualReasons,
type CounterfactualBaselineOptions,
type PlanningArtifacts,
} from './leave-planner.core';
@@ -30,6 +37,7 @@ type WorkerRequest =
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'trace-ack'; count: number }
| {
type: 'counterfactual';
request: PlanningSnapshot;
@@ -37,59 +45,373 @@ type WorkerRequest =
placement: LeavePlacement;
state?: PlanningTraceState;
options?: CounterfactualBaselineOptions;
}
| {
type: 'historical-eligibility';
request: PlanningSnapshot;
placement: LeavePlacement;
state: PlanningTraceState;
options?: CounterfactualBaselineOptions;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningTraceState;
interruption: PlanningReason;
};
type WorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningTraceState;
}
| {
type: 'artifacts-start';
artifacts: Omit<
PlanningArtifacts,
'trace' | 'explanation' | 'explanationError' | 'finalState'
>;
trace?: Omit<PlanningTrace, 'phases'>;
}
| {
type: 'trace-update';
totalMs: number;
update: PlanningTraceUpdate;
acknowledge?: true;
}
| {
type: 'trace-status-index';
statuses: ReadonlyMap<string, TraceCandidateStatus>;
nonWorkingDates: readonly string[];
}
| { type: 'artifacts-complete'; traceTransferMs: number }
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; message: string };
| {
type: 'historical-eligibility-result';
entries: ReturnType<typeof historicalCounterfactualReasons>;
}
| { type: 'error'; code?: string; message: string };
const worker = self as DedicatedWorkerGlobalScope;
const TRACE_OPERATIONS_PER_MESSAGE = 64;
const MAX_TRACE_PACKETS_IN_FLIGHT = 16;
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
try {
if (event.data?.type === 'plan') {
let artifacts: PlanningArtifacts = {};
let outcomePosted = false;
const outcome = calculatePlanningInternal(
{
...event.data.request,
progress: (stage: string) =>
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse),
traceProgress: (totalMs: number) =>
worker.postMessage({
type: 'trace-overhead',
totalMs,
} satisfies WorkerResponse),
},
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse),
(value) => (artifacts = value),
(value) => {
outcomePosted = true;
worker.postMessage({ type: 'result', outcome: value } satisfies WorkerResponse);
},
);
if (!outcomePosted)
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
worker.postMessage({ type: 'artifacts', artifacts } satisfies WorkerResponse);
return;
interface StagedTraceUpdate {
totalMs: number;
update?: PlanningTraceUpdate;
nextOperation: number;
}
interface TraceDelivery {
updates: StagedTraceUpdate[];
nextUpdate: number;
inFlight: number;
traceTransferMs: number;
artifacts?: PlanningArtifacts;
calculationComplete: boolean;
}
let activeTraceDelivery: TraceDelivery | undefined;
function traceStatusIndex(trace: PlanningTrace): {
statuses: Map<string, TraceCandidateStatus>;
nonWorkingDates: string[];
} {
const priority: Record<TraceCandidateStatus, number> = {
ineligible: 0,
'not-examined': 1,
pruned: 2,
'evaluated-rejected': 3,
chosen: 4,
};
const statuses = new Map<string, TraceCandidateStatus>();
const nonWorkingDates = new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []);
for (const phase of trace.phases) {
for (const decision of phase.decisions) {
for (const candidate of decision.candidates) {
const dates = candidate.dates ?? (candidate.date ? [candidate.date] : []);
for (const date of dates) {
const previous = statuses.get(date);
if (!previous || priority[candidate.status] > priority[previous])
statuses.set(date, candidate.status);
}
}
}
if (event.data?.type !== 'counterfactual') return;
const analysis = analyzeCounterfactualInternal(
event.data.request,
event.data.plan,
event.data.placement,
event.data.state,
event.data.options,
);
worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse);
}
return { statuses, nonWorkingDates: [...nonWorkingDates] };
}
function sendArtifacts(artifacts: PlanningArtifacts, deferredTraceTransferMs = 0): void {
const trace = artifacts.trace;
let traceMaterializationMs = artifacts.timings?.traceMaterializationMs ?? 0;
let traceTransferMs = (artifacts.timings?.traceTransferMs ?? 0) + deferredTraceTransferMs;
if (trace) {
const materializationStarted = performance.now();
const statusIndex = traceStatusIndex(trace);
traceMaterializationMs += Math.max(0, performance.now() - materializationStarted);
const transferStarted = performance.now();
worker.postMessage({
type: 'trace-status-index',
statuses: statusIndex.statuses,
nonWorkingDates: statusIndex.nonWorkingDates,
} satisfies WorkerResponse);
traceTransferMs += Math.max(0, performance.now() - transferStarted);
}
const rest: Omit<
PlanningArtifacts,
'trace' | 'explanation' | 'explanationError' | 'finalState'
> = artifacts.timings
? {
timings: {
...artifacts.timings,
traceMaterializationMs,
traceTransferMs,
},
}
: {};
const transferStarted = performance.now();
worker.postMessage({
type: 'artifacts-start',
artifacts: rest,
trace: trace
? {
status: trace.status,
initialState: trace.initialState,
completedStages: trace.completedStages,
nonWorkingDates: trace.nonWorkingDates,
error: trace.error,
}
: undefined,
} satisfies WorkerResponse);
traceTransferMs += Math.max(0, performance.now() - transferStarted);
// Capture updates already carried every candidate in bounded operations.
// The final packet closes the trace without cloning it a second time.
worker.postMessage({ type: 'artifacts-complete', traceTransferMs } satisfies WorkerResponse);
}
function finishTraceDelivery(delivery: TraceDelivery): void {
if (
!delivery.calculationComplete ||
delivery.nextUpdate < delivery.updates.length ||
delivery.inFlight > 0 ||
!delivery.artifacts
)
return;
if (activeTraceDelivery === delivery) activeTraceDelivery = undefined;
try {
sendArtifacts(delivery.artifacts, delivery.traceTransferMs);
} catch (error) {
worker.postMessage({
type: 'error',
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
}
delivery.updates = [];
}
function pumpTraceUpdates(delivery: TraceDelivery): void {
while (
delivery.inFlight < MAX_TRACE_PACKETS_IN_FLIGHT &&
delivery.nextUpdate < delivery.updates.length
) {
const staged = delivery.updates[delivery.nextUpdate];
const update = staged.update;
if (!update) {
delivery.nextUpdate++;
continue;
}
const start = staged.nextOperation;
const end = Math.min(start + TRACE_OPERATIONS_PER_MESSAGE, update.operations.length);
const firstPacket = start === 0;
const packet: WorkerResponse = {
type: 'trace-update',
totalMs: staged.totalMs,
acknowledge: true,
update: {
initialState: firstPacket ? update.initialState : undefined,
nonWorkingDates: firstPacket ? update.nonWorkingDates : undefined,
operations: update.operations.slice(start, end),
},
};
const transferStarted = performance.now();
try {
worker.postMessage(packet satisfies WorkerResponse);
} catch (error) {
if (activeTraceDelivery === delivery) activeTraceDelivery = undefined;
delivery.updates = [];
worker.postMessage({
type: 'error',
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
return;
}
delivery.traceTransferMs += Math.max(0, performance.now() - transferStarted);
delivery.inFlight++;
staged.nextOperation = end;
if (update.operations.length === 0 || end >= update.operations.length) {
// Drop the full drain array as soon as its last bounded packet is posted.
staged.update = undefined;
delivery.nextUpdate++;
}
}
if (delivery.nextUpdate === delivery.updates.length) {
// All queued operation arrays are released; only in-flight structured clones remain.
delivery.updates = [];
delivery.nextUpdate = 0;
}
finishTraceDelivery(delivery);
}
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
try {
if (event.data?.type === 'trace-ack') {
const delivery = activeTraceDelivery;
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
delivery.inFlight = Math.max(0, delivery.inFlight - event.data.count);
pumpTraceUpdates(delivery);
return;
}
if (event.data?.type === 'plan') {
let artifacts: PlanningArtifacts = {};
let outcomePosted = false;
let outcome: PlanningOutcome;
const traceDelivery: TraceDelivery = {
updates: [],
nextUpdate: 0,
inFlight: 0,
traceTransferMs: 0,
calculationComplete: false,
};
activeTraceDelivery = traceDelivery;
try {
outcome = calculatePlanningInternal(
{
...event.data.request,
progress: (stage: string) =>
worker.postMessage({
type: 'progress',
stage,
} satisfies WorkerResponse),
traceProgress: (totalMs: number, update: PlanningTraceUpdate) => {
worker.postMessage({
type: 'trace-overhead',
totalMs,
} satisfies WorkerResponse);
traceDelivery.updates.push({ totalMs, update, nextOperation: 0 });
},
},
(plan, state) =>
worker.postMessage({
type: 'checkpoint',
plan,
state,
} satisfies WorkerResponse),
(value) => (artifacts = value),
(value) => {
outcomePosted = true;
worker.postMessage({
type: 'result',
outcome: value,
} satisfies WorkerResponse);
},
(explanation, explanationError, finalState) =>
worker.postMessage({
type: 'explanation',
explanation,
explanationError,
finalState,
} satisfies WorkerResponse),
);
} catch (error) {
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
traceDelivery.updates = [];
worker.postMessage({
type: 'error',
code: 'planner-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
return;
}
if (!outcomePosted)
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
traceDelivery.artifacts = artifacts;
traceDelivery.calculationComplete = true;
pumpTraceUpdates(traceDelivery);
return;
}
if (event.data?.type === 'explain-checkpoint') {
const result = explainIncompleteCheckpoint(
event.data.request,
event.data.plan,
event.data.state,
event.data.interruption,
);
worker.postMessage({ type: 'explanation', ...result } satisfies WorkerResponse);
return;
}
if (event.data?.type === 'historical-eligibility') {
worker.postMessage({
type: 'historical-eligibility-result',
entries: historicalCounterfactualReasons(
event.data.request,
event.data.state,
event.data.placement,
event.data.options,
),
} satisfies WorkerResponse);
return;
}
if (event.data?.type !== 'counterfactual') return;
try {
let analysis = analyzeCounterfactualInternal(
event.data.request,
event.data.plan,
event.data.placement,
event.data.state,
event.data.options,
);
if (event.data.options && event.data.state) {
const historicalReasons = new Map(
historicalCounterfactualReasons(
event.data.request,
event.data.state,
event.data.placement,
event.data.options,
).map(({ date, reason }) => [date, reason]),
);
analysis = {
...analysis,
results: analysis.results.map((result) => {
const historicalReason = historicalReasons.get(result.date);
return historicalReason ? { ...result, historicalReason } : result;
}),
};
}
worker.postMessage({
type: 'counterfactual-result',
analysis,
} satisfies WorkerResponse);
} catch (error) {
worker.postMessage({
type: 'error',
code: 'counterfactual-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
}
} catch (error) {
worker.postMessage({
type: 'error',
code: 'worker-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
}
+12
View File
@@ -124,6 +124,10 @@ export interface AppTranslations {
expires: string;
criteria: string;
criteriaHelp: string;
traceImpactTitle: string;
traceImpactHelp: string;
traceStageTitle: string;
traceStageHelp: string;
firstDifference: string;
higher: string;
lower: string;
@@ -142,6 +146,7 @@ export interface AppTranslations {
leaveUsed: string;
expires: string;
linkedRules: string;
atRiskContribution: string;
impact: string;
impactHelp: string;
criterion: string;
@@ -192,10 +197,16 @@ export interface AppTranslations {
next: string;
last: string;
remaining: string;
balanceRemaining: string;
counterfactual: string;
counterfactualLoading: string;
counterfactualReady: string;
counterfactualError: string;
retryCounterfactual: string;
historicalEligibilityLoading: string;
historicalEligibilityReady: string;
historicalEligibilityError: string;
retryHistoricalEligibility: string;
eligible: string;
ineligible: string;
historical: string;
@@ -213,6 +224,7 @@ export interface AppTranslations {
outsideHorizon: string;
traceTitle: string;
traceLoading: string;
traceReady: string;
traceUnavailable: string;
phases: string;
firstPhase: string;