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:
1 parent
0ce33cc9d6
commit
01c87d1e44
20 files changed
+7625
-1001
No files matched your search
@@ -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.
|
||||
|
||||
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
|
||||
candidate count. `PlanningIncomplete` is not a conflict: its last completed
|
||||
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 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.
|
||||
|
||||
`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
|
||||
|
||||
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 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';
|
||||
|
||||
describe('leave planner benchmark protocol', () => {
|
||||
// Warm-up and two repetitions include complete trace delivery after planning.
|
||||
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
|
||||
const scenarios = benchmarkScenarios();
|
||||
expect(scenarios).toHaveLength(5);
|
||||
@@ -62,6 +63,11 @@ describe('leave planner benchmark protocol', () => {
|
||||
expect(loaded[1].deterministic).toBe('yes');
|
||||
expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded[0].traceDeliveryMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded[0].planningWorkMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded[0].planningWorkMs).toBeLessThanOrEqual(loaded[0].planningMs);
|
||||
expect(loaded[0].traceCaptureMs).toBeGreaterThan(0);
|
||||
expect(loaded[0].traceMaterializationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded[0].maxUiDelayMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded[0].wallMs).toBeGreaterThanOrEqual(
|
||||
loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs,
|
||||
);
|
||||
@@ -70,9 +76,10 @@ describe('leave planner benchmark protocol', () => {
|
||||
const report = benchmarkReport(measurements);
|
||||
expect(report.split('\n')).toHaveLength(11);
|
||||
expect(report).toContain('planning_ms\ttrace_delivery_ms');
|
||||
expect(report).toContain('planning_work_ms\ttrace_capture_ms');
|
||||
expect(report).toContain('\tbaseline\t');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
}, 30_000);
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -16,6 +16,11 @@ export interface BenchmarkMeasurement {
|
||||
preparationMs: number;
|
||||
planningMs: number;
|
||||
traceDeliveryMs: number;
|
||||
maxUiDelayMs: number;
|
||||
planningWorkMs?: number;
|
||||
traceCaptureMs?: number;
|
||||
traceMaterializationMs?: number;
|
||||
traceTransferMs?: number;
|
||||
wallMs: number;
|
||||
outcome: PlanningOutcome['kind'];
|
||||
lastCompletedStage?: string;
|
||||
@@ -167,63 +172,93 @@ function resultSummary(outcome: PlanningOutcome): {
|
||||
|
||||
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
|
||||
const scenarios = benchmarkScenarios();
|
||||
for (const scenario of scenarios) {
|
||||
const calculation = createPlanningCalculation(structuredClone(scenario.request));
|
||||
await calculation.outcome;
|
||||
await calculation.trace;
|
||||
}
|
||||
for (const scenario of scenarios) await warmUpScenario(scenario);
|
||||
|
||||
const measurements: BenchmarkMeasurement[] = [];
|
||||
for (const scenario of scenarios) {
|
||||
let baseline: string | undefined;
|
||||
for (let repetition = 1; repetition <= repetitions; repetition++) {
|
||||
const wallStart = performance.now();
|
||||
const preparationStart = performance.now();
|
||||
const request = structuredClone(scenario.request);
|
||||
const preparationMs = performance.now() - preparationStart;
|
||||
const calculationStart = performance.now();
|
||||
const calculation = createPlanningCalculation(request);
|
||||
const outcome = await calculation.outcome;
|
||||
const planningMs = performance.now() - calculationStart;
|
||||
const traceStart = performance.now();
|
||||
const trace = await calculation.trace;
|
||||
const traceDeliveryMs = performance.now() - traceStart;
|
||||
const summary = resultSummary(outcome);
|
||||
const stable = JSON.stringify(outcome);
|
||||
const deterministic =
|
||||
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
|
||||
const traceCandidates =
|
||||
trace?.phases.reduce(
|
||||
(total, phase) =>
|
||||
total +
|
||||
phase.decisions.reduce(
|
||||
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
) ?? 0;
|
||||
const wallMs = performance.now() - wallStart;
|
||||
measurements.push({
|
||||
scenario: scenario.name,
|
||||
repetition,
|
||||
preparationMs,
|
||||
planningMs,
|
||||
traceDeliveryMs,
|
||||
wallMs,
|
||||
...summary,
|
||||
deterministic,
|
||||
traceStatus: trace?.status ?? 'not-produced',
|
||||
traceCandidates,
|
||||
});
|
||||
baseline ??= stable;
|
||||
const result = await measureRepetition(scenario, repetition, baseline);
|
||||
measurements.push(result.measurement);
|
||||
baseline ??= result.stable;
|
||||
}
|
||||
}
|
||||
return measurements;
|
||||
}
|
||||
|
||||
async function warmUpScenario(scenario: BenchmarkScenario): Promise<void> {
|
||||
const calculation = createPlanningCalculation(structuredClone(scenario.request));
|
||||
await calculation.outcome;
|
||||
await calculation.trace;
|
||||
}
|
||||
|
||||
async function measureRepetition(
|
||||
scenario: BenchmarkScenario,
|
||||
repetition: number,
|
||||
baseline: string | undefined,
|
||||
): Promise<{ measurement: BenchmarkMeasurement; stable: string }> {
|
||||
const wallStart = performance.now();
|
||||
let lastTick = wallStart;
|
||||
let maxUiDelayMs = 0;
|
||||
const measureDelay = () => {
|
||||
const current = performance.now();
|
||||
maxUiDelayMs = Math.max(maxUiDelayMs, current - lastTick - 50);
|
||||
lastTick = current;
|
||||
};
|
||||
const heartbeat = setInterval(measureDelay, 50);
|
||||
try {
|
||||
const preparationStart = performance.now();
|
||||
const request = structuredClone(scenario.request);
|
||||
const preparationMs = performance.now() - preparationStart;
|
||||
const calculationStart = performance.now();
|
||||
const calculation = createPlanningCalculation(request);
|
||||
const outcome = await calculation.outcome;
|
||||
const planningMs = performance.now() - calculationStart;
|
||||
const traceStart = performance.now();
|
||||
const trace = await calculation.trace;
|
||||
const traceDeliveryMs = performance.now() - traceStart;
|
||||
const timings = await calculation.timings;
|
||||
const summary = resultSummary(outcome);
|
||||
const stable = JSON.stringify(outcome);
|
||||
const deterministic =
|
||||
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
|
||||
const traceCandidates =
|
||||
trace?.phases.reduce(
|
||||
(total, phase) =>
|
||||
total +
|
||||
phase.decisions.reduce(
|
||||
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
) ?? 0;
|
||||
const wallMs = performance.now() - wallStart;
|
||||
measureDelay();
|
||||
return {
|
||||
measurement: {
|
||||
scenario: scenario.name,
|
||||
repetition,
|
||||
preparationMs,
|
||||
planningMs,
|
||||
traceDeliveryMs,
|
||||
maxUiDelayMs,
|
||||
...timings,
|
||||
wallMs,
|
||||
...summary,
|
||||
deterministic,
|
||||
traceStatus: trace?.status ?? 'not-produced',
|
||||
traceCandidates,
|
||||
},
|
||||
stable,
|
||||
};
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}
|
||||
|
||||
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
||||
const header =
|
||||
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates';
|
||||
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms';
|
||||
return [
|
||||
header,
|
||||
...measurements.map((item) =>
|
||||
@@ -240,6 +275,11 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
||||
item.deterministic,
|
||||
item.traceStatus,
|
||||
item.traceCandidates,
|
||||
item.maxUiDelayMs.toFixed(1),
|
||||
item.planningWorkMs?.toFixed(1) ?? '',
|
||||
item.traceCaptureMs?.toFixed(1) ?? '',
|
||||
item.traceMaterializationMs?.toFixed(1) ?? '',
|
||||
item.traceTransferMs?.toFixed(1) ?? '',
|
||||
].join('\t'),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
[disabled]="!isInHorizon(day)"
|
||||
[matTooltip]="dayTooltip"
|
||||
[matTooltipShowDelay]="500"
|
||||
[attr.aria-label]="getDayAriaLabel(day)"
|
||||
[attr.aria-label]="getDayAriaLabel(day, dayTooltip)"
|
||||
[attr.aria-current]="dateKey === currentDate ? 'date' : null"
|
||||
(click)="dateSelected.emit(dateKey)"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
@@ -20,6 +28,7 @@ export interface CalendarDayStatus {
|
||||
imports: [MatTooltip],
|
||||
templateUrl: './calendar-month.html',
|
||||
styleUrl: './calendar-month.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CalendarMonth implements OnInit {
|
||||
protected calendarSettingsService = inject(CalendarSettingsService);
|
||||
@@ -44,6 +53,8 @@ export class CalendarMonth implements OnInit {
|
||||
currentMonth: Date = new Date();
|
||||
|
||||
weeksInAMonth: Month = [];
|
||||
private dayFormatterLocale = '';
|
||||
private dayFormatter?: Intl.DateTimeFormat;
|
||||
|
||||
@Input({ required: true })
|
||||
selectedDates!: SelectedDates;
|
||||
@@ -121,16 +132,18 @@ export class CalendarMonth implements OnInit {
|
||||
: '~×';
|
||||
}
|
||||
|
||||
getDayAriaLabel(day: Date): string {
|
||||
const parts = [
|
||||
day.toLocaleDateString(this.translationService.locale(), {
|
||||
getDayAriaLabel(day: Date, tooltip: string | null = this.getDayTooltip(day)): string {
|
||||
const locale = this.translationService.locale();
|
||||
if (!this.dayFormatter || locale !== this.dayFormatterLocale) {
|
||||
this.dayFormatterLocale = locale;
|
||||
this.dayFormatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
];
|
||||
const tooltip = this.getDayTooltip(day);
|
||||
});
|
||||
}
|
||||
const parts = [this.dayFormatter.format(day)];
|
||||
if (tooltip) parts.push(tooltip);
|
||||
if (!this.isInHorizon(day))
|
||||
parts.push(this.translationService.tr().planning.outsideHorizon);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,7 @@
|
||||
<div class="calendar-year">
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage() }}</p>
|
||||
<p class="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||
{{ liveMessage() }}
|
||||
</p>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading-overlay">
|
||||
@@ -12,7 +14,7 @@
|
||||
></mat-spinner>
|
||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||
</div>
|
||||
<p>{{ tr().loading }}</p>
|
||||
<p role="status">{{ tr().loading }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -170,7 +172,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-step="first"
|
||||
[disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.first"
|
||||
(click)="stepPlacement('first')"
|
||||
>
|
||||
@@ -179,20 +181,20 @@
|
||||
<button
|
||||
type="button"
|
||||
data-step="previous"
|
||||
[disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previous"
|
||||
(click)="stepPlacement('previous')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||
</button>
|
||||
<output>
|
||||
<output aria-current="step" [attr.aria-label]="placementAnnouncement()">
|
||||
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
|
||||
{{ tr().planning.of }} {{ planExplanation()!.placements.length }}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
data-step="next"
|
||||
[disabled]="
|
||||
[attr.aria-disabled]="
|
||||
selectedPlacementIndex() ===
|
||||
planExplanation()!.placements.length - 1
|
||||
"
|
||||
@@ -204,7 +206,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-step="last"
|
||||
[disabled]="
|
||||
[attr.aria-disabled]="
|
||||
selectedPlacementIndex() ===
|
||||
planExplanation()!.placements.length - 1
|
||||
"
|
||||
@@ -283,7 +285,32 @@
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (hasAtRiskContribution(selected)) {
|
||||
<p>{{ tr().planning.explanation.atRiskContribution }}</p>
|
||||
}
|
||||
@if (selected.supporting) {
|
||||
<p>{{ tr().planning.details.supporting }}</p>
|
||||
}
|
||||
<p>
|
||||
<strong>{{ tr().planning.remaining }}:</strong>
|
||||
{{ selected.remainingQuantity }} {{ tr().userInput.daysSuffix }}
|
||||
@if (selected.remainingBalance; as remaining) {
|
||||
·
|
||||
{{
|
||||
tr().planning.details.remainingReasons[remaining.classification]
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@let decisiveObjective =
|
||||
selected.firstDecisiveObjective ??
|
||||
selected.comparison.firstDecisiveObjective;
|
||||
@if (decisiveObjective) {
|
||||
<p class="decisive-summary">
|
||||
<strong>{{ tr().planning.details.firstDifference }}:</strong>
|
||||
{{ objectiveCopy(decisiveObjective).label }}
|
||||
</p>
|
||||
}
|
||||
<section class="day-impact" aria-labelledby="impact-title">
|
||||
<h4 id="impact-title">{{ tr().planning.explanation.impact }}</h4>
|
||||
<p>{{ tr().planning.explanation.impactHelp }}</p>
|
||||
@@ -302,9 +329,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (change of placementChanges(); track change.id) {
|
||||
<tr>
|
||||
<tr [class.decisive-row]="change.id === decisiveObjective">
|
||||
<th scope="row">
|
||||
{{ change.label }}<small>{{ change.help }}</small>
|
||||
@if (change.id === decisiveObjective) {
|
||||
<span class="decisive-label">{{
|
||||
tr().planning.details.firstDifference
|
||||
}}</span>
|
||||
}
|
||||
</th>
|
||||
<td>{{ change.before }}</td>
|
||||
<td>{{ change.after }}</td>
|
||||
@@ -358,9 +390,6 @@
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
@if (selected.supporting) {
|
||||
<p>{{ tr().planning.details.supporting }}</p>
|
||||
}
|
||||
</section>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="calculationTable"
|
||||
@@ -376,7 +405,11 @@
|
||||
|
||||
<button type="button" class="compare-dates-button" (click)="compareDates()">
|
||||
<mat-icon aria-hidden="true">compare_arrows</mat-icon
|
||||
>{{ tr().planning.compareDates }}
|
||||
>{{
|
||||
counterfactualError()
|
||||
? tr().planning.retryCounterfactual
|
||||
: tr().planning.compareDates
|
||||
}}
|
||||
</button>
|
||||
</section>
|
||||
</details>
|
||||
@@ -408,7 +441,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="first"
|
||||
[disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.firstPhase"
|
||||
(click)="stepTracePhase('first')"
|
||||
>
|
||||
@@ -417,20 +450,24 @@
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="previous"
|
||||
[disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previousPhase"
|
||||
(click)="stepTracePhase('previous')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||
</button>
|
||||
<output
|
||||
aria-current="step"
|
||||
[attr.aria-label]="tracePhaseAnnouncement()"
|
||||
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
|
||||
{{ trace.phases.length }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="next"
|
||||
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
|
||||
[attr.aria-disabled]="
|
||||
tracePhaseIndex() === trace.phases.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextPhase"
|
||||
(click)="stepTracePhase('next')"
|
||||
>
|
||||
@@ -439,7 +476,9 @@
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="last"
|
||||
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
|
||||
[attr.aria-disabled]="
|
||||
tracePhaseIndex() === trace.phases.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.lastPhase"
|
||||
(click)="stepTracePhase('last')"
|
||||
>
|
||||
@@ -466,7 +505,7 @@
|
||||
<nav [attr.aria-label]="tr().planning.decisions">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.firstDecision"
|
||||
(click)="stepTraceDecision('first')"
|
||||
>
|
||||
@@ -474,20 +513,22 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previousDecision"
|
||||
(click)="stepTraceDecision('previous')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||
</button>
|
||||
<output
|
||||
aria-current="step"
|
||||
[attr.aria-label]="traceDecisionAnnouncement()"
|
||||
>{{ tr().planning.decision }}
|
||||
{{ traceDecisionIndex() + 1 }} {{ tr().planning.of }}
|
||||
{{ phase.decisions.length }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
[attr.aria-disabled]="
|
||||
traceDecisionIndex() === phase.decisions.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextDecision"
|
||||
@@ -497,7 +538,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
[attr.aria-disabled]="
|
||||
traceDecisionIndex() === phase.decisions.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.lastDecision"
|
||||
@@ -539,6 +580,23 @@
|
||||
{{ tr().planning.finalPlan }}
|
||||
</button>
|
||||
</div>
|
||||
@if (historicalEligibilityLoading()) {
|
||||
<p role="status">
|
||||
{{ tr().planning.historicalEligibilityLoading }}
|
||||
</p>
|
||||
}
|
||||
@if (historicalEligibilityError(); as error) {
|
||||
<p role="alert">
|
||||
{{ tr().planning.historicalEligibilityError }}
|
||||
{{ error }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="retryHistoricalEligibility()"
|
||||
>
|
||||
{{ tr().planning.retryHistoricalEligibility }}
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
<strong>{{ tr().planning.dates }}:</strong>
|
||||
{{
|
||||
@@ -562,13 +620,17 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceCandidatePage() === 0"
|
||||
[attr.aria-disabled]="
|
||||
traceCandidatePage() === 0
|
||||
"
|
||||
[attr.aria-label]="
|
||||
tr().planning.previousCandidates
|
||||
"
|
||||
(click)="stepTraceCandidatePage(-1)"
|
||||
>
|
||||
◀
|
||||
<mat-icon aria-hidden="true"
|
||||
>chevron_left</mat-icon
|
||||
>
|
||||
</button>
|
||||
<output
|
||||
>{{ tr().planning.page }}
|
||||
@@ -578,14 +640,16 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
[attr.aria-disabled]="
|
||||
traceCandidatePage() ===
|
||||
traceCandidatePages() - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextCandidates"
|
||||
(click)="stepTraceCandidatePage(1)"
|
||||
>
|
||||
▶
|
||||
<mat-icon aria-hidden="true"
|
||||
>chevron_right</mat-icon
|
||||
>
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
@@ -624,6 +688,100 @@
|
||||
@if (candidate.reason) {
|
||||
<p>{{ candidate.reason.message }}</p>
|
||||
}
|
||||
@if (candidate.technicalComparison?.length) {
|
||||
<section class="technical-comparison">
|
||||
<h5>
|
||||
{{
|
||||
tr().planning.details
|
||||
.traceStageTitle
|
||||
}}
|
||||
</h5>
|
||||
<p class="calculation-intro">
|
||||
{{
|
||||
tr().planning.details
|
||||
.traceStageHelp
|
||||
}}
|
||||
</p>
|
||||
<table class="impact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
{{
|
||||
tr().planning
|
||||
.explanation
|
||||
.criterion
|
||||
}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{
|
||||
tr().planning.before
|
||||
}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{
|
||||
tr().planning.after
|
||||
}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
comparator of candidate.technicalComparison;
|
||||
track $index
|
||||
) {
|
||||
<tr
|
||||
[class.decisive-row]="
|
||||
comparator.name ===
|
||||
candidate.firstDecisiveComparator
|
||||
"
|
||||
>
|
||||
<th scope="row">
|
||||
<code>{{
|
||||
comparator.name
|
||||
}}</code>
|
||||
<small>{{
|
||||
comparator.direction ===
|
||||
'maximize'
|
||||
? tr()
|
||||
.planning
|
||||
.details
|
||||
.higher
|
||||
: tr()
|
||||
.planning
|
||||
.details
|
||||
.lower
|
||||
}}</small>
|
||||
@if (
|
||||
comparator.name ===
|
||||
candidate.firstDecisiveComparator
|
||||
) {
|
||||
<span
|
||||
class="decisive-label"
|
||||
>{{
|
||||
tr()
|
||||
.planning
|
||||
.details
|
||||
.firstDifference
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</th>
|
||||
<td>
|
||||
{{
|
||||
comparator.before
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
comparator.after
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
@if (candidate.comparison; as comparison) {
|
||||
<div class="calculation-content">
|
||||
<ng-container
|
||||
@@ -636,6 +794,10 @@
|
||||
tr().planning.before,
|
||||
afterLabel: tr().planning.after,
|
||||
audit: comparison,
|
||||
traceEffect:
|
||||
!!candidate
|
||||
.technicalComparison
|
||||
?.length,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
@@ -687,6 +849,21 @@
|
||||
{{ tr().planning.placements }},
|
||||
{{ state.earnedActivations.length }}
|
||||
{{ tr().planning.activations }}
|
||||
@for (balance of state.balances; track balance.id) {
|
||||
<span class="trace-state-balance"
|
||||
>{{ balance.id }}: {{ balance.remaining }}
|
||||
{{ tr().planning.balanceRemaining }}
|
||||
<small
|
||||
>{{ tr().planning.details.available }}
|
||||
{{ displayDate(balance.availableFrom) }}
|
||||
·
|
||||
{{ tr().planning.details.expires }}
|
||||
{{
|
||||
displayDate(balance.expiryDate)
|
||||
}}</small
|
||||
></span
|
||||
>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@@ -697,7 +874,7 @@
|
||||
}
|
||||
</div>
|
||||
} @else if (traceResolved()) {
|
||||
<p>{{ tr().planning.traceUnavailable }}</p>
|
||||
<p role="status">{{ tr().planning.traceUnavailable }}</p>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
@@ -711,6 +888,16 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (counterfactualLoading()) {
|
||||
<p class="counterfactual-progress" aria-hidden="true">
|
||||
{{ tr().planning.counterfactualLoading }}
|
||||
</p>
|
||||
}
|
||||
@if (counterfactualError()) {
|
||||
<div class="counterfactual-inline-error" role="alert">
|
||||
<p>{{ tr().planning.counterfactualError }} {{ counterfactualError() }}</p>
|
||||
</div>
|
||||
}
|
||||
<p class="calendar-help">
|
||||
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
|
||||
</p>
|
||||
@@ -828,10 +1015,23 @@
|
||||
let-beforeLabel="beforeLabel"
|
||||
let-afterLabel="afterLabel"
|
||||
let-audit="audit"
|
||||
let-traceEffect="traceEffect"
|
||||
>
|
||||
<section class="criteria-detail">
|
||||
<h4>{{ tr().planning.details.criteria }}</h4>
|
||||
<p class="calculation-intro">{{ tr().planning.details.criteriaHelp }}</p>
|
||||
<h4>
|
||||
{{
|
||||
traceEffect
|
||||
? tr().planning.details.traceImpactTitle
|
||||
: tr().planning.details.criteria
|
||||
}}
|
||||
</h4>
|
||||
<p class="calculation-intro">
|
||||
{{
|
||||
traceEffect
|
||||
? tr().planning.details.traceImpactHelp
|
||||
: tr().planning.details.criteriaHelp
|
||||
}}
|
||||
</p>
|
||||
<table class="impact-table criteria-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -849,7 +1049,7 @@
|
||||
@if (objective.numeric) {
|
||||
<tr
|
||||
[class.decisive-row]="
|
||||
objective.id === comparison.firstDecisiveObjective
|
||||
!traceEffect && objective.id === comparison.firstDecisiveObjective
|
||||
"
|
||||
>
|
||||
<th scope="row">
|
||||
@@ -862,7 +1062,10 @@
|
||||
? tr().planning.details.higher
|
||||
: tr().planning.details.lower
|
||||
}}</small>
|
||||
@if (objective.id === comparison.firstDecisiveObjective) {
|
||||
@if (
|
||||
!traceEffect &&
|
||||
objective.id === comparison.firstDecisiveObjective
|
||||
) {
|
||||
<span class="decisive-label">{{
|
||||
tr().planning.details.firstDifference
|
||||
}}</span>
|
||||
@@ -886,7 +1089,10 @@
|
||||
@if (!objective.numeric) {
|
||||
<li>
|
||||
{{ objective.label }}
|
||||
@if (objective.id === comparison.firstDecisiveObjective) {
|
||||
@if (
|
||||
!traceEffect &&
|
||||
objective.id === comparison.firstDecisiveObjective
|
||||
) {
|
||||
<span class="decisive-label">{{
|
||||
tr().planning.details.firstDifference
|
||||
}}</span>
|
||||
@@ -940,7 +1146,9 @@
|
||||
<p>{{ tr().planning.counterfactualLoading }}</p>
|
||||
</div>
|
||||
} @else if (counterfactualError()) {
|
||||
<p class="inline-error" role="alert">{{ tr().planning.counterfactualError }}</p>
|
||||
<div class="counterfactual-inline-error" role="alert">
|
||||
<p>{{ tr().planning.counterfactualError }} {{ counterfactualError() }}</p>
|
||||
</div>
|
||||
} @else if (counterfactual(); as analysis) {
|
||||
<div class="comparison-date-pair">
|
||||
<div class="original-date">
|
||||
@@ -1106,6 +1314,12 @@
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@if (dateStatuses()[result.date]?.reason; as historicalReason) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.reason }}:</strong>
|
||||
{{ historicalReason }}
|
||||
</p>
|
||||
}
|
||||
@if (dateComparison(); as comparison) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="calculationTable"
|
||||
@@ -1118,6 +1332,7 @@
|
||||
? tr().planning.explanation.withCandidate
|
||||
: tr().planning.explanation.alternative,
|
||||
audit: result,
|
||||
traceEffect: traceReplay(),
|
||||
}"
|
||||
/>
|
||||
} @else {
|
||||
@@ -1136,11 +1351,17 @@
|
||||
</mat-dialog-content>
|
||||
<footer class="comparison-footer">
|
||||
<span>{{ tr().planning.comparison.preview }}</span>
|
||||
<button type="button" class="comparison-done" (click)="clearCounterfactual()">
|
||||
<button
|
||||
type="button"
|
||||
class="comparison-done"
|
||||
(click)="counterfactualError() ? retryCounterfactual() : clearCounterfactual()"
|
||||
>
|
||||
{{
|
||||
traceReplay()
|
||||
? tr().planning.comparison.backToTrace
|
||||
: tr().planning.comparison.backToPlan
|
||||
counterfactualError()
|
||||
? tr().planning.retryCounterfactual
|
||||
: traceReplay()
|
||||
? tr().planning.comparison.backToTrace
|
||||
: tr().planning.comparison.backToPlan
|
||||
}}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -67,6 +67,10 @@ button {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
summary {
|
||||
@@ -353,12 +357,27 @@ output {
|
||||
margin: 0;
|
||||
}
|
||||
.inline-error,
|
||||
.counterfactual-inline-error,
|
||||
.conflict-explanation {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 4px solid var(--ui-error-text);
|
||||
background: var(--ui-error-bg);
|
||||
color: var(--ui-error-text);
|
||||
}
|
||||
.counterfactual-inline-error {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.counterfactual-progress {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.decisive-summary {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 3px solid var(--mat-sys-primary);
|
||||
background: var(--ui-panel-bg);
|
||||
}
|
||||
.incomplete-explanation,
|
||||
.incomplete-incumbent {
|
||||
padding: 1rem;
|
||||
@@ -461,6 +480,8 @@ output {
|
||||
}
|
||||
small {
|
||||
color: var(--ui-text-secondary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.trace-phase-stepper {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import {
|
||||
applyReplayDeltas,
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
LeavePlacement,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
|
||||
type PlacementExplanation = PlanExplanation['placements'][number];
|
||||
type TraceMoment = 'before' | 'after';
|
||||
type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-year',
|
||||
@@ -110,12 +112,24 @@ export class CalendarYear {
|
||||
protected readonly traceReplay = signal(false);
|
||||
protected readonly selectedTraceDate = signal<string | null>(null);
|
||||
protected readonly liveMessage = signal('');
|
||||
private readonly historicalIneligibility = signal<ReadonlyMap<string, PlanningReason>>(
|
||||
new Map(),
|
||||
);
|
||||
protected readonly historicalEligibilityLoading = signal(false);
|
||||
protected readonly historicalEligibilityError = signal<string | null>(null);
|
||||
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
|
||||
protected readonly selectedDates = signal(new SelectedDates());
|
||||
|
||||
private calculationVersion = 0;
|
||||
private counterfactualVersion = 0;
|
||||
private historicalEligibilityVersion = 0;
|
||||
private activeCalculation: PlanningCalculationContext | null = null;
|
||||
private readonly traceDecisionIndices = new WeakMap<
|
||||
PlanningTrace,
|
||||
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
|
||||
>();
|
||||
private displayDateLocale = '';
|
||||
private displayDateFormatter?: Intl.DateTimeFormat;
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
@@ -146,6 +160,11 @@ export class CalendarYear {
|
||||
protected readonly selectedTraceDecision = computed(
|
||||
() => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()],
|
||||
);
|
||||
protected readonly selectedTraceTransition = computed(() =>
|
||||
this.traceReplay()
|
||||
? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision())
|
||||
: undefined,
|
||||
);
|
||||
|
||||
protected readonly visibleTraceCandidates = computed(() => {
|
||||
const start = this.traceCandidatePage() * 50;
|
||||
@@ -227,12 +246,7 @@ export class CalendarYear {
|
||||
);
|
||||
|
||||
protected readonly traceState = computed(() => {
|
||||
if (!this.traceReplay()) return undefined;
|
||||
return this.replayTraceState(
|
||||
this.planningTrace(),
|
||||
this.selectedTraceDecision()?.id,
|
||||
this.traceMoment(),
|
||||
);
|
||||
return this.selectedTraceTransition()?.[this.traceMoment()];
|
||||
});
|
||||
|
||||
protected readonly calendarSelectedDates = computed(() => {
|
||||
@@ -250,7 +264,7 @@ export class CalendarYear {
|
||||
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
|
||||
if (this.traceReplay())
|
||||
return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null;
|
||||
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
|
||||
return this.selectedExplanation()?.placement.date ?? null;
|
||||
});
|
||||
|
||||
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
|
||||
@@ -307,6 +321,14 @@ export class CalendarYear {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [date, reason] of this.historicalIneligibility()) {
|
||||
if (statuses[date]?.kind !== 'not-examined') continue;
|
||||
statuses[date] = {
|
||||
kind: 'ineligible',
|
||||
label: this.traceStatusLabel('ineligible'),
|
||||
reason: this.reasonLabel(reason),
|
||||
};
|
||||
}
|
||||
const decision = this.selectedTraceDecision();
|
||||
if (decision) {
|
||||
const changedDates = new Set<string>();
|
||||
@@ -325,7 +347,7 @@ export class CalendarYear {
|
||||
changeLabel: this.traceChangeLabel(change),
|
||||
};
|
||||
};
|
||||
for (const delta of decision.deltas) {
|
||||
for (const delta of decision.branch?.deltas ?? decision.deltas) {
|
||||
if (delta.kind === 'placement-added') setChange(delta.date, 'added');
|
||||
else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed');
|
||||
else if (delta.kind === 'placement-moved') {
|
||||
@@ -333,11 +355,10 @@ export class CalendarYear {
|
||||
setChange(delta.toDate, 'moved-to');
|
||||
} else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
|
||||
}
|
||||
const before = this.replayTraceState(trace, decision.id, 'before');
|
||||
const after = this.replayTraceState(trace, decision.id, 'after');
|
||||
const transition = this.selectedTraceTransition();
|
||||
for (const placement of [
|
||||
...(before?.placements ?? []),
|
||||
...(after?.placements ?? []),
|
||||
...(transition?.before.placements ?? []),
|
||||
...(transition?.after.placements ?? []),
|
||||
]) {
|
||||
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
|
||||
}
|
||||
@@ -351,6 +372,9 @@ export class CalendarYear {
|
||||
...status,
|
||||
kind: historicalStatus,
|
||||
label: this.traceStatusLabel(historicalStatus),
|
||||
reason: result.historicalReason
|
||||
? this.reasonLabel(result.historicalReason)
|
||||
: status?.reason,
|
||||
hypotheticalLabel:
|
||||
result.status === 'eligible'
|
||||
? this.tr().planning.eligible
|
||||
@@ -367,7 +391,11 @@ export class CalendarYear {
|
||||
statuses[result.date] = {
|
||||
kind: historicalStatus,
|
||||
label: this.traceStatusLabel(historicalStatus),
|
||||
reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
|
||||
reason: result.historicalReason
|
||||
? this.reasonLabel(result.historicalReason)
|
||||
: historicalStatus === 'ineligible'
|
||||
? nonWorkingReason
|
||||
: undefined,
|
||||
hypotheticalLabel:
|
||||
result.status === 'eligible'
|
||||
? this.tr().planning.eligible
|
||||
@@ -381,11 +409,18 @@ export class CalendarYear {
|
||||
protected readonly months = computed(() => {
|
||||
const horizon = this.planningHorizon();
|
||||
if (!horizon) return [];
|
||||
const start = new Date(`${horizon.start}T00:00:00`);
|
||||
const last = new Date(`${horizon.end}T00:00:00`);
|
||||
let first = new Date(`${horizon.start}T00:00:00`);
|
||||
let last = new Date(`${horizon.end}T00:00:00`);
|
||||
last.setDate(last.getDate() - 1);
|
||||
for (const period of this.displayedPlan()?.timeOffPeriods ?? []) {
|
||||
for (const date of period.nonWorkingDates) {
|
||||
const contextDate = new Date(`${date}T00:00:00`);
|
||||
if (date < horizon.start && contextDate < first) first = contextDate;
|
||||
else if (date >= horizon.end && contextDate > last) last = contextDate;
|
||||
}
|
||||
}
|
||||
const result: { monthIndex: number; year: number }[] = [];
|
||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
|
||||
while (cursor <= last) {
|
||||
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
@@ -406,6 +441,7 @@ export class CalendarYear {
|
||||
const version = ++this.calculationVersion;
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
this.liveMessage.set(this.tr().loading);
|
||||
queueMicrotask(() => {
|
||||
let calculation: PlanningCalculationContext;
|
||||
try {
|
||||
@@ -494,26 +530,57 @@ export class CalendarYear {
|
||||
this.traceCandidatePage.set(0);
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(false);
|
||||
this.clearHistoricalEligibility();
|
||||
this.selectedTraceDate.set(null);
|
||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
private loadArtifacts(calculation: PlanningCalculationContext, version: number): void {
|
||||
void calculation.explanation.then((explanation) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.planningExplanation.set(explanation ?? null);
|
||||
if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen())
|
||||
this.selectPlacement(0);
|
||||
});
|
||||
void calculation.explanationError.then((reason) => {
|
||||
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
|
||||
});
|
||||
void calculation.explanation
|
||||
.then((explanation) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.planningExplanation.set(explanation ?? null);
|
||||
if (explanation?.kind === 'PlanExplanation') {
|
||||
this.planDetailsOpen.set(true);
|
||||
this.selectPlacement(0, false, false);
|
||||
this.liveMessage.set(
|
||||
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
const reason = {
|
||||
code: 'explanation-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
this.explanationError.set(reason);
|
||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
||||
});
|
||||
void calculation.explanationError
|
||||
.then((reason) => {
|
||||
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
const reason = {
|
||||
code: 'explanation-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
this.explanationError.set(reason);
|
||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
||||
});
|
||||
void calculation.trace
|
||||
.then((trace) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.planningTrace.set(trace ?? null);
|
||||
this.traceResolved.set(true);
|
||||
this.liveMessage.set(
|
||||
trace?.status === 'failed'
|
||||
? this.tr().planning.traceUnavailable
|
||||
: this.tr().planning.traceReady,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
@@ -533,6 +600,7 @@ export class CalendarYear {
|
||||
},
|
||||
});
|
||||
this.traceResolved.set(true);
|
||||
this.liveMessage.set(this.tr().planning.traceUnavailable);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === this.calculationVersion) this.traceLoading.set(false);
|
||||
@@ -553,7 +621,7 @@ export class CalendarYear {
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
protected selectPlacement(index: number): void {
|
||||
protected selectPlacement(index: number, analyze = true, announce = true): void {
|
||||
const explanation = this.planExplanation();
|
||||
if (!explanation?.placements.length) return;
|
||||
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
|
||||
@@ -561,14 +629,27 @@ export class CalendarYear {
|
||||
this.traceReplay.set(false);
|
||||
this.clearCounterfactual();
|
||||
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
|
||||
);
|
||||
if (announce) this.liveMessage.set(this.placementAnnouncement());
|
||||
if (analyze) void this.analyzeSelection(explanation.placements[selected].placement);
|
||||
}
|
||||
|
||||
protected compareDates(): void {
|
||||
const placement = this.selectedExplanation()?.placement;
|
||||
if (placement) void this.analyzeSelection(placement);
|
||||
if (!placement) return;
|
||||
this.openComparisonDialog();
|
||||
if (this.counterfactual()) {
|
||||
if (this.selectedCounterfactualDate() === placement.date) {
|
||||
const alternative = this.nearbyComparisonDates()[0];
|
||||
if (alternative) this.selectCalendarDate(alternative.date);
|
||||
}
|
||||
} else if (!this.counterfactualLoading()) {
|
||||
void this.analyzeSelection(placement, true);
|
||||
}
|
||||
}
|
||||
|
||||
protected retryCounterfactual(): void {
|
||||
if (this.counterfactualSelection)
|
||||
void this.analyzeSelection(this.counterfactualSelection, !!this.comparisonDialog);
|
||||
}
|
||||
|
||||
protected togglePlanDetails(event: Event): void {
|
||||
@@ -592,15 +673,15 @@ export class CalendarYear {
|
||||
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
|
||||
const current = this.selectedPlacementIndex();
|
||||
this.selectPlacement(
|
||||
const target =
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
: current + 1;
|
||||
if (target !== current) this.selectPlacement(target);
|
||||
}
|
||||
|
||||
protected selectCalendarDate(date: string): void {
|
||||
@@ -616,10 +697,6 @@ export class CalendarYear {
|
||||
this.planDetailsOpen.set(true);
|
||||
this.traceOpen.set(false);
|
||||
this.selectPlacement(index);
|
||||
this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
|
||||
this.planDetails()
|
||||
?.nativeElement.querySelector('summary')
|
||||
?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -661,25 +738,24 @@ export class CalendarYear {
|
||||
this.traceCandidatePage.set(0);
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(false);
|
||||
this.clearHistoricalEligibility();
|
||||
this.selectedTraceDate.set(null);
|
||||
this.clearCounterfactual();
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
|
||||
);
|
||||
this.liveMessage.set(this.tracePhaseAnnouncement());
|
||||
}
|
||||
|
||||
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
|
||||
const current = this.tracePhaseIndex();
|
||||
this.selectTracePhase(
|
||||
const target =
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
: current + 1;
|
||||
if (target !== current) this.selectTracePhase(target);
|
||||
}
|
||||
|
||||
protected selectTraceDecision(index: number): void {
|
||||
@@ -691,28 +767,94 @@ export class CalendarYear {
|
||||
this.traceReplay.set(true);
|
||||
this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
|
||||
this.clearCounterfactual();
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
|
||||
);
|
||||
this.liveMessage.set(this.traceDecisionAnnouncement());
|
||||
void this.loadHistoricalEligibility();
|
||||
}
|
||||
|
||||
private selectedDecisionPlacement(decision: PlanningTraceDecision): LeavePlacement | undefined {
|
||||
const placements = [
|
||||
...decision.candidates.flatMap((candidate) =>
|
||||
candidate.placement ? [candidate.placement] : [],
|
||||
),
|
||||
...(decision.branch?.deltas ?? decision.deltas).flatMap((delta) =>
|
||||
delta.placement ? [delta.placement] : [],
|
||||
),
|
||||
];
|
||||
const selectedDate = this.selectedTraceDate() ?? decision.dates[0];
|
||||
return placements.find((placement) => placement.date === selectedDate) ?? placements[0];
|
||||
}
|
||||
|
||||
private clearHistoricalEligibility(): void {
|
||||
this.historicalEligibilityVersion++;
|
||||
this.historicalIneligibility.set(new Map());
|
||||
this.historicalEligibilityLoading.set(false);
|
||||
this.historicalEligibilityError.set(null);
|
||||
}
|
||||
|
||||
private async loadHistoricalEligibility(): Promise<void> {
|
||||
const version = ++this.historicalEligibilityVersion;
|
||||
this.historicalIneligibility.set(new Map());
|
||||
this.historicalEligibilityLoading.set(false);
|
||||
this.historicalEligibilityError.set(null);
|
||||
const calculation = this.activeCalculation;
|
||||
const decision = this.selectedTraceDecision();
|
||||
const placement = decision && this.selectedDecisionPlacement(decision);
|
||||
if (!this.traceReplay() || !calculation || !decision || !placement) return;
|
||||
this.historicalEligibilityLoading.set(true);
|
||||
this.liveMessage.set(this.tr().planning.historicalEligibilityLoading);
|
||||
try {
|
||||
const reasons = await calculation.historicalEligibility({
|
||||
decisionId: decision.id,
|
||||
placement,
|
||||
});
|
||||
if (
|
||||
version !== this.historicalEligibilityVersion ||
|
||||
calculation !== this.activeCalculation
|
||||
)
|
||||
return;
|
||||
this.historicalIneligibility.set(
|
||||
new Map(reasons.map(({ date, reason }) => [date, reason])),
|
||||
);
|
||||
this.liveMessage.set(this.tr().planning.historicalEligibilityReady);
|
||||
} catch (error) {
|
||||
if (version !== this.historicalEligibilityVersion) return;
|
||||
this.historicalIneligibility.set(new Map());
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.historicalEligibilityError.set(message);
|
||||
this.liveMessage.set(`${this.tr().planning.historicalEligibilityError} ${message}`);
|
||||
} finally {
|
||||
if (version === this.historicalEligibilityVersion)
|
||||
this.historicalEligibilityLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected retryHistoricalEligibility(): void {
|
||||
void this.loadHistoricalEligibility();
|
||||
}
|
||||
|
||||
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
|
||||
const current = this.traceDecisionIndex();
|
||||
this.selectTraceDecision(
|
||||
const target =
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
: current + 1;
|
||||
if (target !== current) this.selectTraceDecision(target);
|
||||
}
|
||||
|
||||
protected stepTraceCandidatePage(direction: -1 | 1): void {
|
||||
this.traceCandidatePage.update((page) =>
|
||||
Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)),
|
||||
const page = Math.max(
|
||||
0,
|
||||
Math.min(this.traceCandidatePage() + direction, this.traceCandidatePages() - 1),
|
||||
);
|
||||
if (page === this.traceCandidatePage()) return;
|
||||
this.traceCandidatePage.set(page);
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.page} ${this.traceCandidatePage() + 1} ${this.tr().planning.of} ${this.traceCandidatePages()}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -720,13 +862,13 @@ export class CalendarYear {
|
||||
this.clearCounterfactual();
|
||||
this.traceMoment.set(moment);
|
||||
this.traceReplay.set(true);
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
|
||||
);
|
||||
this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`);
|
||||
void this.loadHistoricalEligibility();
|
||||
}
|
||||
|
||||
protected showFinalPlan(): void {
|
||||
this.traceReplay.set(false);
|
||||
this.clearHistoricalEligibility();
|
||||
this.clearCounterfactual();
|
||||
this.liveMessage.set(this.tr().planning.finalPlan);
|
||||
}
|
||||
@@ -737,7 +879,10 @@ export class CalendarYear {
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(true);
|
||||
this.selectedTraceDate.set(candidate.placement.date);
|
||||
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
|
||||
void this.analyzeSelection(
|
||||
{ decisionId: decision.id, placement: candidate.placement },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private nonWorkingDateReason(
|
||||
@@ -750,59 +895,81 @@ export class CalendarYear {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
|
||||
private counterfactualSelection: CounterfactualSelection | null = null;
|
||||
|
||||
private async analyzeSelection(
|
||||
selection: CounterfactualSelection,
|
||||
openDialog = false,
|
||||
): Promise<void> {
|
||||
const calculation = this.activeCalculation;
|
||||
if (!calculation) return;
|
||||
this.counterfactualSelection = selection;
|
||||
const version = ++this.counterfactualVersion;
|
||||
this.counterfactual.set(null);
|
||||
this.counterfactualError.set(null);
|
||||
this.counterfactualLoading.set(true);
|
||||
this.invalidComparisonDate.set(false);
|
||||
if (!this.comparisonDialog) {
|
||||
const dialog = this.dialog.open(this.comparisonTemplate(), {
|
||||
width: '48rem',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
maxHeight: 'calc(100dvh - 2rem)',
|
||||
autoFocus: 'first-heading',
|
||||
ariaLabelledBy: 'counterfactual-title',
|
||||
ariaDescribedBy: 'comparison-intro',
|
||||
});
|
||||
this.comparisonDialog = dialog;
|
||||
dialog.beforeClosed().subscribe(() => {
|
||||
if (this.comparisonDialog === dialog) {
|
||||
this.comparisonDialog = null;
|
||||
this.clearCounterfactual();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (openDialog) this.openComparisonDialog();
|
||||
const placement =
|
||||
typeof selection === 'string'
|
||||
? selection
|
||||
: 'placement' in selection
|
||||
? selection.placement
|
||||
: selection;
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
|
||||
);
|
||||
try {
|
||||
const result = await calculation.analyzeCounterfactual(selection);
|
||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
||||
return;
|
||||
if (result.kind === 'Counterfactual') {
|
||||
this.counterfactual.set(result);
|
||||
this.selectCalendarDate(
|
||||
this.traceReplay()
|
||||
? result.placement.date
|
||||
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
|
||||
);
|
||||
if (openDialog || this.comparisonDialog)
|
||||
this.selectCalendarDate(
|
||||
this.traceReplay()
|
||||
? result.placement.date
|
||||
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
|
||||
);
|
||||
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
||||
}
|
||||
} catch (error) {
|
||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
||||
return;
|
||||
this.counterfactualError.set(error instanceof Error ? error.message : String(error));
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.counterfactualError.set(message);
|
||||
this.liveMessage.set(`${this.tr().planning.counterfactualError} ${message}`);
|
||||
} finally {
|
||||
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private openComparisonDialog(): void {
|
||||
if (this.comparisonDialog) return;
|
||||
const dialog = this.dialog.open(this.comparisonTemplate(), {
|
||||
width: '48rem',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
maxHeight: 'calc(100dvh - 2rem)',
|
||||
autoFocus: 'first-heading',
|
||||
ariaLabelledBy: 'counterfactual-title',
|
||||
ariaDescribedBy: 'comparison-intro',
|
||||
});
|
||||
this.comparisonDialog = dialog;
|
||||
dialog.beforeClosed().subscribe(() => {
|
||||
if (this.comparisonDialog === dialog) {
|
||||
this.comparisonDialog = null;
|
||||
this.clearCounterfactual();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected clearCounterfactual(): void {
|
||||
const dialog = this.comparisonDialog;
|
||||
this.comparisonDialog = null;
|
||||
dialog?.close();
|
||||
this.counterfactualVersion++;
|
||||
this.counterfactual.set(null);
|
||||
this.counterfactualSelection = null;
|
||||
this.selectedCounterfactualDate.set(null);
|
||||
this.counterfactualLoading.set(false);
|
||||
this.counterfactualError.set(null);
|
||||
@@ -810,70 +977,130 @@ export class CalendarYear {
|
||||
this.comparisonDateInput.set(null);
|
||||
}
|
||||
|
||||
private replayTraceState(
|
||||
private replayTraceTransition(
|
||||
trace: PlanningTrace | null,
|
||||
decisionId: string | undefined,
|
||||
moment: TraceMoment,
|
||||
): PlanningTraceState | undefined {
|
||||
if (!trace || !decisionId) return undefined;
|
||||
const balances = trace.initialState.balances.map((balance) => ({ ...balance }));
|
||||
const placements = new Map(
|
||||
trace.initialState.placements.map((placement) => [placement.date, { ...placement }]),
|
||||
);
|
||||
const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
}));
|
||||
decision: PlanningTraceDecision | undefined,
|
||||
): TraceTransition | undefined {
|
||||
if (!trace || !decision) return undefined;
|
||||
const state = {
|
||||
balances: trace.initialState.balances.map((balance) => ({ ...balance })),
|
||||
placements: new Map(
|
||||
trace.initialState.placements.map((placement) => [
|
||||
placement.date,
|
||||
{ ...placement, ruleIds: [...placement.ruleIds] },
|
||||
]),
|
||||
),
|
||||
earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
})),
|
||||
};
|
||||
const snapshot = (): PlanningTraceState => ({
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
balances,
|
||||
placements: [...placements.values()],
|
||||
earnedActivations,
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements: [...state.placements.values()].map((placement) => ({
|
||||
...placement,
|
||||
ruleIds: [...placement.ruleIds],
|
||||
})),
|
||||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
})),
|
||||
});
|
||||
for (const decision of trace.phases.flatMap((phase) => phase.decisions)) {
|
||||
if (decision.id === decisionId && moment === 'before') return snapshot();
|
||||
this.applyTraceDecision(decision, placements, balances, earnedActivations);
|
||||
if (decision.id === decisionId) return snapshot();
|
||||
if (decision.branch) {
|
||||
const decisionIndex = this.getTraceDecisionIndex(trace);
|
||||
const target = decisionIndex.get(decision.id);
|
||||
if (!target) return undefined;
|
||||
const branchAncestors: PlanningTraceDecision[] = [];
|
||||
const visited = new Set([decision.id]);
|
||||
let current = decision;
|
||||
let committedParentOrdinal: number | undefined;
|
||||
while (current.branch) {
|
||||
branchAncestors.unshift(current);
|
||||
const parentId = current.branch.parentDecisionId;
|
||||
if (!parentId) break;
|
||||
if (visited.has(parentId)) return undefined;
|
||||
visited.add(parentId);
|
||||
const parent = decisionIndex.get(parentId);
|
||||
if (!parent) return undefined;
|
||||
if (!parent.decision.branch) {
|
||||
committedParentOrdinal = parent.ordinal;
|
||||
break;
|
||||
}
|
||||
current = parent.decision;
|
||||
}
|
||||
const branchRoot = branchAncestors[0];
|
||||
const branchRootIndex = branchRoot && decisionIndex.get(branchRoot.id)?.ordinal;
|
||||
if (branchRootIndex === undefined || target.ordinal < branchRootIndex) return undefined;
|
||||
const replayThrough = committedParentOrdinal ?? branchRootIndex - 1;
|
||||
if (replayThrough >= branchRootIndex) return undefined;
|
||||
let ordinal = 0;
|
||||
for (const phase of trace.phases) {
|
||||
for (const prior of phase.decisions) {
|
||||
if (ordinal++ > replayThrough) break;
|
||||
applyReplayDeltas(state, prior.deltas);
|
||||
}
|
||||
if (ordinal > replayThrough) break;
|
||||
}
|
||||
for (const ancestor of branchAncestors.slice(0, -1))
|
||||
applyReplayDeltas(state, ancestor.branch!.deltas);
|
||||
const before = snapshot();
|
||||
applyReplayDeltas(state, decision.branch.deltas);
|
||||
return { before, after: snapshot() };
|
||||
}
|
||||
for (const phase of trace.phases) {
|
||||
for (const prior of phase.decisions) {
|
||||
if (prior.id === decision.id) {
|
||||
const before = snapshot();
|
||||
applyReplayDeltas(state, prior.deltas);
|
||||
return { before, after: snapshot() };
|
||||
}
|
||||
applyReplayDeltas(state, prior.deltas);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private applyTraceDecision(
|
||||
decision: PlanningTraceDecision,
|
||||
placements: Map<string, LeavePlacement>,
|
||||
balances: PlanningTraceState['balances'][number][],
|
||||
earnedActivations: Array<{
|
||||
id: string;
|
||||
ruleId: string;
|
||||
occurrence: string;
|
||||
dates: string[];
|
||||
}>,
|
||||
): void {
|
||||
for (const delta of decision.deltas) {
|
||||
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
|
||||
if (delta.placement)
|
||||
placements.set(delta.placement.date, {
|
||||
...delta.placement,
|
||||
ruleIds: [...delta.placement.ruleIds],
|
||||
});
|
||||
const balanceIndex = balances.findIndex(
|
||||
(candidate) => candidate.id === delta.balanceId,
|
||||
);
|
||||
if (balanceIndex >= 0 && delta.remainingAfter !== undefined)
|
||||
balances[balanceIndex] = {
|
||||
...balances[balanceIndex],
|
||||
remaining: delta.remainingAfter,
|
||||
};
|
||||
else if (balanceIndex < 0 && delta.balance) balances.push({ ...delta.balance });
|
||||
if (
|
||||
delta.activation &&
|
||||
!earnedActivations.some((activation) => activation.id === delta.activation!.id)
|
||||
)
|
||||
earnedActivations.push({
|
||||
...delta.activation,
|
||||
dates: [...delta.activation.dates],
|
||||
});
|
||||
private getTraceDecisionIndex(
|
||||
trace: PlanningTrace,
|
||||
): ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }> {
|
||||
let index = this.traceDecisionIndices.get(trace);
|
||||
if (!index) {
|
||||
const decisions = new Map<
|
||||
string,
|
||||
{ decision: PlanningTraceDecision; ordinal: number }
|
||||
>();
|
||||
let ordinal = 0;
|
||||
for (const phase of trace.phases)
|
||||
for (const decision of phase.decisions)
|
||||
decisions.set(decision.id, { decision, ordinal: ordinal++ });
|
||||
index = decisions;
|
||||
this.traceDecisionIndices.set(trace, index);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
protected placementAnnouncement(): string {
|
||||
const selected = this.selectedExplanation();
|
||||
const count = this.planExplanation()?.placements.length ?? 0;
|
||||
if (!selected || !count) return '';
|
||||
return `${this.tr().planning.placement} ${this.selectedPlacementIndex() + 1} ${this.tr().planning.of} ${count}: ${this.displayDate(selected.placement.date)}`;
|
||||
}
|
||||
|
||||
protected hasAtRiskContribution(explanation: PlacementExplanation): boolean {
|
||||
return explanation.contributions.some((contribution) => contribution.kind === 'at-risk');
|
||||
}
|
||||
|
||||
protected tracePhaseAnnouncement(): string {
|
||||
const phases = this.planningTrace()?.phases ?? [];
|
||||
const phase = phases[this.tracePhaseIndex()];
|
||||
return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`;
|
||||
}
|
||||
|
||||
protected traceDecisionAnnouncement(): string {
|
||||
const decisions = this.selectedTracePhase()?.decisions ?? [];
|
||||
const decision = decisions[this.traceDecisionIndex()];
|
||||
return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`;
|
||||
}
|
||||
|
||||
protected hasPlannerError(): boolean {
|
||||
@@ -974,10 +1201,15 @@ export class CalendarYear {
|
||||
}
|
||||
|
||||
protected displayDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(this.translationService.locale(), {
|
||||
dateStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(value));
|
||||
const locale = this.translationService.locale();
|
||||
if (!this.displayDateFormatter || locale !== this.displayDateLocale) {
|
||||
this.displayDateLocale = locale;
|
||||
this.displayDateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
return this.displayDateFormatter.format(new Date(value));
|
||||
}
|
||||
|
||||
protected planningDate(value: string | Date | undefined): string {
|
||||
@@ -989,6 +1221,10 @@ export class CalendarYear {
|
||||
return this.tr().planning.traceStatuses[status];
|
||||
}
|
||||
|
||||
protected reasonLabel(reason: PlanningReason): string {
|
||||
return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message;
|
||||
}
|
||||
|
||||
protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
|
||||
return this.tr().planning.tracePhaseStatuses[status];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
beforeEach(() => vi.stubGlobal('window', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('quality calculation trace', () => {
|
||||
it('separates source effects from the actual incumbent comparison', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2024-01-01',
|
||||
expiryDate: '2024-01-31',
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(trace?.status).toBe('ready');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const phase = trace.phases.find((item) => item.name === 'non-working-day-gain');
|
||||
const jan3Decision = phase?.decisions.find((decision) =>
|
||||
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'),
|
||||
);
|
||||
const jan2Decision = phase?.decisions.find((decision) =>
|
||||
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'),
|
||||
);
|
||||
const jan5 = jan3Decision?.candidates.find(
|
||||
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05',
|
||||
);
|
||||
const jan8 = jan3Decision?.candidates.find(
|
||||
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08',
|
||||
);
|
||||
|
||||
expect(jan2Decision).toBeDefined();
|
||||
expect(jan3Decision).toBeDefined();
|
||||
expect(jan3Decision).not.toBe(jan2Decision);
|
||||
expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']);
|
||||
expect(jan5?.status).toBe('chosen');
|
||||
expect(jan8?.status).toBe('evaluated-rejected');
|
||||
expect(jan3Decision?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-moved',
|
||||
fromDate: '2024-01-03',
|
||||
toDate: '2024-01-05',
|
||||
}),
|
||||
);
|
||||
|
||||
const sourceValue = (
|
||||
candidate: typeof jan5,
|
||||
state: 'before' | 'after',
|
||||
objective: string,
|
||||
) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value;
|
||||
expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3);
|
||||
expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5);
|
||||
expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20);
|
||||
expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18);
|
||||
expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3);
|
||||
expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5);
|
||||
expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20);
|
||||
expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17);
|
||||
expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain');
|
||||
|
||||
expect(jan8?.technicalComparison).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'non-working-day-gain',
|
||||
direction: 'maximize',
|
||||
before: 5,
|
||||
after: 5,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'),
|
||||
).toBe(false);
|
||||
expect(jan8?.firstDecisiveComparator).toBeUndefined();
|
||||
expect(jan8?.reason?.message).toContain('did not improve the current best result');
|
||||
});
|
||||
});
|
||||
+2383
-275
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
@@ -4,13 +4,20 @@ import type {
|
||||
CounterfactualAnalysis,
|
||||
LeavePlacement,
|
||||
PlanOutcome,
|
||||
PlanningExplanation,
|
||||
PlanningOutcome,
|
||||
PlanningReason,
|
||||
PlanningTrace,
|
||||
PlanningTraceUpdate,
|
||||
PlanningSnapshot,
|
||||
PlanningTraceState,
|
||||
TraceCandidateStatus,
|
||||
} from './leave-planner.service';
|
||||
import {
|
||||
analyzeCounterfactualInternal,
|
||||
calculatePlanningInternal,
|
||||
explainIncompleteCheckpoint,
|
||||
historicalCounterfactualReasons,
|
||||
type CounterfactualBaselineOptions,
|
||||
type PlanningArtifacts,
|
||||
} from './leave-planner.core';
|
||||
@@ -30,6 +37,7 @@ type WorkerRequest =
|
||||
type: 'plan';
|
||||
request: WorkerPlanningRequest;
|
||||
}
|
||||
| { type: 'trace-ack'; count: number }
|
||||
| {
|
||||
type: 'counterfactual';
|
||||
request: PlanningSnapshot;
|
||||
@@ -37,59 +45,373 @@ type WorkerRequest =
|
||||
placement: LeavePlacement;
|
||||
state?: PlanningTraceState;
|
||||
options?: CounterfactualBaselineOptions;
|
||||
}
|
||||
| {
|
||||
type: 'historical-eligibility';
|
||||
request: PlanningSnapshot;
|
||||
placement: LeavePlacement;
|
||||
state: PlanningTraceState;
|
||||
options?: CounterfactualBaselineOptions;
|
||||
}
|
||||
| {
|
||||
type: 'explain-checkpoint';
|
||||
request: PlanningSnapshot;
|
||||
plan: PlanOutcome;
|
||||
state: PlanningTraceState;
|
||||
interruption: PlanningReason;
|
||||
};
|
||||
|
||||
type WorkerResponse =
|
||||
| { type: 'progress'; stage: string }
|
||||
| { type: 'trace-overhead'; totalMs: number }
|
||||
| { type: 'checkpoint'; plan: PlanOutcome }
|
||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
|
||||
| { type: 'result'; outcome: PlanningOutcome }
|
||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
||||
| {
|
||||
type: 'explanation';
|
||||
explanation?: PlanningExplanation;
|
||||
explanationError?: PlanningReason;
|
||||
finalState?: PlanningTraceState;
|
||||
}
|
||||
| {
|
||||
type: 'artifacts-start';
|
||||
artifacts: Omit<
|
||||
PlanningArtifacts,
|
||||
'trace' | 'explanation' | 'explanationError' | 'finalState'
|
||||
>;
|
||||
trace?: Omit<PlanningTrace, 'phases'>;
|
||||
}
|
||||
| {
|
||||
type: 'trace-update';
|
||||
totalMs: number;
|
||||
update: PlanningTraceUpdate;
|
||||
acknowledge?: true;
|
||||
}
|
||||
| {
|
||||
type: 'trace-status-index';
|
||||
statuses: ReadonlyMap<string, TraceCandidateStatus>;
|
||||
nonWorkingDates: readonly string[];
|
||||
}
|
||||
| { type: 'artifacts-complete'; traceTransferMs: number }
|
||||
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
|
||||
| { type: 'error'; message: string };
|
||||
| {
|
||||
type: 'historical-eligibility-result';
|
||||
entries: ReturnType<typeof historicalCounterfactualReasons>;
|
||||
}
|
||||
| { type: 'error'; code?: string; message: string };
|
||||
|
||||
const worker = self as DedicatedWorkerGlobalScope;
|
||||
const TRACE_OPERATIONS_PER_MESSAGE = 64;
|
||||
const MAX_TRACE_PACKETS_IN_FLIGHT = 16;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
try {
|
||||
if (event.data?.type === 'plan') {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
let outcomePosted = false;
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
...event.data.request,
|
||||
progress: (stage: string) =>
|
||||
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse),
|
||||
traceProgress: (totalMs: number) =>
|
||||
worker.postMessage({
|
||||
type: 'trace-overhead',
|
||||
totalMs,
|
||||
} satisfies WorkerResponse),
|
||||
},
|
||||
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse),
|
||||
(value) => (artifacts = value),
|
||||
(value) => {
|
||||
outcomePosted = true;
|
||||
worker.postMessage({ type: 'result', outcome: value } satisfies WorkerResponse);
|
||||
},
|
||||
);
|
||||
if (!outcomePosted)
|
||||
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
|
||||
worker.postMessage({ type: 'artifacts', artifacts } satisfies WorkerResponse);
|
||||
return;
|
||||
interface StagedTraceUpdate {
|
||||
totalMs: number;
|
||||
update?: PlanningTraceUpdate;
|
||||
nextOperation: number;
|
||||
}
|
||||
|
||||
interface TraceDelivery {
|
||||
updates: StagedTraceUpdate[];
|
||||
nextUpdate: number;
|
||||
inFlight: number;
|
||||
traceTransferMs: number;
|
||||
artifacts?: PlanningArtifacts;
|
||||
calculationComplete: boolean;
|
||||
}
|
||||
|
||||
let activeTraceDelivery: TraceDelivery | undefined;
|
||||
|
||||
function traceStatusIndex(trace: PlanningTrace): {
|
||||
statuses: Map<string, TraceCandidateStatus>;
|
||||
nonWorkingDates: string[];
|
||||
} {
|
||||
const priority: Record<TraceCandidateStatus, number> = {
|
||||
ineligible: 0,
|
||||
'not-examined': 1,
|
||||
pruned: 2,
|
||||
'evaluated-rejected': 3,
|
||||
chosen: 4,
|
||||
};
|
||||
const statuses = new Map<string, TraceCandidateStatus>();
|
||||
const nonWorkingDates = new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []);
|
||||
for (const phase of trace.phases) {
|
||||
for (const decision of phase.decisions) {
|
||||
for (const candidate of decision.candidates) {
|
||||
const dates = candidate.dates ?? (candidate.date ? [candidate.date] : []);
|
||||
for (const date of dates) {
|
||||
const previous = statuses.get(date);
|
||||
if (!previous || priority[candidate.status] > priority[previous])
|
||||
statuses.set(date, candidate.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.data?.type !== 'counterfactual') return;
|
||||
const analysis = analyzeCounterfactualInternal(
|
||||
event.data.request,
|
||||
event.data.plan,
|
||||
event.data.placement,
|
||||
event.data.state,
|
||||
event.data.options,
|
||||
);
|
||||
worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse);
|
||||
}
|
||||
return { statuses, nonWorkingDates: [...nonWorkingDates] };
|
||||
}
|
||||
|
||||
function sendArtifacts(artifacts: PlanningArtifacts, deferredTraceTransferMs = 0): void {
|
||||
const trace = artifacts.trace;
|
||||
let traceMaterializationMs = artifacts.timings?.traceMaterializationMs ?? 0;
|
||||
let traceTransferMs = (artifacts.timings?.traceTransferMs ?? 0) + deferredTraceTransferMs;
|
||||
if (trace) {
|
||||
const materializationStarted = performance.now();
|
||||
const statusIndex = traceStatusIndex(trace);
|
||||
traceMaterializationMs += Math.max(0, performance.now() - materializationStarted);
|
||||
const transferStarted = performance.now();
|
||||
worker.postMessage({
|
||||
type: 'trace-status-index',
|
||||
statuses: statusIndex.statuses,
|
||||
nonWorkingDates: statusIndex.nonWorkingDates,
|
||||
} satisfies WorkerResponse);
|
||||
traceTransferMs += Math.max(0, performance.now() - transferStarted);
|
||||
}
|
||||
const rest: Omit<
|
||||
PlanningArtifacts,
|
||||
'trace' | 'explanation' | 'explanationError' | 'finalState'
|
||||
> = artifacts.timings
|
||||
? {
|
||||
timings: {
|
||||
...artifacts.timings,
|
||||
traceMaterializationMs,
|
||||
traceTransferMs,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const transferStarted = performance.now();
|
||||
worker.postMessage({
|
||||
type: 'artifacts-start',
|
||||
artifacts: rest,
|
||||
trace: trace
|
||||
? {
|
||||
status: trace.status,
|
||||
initialState: trace.initialState,
|
||||
completedStages: trace.completedStages,
|
||||
nonWorkingDates: trace.nonWorkingDates,
|
||||
error: trace.error,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies WorkerResponse);
|
||||
traceTransferMs += Math.max(0, performance.now() - transferStarted);
|
||||
// Capture updates already carried every candidate in bounded operations.
|
||||
// The final packet closes the trace without cloning it a second time.
|
||||
worker.postMessage({ type: 'artifacts-complete', traceTransferMs } satisfies WorkerResponse);
|
||||
}
|
||||
|
||||
function finishTraceDelivery(delivery: TraceDelivery): void {
|
||||
if (
|
||||
!delivery.calculationComplete ||
|
||||
delivery.nextUpdate < delivery.updates.length ||
|
||||
delivery.inFlight > 0 ||
|
||||
!delivery.artifacts
|
||||
)
|
||||
return;
|
||||
if (activeTraceDelivery === delivery) activeTraceDelivery = undefined;
|
||||
try {
|
||||
sendArtifacts(delivery.artifacts, delivery.traceTransferMs);
|
||||
} catch (error) {
|
||||
worker.postMessage({
|
||||
type: 'error',
|
||||
code: 'trace-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerResponse);
|
||||
}
|
||||
delivery.updates = [];
|
||||
}
|
||||
|
||||
function pumpTraceUpdates(delivery: TraceDelivery): void {
|
||||
while (
|
||||
delivery.inFlight < MAX_TRACE_PACKETS_IN_FLIGHT &&
|
||||
delivery.nextUpdate < delivery.updates.length
|
||||
) {
|
||||
const staged = delivery.updates[delivery.nextUpdate];
|
||||
const update = staged.update;
|
||||
if (!update) {
|
||||
delivery.nextUpdate++;
|
||||
continue;
|
||||
}
|
||||
const start = staged.nextOperation;
|
||||
const end = Math.min(start + TRACE_OPERATIONS_PER_MESSAGE, update.operations.length);
|
||||
const firstPacket = start === 0;
|
||||
const packet: WorkerResponse = {
|
||||
type: 'trace-update',
|
||||
totalMs: staged.totalMs,
|
||||
acknowledge: true,
|
||||
update: {
|
||||
initialState: firstPacket ? update.initialState : undefined,
|
||||
nonWorkingDates: firstPacket ? update.nonWorkingDates : undefined,
|
||||
operations: update.operations.slice(start, end),
|
||||
},
|
||||
};
|
||||
const transferStarted = performance.now();
|
||||
try {
|
||||
worker.postMessage(packet satisfies WorkerResponse);
|
||||
} catch (error) {
|
||||
if (activeTraceDelivery === delivery) activeTraceDelivery = undefined;
|
||||
delivery.updates = [];
|
||||
worker.postMessage({
|
||||
type: 'error',
|
||||
code: 'trace-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
delivery.traceTransferMs += Math.max(0, performance.now() - transferStarted);
|
||||
delivery.inFlight++;
|
||||
staged.nextOperation = end;
|
||||
if (update.operations.length === 0 || end >= update.operations.length) {
|
||||
// Drop the full drain array as soon as its last bounded packet is posted.
|
||||
staged.update = undefined;
|
||||
delivery.nextUpdate++;
|
||||
}
|
||||
}
|
||||
if (delivery.nextUpdate === delivery.updates.length) {
|
||||
// All queued operation arrays are released; only in-flight structured clones remain.
|
||||
delivery.updates = [];
|
||||
delivery.nextUpdate = 0;
|
||||
}
|
||||
finishTraceDelivery(delivery);
|
||||
}
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
try {
|
||||
if (event.data?.type === 'trace-ack') {
|
||||
const delivery = activeTraceDelivery;
|
||||
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
|
||||
delivery.inFlight = Math.max(0, delivery.inFlight - event.data.count);
|
||||
pumpTraceUpdates(delivery);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'plan') {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
let outcomePosted = false;
|
||||
let outcome: PlanningOutcome;
|
||||
const traceDelivery: TraceDelivery = {
|
||||
updates: [],
|
||||
nextUpdate: 0,
|
||||
inFlight: 0,
|
||||
traceTransferMs: 0,
|
||||
calculationComplete: false,
|
||||
};
|
||||
activeTraceDelivery = traceDelivery;
|
||||
try {
|
||||
outcome = calculatePlanningInternal(
|
||||
{
|
||||
...event.data.request,
|
||||
progress: (stage: string) =>
|
||||
worker.postMessage({
|
||||
type: 'progress',
|
||||
stage,
|
||||
} satisfies WorkerResponse),
|
||||
traceProgress: (totalMs: number, update: PlanningTraceUpdate) => {
|
||||
worker.postMessage({
|
||||
type: 'trace-overhead',
|
||||
totalMs,
|
||||
} satisfies WorkerResponse);
|
||||
traceDelivery.updates.push({ totalMs, update, nextOperation: 0 });
|
||||
},
|
||||
},
|
||||
(plan, state) =>
|
||||
worker.postMessage({
|
||||
type: 'checkpoint',
|
||||
plan,
|
||||
state,
|
||||
} satisfies WorkerResponse),
|
||||
(value) => (artifacts = value),
|
||||
(value) => {
|
||||
outcomePosted = true;
|
||||
worker.postMessage({
|
||||
type: 'result',
|
||||
outcome: value,
|
||||
} satisfies WorkerResponse);
|
||||
},
|
||||
(explanation, explanationError, finalState) =>
|
||||
worker.postMessage({
|
||||
type: 'explanation',
|
||||
explanation,
|
||||
explanationError,
|
||||
finalState,
|
||||
} satisfies WorkerResponse),
|
||||
);
|
||||
} catch (error) {
|
||||
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
|
||||
traceDelivery.updates = [];
|
||||
worker.postMessage({
|
||||
type: 'error',
|
||||
code: 'planner-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if (!outcomePosted)
|
||||
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
|
||||
traceDelivery.artifacts = artifacts;
|
||||
traceDelivery.calculationComplete = true;
|
||||
pumpTraceUpdates(traceDelivery);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'explain-checkpoint') {
|
||||
const result = explainIncompleteCheckpoint(
|
||||
event.data.request,
|
||||
event.data.plan,
|
||||
event.data.state,
|
||||
event.data.interruption,
|
||||
);
|
||||
worker.postMessage({ type: 'explanation', ...result } satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'historical-eligibility') {
|
||||
worker.postMessage({
|
||||
type: 'historical-eligibility-result',
|
||||
entries: historicalCounterfactualReasons(
|
||||
event.data.request,
|
||||
event.data.state,
|
||||
event.data.placement,
|
||||
event.data.options,
|
||||
),
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type !== 'counterfactual') return;
|
||||
try {
|
||||
let analysis = analyzeCounterfactualInternal(
|
||||
event.data.request,
|
||||
event.data.plan,
|
||||
event.data.placement,
|
||||
event.data.state,
|
||||
event.data.options,
|
||||
);
|
||||
if (event.data.options && event.data.state) {
|
||||
const historicalReasons = new Map(
|
||||
historicalCounterfactualReasons(
|
||||
event.data.request,
|
||||
event.data.state,
|
||||
event.data.placement,
|
||||
event.data.options,
|
||||
).map(({ date, reason }) => [date, reason]),
|
||||
);
|
||||
analysis = {
|
||||
...analysis,
|
||||
results: analysis.results.map((result) => {
|
||||
const historicalReason = historicalReasons.get(result.date);
|
||||
return historicalReason ? { ...result, historicalReason } : result;
|
||||
}),
|
||||
};
|
||||
}
|
||||
worker.postMessage({
|
||||
type: 'counterfactual-result',
|
||||
analysis,
|
||||
} satisfies WorkerResponse);
|
||||
} catch (error) {
|
||||
worker.postMessage({
|
||||
type: 'error',
|
||||
code: 'counterfactual-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
worker.postMessage({
|
||||
type: 'error',
|
||||
code: 'worker-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerResponse);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,10 @@ export interface AppTranslations {
|
||||
expires: string;
|
||||
criteria: string;
|
||||
criteriaHelp: string;
|
||||
traceImpactTitle: string;
|
||||
traceImpactHelp: string;
|
||||
traceStageTitle: string;
|
||||
traceStageHelp: string;
|
||||
firstDifference: string;
|
||||
higher: string;
|
||||
lower: string;
|
||||
@@ -142,6 +146,7 @@ export interface AppTranslations {
|
||||
leaveUsed: string;
|
||||
expires: string;
|
||||
linkedRules: string;
|
||||
atRiskContribution: string;
|
||||
impact: string;
|
||||
impactHelp: string;
|
||||
criterion: string;
|
||||
@@ -192,10 +197,16 @@ export interface AppTranslations {
|
||||
next: string;
|
||||
last: string;
|
||||
remaining: string;
|
||||
balanceRemaining: string;
|
||||
counterfactual: string;
|
||||
counterfactualLoading: string;
|
||||
counterfactualReady: string;
|
||||
counterfactualError: string;
|
||||
retryCounterfactual: string;
|
||||
historicalEligibilityLoading: string;
|
||||
historicalEligibilityReady: string;
|
||||
historicalEligibilityError: string;
|
||||
retryHistoricalEligibility: string;
|
||||
eligible: string;
|
||||
ineligible: string;
|
||||
historical: string;
|
||||
@@ -213,6 +224,7 @@ export interface AppTranslations {
|
||||
outsideHorizon: string;
|
||||
traceTitle: string;
|
||||
traceLoading: string;
|
||||
traceReady: string;
|
||||
traceUnavailable: string;
|
||||
phases: string;
|
||||
firstPhase: string;
|
||||
|
||||
@@ -119,10 +119,16 @@
|
||||
"next": "Nächste Platzierung",
|
||||
"last": "Letzte Platzierung",
|
||||
"remaining": "Ungenutzte Tage im endgültigen Plan",
|
||||
"balanceRemaining": "verbleibend",
|
||||
"counterfactual": "Alternative Termine",
|
||||
"counterfactualLoading": "Alle Daten im Horizont werden analysiert…",
|
||||
"counterfactualReady": "Alternative Termine sind bereit.",
|
||||
"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",
|
||||
"ineligible": "Nicht geeignet",
|
||||
"historical": "Status der ursprünglichen Suche",
|
||||
@@ -140,6 +146,7 @@
|
||||
"outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts",
|
||||
"traceTitle": "Erweitert: ursprüngliche Entscheidungsspur",
|
||||
"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.",
|
||||
"phases": "Spurphasen",
|
||||
"firstPhase": "Erste Phase",
|
||||
@@ -200,6 +207,7 @@
|
||||
"leaveUsed": "Dieses Datum nutzt 1 Tag aus „{balance}“.",
|
||||
"expires": "Dieses Guthaben ist nutzbar bis",
|
||||
"linkedRules": "Zugehörige Regeln:",
|
||||
"atRiskContribution": "Schützt Urlaub, der zu verfallen droht.",
|
||||
"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.",
|
||||
"criterion": "Im gesamten Plan",
|
||||
@@ -218,6 +226,10 @@
|
||||
"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.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
"label": "Unerfüllte Verpflichtungen",
|
||||
"help": "Anzahl nicht erfüllter Pflichtregeln. Ein gültiger Plan hat den Wert null."
|
||||
},
|
||||
"unplaced-at-risk": {
|
||||
"label": "Noch nicht genutzte Tage mit Verfallsrisiko",
|
||||
"help": "Ungenutzte Tage, die im Planungszeitraum verfallen. Weniger ist besser."
|
||||
@@ -282,6 +294,10 @@
|
||||
"expires": "Verfällt am",
|
||||
"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.",
|
||||
"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",
|
||||
"higher": "Höher ist besser",
|
||||
"lower": "Niedriger ist besser",
|
||||
|
||||
@@ -119,10 +119,16 @@
|
||||
"next": "Next placement",
|
||||
"last": "Last placement",
|
||||
"remaining": "Unused days in the final plan",
|
||||
"balanceRemaining": "remaining",
|
||||
"counterfactual": "Date alternatives",
|
||||
"counterfactualLoading": "Analyzing every date in the horizon…",
|
||||
"counterfactualReady": "Date alternatives are ready.",
|
||||
"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",
|
||||
"ineligible": "Ineligible",
|
||||
"historical": "Original search status",
|
||||
@@ -140,6 +146,7 @@
|
||||
"outsideHorizon": "Context date outside the planning horizon",
|
||||
"traceTitle": "Advanced: original decision trace",
|
||||
"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.",
|
||||
"phases": "Trace phases",
|
||||
"firstPhase": "First phase",
|
||||
@@ -200,6 +207,7 @@
|
||||
"leaveUsed": "This date uses 1 day from {balance}.",
|
||||
"expires": "Use this balance by",
|
||||
"linkedRules": "Linked rules:",
|
||||
"atRiskContribution": "Protects leave at risk of expiry.",
|
||||
"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.",
|
||||
"criterion": "Across your plan",
|
||||
@@ -218,6 +226,10 @@
|
||||
"withCandidate": "With this candidate",
|
||||
"comparisonUnavailable": "The comparison for the original date is unavailable, so the change cannot be shown.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
"label": "Unmet obligations",
|
||||
"help": "Number of required obligations left unsatisfied. A valid plan has zero."
|
||||
},
|
||||
"unplaced-at-risk": {
|
||||
"label": "Leave left at risk of expiry",
|
||||
"help": "Unused days expiring within the planning period. Fewer is better."
|
||||
@@ -282,6 +294,10 @@
|
||||
"expires": "Expires on",
|
||||
"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.",
|
||||
"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",
|
||||
"higher": "Higher is better",
|
||||
"lower": "Lower is better",
|
||||
|
||||
@@ -119,10 +119,16 @@
|
||||
"next": "Asignación siguiente",
|
||||
"last": "Última asignación",
|
||||
"remaining": "Días sin usar en el plan final",
|
||||
"balanceRemaining": "restantes",
|
||||
"counterfactual": "Fechas alternativas",
|
||||
"counterfactualLoading": "Analizando cada fecha del horizonte…",
|
||||
"counterfactualReady": "Las fechas alternativas están listas.",
|
||||
"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",
|
||||
"ineligible": "No válida",
|
||||
"historical": "Estado de la búsqueda original",
|
||||
@@ -140,6 +146,7 @@
|
||||
"outsideHorizon": "Fecha de contexto fuera del horizonte de planificación",
|
||||
"traceTitle": "Avanzado: traza de decisiones original",
|
||||
"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.",
|
||||
"phases": "Fases de la traza",
|
||||
"firstPhase": "Primera fase",
|
||||
@@ -200,6 +207,7 @@
|
||||
"leaveUsed": "Esta fecha usa 1 día del saldo «{balance}».",
|
||||
"expires": "Usa este saldo hasta el",
|
||||
"linkedRules": "Reglas vinculadas:",
|
||||
"atRiskContribution": "Protege permisos que podrían vencer.",
|
||||
"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.",
|
||||
"criterion": "En todo tu plan",
|
||||
@@ -218,6 +226,10 @@
|
||||
"withCandidate": "Con este candidato",
|
||||
"comparisonUnavailable": "La comparación de la fecha original no está disponible, por lo que no se puede mostrar el cambio.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
"label": "Obligaciones sin cumplir",
|
||||
"help": "Número de reglas obligatorias incumplidas. Un plan válido tiene cero."
|
||||
},
|
||||
"unplaced-at-risk": {
|
||||
"label": "Días sin usar que podrían vencer",
|
||||
"help": "Días sin planificar que vencen durante el período. Menos es mejor."
|
||||
@@ -282,6 +294,10 @@
|
||||
"expires": "Vence el",
|
||||
"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.",
|
||||
"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",
|
||||
"higher": "Más es mejor",
|
||||
"lower": "Menos es mejor",
|
||||
|
||||
@@ -119,10 +119,16 @@
|
||||
"next": "Placement suivant",
|
||||
"last": "Dernier placement",
|
||||
"remaining": "Jours non utilisés dans le planning final",
|
||||
"balanceRemaining": "restants",
|
||||
"counterfactual": "Dates alternatives",
|
||||
"counterfactualLoading": "Analyse de chaque date de l’horizon…",
|
||||
"counterfactualReady": "Les dates alternatives sont prêtes.",
|
||||
"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",
|
||||
"ineligible": "Inéligible",
|
||||
"historical": "Statut de la recherche originale",
|
||||
@@ -140,6 +146,7 @@
|
||||
"outsideHorizon": "Date de contexte hors de l’horizon de planification",
|
||||
"traceTitle": "Avancé : trace des décisions originales",
|
||||
"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.",
|
||||
"phases": "Phases de la trace",
|
||||
"firstPhase": "Première phase",
|
||||
@@ -200,6 +207,7 @@
|
||||
"leaveUsed": "Cette date utilise 1 jour du solde « {balance} ».",
|
||||
"expires": "Solde à utiliser au plus tard le",
|
||||
"linkedRules": "Règles liées :",
|
||||
"atRiskContribution": "Protège des congés à risque d’expiration.",
|
||||
"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.",
|
||||
"criterion": "Sur tout le planning",
|
||||
@@ -218,6 +226,10 @@
|
||||
"withCandidate": "Avec ce candidat",
|
||||
"comparisonUnavailable": "La comparaison pour la date initiale est indisponible ; la différence ne peut donc pas être affichée.",
|
||||
"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": {
|
||||
"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."
|
||||
@@ -282,6 +294,10 @@
|
||||
"expires": "Expire le",
|
||||
"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.",
|
||||
"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",
|
||||
"higher": "Plus, c’est mieux",
|
||||
"lower": "Moins, c’est mieux",
|
||||
|
||||
Reference in new issue
Block a user