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
+7485 -861

No files matched your search

+78 -2
View File
@@ -5,7 +5,9 @@ Open the deployed application at `/benchmark` on the reference phone. Tap
report** and save the tab-separated output with the date and build identifier. report** and save the tab-separated output with the date and build identifier.
The harness measures preparation, planning-to-outcome latency, later trace The harness measures preparation, planning-to-outcome latency, later trace
delivery, and total wall time separately. It records the outcome, last delivery, and total wall time separately. It also reports planning work, trace
capture, trace materialization, and trace transfer independently, plus the
largest extra delay of a 50 ms UI heartbeat. It records the outcome, last
completed quality stage, placement count, determinism, trace status, and trace completed quality stage, placement count, determinism, trace status, and trace
candidate count. `PlanningIncomplete` is not a conflict: its last completed candidate count. `PlanningIncomplete` is not a conflict: its last completed
stage and incumbent are retained. stage and incumbent are retained.
@@ -30,9 +32,18 @@ stage and incumbent are retained.
The browser page uses the same `createPlanningCalculation(request)` context as The browser page uses the same `createPlanningCalculation(request)` context as
the calendar and adds no solver or backend dependency. The ten-second release the calendar and adds no solver or backend dependency. The ten-second release
ceiling applies to planning latency; trace delivery is reported separately and ceiling applies to planning work; trace delivery is reported separately and
cannot replace a valid planning outcome. cannot replace a valid planning outcome.
`planning_ms` is the observed latency until the outcome is usable;
`planning_work_ms` excludes trace capture. `trace_capture_ms` accounts for
instrumentation during planning, `trace_materialization_ms` accounts for
building the stable explanation and finishing the trace after the outcome.
`trace_transfer_ms` measures worker transfer and receiver assembly work;
`trace_delivery_ms` measures elapsed time until the complete trace is ready. `max_ui_delay_ms` includes
planning, trace delivery, and report preparation. Trace readiness has no
ten-second ceiling and never permits truncating actual candidate evaluations.
## Hardware status ## Hardware status
No reference-phone run has been recorded. The project owner elected to close No reference-phone run has been recorded. The project owner elected to close
@@ -72,3 +83,68 @@ loaded year 1 0.3 5901.5 5677.7 11581.0 Plan dates 144 baseline ready 865117
loaded year 2 0.8 6167.5 7582.4 13752.4 Plan dates 144 yes ready 865117 loaded year 2 0.8 6167.5 7582.4 13752.4 Plan dates 144 yes ready 865117
loaded year 3 0.6 5738.3 6219.5 11962.5 Plan dates 144 yes ready 865117 loaded year 3 0.6 5738.3 6219.5 11962.5 Plan dates 144 yes ready 865117
``` ```
## Exact-trace verification — 2026-10-03
Working changes based on `0ce33cc9d6fe7381965a9162571abe205d0b7350`,
Chromium 149.0.7827.55 on the same AMD Ryzen 7 7800X3D desktop,
412×915 pixels, device scale 2.625, touch enabled, and 4× CPU throttling.
One warm-up preceded three recorded repetitions. This is desktop emulation;
no physical-phone result is claimed, and the earlier release exception is not
used as evidence for this implementation.
All five scenarios matched their pre-change outcomes in a separate core
comparison, including traced versus untraced runs. Every repeated browser
outcome matched its first repetition. Each loaded run returned 144 placements
and retained all 868,919 actual trace candidate records with a ready trace.
Loaded planning work took 5.81–6.18 seconds, below its ten-second budget.
Observed outcome latency was 10.36–10.85 seconds, including trace capture;
this latency is distinct from the planning-work budget. Complete trace delivery
followed 24.09–25.10 seconds later. No evaluation was truncated to meet a timing
target. The worst extra delay of the 50 ms heartbeat was 946.8 ms across the
loaded runs, including report preparation, compared with 4.65–5.49 seconds
before acknowledged delivery in the same final-trace harness.
The worker stages snapshots during planning and delivers at most sixteen
64-operation packets before waiting for receiver acknowledgements. The
receiver applies bounded batches, releases consumed packets, and yields
between tasks. The worker stops after its terminal packet; benchmark helpers
keep completed trace references out of the next repetition. These changes
avoid the observed message-flood pauses and repeated-run renderer crashes.
The complete trace still requires substantial memory: a separate unthrottled
core profile retained approximately 1.06 GB of heap for the loaded trace.
These results do not establish physical-device performance.
A separate rendered-calendar run at the same emulated mobile size used twelve
balances of twelve days each. It produced 144 placements, 113 gained
non-working days, and a longest work sequence of five. Full trace navigation
and date comparison completed without browser errors. Maximum extra heartbeat
delays were 487.4 ms during planning and calendar rendering, 380.8 ms during
trace exploration, and 540.3 ms during comparison. Keyboard navigation,
dialog focus restoration, background comparison without an implicit modal,
and responsive widths of 375, 768, and 1440 pixels also passed.
The final suite passes 201 tests across 23 files, application and specification
type checks, ESLint, and the production build. Build warnings remain for the
2.55 MB initial bundle (2.50 MB warning budget) and 13.41 kB calendar stylesheet
(8.00 kB warning budget); hard budgets are not exceeded.
```tsv
scenario repetition preparation_ms planning_ms trace_delivery_ms wall_ms outcome last_stage placements deterministic trace_status trace_candidates max_ui_delay_ms planning_work_ms trace_capture_ms trace_materialization_ms trace_transfer_ms
representative year 1 0.0 622.4 716.9 1340.3 Plan dates 30 baseline ready 29296 18.2 265.0 256.4 7.4 166.4
representative year 2 0.0 593.5 695.8 1289.4 Plan dates 30 yes ready 29296 14.5 255.3 233.8 7.5 184.1
representative year 3 0.0 592.0 721.0 1313.2 Plan dates 30 yes ready 29296 11.5 256.1 234.4 9.2 191.8
typed obligations 1 0.1 259.7 324.3 584.6 Plan dates 10 baseline ready 13005 11.8 106.8 56.6 3.7 70.2
typed obligations 2 0.1 266.2 330.6 596.9 Plan dates 10 yes ready 13005 11.7 110.6 60.3 4.1 81.9
typed obligations 3 0.1 259.6 341.8 601.5 Plan dates 10 yes ready 13005 15.7 112.3 54.7 4.0 75.9
chained earned entitlements 1 0.1 94.5 3.0 97.6 Plan dates 3 baseline ready 15 0.8 3.4 1.3 0.9 0.3
chained earned entitlements 2 0.0 100.2 2.5 102.7 Plan dates 3 yes ready 15 0.2 3.6 1.4 0.7 0.1
chained earned entitlements 3 0.0 96.0 1.5 97.5 Plan dates 3 yes ready 15 0.1 3.2 1.5 0.7 0.1
deliberate conflict 1 0.0 94.4 2.4 96.8 PlanningConflict 0 baseline ready 4 0.2 0.9 0.8 0.3 0.2
deliberate conflict 2 0.0 93.2 1.0 95.0 PlanningConflict 0 yes ready 4 0.2 1.1 0.5 0.2 0.0
deliberate conflict 3 0.1 92.8 1.6 95.2 PlanningConflict 0 yes ready 4 1.4 1.0 0.7 0.2 0.1
loaded year 1 0.0 10852.1 25102.6 36002.9 Plan dates 144 baseline ready 868919 946.8 6014.8 4730.6 948.3 8862.1
loaded year 2 0.1 10364.4 24816.9 35216.6 Plan dates 144 yes ready 868919 791.2 5813.1 4440.6 296.7 8693.3
loaded year 3 0.1 10807.8 24089.4 34933.6 Plan dates 144 yes ready 868919 721.7 6176.0 4526.6 978.7 8408.0
```
@@ -7,6 +7,7 @@ import {
} from './leave-planner-benchmark'; } from './leave-planner-benchmark';
describe('leave planner benchmark protocol', () => { 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 () => { it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
const scenarios = benchmarkScenarios(); const scenarios = benchmarkScenarios();
expect(scenarios).toHaveLength(5); expect(scenarios).toHaveLength(5);
@@ -62,6 +63,11 @@ describe('leave planner benchmark protocol', () => {
expect(loaded[1].deterministic).toBe('yes'); expect(loaded[1].deterministic).toBe('yes');
expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0); expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].traceDeliveryMs).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( expect(loaded[0].wallMs).toBeGreaterThanOrEqual(
loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs, loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs,
); );
@@ -70,9 +76,10 @@ describe('leave planner benchmark protocol', () => {
const report = benchmarkReport(measurements); const report = benchmarkReport(measurements);
expect(report.split('\n')).toHaveLength(11); expect(report.split('\n')).toHaveLength(11);
expect(report).toContain('planning_ms\ttrace_delivery_ms'); expect(report).toContain('planning_ms\ttrace_delivery_ms');
expect(report).toContain('planning_work_ms\ttrace_capture_ms');
expect(report).toContain('\tbaseline\t'); expect(report).toContain('\tbaseline\t');
} finally { } finally {
vi.stubGlobal('window', browserWindow); vi.stubGlobal('window', browserWindow);
} }
}, 30_000); }, 90_000);
}); });
+51 -11
View File
@@ -16,6 +16,11 @@ export interface BenchmarkMeasurement {
preparationMs: number; preparationMs: number;
planningMs: number; planningMs: number;
traceDeliveryMs: number; traceDeliveryMs: number;
maxUiDelayMs: number;
planningWorkMs?: number;
traceCaptureMs?: number;
traceMaterializationMs?: number;
traceTransferMs?: number;
wallMs: number; wallMs: number;
outcome: PlanningOutcome['kind']; outcome: PlanningOutcome['kind'];
lastCompletedStage?: string; lastCompletedStage?: string;
@@ -167,17 +172,41 @@ function resultSummary(outcome: PlanningOutcome): {
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> { export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
const scenarios = benchmarkScenarios(); const scenarios = benchmarkScenarios();
for (const scenario of scenarios) { for (const scenario of scenarios) await warmUpScenario(scenario);
const calculation = createPlanningCalculation(structuredClone(scenario.request));
await calculation.outcome;
await calculation.trace;
}
const measurements: BenchmarkMeasurement[] = []; const measurements: BenchmarkMeasurement[] = [];
for (const scenario of scenarios) { for (const scenario of scenarios) {
let baseline: string | undefined; let baseline: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) { for (let repetition = 1; repetition <= repetitions; repetition++) {
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(); 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 preparationStart = performance.now();
const request = structuredClone(scenario.request); const request = structuredClone(scenario.request);
const preparationMs = performance.now() - preparationStart; const preparationMs = performance.now() - preparationStart;
@@ -188,6 +217,7 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
const traceStart = performance.now(); const traceStart = performance.now();
const trace = await calculation.trace; const trace = await calculation.trace;
const traceDeliveryMs = performance.now() - traceStart; const traceDeliveryMs = performance.now() - traceStart;
const timings = await calculation.timings;
const summary = resultSummary(outcome); const summary = resultSummary(outcome);
const stable = JSON.stringify(outcome); const stable = JSON.stringify(outcome);
const deterministic = const deterministic =
@@ -203,27 +233,32 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
0, 0,
) ?? 0; ) ?? 0;
const wallMs = performance.now() - wallStart; const wallMs = performance.now() - wallStart;
measurements.push({ measureDelay();
return {
measurement: {
scenario: scenario.name, scenario: scenario.name,
repetition, repetition,
preparationMs, preparationMs,
planningMs, planningMs,
traceDeliveryMs, traceDeliveryMs,
maxUiDelayMs,
...timings,
wallMs, wallMs,
...summary, ...summary,
deterministic, deterministic,
traceStatus: trace?.status ?? 'not-produced', traceStatus: trace?.status ?? 'not-produced',
traceCandidates, traceCandidates,
}); },
baseline ??= stable; stable,
};
} finally {
clearInterval(heartbeat);
} }
} }
return measurements;
}
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
const header = 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 [ return [
header, header,
...measurements.map((item) => ...measurements.map((item) =>
@@ -240,6 +275,11 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
item.deterministic, item.deterministic,
item.traceStatus, item.traceStatus,
item.traceCandidates, 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('\t'),
), ),
].join('\n'); ].join('\n');
@@ -56,7 +56,7 @@
[disabled]="!isInHorizon(day)" [disabled]="!isInHorizon(day)"
[matTooltip]="dayTooltip" [matTooltip]="dayTooltip"
[matTooltipShowDelay]="500" [matTooltipShowDelay]="500"
[attr.aria-label]="getDayAriaLabel(day)" [attr.aria-label]="getDayAriaLabel(day, dayTooltip)"
[attr.aria-current]="dateKey === currentDate ? 'date' : null" [attr.aria-current]="dateKey === currentDate ? 'date' : null"
(click)="dateSelected.emit(dateKey)" (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 { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service';
@@ -20,6 +28,7 @@ export interface CalendarDayStatus {
imports: [MatTooltip], imports: [MatTooltip],
templateUrl: './calendar-month.html', templateUrl: './calendar-month.html',
styleUrl: './calendar-month.scss', styleUrl: './calendar-month.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CalendarMonth implements OnInit { export class CalendarMonth implements OnInit {
protected calendarSettingsService = inject(CalendarSettingsService); protected calendarSettingsService = inject(CalendarSettingsService);
@@ -44,6 +53,8 @@ export class CalendarMonth implements OnInit {
currentMonth: Date = new Date(); currentMonth: Date = new Date();
weeksInAMonth: Month = []; weeksInAMonth: Month = [];
private dayFormatterLocale = '';
private dayFormatter?: Intl.DateTimeFormat;
@Input({ required: true }) @Input({ required: true })
selectedDates!: SelectedDates; selectedDates!: SelectedDates;
@@ -121,16 +132,18 @@ export class CalendarMonth implements OnInit {
: '~×'; : '~×';
} }
getDayAriaLabel(day: Date): string { getDayAriaLabel(day: Date, tooltip: string | null = this.getDayTooltip(day)): string {
const parts = [ const locale = this.translationService.locale();
day.toLocaleDateString(this.translationService.locale(), { if (!this.dayFormatter || locale !== this.dayFormatterLocale) {
this.dayFormatterLocale = locale;
this.dayFormatter = new Intl.DateTimeFormat(locale, {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
}), });
]; }
const tooltip = this.getDayTooltip(day); const parts = [this.dayFormatter.format(day)];
if (tooltip) parts.push(tooltip); if (tooltip) parts.push(tooltip);
if (!this.isInHorizon(day)) if (!this.isInHorizon(day))
parts.push(this.translationService.tr().planning.outsideHorizon); 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"> <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()) { @if (isLoading()) {
<div class="loading-overlay"> <div class="loading-overlay">
@@ -12,7 +14,7 @@
></mat-spinner> ></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div> <div class="spinner-text">{{ loadingProgress() }}%</div>
</div> </div>
<p>{{ tr().loading }}</p> <p role="status">{{ tr().loading }}</p>
</div> </div>
</div> </div>
} }
@@ -170,7 +172,7 @@
<button <button
type="button" type="button"
data-step="first" data-step="first"
[disabled]="selectedPlacementIndex() === 0" [attr.aria-disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.first" [attr.aria-label]="tr().planning.first"
(click)="stepPlacement('first')" (click)="stepPlacement('first')"
> >
@@ -179,20 +181,20 @@
<button <button
type="button" type="button"
data-step="previous" data-step="previous"
[disabled]="selectedPlacementIndex() === 0" [attr.aria-disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.previous" [attr.aria-label]="tr().planning.previous"
(click)="stepPlacement('previous')" (click)="stepPlacement('previous')"
> >
<mat-icon aria-hidden="true">chevron_left</mat-icon> <mat-icon aria-hidden="true">chevron_left</mat-icon>
</button> </button>
<output> <output aria-current="step" [attr.aria-label]="placementAnnouncement()">
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }} {{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
{{ tr().planning.of }} {{ planExplanation()!.placements.length }} {{ tr().planning.of }} {{ planExplanation()!.placements.length }}
</output> </output>
<button <button
type="button" type="button"
data-step="next" data-step="next"
[disabled]=" [attr.aria-disabled]="
selectedPlacementIndex() === selectedPlacementIndex() ===
planExplanation()!.placements.length - 1 planExplanation()!.placements.length - 1
" "
@@ -204,7 +206,7 @@
<button <button
type="button" type="button"
data-step="last" data-step="last"
[disabled]=" [attr.aria-disabled]="
selectedPlacementIndex() === selectedPlacementIndex() ===
planExplanation()!.placements.length - 1 planExplanation()!.placements.length - 1
" "
@@ -283,7 +285,32 @@
} }
</p> </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> </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"> <section class="day-impact" aria-labelledby="impact-title">
<h4 id="impact-title">{{ tr().planning.explanation.impact }}</h4> <h4 id="impact-title">{{ tr().planning.explanation.impact }}</h4>
<p>{{ tr().planning.explanation.impactHelp }}</p> <p>{{ tr().planning.explanation.impactHelp }}</p>
@@ -302,9 +329,14 @@
</thead> </thead>
<tbody> <tbody>
@for (change of placementChanges(); track change.id) { @for (change of placementChanges(); track change.id) {
<tr> <tr [class.decisive-row]="change.id === decisiveObjective">
<th scope="row"> <th scope="row">
{{ change.label }}<small>{{ change.help }}</small> {{ change.label }}<small>{{ change.help }}</small>
@if (change.id === decisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
}
</th> </th>
<td>{{ change.before }}</td> <td>{{ change.before }}</td>
<td>{{ change.after }}</td> <td>{{ change.after }}</td>
@@ -358,9 +390,6 @@
}} }}
</p> </p>
} }
@if (selected.supporting) {
<p>{{ tr().planning.details.supporting }}</p>
}
</section> </section>
<ng-container <ng-container
[ngTemplateOutlet]="calculationTable" [ngTemplateOutlet]="calculationTable"
@@ -376,7 +405,11 @@
<button type="button" class="compare-dates-button" (click)="compareDates()"> <button type="button" class="compare-dates-button" (click)="compareDates()">
<mat-icon aria-hidden="true">compare_arrows</mat-icon <mat-icon aria-hidden="true">compare_arrows</mat-icon
>{{ tr().planning.compareDates }} >{{
counterfactualError()
? tr().planning.retryCounterfactual
: tr().planning.compareDates
}}
</button> </button>
</section> </section>
</details> </details>
@@ -408,7 +441,7 @@
<button <button
type="button" type="button"
data-phase-step="first" data-phase-step="first"
[disabled]="tracePhaseIndex() === 0" [attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.firstPhase" [attr.aria-label]="tr().planning.firstPhase"
(click)="stepTracePhase('first')" (click)="stepTracePhase('first')"
> >
@@ -417,20 +450,24 @@
<button <button
type="button" type="button"
data-phase-step="previous" data-phase-step="previous"
[disabled]="tracePhaseIndex() === 0" [attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.previousPhase" [attr.aria-label]="tr().planning.previousPhase"
(click)="stepTracePhase('previous')" (click)="stepTracePhase('previous')"
> >
<mat-icon aria-hidden="true">chevron_left</mat-icon> <mat-icon aria-hidden="true">chevron_left</mat-icon>
</button> </button>
<output <output
aria-current="step"
[attr.aria-label]="tracePhaseAnnouncement()"
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }} >{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
{{ trace.phases.length }}</output {{ trace.phases.length }}</output
> >
<button <button
type="button" type="button"
data-phase-step="next" data-phase-step="next"
[disabled]="tracePhaseIndex() === trace.phases.length - 1" [attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.nextPhase" [attr.aria-label]="tr().planning.nextPhase"
(click)="stepTracePhase('next')" (click)="stepTracePhase('next')"
> >
@@ -439,7 +476,9 @@
<button <button
type="button" type="button"
data-phase-step="last" data-phase-step="last"
[disabled]="tracePhaseIndex() === trace.phases.length - 1" [attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.lastPhase" [attr.aria-label]="tr().planning.lastPhase"
(click)="stepTracePhase('last')" (click)="stepTracePhase('last')"
> >
@@ -466,7 +505,7 @@
<nav [attr.aria-label]="tr().planning.decisions"> <nav [attr.aria-label]="tr().planning.decisions">
<button <button
type="button" type="button"
[disabled]="traceDecisionIndex() === 0" [attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.firstDecision" [attr.aria-label]="tr().planning.firstDecision"
(click)="stepTraceDecision('first')" (click)="stepTraceDecision('first')"
> >
@@ -474,20 +513,22 @@
</button> </button>
<button <button
type="button" type="button"
[disabled]="traceDecisionIndex() === 0" [attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.previousDecision" [attr.aria-label]="tr().planning.previousDecision"
(click)="stepTraceDecision('previous')" (click)="stepTraceDecision('previous')"
> >
<mat-icon aria-hidden="true">chevron_left</mat-icon> <mat-icon aria-hidden="true">chevron_left</mat-icon>
</button> </button>
<output <output
aria-current="step"
[attr.aria-label]="traceDecisionAnnouncement()"
>{{ tr().planning.decision }} >{{ tr().planning.decision }}
{{ traceDecisionIndex() + 1 }} {{ tr().planning.of }} {{ traceDecisionIndex() + 1 }} {{ tr().planning.of }}
{{ phase.decisions.length }}</output {{ phase.decisions.length }}</output
> >
<button <button
type="button" type="button"
[disabled]=" [attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1 traceDecisionIndex() === phase.decisions.length - 1
" "
[attr.aria-label]="tr().planning.nextDecision" [attr.aria-label]="tr().planning.nextDecision"
@@ -497,7 +538,7 @@
</button> </button>
<button <button
type="button" type="button"
[disabled]=" [attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1 traceDecisionIndex() === phase.decisions.length - 1
" "
[attr.aria-label]="tr().planning.lastDecision" [attr.aria-label]="tr().planning.lastDecision"
@@ -539,6 +580,23 @@
{{ tr().planning.finalPlan }} {{ tr().planning.finalPlan }}
</button> </button>
</div> </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> <p>
<strong>{{ tr().planning.dates }}:</strong> <strong>{{ tr().planning.dates }}:</strong>
{{ {{
@@ -562,13 +620,17 @@
> >
<button <button
type="button" type="button"
[disabled]="traceCandidatePage() === 0" [attr.aria-disabled]="
traceCandidatePage() === 0
"
[attr.aria-label]=" [attr.aria-label]="
tr().planning.previousCandidates tr().planning.previousCandidates
" "
(click)="stepTraceCandidatePage(-1)" (click)="stepTraceCandidatePage(-1)"
> >
◀ <mat-icon aria-hidden="true"
>chevron_left</mat-icon
>
</button> </button>
<output <output
>{{ tr().planning.page }} >{{ tr().planning.page }}
@@ -578,14 +640,16 @@
> >
<button <button
type="button" type="button"
[disabled]=" [attr.aria-disabled]="
traceCandidatePage() === traceCandidatePage() ===
traceCandidatePages() - 1 traceCandidatePages() - 1
" "
[attr.aria-label]="tr().planning.nextCandidates" [attr.aria-label]="tr().planning.nextCandidates"
(click)="stepTraceCandidatePage(1)" (click)="stepTraceCandidatePage(1)"
> >
▶ <mat-icon aria-hidden="true"
>chevron_right</mat-icon
>
</button> </button>
</nav> </nav>
} }
@@ -624,6 +688,100 @@
@if (candidate.reason) { @if (candidate.reason) {
<p>{{ candidate.reason.message }}</p> <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) { @if (candidate.comparison; as comparison) {
<div class="calculation-content"> <div class="calculation-content">
<ng-container <ng-container
@@ -636,6 +794,10 @@
tr().planning.before, tr().planning.before,
afterLabel: tr().planning.after, afterLabel: tr().planning.after,
audit: comparison, audit: comparison,
traceEffect:
!!candidate
.technicalComparison
?.length,
}" }"
/> />
</div> </div>
@@ -687,6 +849,21 @@
{{ tr().planning.placements }}, {{ tr().planning.placements }},
{{ state.earnedActivations.length }} {{ state.earnedActivations.length }}
{{ tr().planning.activations }} {{ 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> </p>
} }
} }
@@ -697,7 +874,7 @@
} }
</div> </div>
} @else if (traceResolved()) { } @else if (traceResolved()) {
<p>{{ tr().planning.traceUnavailable }}</p> <p role="status">{{ tr().planning.traceUnavailable }}</p>
} }
</details> </details>
} }
@@ -711,6 +888,16 @@
</button> </button>
</div> </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"> <p class="calendar-help">
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }} <mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
</p> </p>
@@ -828,10 +1015,23 @@
let-beforeLabel="beforeLabel" let-beforeLabel="beforeLabel"
let-afterLabel="afterLabel" let-afterLabel="afterLabel"
let-audit="audit" let-audit="audit"
let-traceEffect="traceEffect"
> >
<section class="criteria-detail"> <section class="criteria-detail">
<h4>{{ tr().planning.details.criteria }}</h4> <h4>
<p class="calculation-intro">{{ tr().planning.details.criteriaHelp }}</p> {{
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"> <table class="impact-table criteria-table">
<thead> <thead>
<tr> <tr>
@@ -849,7 +1049,7 @@
@if (objective.numeric) { @if (objective.numeric) {
<tr <tr
[class.decisive-row]=" [class.decisive-row]="
objective.id === comparison.firstDecisiveObjective !traceEffect && objective.id === comparison.firstDecisiveObjective
" "
> >
<th scope="row"> <th scope="row">
@@ -862,7 +1062,10 @@
? tr().planning.details.higher ? tr().planning.details.higher
: tr().planning.details.lower : tr().planning.details.lower
}}</small> }}</small>
@if (objective.id === comparison.firstDecisiveObjective) { @if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
<span class="decisive-label">{{ <span class="decisive-label">{{
tr().planning.details.firstDifference tr().planning.details.firstDifference
}}</span> }}</span>
@@ -886,7 +1089,10 @@
@if (!objective.numeric) { @if (!objective.numeric) {
<li> <li>
{{ objective.label }} {{ objective.label }}
@if (objective.id === comparison.firstDecisiveObjective) { @if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
<span class="decisive-label">{{ <span class="decisive-label">{{
tr().planning.details.firstDifference tr().planning.details.firstDifference
}}</span> }}</span>
@@ -940,7 +1146,9 @@
<p>{{ tr().planning.counterfactualLoading }}</p> <p>{{ tr().planning.counterfactualLoading }}</p>
</div> </div>
} @else if (counterfactualError()) { } @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) { } @else if (counterfactual(); as analysis) {
<div class="comparison-date-pair"> <div class="comparison-date-pair">
<div class="original-date"> <div class="original-date">
@@ -1106,6 +1314,12 @@
) )
}} }}
</p> </p>
@if (dateStatuses()[result.date]?.reason; as historicalReason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ historicalReason }}
</p>
}
@if (dateComparison(); as comparison) { @if (dateComparison(); as comparison) {
<ng-container <ng-container
[ngTemplateOutlet]="calculationTable" [ngTemplateOutlet]="calculationTable"
@@ -1118,6 +1332,7 @@
? tr().planning.explanation.withCandidate ? tr().planning.explanation.withCandidate
: tr().planning.explanation.alternative, : tr().planning.explanation.alternative,
audit: result, audit: result,
traceEffect: traceReplay(),
}" }"
/> />
} @else { } @else {
@@ -1136,9 +1351,15 @@
</mat-dialog-content> </mat-dialog-content>
<footer class="comparison-footer"> <footer class="comparison-footer">
<span>{{ tr().planning.comparison.preview }}</span> <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() counterfactualError()
? tr().planning.retryCounterfactual
: traceReplay()
? tr().planning.comparison.backToTrace ? tr().planning.comparison.backToTrace
: tr().planning.comparison.backToPlan : tr().planning.comparison.backToPlan
}} }}
@@ -67,6 +67,10 @@ button {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
&[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
} }
summary { summary {
@@ -353,12 +357,27 @@ output {
margin: 0; margin: 0;
} }
.inline-error, .inline-error,
.counterfactual-inline-error,
.conflict-explanation { .conflict-explanation {
padding: 0.75rem; padding: 0.75rem;
border-inline-start: 4px solid var(--ui-error-text); border-inline-start: 4px solid var(--ui-error-text);
background: var(--ui-error-bg); background: var(--ui-error-bg);
color: var(--ui-error-text); 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-explanation,
.incomplete-incumbent { .incomplete-incumbent {
padding: 1rem; padding: 1rem;
@@ -461,6 +480,8 @@ output {
} }
small { small {
color: var(--ui-text-secondary); color: var(--ui-text-secondary);
flex-shrink: 0;
white-space: nowrap;
} }
} }
.trace-phase-stepper { .trace-phase-stepper {
+363 -127
View File
@@ -33,6 +33,7 @@ import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service'; import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service'; import { UserInputService } from '../../services/user-input-service';
import { import {
applyReplayDeltas,
CounterfactualAnalysis, CounterfactualAnalysis,
CounterfactualSelection, CounterfactualSelection,
LeavePlacement, LeavePlacement,
@@ -52,6 +53,7 @@ import {
type PlacementExplanation = PlanExplanation['placements'][number]; type PlacementExplanation = PlanExplanation['placements'][number];
type TraceMoment = 'before' | 'after'; type TraceMoment = 'before' | 'after';
type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
@Component({ @Component({
selector: 'app-calendar-year', selector: 'app-calendar-year',
@@ -110,12 +112,24 @@ export class CalendarYear {
protected readonly traceReplay = signal(false); protected readonly traceReplay = signal(false);
protected readonly selectedTraceDate = signal<string | null>(null); protected readonly selectedTraceDate = signal<string | null>(null);
protected readonly liveMessage = signal(''); 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 planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates()); protected readonly selectedDates = signal(new SelectedDates());
private calculationVersion = 0; private calculationVersion = 0;
private counterfactualVersion = 0; private counterfactualVersion = 0;
private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null; 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() { get tr() {
return this.translationService.tr; return this.translationService.tr;
@@ -146,6 +160,11 @@ export class CalendarYear {
protected readonly selectedTraceDecision = computed( protected readonly selectedTraceDecision = computed(
() => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()], () => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()],
); );
protected readonly selectedTraceTransition = computed(() =>
this.traceReplay()
? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision())
: undefined,
);
protected readonly visibleTraceCandidates = computed(() => { protected readonly visibleTraceCandidates = computed(() => {
const start = this.traceCandidatePage() * 50; const start = this.traceCandidatePage() * 50;
@@ -227,12 +246,7 @@ export class CalendarYear {
); );
protected readonly traceState = computed(() => { protected readonly traceState = computed(() => {
if (!this.traceReplay()) return undefined; return this.selectedTraceTransition()?.[this.traceMoment()];
return this.replayTraceState(
this.planningTrace(),
this.selectedTraceDecision()?.id,
this.traceMoment(),
);
}); });
protected readonly calendarSelectedDates = computed(() => { protected readonly calendarSelectedDates = computed(() => {
@@ -250,7 +264,7 @@ export class CalendarYear {
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date; return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
if (this.traceReplay()) if (this.traceReplay())
return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null; 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>>>(() => { 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(); const decision = this.selectedTraceDecision();
if (decision) { if (decision) {
const changedDates = new Set<string>(); const changedDates = new Set<string>();
@@ -325,7 +347,7 @@ export class CalendarYear {
changeLabel: this.traceChangeLabel(change), 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'); if (delta.kind === 'placement-added') setChange(delta.date, 'added');
else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed'); else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed');
else if (delta.kind === 'placement-moved') { else if (delta.kind === 'placement-moved') {
@@ -333,11 +355,10 @@ export class CalendarYear {
setChange(delta.toDate, 'moved-to'); setChange(delta.toDate, 'moved-to');
} else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared'); } else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
} }
const before = this.replayTraceState(trace, decision.id, 'before'); const transition = this.selectedTraceTransition();
const after = this.replayTraceState(trace, decision.id, 'after');
for (const placement of [ for (const placement of [
...(before?.placements ?? []), ...(transition?.before.placements ?? []),
...(after?.placements ?? []), ...(transition?.after.placements ?? []),
]) { ]) {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged'); if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
} }
@@ -351,6 +372,9 @@ export class CalendarYear {
...status, ...status,
kind: historicalStatus, kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus), label: this.traceStatusLabel(historicalStatus),
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: status?.reason,
hypotheticalLabel: hypotheticalLabel:
result.status === 'eligible' result.status === 'eligible'
? this.tr().planning.eligible ? this.tr().planning.eligible
@@ -367,7 +391,11 @@ export class CalendarYear {
statuses[result.date] = { statuses[result.date] = {
kind: historicalStatus, kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus), label: this.traceStatusLabel(historicalStatus),
reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined, reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: historicalStatus === 'ineligible'
? nonWorkingReason
: undefined,
hypotheticalLabel: hypotheticalLabel:
result.status === 'eligible' result.status === 'eligible'
? this.tr().planning.eligible ? this.tr().planning.eligible
@@ -381,11 +409,18 @@ export class CalendarYear {
protected readonly months = computed(() => { protected readonly months = computed(() => {
const horizon = this.planningHorizon(); const horizon = this.planningHorizon();
if (!horizon) return []; if (!horizon) return [];
const start = new Date(`${horizon.start}T00:00:00`); let first = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`); let last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1); 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 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) { while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1); cursor.setMonth(cursor.getMonth() + 1);
@@ -406,6 +441,7 @@ export class CalendarYear {
const version = ++this.calculationVersion; const version = ++this.calculationVersion;
this.isLoading.set(true); this.isLoading.set(true);
this.loadingProgress.set(0); this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
queueMicrotask(() => { queueMicrotask(() => {
let calculation: PlanningCalculationContext; let calculation: PlanningCalculationContext;
try { try {
@@ -494,26 +530,57 @@ export class CalendarYear {
this.traceCandidatePage.set(0); this.traceCandidatePage.set(0);
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(false); this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null); this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]); this.userInput.remainingVacationDaysSignal.set([]);
} }
private loadArtifacts(calculation: PlanningCalculationContext, version: number): void { private loadArtifacts(calculation: PlanningCalculationContext, version: number): void {
void calculation.explanation.then((explanation) => { void calculation.explanation
.then((explanation) => {
if (version !== this.calculationVersion) return; if (version !== this.calculationVersion) return;
this.planningExplanation.set(explanation ?? null); this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen()) if (explanation?.kind === 'PlanExplanation') {
this.selectPlacement(0); 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) => { void calculation.explanationError
.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null); 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 void calculation.trace
.then((trace) => { .then((trace) => {
if (version !== this.calculationVersion) return; if (version !== this.calculationVersion) return;
this.planningTrace.set(trace ?? null); this.planningTrace.set(trace ?? null);
this.traceResolved.set(true); this.traceResolved.set(true);
this.liveMessage.set(
trace?.status === 'failed'
? this.tr().planning.traceUnavailable
: this.tr().planning.traceReady,
);
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (version !== this.calculationVersion) return; if (version !== this.calculationVersion) return;
@@ -533,6 +600,7 @@ export class CalendarYear {
}, },
}); });
this.traceResolved.set(true); this.traceResolved.set(true);
this.liveMessage.set(this.tr().planning.traceUnavailable);
}) })
.finally(() => { .finally(() => {
if (version === this.calculationVersion) this.traceLoading.set(false); if (version === this.calculationVersion) this.traceLoading.set(false);
@@ -553,7 +621,7 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]); this.userInput.remainingVacationDaysSignal.set([]);
} }
protected selectPlacement(index: number): void { protected selectPlacement(index: number, analyze = true, announce = true): void {
const explanation = this.planExplanation(); const explanation = this.planExplanation();
if (!explanation?.placements.length) return; if (!explanation?.placements.length) return;
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1)); const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
@@ -561,14 +629,27 @@ export class CalendarYear {
this.traceReplay.set(false); this.traceReplay.set(false);
this.clearCounterfactual(); this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date); this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
this.liveMessage.set( if (announce) this.liveMessage.set(this.placementAnnouncement());
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`, if (analyze) void this.analyzeSelection(explanation.placements[selected].placement);
);
} }
protected compareDates(): void { protected compareDates(): void {
const placement = this.selectedExplanation()?.placement; 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 { protected togglePlanDetails(event: Event): void {
@@ -592,15 +673,15 @@ export class CalendarYear {
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void { protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1; const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex(); const current = this.selectedPlacementIndex();
this.selectPlacement( const target =
step === 'first' step === 'first'
? 0 ? 0
: step === 'last' : step === 'last'
? last ? last
: step === 'previous' : step === 'previous'
? current - 1 ? current - 1
: current + 1, : current + 1;
); if (target !== current) this.selectPlacement(target);
} }
protected selectCalendarDate(date: string): void { protected selectCalendarDate(date: string): void {
@@ -616,10 +697,6 @@ export class CalendarYear {
this.planDetailsOpen.set(true); this.planDetailsOpen.set(true);
this.traceOpen.set(false); this.traceOpen.set(false);
this.selectPlacement(index); this.selectPlacement(index);
this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
this.planDetails()
?.nativeElement.querySelector('summary')
?.focus({ preventScroll: true });
return; return;
} }
} }
@@ -661,25 +738,24 @@ export class CalendarYear {
this.traceCandidatePage.set(0); this.traceCandidatePage.set(0);
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(false); this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null); this.selectedTraceDate.set(null);
this.clearCounterfactual(); this.clearCounterfactual();
this.liveMessage.set( this.liveMessage.set(this.tracePhaseAnnouncement());
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
);
} }
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void { protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1; const last = (this.planningTrace()?.phases.length ?? 1) - 1;
const current = this.tracePhaseIndex(); const current = this.tracePhaseIndex();
this.selectTracePhase( const target =
step === 'first' step === 'first'
? 0 ? 0
: step === 'last' : step === 'last'
? last ? last
: step === 'previous' : step === 'previous'
? current - 1 ? current - 1
: current + 1, : current + 1;
); if (target !== current) this.selectTracePhase(target);
} }
protected selectTraceDecision(index: number): void { protected selectTraceDecision(index: number): void {
@@ -691,28 +767,94 @@ export class CalendarYear {
this.traceReplay.set(true); this.traceReplay.set(true);
this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null); this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
this.clearCounterfactual(); this.clearCounterfactual();
this.liveMessage.set( this.liveMessage.set(this.traceDecisionAnnouncement());
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, 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 { protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1; const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
const current = this.traceDecisionIndex(); const current = this.traceDecisionIndex();
this.selectTraceDecision( const target =
step === 'first' step === 'first'
? 0 ? 0
: step === 'last' : step === 'last'
? last ? last
: step === 'previous' : step === 'previous'
? current - 1 ? current - 1
: current + 1, : current + 1;
); if (target !== current) this.selectTraceDecision(target);
} }
protected stepTraceCandidatePage(direction: -1 | 1): void { protected stepTraceCandidatePage(direction: -1 | 1): void {
this.traceCandidatePage.update((page) => const page = Math.max(
Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)), 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.clearCounterfactual();
this.traceMoment.set(moment); this.traceMoment.set(moment);
this.traceReplay.set(true); this.traceReplay.set(true);
this.liveMessage.set( this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`);
`${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, void this.loadHistoricalEligibility();
);
} }
protected showFinalPlan(): void { protected showFinalPlan(): void {
this.traceReplay.set(false); this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.clearCounterfactual(); this.clearCounterfactual();
this.liveMessage.set(this.tr().planning.finalPlan); this.liveMessage.set(this.tr().planning.finalPlan);
} }
@@ -737,7 +879,10 @@ export class CalendarYear {
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(true); this.traceReplay.set(true);
this.selectedTraceDate.set(candidate.placement.date); 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( private nonWorkingDateReason(
@@ -750,15 +895,57 @@ export class CalendarYear {
return undefined; 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; const calculation = this.activeCalculation;
if (!calculation) return; if (!calculation) return;
this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion; const version = ++this.counterfactualVersion;
this.counterfactual.set(null); this.counterfactual.set(null);
this.counterfactualError.set(null); this.counterfactualError.set(null);
this.counterfactualLoading.set(true); this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false); this.invalidComparisonDate.set(false);
if (!this.comparisonDialog) { 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);
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;
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(), { const dialog = this.dialog.open(this.comparisonTemplate(), {
width: '48rem', width: '48rem',
maxWidth: 'calc(100vw - 2rem)', maxWidth: 'calc(100vw - 2rem)',
@@ -775,27 +962,6 @@ export class CalendarYear {
} }
}); });
} }
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),
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
this.counterfactualError.set(error instanceof Error ? error.message : String(error));
} finally {
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
}
}
protected clearCounterfactual(): void { protected clearCounterfactual(): void {
const dialog = this.comparisonDialog; const dialog = this.comparisonDialog;
@@ -803,6 +969,7 @@ export class CalendarYear {
dialog?.close(); dialog?.close();
this.counterfactualVersion++; this.counterfactualVersion++;
this.counterfactual.set(null); this.counterfactual.set(null);
this.counterfactualSelection = null;
this.selectedCounterfactualDate.set(null); this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false); this.counterfactualLoading.set(false);
this.counterfactualError.set(null); this.counterfactualError.set(null);
@@ -810,70 +977,130 @@ export class CalendarYear {
this.comparisonDateInput.set(null); this.comparisonDateInput.set(null);
} }
private replayTraceState( private replayTraceTransition(
trace: PlanningTrace | null, trace: PlanningTrace | null,
decisionId: string | undefined, decision: PlanningTraceDecision | undefined,
moment: TraceMoment, ): TraceTransition | undefined {
): PlanningTraceState | undefined { if (!trace || !decision) return undefined;
if (!trace || !decisionId) return undefined; const state = {
const balances = trace.initialState.balances.map((balance) => ({ ...balance })); balances: trace.initialState.balances.map((balance) => ({ ...balance })),
const placements = new Map( placements: new Map(
trace.initialState.placements.map((placement) => [placement.date, { ...placement }]), trace.initialState.placements.map((placement) => [
); placement.date,
const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({ { ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
...activation, ...activation,
dates: [...activation.dates], dates: [...activation.dates],
})); })),
};
const snapshot = (): PlanningTraceState => ({ const snapshot = (): PlanningTraceState => ({
horizon: { ...trace.initialState.horizon }, horizon: { ...trace.initialState.horizon },
balances, balances: state.balances.map((balance) => ({ ...balance })),
placements: [...placements.values()], placements: [...state.placements.values()].map((placement) => ({
earnedActivations, ...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.branch) {
if (decision.id === decisionId && moment === 'before') return snapshot(); const decisionIndex = this.getTraceDecisionIndex(trace);
this.applyTraceDecision(decision, placements, balances, earnedActivations); const target = decisionIndex.get(decision.id);
if (decision.id === decisionId) return snapshot(); 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; return undefined;
} }
private applyTraceDecision( private getTraceDecisionIndex(
decision: PlanningTraceDecision, trace: PlanningTrace,
placements: Map<string, LeavePlacement>, ): ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }> {
balances: PlanningTraceState['balances'][number][], let index = this.traceDecisionIndices.get(trace);
earnedActivations: Array<{ if (!index) {
id: string; const decisions = new Map<
ruleId: string; string,
occurrence: string; { decision: PlanningTraceDecision; ordinal: number }
dates: string[]; >();
}>, let ordinal = 0;
): void { for (const phase of trace.phases)
for (const delta of decision.deltas) { for (const decision of phase.decisions)
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); decisions.set(decision.id, { decision, ordinal: ordinal++ });
if (delta.placement) index = decisions;
placements.set(delta.placement.date, { this.traceDecisionIndices.set(trace, index);
...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],
});
} }
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 { protected hasPlannerError(): boolean {
@@ -974,10 +1201,15 @@ export class CalendarYear {
} }
protected displayDate(value: string): string { protected displayDate(value: string): string {
return new Intl.DateTimeFormat(this.translationService.locale(), { const locale = this.translationService.locale();
if (!this.displayDateFormatter || locale !== this.displayDateLocale) {
this.displayDateLocale = locale;
this.displayDateFormatter = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium', dateStyle: 'medium',
timeZone: 'UTC', timeZone: 'UTC',
}).format(new Date(value)); });
}
return this.displayDateFormatter.format(new Date(value));
} }
protected planningDate(value: string | Date | undefined): string { protected planningDate(value: string | Date | undefined): string {
@@ -989,6 +1221,10 @@ export class CalendarYear {
return this.tr().planning.traceStatuses[status]; 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 { protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
return this.tr().planning.tracePhaseStatuses[status]; 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
+333 -11
View File
@@ -4,13 +4,20 @@ import type {
CounterfactualAnalysis, CounterfactualAnalysis,
LeavePlacement, LeavePlacement,
PlanOutcome, PlanOutcome,
PlanningExplanation,
PlanningOutcome, PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceUpdate,
PlanningSnapshot, PlanningSnapshot,
PlanningTraceState, PlanningTraceState,
TraceCandidateStatus,
} from './leave-planner.service'; } from './leave-planner.service';
import { import {
analyzeCounterfactualInternal, analyzeCounterfactualInternal,
calculatePlanningInternal, calculatePlanningInternal,
explainIncompleteCheckpoint,
historicalCounterfactualReasons,
type CounterfactualBaselineOptions, type CounterfactualBaselineOptions,
type PlanningArtifacts, type PlanningArtifacts,
} from './leave-planner.core'; } from './leave-planner.core';
@@ -30,6 +37,7 @@ type WorkerRequest =
type: 'plan'; type: 'plan';
request: WorkerPlanningRequest; request: WorkerPlanningRequest;
} }
| { type: 'trace-ack'; count: number }
| { | {
type: 'counterfactual'; type: 'counterfactual';
request: PlanningSnapshot; request: PlanningSnapshot;
@@ -37,59 +45,373 @@ type WorkerRequest =
placement: LeavePlacement; placement: LeavePlacement;
state?: PlanningTraceState; state?: PlanningTraceState;
options?: CounterfactualBaselineOptions; 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 WorkerResponse =
| { type: 'progress'; stage: string } | { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number } | { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome } | { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
| { type: 'result'; outcome: PlanningOutcome } | { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts } | { 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: '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 worker = self as DedicatedWorkerGlobalScope;
const TRACE_OPERATIONS_PER_MESSAGE = 64;
const MAX_TRACE_PACKETS_IN_FLIGHT = 16;
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);
}
}
}
}
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>) => { worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
try { 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') { if (event.data?.type === 'plan') {
let artifacts: PlanningArtifacts = {}; let artifacts: PlanningArtifacts = {};
let outcomePosted = false; let outcomePosted = false;
const outcome = calculatePlanningInternal( let outcome: PlanningOutcome;
const traceDelivery: TraceDelivery = {
updates: [],
nextUpdate: 0,
inFlight: 0,
traceTransferMs: 0,
calculationComplete: false,
};
activeTraceDelivery = traceDelivery;
try {
outcome = calculatePlanningInternal(
{ {
...event.data.request, ...event.data.request,
progress: (stage: string) => progress: (stage: string) =>
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse), worker.postMessage({
traceProgress: (totalMs: number) => type: 'progress',
stage,
} satisfies WorkerResponse),
traceProgress: (totalMs: number, update: PlanningTraceUpdate) => {
worker.postMessage({ worker.postMessage({
type: 'trace-overhead', type: 'trace-overhead',
totalMs, totalMs,
} satisfies WorkerResponse), } satisfies WorkerResponse);
traceDelivery.updates.push({ totalMs, update, nextOperation: 0 });
}, },
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse), },
(plan, state) =>
worker.postMessage({
type: 'checkpoint',
plan,
state,
} satisfies WorkerResponse),
(value) => (artifacts = value), (value) => (artifacts = value),
(value) => { (value) => {
outcomePosted = true; outcomePosted = true;
worker.postMessage({ type: 'result', outcome: value } satisfies WorkerResponse); 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) if (!outcomePosted)
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
worker.postMessage({ type: 'artifacts', artifacts } 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; return;
} }
if (event.data?.type !== 'counterfactual') return; if (event.data?.type !== 'counterfactual') return;
const analysis = analyzeCounterfactualInternal( try {
let analysis = analyzeCounterfactualInternal(
event.data.request, event.data.request,
event.data.plan, event.data.plan,
event.data.placement, event.data.placement,
event.data.state, event.data.state,
event.data.options, event.data.options,
); );
worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse); 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) { } catch (error) {
worker.postMessage({ worker.postMessage({
type: 'error', 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), message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse); } satisfies WorkerResponse);
} }
+12
View File
@@ -124,6 +124,10 @@ export interface AppTranslations {
expires: string; expires: string;
criteria: string; criteria: string;
criteriaHelp: string; criteriaHelp: string;
traceImpactTitle: string;
traceImpactHelp: string;
traceStageTitle: string;
traceStageHelp: string;
firstDifference: string; firstDifference: string;
higher: string; higher: string;
lower: string; lower: string;
@@ -142,6 +146,7 @@ export interface AppTranslations {
leaveUsed: string; leaveUsed: string;
expires: string; expires: string;
linkedRules: string; linkedRules: string;
atRiskContribution: string;
impact: string; impact: string;
impactHelp: string; impactHelp: string;
criterion: string; criterion: string;
@@ -192,10 +197,16 @@ export interface AppTranslations {
next: string; next: string;
last: string; last: string;
remaining: string; remaining: string;
balanceRemaining: string;
counterfactual: string; counterfactual: string;
counterfactualLoading: string; counterfactualLoading: string;
counterfactualReady: string; counterfactualReady: string;
counterfactualError: string; counterfactualError: string;
retryCounterfactual: string;
historicalEligibilityLoading: string;
historicalEligibilityReady: string;
historicalEligibilityError: string;
retryHistoricalEligibility: string;
eligible: string; eligible: string;
ineligible: string; ineligible: string;
historical: string; historical: string;
@@ -213,6 +224,7 @@ export interface AppTranslations {
outsideHorizon: string; outsideHorizon: string;
traceTitle: string; traceTitle: string;
traceLoading: string; traceLoading: string;
traceReady: string;
traceUnavailable: string; traceUnavailable: string;
phases: string; phases: string;
firstPhase: string; firstPhase: string;
+16
View File
@@ -119,10 +119,16 @@
"next": "Nächste Platzierung", "next": "Nächste Platzierung",
"last": "Letzte Platzierung", "last": "Letzte Platzierung",
"remaining": "Ungenutzte Tage im endgültigen Plan", "remaining": "Ungenutzte Tage im endgültigen Plan",
"balanceRemaining": "verbleibend",
"counterfactual": "Alternative Termine", "counterfactual": "Alternative Termine",
"counterfactualLoading": "Alle Daten im Horizont werden analysiert…", "counterfactualLoading": "Alle Daten im Horizont werden analysiert…",
"counterfactualReady": "Alternative Termine sind bereit.", "counterfactualReady": "Alternative Termine sind bereit.",
"counterfactualError": "Alternative Termine konnten nicht berechnet werden.", "counterfactualError": "Alternative Termine konnten nicht berechnet werden.",
"retryCounterfactual": "Datumsanalyse erneut versuchen",
"historicalEligibilityLoading": "Prüfe, an welchen Tagen dieses Urlaubsguthaben verfügbar war …",
"historicalEligibilityReady": "Die historische Datumseignung ist bereit.",
"historicalEligibilityError": "Die historische Datumseignung konnte nicht geprüft werden.",
"retryHistoricalEligibility": "Historische Datumseignung erneut prüfen",
"eligible": "Geeignet", "eligible": "Geeignet",
"ineligible": "Nicht geeignet", "ineligible": "Nicht geeignet",
"historical": "Status der ursprünglichen Suche", "historical": "Status der ursprünglichen Suche",
@@ -140,6 +146,7 @@
"outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts", "outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts",
"traceTitle": "Erweitert: ursprüngliche Entscheidungsspur", "traceTitle": "Erweitert: ursprüngliche Entscheidungsspur",
"traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…", "traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…",
"traceReady": "Der Plan ist fertig und seine Entscheidungsspur wurde geladen.",
"traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.", "traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.",
"phases": "Spurphasen", "phases": "Spurphasen",
"firstPhase": "Erste Phase", "firstPhase": "Erste Phase",
@@ -200,6 +207,7 @@
"leaveUsed": "Dieses Datum nutzt 1 Tag aus „{balance}“.", "leaveUsed": "Dieses Datum nutzt 1 Tag aus „{balance}“.",
"expires": "Dieses Guthaben ist nutzbar bis", "expires": "Dieses Guthaben ist nutzbar bis",
"linkedRules": "Zugehörige Regeln:", "linkedRules": "Zugehörige Regeln:",
"atRiskContribution": "Schützt Urlaub, der zu verfallen droht.",
"impact": "Was dieser Tag verändert", "impact": "Was dieser Tag verändert",
"impactHelp": "Vergleichen Sie den gesamten Plan mit und ohne diesen Urlaubstag. Alle anderen Urlaubstermine bleiben gleich; nur geänderte Werte werden angezeigt.", "impactHelp": "Vergleichen Sie den gesamten Plan mit und ohne diesen Urlaubstag. Alle anderen Urlaubstermine bleiben gleich; nur geänderte Werte werden angezeigt.",
"criterion": "Im gesamten Plan", "criterion": "Im gesamten Plan",
@@ -218,6 +226,10 @@
"withCandidate": "Mit diesem Kandidaten", "withCandidate": "Mit diesem Kandidaten",
"comparisonUnavailable": "Der Vergleich für das ursprüngliche Datum ist nicht verfügbar. Daher lässt sich die Änderung nicht anzeigen.", "comparisonUnavailable": "Der Vergleich für das ursprüngliche Datum ist nicht verfügbar. Daher lässt sich die Änderung nicht anzeigen.",
"objectives": { "objectives": {
"obligation-validity": {
"label": "Unerfüllte Verpflichtungen",
"help": "Anzahl nicht erfüllter Pflichtregeln. Ein gültiger Plan hat den Wert null."
},
"unplaced-at-risk": { "unplaced-at-risk": {
"label": "Noch nicht genutzte Tage mit Verfallsrisiko", "label": "Noch nicht genutzte Tage mit Verfallsrisiko",
"help": "Ungenutzte Tage, die im Planungszeitraum verfallen. Weniger ist besser." "help": "Ungenutzte Tage, die im Planungszeitraum verfallen. Weniger ist besser."
@@ -282,6 +294,10 @@
"expires": "Verfällt am", "expires": "Verfällt am",
"criteria": "Die Prioritäten des Planers", "criteria": "Die Prioritäten des Planers",
"criteriaHelp": "Von oben nach unten lesen: Der erste Unterschied hat Vorrang. Wählen Sie ein Kriterium, um seine Berechnung zu sehen.", "criteriaHelp": "Von oben nach unten lesen: Der erste Unterschied hat Vorrang. Wählen Sie ein Kriterium, um seine Berechnung zu sehen.",
"traceImpactTitle": "Auswirkung dieses Zweigs auf den Gesamtplan",
"traceImpactHelp": "Diese Werte vergleichen den vollständigen Zustand vor und nach diesem Zweig. Sie zeigen seine Auswirkungen, aber nicht, warum der Planer ihn gewählt hat.",
"traceStageTitle": "Tatsächlicher Vergleich in dieser Planungsstufe",
"traceStageHelp": "Diese Rohwerte wurden bei dieser Entscheidung verglichen; der erste Unterschied erklärt den Status des Zweigs. Die Tabelle zum Gesamtplan unten zeigt die Auswirkung dieser Wahl.",
"firstDifference": "Erster Unterschied", "firstDifference": "Erster Unterschied",
"higher": "Höher ist besser", "higher": "Höher ist besser",
"lower": "Niedriger ist besser", "lower": "Niedriger ist besser",
+16
View File
@@ -119,10 +119,16 @@
"next": "Next placement", "next": "Next placement",
"last": "Last placement", "last": "Last placement",
"remaining": "Unused days in the final plan", "remaining": "Unused days in the final plan",
"balanceRemaining": "remaining",
"counterfactual": "Date alternatives", "counterfactual": "Date alternatives",
"counterfactualLoading": "Analyzing every date in the horizon…", "counterfactualLoading": "Analyzing every date in the horizon…",
"counterfactualReady": "Date alternatives are ready.", "counterfactualReady": "Date alternatives are ready.",
"counterfactualError": "Date alternatives could not be calculated.", "counterfactualError": "Date alternatives could not be calculated.",
"retryCounterfactual": "Retry date analysis",
"historicalEligibilityLoading": "Checking which dates this leave could use…",
"historicalEligibilityReady": "Original date eligibility is ready.",
"historicalEligibilityError": "Original date eligibility could not be checked.",
"retryHistoricalEligibility": "Retry date eligibility check",
"eligible": "Eligible", "eligible": "Eligible",
"ineligible": "Ineligible", "ineligible": "Ineligible",
"historical": "Original search status", "historical": "Original search status",
@@ -140,6 +146,7 @@
"outsideHorizon": "Context date outside the planning horizon", "outsideHorizon": "Context date outside the planning horizon",
"traceTitle": "Advanced: original decision trace", "traceTitle": "Advanced: original decision trace",
"traceLoading": "The plan is ready; its decision trace is still loading…", "traceLoading": "The plan is ready; its decision trace is still loading…",
"traceReady": "The plan is ready and its decision trace has loaded.",
"traceUnavailable": "No decision trace is available for this result.", "traceUnavailable": "No decision trace is available for this result.",
"phases": "Trace phases", "phases": "Trace phases",
"firstPhase": "First phase", "firstPhase": "First phase",
@@ -200,6 +207,7 @@
"leaveUsed": "This date uses 1 day from {balance}.", "leaveUsed": "This date uses 1 day from {balance}.",
"expires": "Use this balance by", "expires": "Use this balance by",
"linkedRules": "Linked rules:", "linkedRules": "Linked rules:",
"atRiskContribution": "Protects leave at risk of expiry.",
"impact": "What this day changes", "impact": "What this day changes",
"impactHelp": "Compare the whole plan with and without this day of leave. All other leave dates stay the same; only changed totals are shown.", "impactHelp": "Compare the whole plan with and without this day of leave. All other leave dates stay the same; only changed totals are shown.",
"criterion": "Across your plan", "criterion": "Across your plan",
@@ -218,6 +226,10 @@
"withCandidate": "With this candidate", "withCandidate": "With this candidate",
"comparisonUnavailable": "The comparison for the original date is unavailable, so the change cannot be shown.", "comparisonUnavailable": "The comparison for the original date is unavailable, so the change cannot be shown.",
"objectives": { "objectives": {
"obligation-validity": {
"label": "Unmet obligations",
"help": "Number of required obligations left unsatisfied. A valid plan has zero."
},
"unplaced-at-risk": { "unplaced-at-risk": {
"label": "Leave left at risk of expiry", "label": "Leave left at risk of expiry",
"help": "Unused days expiring within the planning period. Fewer is better." "help": "Unused days expiring within the planning period. Fewer is better."
@@ -282,6 +294,10 @@
"expires": "Expires on", "expires": "Expires on",
"criteria": "The planner’s priorities", "criteria": "The planner’s priorities",
"criteriaHelp": "Read from top to bottom: the first difference takes priority. Select a criterion to see how it is calculated.", "criteriaHelp": "Read from top to bottom: the first difference takes priority. Select a criterion to see how it is calculated.",
"traceImpactTitle": "Full-plan effect of this branch",
"traceImpactHelp": "These values compare the complete state before and after this branch. They show its impact; they do not explain why the planner chose it.",
"traceStageTitle": "Actual stage comparison",
"traceStageHelp": "These raw values were compared at this decision; the first difference explains the candidate’s status. The full-plan table below shows the effect after this choice.",
"firstDifference": "First difference", "firstDifference": "First difference",
"higher": "Higher is better", "higher": "Higher is better",
"lower": "Lower is better", "lower": "Lower is better",
+16
View File
@@ -119,10 +119,16 @@
"next": "Asignación siguiente", "next": "Asignación siguiente",
"last": "Última asignación", "last": "Última asignación",
"remaining": "Días sin usar en el plan final", "remaining": "Días sin usar en el plan final",
"balanceRemaining": "restantes",
"counterfactual": "Fechas alternativas", "counterfactual": "Fechas alternativas",
"counterfactualLoading": "Analizando cada fecha del horizonte…", "counterfactualLoading": "Analizando cada fecha del horizonte…",
"counterfactualReady": "Las fechas alternativas están listas.", "counterfactualReady": "Las fechas alternativas están listas.",
"counterfactualError": "No se pudieron calcular las fechas alternativas.", "counterfactualError": "No se pudieron calcular las fechas alternativas.",
"retryCounterfactual": "Reintentar el análisis de fechas",
"historicalEligibilityLoading": "Comprobando qué fechas podía usar este permiso…",
"historicalEligibilityReady": "La elegibilidad histórica de las fechas está lista.",
"historicalEligibilityError": "No se pudo comprobar la elegibilidad histórica de las fechas.",
"retryHistoricalEligibility": "Reintentar la comprobación de elegibilidad",
"eligible": "Válida", "eligible": "Válida",
"ineligible": "No válida", "ineligible": "No válida",
"historical": "Estado de la búsqueda original", "historical": "Estado de la búsqueda original",
@@ -140,6 +146,7 @@
"outsideHorizon": "Fecha de contexto fuera del horizonte de planificación", "outsideHorizon": "Fecha de contexto fuera del horizonte de planificación",
"traceTitle": "Avanzado: traza de decisiones original", "traceTitle": "Avanzado: traza de decisiones original",
"traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…", "traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…",
"traceReady": "El plan está listo y se ha cargado su traza de decisiones.",
"traceUnavailable": "No hay una traza de decisiones disponible para este resultado.", "traceUnavailable": "No hay una traza de decisiones disponible para este resultado.",
"phases": "Fases de la traza", "phases": "Fases de la traza",
"firstPhase": "Primera fase", "firstPhase": "Primera fase",
@@ -200,6 +207,7 @@
"leaveUsed": "Esta fecha usa 1 día del saldo «{balance}».", "leaveUsed": "Esta fecha usa 1 día del saldo «{balance}».",
"expires": "Usa este saldo hasta el", "expires": "Usa este saldo hasta el",
"linkedRules": "Reglas vinculadas:", "linkedRules": "Reglas vinculadas:",
"atRiskContribution": "Protege permisos que podrían vencer.",
"impact": "Qué cambia con este día", "impact": "Qué cambia con este día",
"impactHelp": "Compara todo el plan con y sin este día de permiso. Las demás fechas no cambian; solo se muestran los totales que varían.", "impactHelp": "Compara todo el plan con y sin este día de permiso. Las demás fechas no cambian; solo se muestran los totales que varían.",
"criterion": "En todo tu plan", "criterion": "En todo tu plan",
@@ -218,6 +226,10 @@
"withCandidate": "Con este candidato", "withCandidate": "Con este candidato",
"comparisonUnavailable": "La comparación de la fecha original no está disponible, por lo que no se puede mostrar el cambio.", "comparisonUnavailable": "La comparación de la fecha original no está disponible, por lo que no se puede mostrar el cambio.",
"objectives": { "objectives": {
"obligation-validity": {
"label": "Obligaciones sin cumplir",
"help": "Número de reglas obligatorias incumplidas. Un plan válido tiene cero."
},
"unplaced-at-risk": { "unplaced-at-risk": {
"label": "Días sin usar que podrían vencer", "label": "Días sin usar que podrían vencer",
"help": "Días sin planificar que vencen durante el período. Menos es mejor." "help": "Días sin planificar que vencen durante el período. Menos es mejor."
@@ -282,6 +294,10 @@
"expires": "Vence el", "expires": "Vence el",
"criteria": "Las prioridades del cálculo", "criteria": "Las prioridades del cálculo",
"criteriaHelp": "Lee de arriba abajo: la primera diferencia tiene prioridad. Selecciona un criterio para ver cómo se calcula.", "criteriaHelp": "Lee de arriba abajo: la primera diferencia tiene prioridad. Selecciona un criterio para ver cómo se calcula.",
"traceImpactTitle": "Efecto de esta rama en el plan completo",
"traceImpactHelp": "Estos valores comparan el estado completo antes y después de esta rama. Muestran su efecto, no por qué el planificador la eligió.",
"traceStageTitle": "Comparación real en esta etapa",
"traceStageHelp": "Estos valores brutos se compararon en esta decisión; la primera diferencia explica el estado de la opción. La tabla del plan completo muestra el efecto de esta elección.",
"firstDifference": "Primera diferencia", "firstDifference": "Primera diferencia",
"higher": "Más es mejor", "higher": "Más es mejor",
"lower": "Menos es mejor", "lower": "Menos es mejor",
+16
View File
@@ -119,10 +119,16 @@
"next": "Placement suivant", "next": "Placement suivant",
"last": "Dernier placement", "last": "Dernier placement",
"remaining": "Jours non utilisés dans le planning final", "remaining": "Jours non utilisés dans le planning final",
"balanceRemaining": "restants",
"counterfactual": "Dates alternatives", "counterfactual": "Dates alternatives",
"counterfactualLoading": "Analyse de chaque date de l’horizon…", "counterfactualLoading": "Analyse de chaque date de l’horizon…",
"counterfactualReady": "Les dates alternatives sont prêtes.", "counterfactualReady": "Les dates alternatives sont prêtes.",
"counterfactualError": "Les dates alternatives n’ont pas pu être calculées.", "counterfactualError": "Les dates alternatives n’ont pas pu être calculées.",
"retryCounterfactual": "Relancer l’analyse des dates",
"historicalEligibilityLoading": "Vérification des dates utilisables pour ce congé…",
"historicalEligibilityReady": "L’éligibilité historique des dates est prête.",
"historicalEligibilityError": "L’éligibilité historique des dates n’a pas pu être vérifiée.",
"retryHistoricalEligibility": "Revérifier l’éligibilité historique",
"eligible": "Éligible", "eligible": "Éligible",
"ineligible": "Inéligible", "ineligible": "Inéligible",
"historical": "Statut de la recherche originale", "historical": "Statut de la recherche originale",
@@ -140,6 +146,7 @@
"outsideHorizon": "Date de contexte hors de l’horizon de planification", "outsideHorizon": "Date de contexte hors de l’horizon de planification",
"traceTitle": "Avancé : trace des décisions originales", "traceTitle": "Avancé : trace des décisions originales",
"traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…", "traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…",
"traceReady": "Le plan est prêt et sa trace de décision est chargée.",
"traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.", "traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.",
"phases": "Phases de la trace", "phases": "Phases de la trace",
"firstPhase": "Première phase", "firstPhase": "Première phase",
@@ -200,6 +207,7 @@
"leaveUsed": "Cette date utilise 1 jour du solde « {balance} ».", "leaveUsed": "Cette date utilise 1 jour du solde « {balance} ».",
"expires": "Solde à utiliser au plus tard le", "expires": "Solde à utiliser au plus tard le",
"linkedRules": "Règles liées :", "linkedRules": "Règles liées :",
"atRiskContribution": "Protège des congés à risque d’expiration.",
"impact": "Ce que ce jour change", "impact": "Ce que ce jour change",
"impactHelp": "Comparez tout le planning avec et sans ce jour de congé. Les autres dates restent identiques ; seuls les totaux qui changent sont affichés.", "impactHelp": "Comparez tout le planning avec et sans ce jour de congé. Les autres dates restent identiques ; seuls les totaux qui changent sont affichés.",
"criterion": "Sur tout le planning", "criterion": "Sur tout le planning",
@@ -218,6 +226,10 @@
"withCandidate": "Avec ce candidat", "withCandidate": "Avec ce candidat",
"comparisonUnavailable": "La comparaison pour la date initiale est indisponible ; la différence ne peut donc pas être affichée.", "comparisonUnavailable": "La comparaison pour la date initiale est indisponible ; la différence ne peut donc pas être affichée.",
"objectives": { "objectives": {
"obligation-validity": {
"label": "Obligations non satisfaites",
"help": "Nombre de règles obligatoires non respectées. Un plan valide en compte zéro."
},
"unplaced-at-risk": { "unplaced-at-risk": {
"label": "Congés risquant d’expirer sans être utilisés", "label": "Congés risquant d’expirer sans être utilisés",
"help": "Jours non posés expirant pendant la période planifiée. Moins, c’est mieux." "help": "Jours non posés expirant pendant la période planifiée. Moins, c’est mieux."
@@ -282,6 +294,10 @@
"expires": "Expire le", "expires": "Expire le",
"criteria": "Les priorités du calcul", "criteria": "Les priorités du calcul",
"criteriaHelp": "Lisez de haut en bas : la première différence est prioritaire. Sélectionnez un critère pour comprendre son calcul.", "criteriaHelp": "Lisez de haut en bas : la première différence est prioritaire. Sélectionnez un critère pour comprendre son calcul.",
"traceImpactTitle": "Effet de cette branche sur le plan complet",
"traceImpactHelp": "Ces valeurs comparent l’état complet avant et après cette branche. Elles montrent son effet, pas pourquoi le calcul l’a choisie.",
"traceStageTitle": "Comparaison réelle à cette étape",
"traceStageHelp": "Ces valeurs brutes ont été comparées lors de cette décision ; la première différence explique le statut de la possibilité. Le tableau du plan complet ci-dessous montre l’effet de ce choix.",
"firstDifference": "Première différence", "firstDifference": "Première différence",
"higher": "Plus, c’est mieux", "higher": "Plus, c’est mieux",
"lower": "Moins, c’est mieux", "lower": "Moins, c’est mieux",