feat(planning): explain and trace planning decisions
This commit is contained in:
1 parent
609b3dece9
commit
d7a13b0b1e
24 files changed
+6073
-342
No files matched your search
@@ -96,6 +96,15 @@ A malformed request that cannot describe a plan, such as a reversed date window.
|
||||
**Planning outcome**:
|
||||
The result of a calculation: invalid input, a planning conflict, an optimization result, or an incomplete calculation.
|
||||
|
||||
**Stable plan explanation**:
|
||||
A deterministic explanation derived from the final displayed plan. It identifies each placement's entitlement and rule provenance, contributions, full objective vector, and the first objective that distinguishes the plan with that placement restored from the same plan with only that placement removed.
|
||||
|
||||
**Original decision trace**:
|
||||
The untruncated phase-by-phase record captured by the calculation that produced the displayed outcome, including atomic decisions, candidate statuses, and state deltas. It is historical evidence and is never recomputed from the final plan.
|
||||
|
||||
**Counterfactual date analysis**:
|
||||
A hypothetical comparison that keeps the calculation snapshot and all unrelated placements fixed while testing one selected placement across every date in the half-open planning horizon. It neither mutates the plan nor rewrites the original trace.
|
||||
|
||||
**Incomplete calculation**:
|
||||
A calculation stopped before it completed the planning objective order. It may expose its best valid plan but never implies that the obligations conflict.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ The planner answers four distinct questions:
|
||||
- Can all mandatory rules be satisfied together?
|
||||
- What is the best plan found by the ordered heuristic?
|
||||
- If calculation stops early, what valid incumbent is available?
|
||||
- Why did each displayed placement survive the objective order?
|
||||
- Which alternatives were considered, and what would another date change?
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -18,7 +20,7 @@ flowchart LR
|
||||
subgraph Browser
|
||||
UI["Angular standalone components"]
|
||||
State["Signals and localStorage-backed settings"]
|
||||
Facade["planLeave(request)<br/>async public boundary"]
|
||||
Facade["Planning calculation context<br/>immutable snapshot + async artifacts"]
|
||||
Worker["Leave-planner Web Worker"]
|
||||
Core["Synchronous planner core"]
|
||||
Holidays["date-holidays<br/>French public calendar"]
|
||||
@@ -28,7 +30,7 @@ flowchart LR
|
||||
Facade --> Worker
|
||||
Worker --> Core
|
||||
Core --> Holidays
|
||||
Worker -- "progress, checkpoints, outcome" --> Facade
|
||||
Worker -- "progress, checkpoints, outcome, then artifacts" --> Facade
|
||||
Facade --> UI
|
||||
end
|
||||
```
|
||||
@@ -38,12 +40,12 @@ Leave calculation stays in the browser. The production Express process serves th
|
||||
### Main execution path
|
||||
|
||||
1. `CalendarYear` takes a snapshot of leave balances and ordered rules.
|
||||
2. `CalendarService` supplies the Europe/Paris planning horizon and exposes the single `planLeave` operation.
|
||||
2. `CalendarService` supplies the Europe/Paris planning horizon and opens one immutable planning calculation context.
|
||||
3. `leave-planner.service.ts` copies the request into a canonical transport shape and starts the worker.
|
||||
4. `leave-planner.worker.ts` runs the framework-free core and sends progress, valid checkpoints, and the final outcome.
|
||||
4. `leave-planner.worker.ts` runs the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and original trace.
|
||||
5. `CalendarYear` ignores stale calculations, renders the selected dates, and reports remaining balances or errors.
|
||||
|
||||
The public boundary has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict.
|
||||
Planning work has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict. Explanation and trace delivery happen after the outcome and do not consume that planning budget.
|
||||
|
||||
## Planning model
|
||||
|
||||
@@ -193,7 +195,7 @@ Open <http://localhost:4200>. The development server reloads when source files c
|
||||
|
||||
## Performance benchmark
|
||||
|
||||
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, and a loaded year. It reports preparation time, calculation time, total wall time, outcome, last completed stage, placement count, and determinism.
|
||||
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, and a loaded year. It reports preparation time, planning latency, separate trace-delivery latency, total wall time, outcome, last completed stage, placement count, determinism, and trace size/status.
|
||||
|
||||
The release protocol and reference-phone thresholds are documented in [docs/benchmark/README.md](docs/benchmark/README.md). Phone timings are release evidence, not CI assertions.
|
||||
|
||||
@@ -222,3 +224,4 @@ GitLab CI runs independent validation, test, build, security, and image stages:
|
||||
- [ADR 0001](docs/adr/0001-ordered-heuristic-optimization.md) records the ordered heuristic decision.
|
||||
- [ADR 0002](docs/adr/0002-personal-client-side-planner-for-now.md) records the client-side scope.
|
||||
- [ADR 0003](docs/adr/0003-constraint-first-lexicographic-planning.md) records the constraint-first planner architecture.
|
||||
- [ADR 0004](docs/adr/0004-stable-explanation-original-trace-counterfactual.md) separates the outcome, stable explanation, original trace, and hypothetical analysis.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Stable Explanation, Original Trace, and Counterfactuals
|
||||
|
||||
VacOptimizer exposes one client-side calculation context. Its immutable submitted snapshot and planning outcome are independent from three explanation concerns:
|
||||
|
||||
- the stable plan explanation is derived from the final displayed plan and remains useful for complete or incumbent plans;
|
||||
- the calculation trace is captured while the producing run executes, grouped by phase and atomic decision, and may become ready after the outcome;
|
||||
- counterfactual date analysis is an explicitly hypothetical operation that removes only the selected final placement, or starts from a selected trace decision's exact pre-decision state, and never rewrites the plan or historical trace.
|
||||
|
||||
The public planning outcome is delivered as soon as planning work finishes. Trace capture and delivery have separate readiness/failure state, remain local to the latest calculation, and cannot turn a valid outcome into an explanation error. Counterfactual work is cancellable or ignored when its calculation identity is stale. The existing deterministic constraint-first planner and its planning-work deadline remain unchanged.
|
||||
@@ -4,10 +4,11 @@ Open the deployed application at `/benchmark` on the reference phone. Tap
|
||||
**Run benchmark**, wait for the five scenarios to finish, then tap **Copy
|
||||
report** and save the tab-separated output with the date and build identifier.
|
||||
|
||||
The harness measures preparation, asynchronous calculation, and total wall
|
||||
time separately. It records the outcome, last completed quality stage,
|
||||
placement count, and determinism between repetitions. `PlanningIncomplete` is
|
||||
not a conflict: its last completed stage and incumbent are retained.
|
||||
The harness measures preparation, planning-to-outcome latency, later trace
|
||||
delivery, and total wall time separately. 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.
|
||||
|
||||
## Protocol
|
||||
|
||||
@@ -24,5 +25,7 @@ not a conflict: its last completed stage and incumbent are retained.
|
||||
hardware limitations. No mobile-device measurement is claimed by the source
|
||||
tree until a human records it here.
|
||||
|
||||
The browser page uses the same `planLeave(request)` operation as the calendar
|
||||
and adds no solver or backend dependency.
|
||||
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
|
||||
cannot replace a valid planning outcome.
|
||||
@@ -29,7 +29,13 @@ describe('leave planner benchmark protocol', () => {
|
||||
expect(chained?.placements).toBeGreaterThanOrEqual(3);
|
||||
expect(loaded?.outcome).toBe('Plan');
|
||||
expect(loaded?.deterministic).toBe(true);
|
||||
expect(benchmarkReport(measurements).split('\n')).toHaveLength(6);
|
||||
expect(loaded?.planningMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded?.traceDeliveryMs).toBeGreaterThanOrEqual(0);
|
||||
expect(loaded?.traceStatus).toBe('ready');
|
||||
expect(loaded?.traceCandidates).toBeGreaterThan(0);
|
||||
const report = benchmarkReport(measurements);
|
||||
expect(report.split('\n')).toHaveLength(6);
|
||||
expect(report).toContain('planning_ms\ttrace_delivery_ms');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { planLeave, PlanningOutcome, PlanningRequest } from '../services/leave-planner.service';
|
||||
import {
|
||||
createPlanningCalculation,
|
||||
PlanningOutcome,
|
||||
PlanningRequest,
|
||||
PlanningTrace,
|
||||
} from '../services/leave-planner.service';
|
||||
|
||||
export interface BenchmarkScenario {
|
||||
name: string;
|
||||
@@ -9,12 +14,15 @@ export interface BenchmarkMeasurement {
|
||||
scenario: string;
|
||||
repetition: number;
|
||||
preparationMs: number;
|
||||
calculationMs: number;
|
||||
planningMs: number;
|
||||
traceDeliveryMs: number;
|
||||
wallMs: number;
|
||||
outcome: PlanningOutcome['kind'];
|
||||
lastCompletedStage?: string;
|
||||
placements: number;
|
||||
deterministic: boolean;
|
||||
traceStatus: PlanningTrace['status'] | 'not-produced';
|
||||
traceCandidates: number;
|
||||
}
|
||||
|
||||
const balance = (
|
||||
@@ -166,18 +174,35 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
|
||||
const request = structuredClone(scenario.request);
|
||||
const preparationMs = performance.now() - preparationStart;
|
||||
const calculationStart = performance.now();
|
||||
const outcome = await planLeave(request);
|
||||
const calculationMs = performance.now() - calculationStart;
|
||||
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);
|
||||
measurements.push({
|
||||
scenario: scenario.name,
|
||||
repetition,
|
||||
preparationMs,
|
||||
calculationMs,
|
||||
wallMs: preparationMs + calculationMs,
|
||||
planningMs,
|
||||
traceDeliveryMs,
|
||||
wallMs: preparationMs + planningMs + traceDeliveryMs,
|
||||
...summary,
|
||||
deterministic: previous === undefined || previous === stable,
|
||||
traceStatus: trace?.status ?? 'not-produced',
|
||||
traceCandidates:
|
||||
trace?.phases.reduce(
|
||||
(total, phase) =>
|
||||
total +
|
||||
phase.decisions.reduce(
|
||||
(decisionTotal, decision) =>
|
||||
decisionTotal + decision.candidates.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
) ?? 0,
|
||||
});
|
||||
previous = stable;
|
||||
}
|
||||
@@ -187,7 +212,7 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
|
||||
|
||||
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
||||
const header =
|
||||
'scenario\trepetition\tpreparation_ms\tcalculation_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic';
|
||||
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates';
|
||||
return [
|
||||
header,
|
||||
...measurements.map((item) =>
|
||||
@@ -195,12 +220,15 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
||||
item.scenario,
|
||||
item.repetition,
|
||||
item.preparationMs.toFixed(1),
|
||||
item.calculationMs.toFixed(1),
|
||||
item.planningMs.toFixed(1),
|
||||
item.traceDeliveryMs.toFixed(1),
|
||||
item.wallMs.toFixed(1),
|
||||
item.outcome,
|
||||
item.lastCompletedStage ?? '',
|
||||
item.placements,
|
||||
item.deterministic,
|
||||
item.traceStatus,
|
||||
item.traceCandidates,
|
||||
].join('\t'),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
@@ -26,21 +26,45 @@
|
||||
<p class="day empty"></p>
|
||||
} @else {
|
||||
@let dayTooltip = getDayTooltip(day);
|
||||
<p
|
||||
@let dateKey = getDateKey(day);
|
||||
@let status = getStatus(day);
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-date]="dateKey"
|
||||
[class.past]="day < getNow()"
|
||||
[class.start]="selectedDates.isStart(day)"
|
||||
[class.end]="selectedDates.isEnd(day)"
|
||||
[class.selected]="selectedDates.isSelected(day)"
|
||||
[class.current]="dateKey === currentDate"
|
||||
[class.context-only]="!isInHorizon(day)"
|
||||
[class.eligible]="status?.kind === 'eligible'"
|
||||
[class.ineligible]="status?.kind === 'ineligible'"
|
||||
[class.chosen]="status?.kind === 'chosen'"
|
||||
[class.evaluated-rejected]="status?.kind === 'evaluated-rejected'"
|
||||
[class.pruned]="status?.kind === 'pruned'"
|
||||
[class.not-examined]="status?.kind === 'not-examined'"
|
||||
[class.change-added]="status?.change === 'added'"
|
||||
[class.change-moved-from]="status?.change === 'moved-from'"
|
||||
[class.change-moved-to]="status?.change === 'moved-to'"
|
||||
[class.change-removed]="status?.change === 'removed'"
|
||||
[class.change-shared]="status?.change === 'shared'"
|
||||
[class.change-unchanged]="status?.change === 'unchanged'"
|
||||
[class]="selectedDates.getClasses(day)"
|
||||
class="day number"
|
||||
[disabled]="!isInHorizon(day)"
|
||||
[matTooltip]="dayTooltip"
|
||||
[matTooltipShowDelay]="500"
|
||||
[attr.aria-label]="
|
||||
dayTooltip ? day.getDate() + ': ' + dayTooltip : null
|
||||
"
|
||||
[attr.aria-label]="getDayAriaLabel(day)"
|
||||
[attr.aria-current]="dateKey === currentDate ? 'date' : null"
|
||||
(click)="dateSelected.emit(dateKey)"
|
||||
>
|
||||
{{ day.getDate() }}
|
||||
</p>
|
||||
<span>{{ day.getDate() }}</span>
|
||||
@if (status) {
|
||||
<small class="status-marker" aria-hidden="true">{{
|
||||
getStatusMarker(day)
|
||||
}}</small>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
}
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -32,7 +33,10 @@
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 30%;
|
||||
border: 1px solid transparent;
|
||||
color: var(--number-color);
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
|
||||
&.empty {
|
||||
visibility: hidden;
|
||||
@@ -41,7 +45,17 @@
|
||||
&.number:hover {
|
||||
background-color: var(--number-bg-hover);
|
||||
color: var(--number-color-hover);
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.number:focus-visible {
|
||||
outline: 3px solid var(--today-border-color, #1976d2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.context-only {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.past {
|
||||
@@ -49,8 +63,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
.status-marker {
|
||||
position: absolute;
|
||||
inset-block-start: -0.35rem;
|
||||
inset-inline-end: -0.2rem;
|
||||
min-width: 0.85rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
background: var(--ui-card-bg, #fff);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.current {
|
||||
outline: 3px double var(--today-border-color, #1976d2);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.eligible,
|
||||
.chosen {
|
||||
border-block-end: 3px solid var(--ui-success-text, #174d26);
|
||||
}
|
||||
|
||||
.ineligible,
|
||||
.evaluated-rejected {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.pruned {
|
||||
border: 2px dotted currentColor;
|
||||
}
|
||||
|
||||
.not-examined {
|
||||
border: 2px dashed currentColor;
|
||||
}
|
||||
|
||||
.change-added,
|
||||
.change-moved-to {
|
||||
box-shadow: inset 0 -3px var(--ui-success-text, #174d26);
|
||||
}
|
||||
|
||||
.change-moved-from,
|
||||
.change-removed {
|
||||
box-shadow: inset 0 3px var(--ui-error-text, #8b1a1a);
|
||||
}
|
||||
|
||||
.change-shared {
|
||||
box-shadow: inset 3px 0 var(--today-border-color, #1976d2);
|
||||
}
|
||||
|
||||
.change-unchanged {
|
||||
box-shadow: inset 0 0 0 1px currentColor;
|
||||
}
|
||||
|
||||
.selected {
|
||||
cursor: help !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-only.selected {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selected:not(.start):not(.end) {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { 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';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
export interface CalendarDayStatus {
|
||||
kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined';
|
||||
label: string;
|
||||
reason?: string;
|
||||
historicalLabel?: string;
|
||||
change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged';
|
||||
changeLabel?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-month',
|
||||
imports: [MatTooltip],
|
||||
@@ -38,6 +47,89 @@ export class CalendarMonth implements OnInit {
|
||||
@Input({ required: true })
|
||||
selectedDates!: SelectedDates;
|
||||
|
||||
@Input()
|
||||
horizonStart?: string;
|
||||
|
||||
@Input()
|
||||
horizonEnd?: string;
|
||||
|
||||
@Input()
|
||||
currentDate: string | null = null;
|
||||
|
||||
@Input()
|
||||
dateStatuses: Readonly<Record<string, CalendarDayStatus>> = {};
|
||||
|
||||
@Output()
|
||||
readonly dateSelected = new EventEmitter<string>();
|
||||
|
||||
getDateKey(day: Date): string {
|
||||
return [
|
||||
day.getFullYear(),
|
||||
String(day.getMonth() + 1).padStart(2, '0'),
|
||||
String(day.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
isInHorizon(day: Date): boolean {
|
||||
const date = this.getDateKey(day);
|
||||
return (
|
||||
(!this.horizonStart || date >= this.horizonStart) &&
|
||||
(!this.horizonEnd || date < this.horizonEnd)
|
||||
);
|
||||
}
|
||||
|
||||
getStatus(day: Date): CalendarDayStatus | undefined {
|
||||
return this.dateStatuses[this.getDateKey(day)];
|
||||
}
|
||||
|
||||
getStatusMarker(day: Date): string {
|
||||
switch (this.getStatus(day)?.change) {
|
||||
case 'added':
|
||||
return '+';
|
||||
case 'moved-from':
|
||||
return '←';
|
||||
case 'moved-to':
|
||||
return '→';
|
||||
case 'removed':
|
||||
return '−';
|
||||
case 'shared':
|
||||
return '≈';
|
||||
case 'unchanged':
|
||||
return '=';
|
||||
}
|
||||
switch (this.getStatus(day)?.kind) {
|
||||
case 'chosen':
|
||||
case 'eligible':
|
||||
return '✓';
|
||||
case 'ineligible':
|
||||
return '×';
|
||||
case 'evaluated-rejected':
|
||||
return '−';
|
||||
case 'pruned':
|
||||
return '⌁';
|
||||
case 'not-examined':
|
||||
return '?';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getDayAriaLabel(day: Date): string {
|
||||
const parts = [
|
||||
day.toLocaleDateString(this.translationService.locale(), {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
];
|
||||
const tooltip = this.getDayTooltip(day);
|
||||
if (tooltip) parts.push(tooltip);
|
||||
if (!this.isInHorizon(day))
|
||||
parts.push(this.translationService.tr().planning.outsideHorizon);
|
||||
return parts.join('. ');
|
||||
}
|
||||
|
||||
getMonthName(): string {
|
||||
const locale = this.translationService.locale();
|
||||
const tempDate = new Date(this.year, this.monthIndex, 1);
|
||||
@@ -69,9 +161,19 @@ export class CalendarMonth implements OnInit {
|
||||
}
|
||||
|
||||
getDayTooltip(day: Date): string | null {
|
||||
const tooltips = this.selectedDates.datesSelected
|
||||
const tooltips: string[] = this.selectedDates.datesSelected
|
||||
.filter((dateSelected) => dateSelected.isSelected(day) && dateSelected.type)
|
||||
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
|
||||
const status = this.getStatus(day);
|
||||
if (status) {
|
||||
tooltips.push(status.label);
|
||||
if (status.changeLabel) tooltips.push(status.changeLabel);
|
||||
if (status.historicalLabel)
|
||||
tooltips.push(
|
||||
`${this.translationService.tr().planning.historical}: ${status.historicalLabel}`,
|
||||
);
|
||||
if (status.reason) tooltips.push(status.reason);
|
||||
}
|
||||
return tooltips.length > 0 ? tooltips.join(' + ') : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,26 @@ import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { CalendarYear } from './calendar-year';
|
||||
import { CalendarService, SelectedDates, SelectableDayType } from '../../services/calendar-service';
|
||||
import {
|
||||
CalendarService,
|
||||
DayType,
|
||||
SelectedDate,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
} from '../../services/calendar-service';
|
||||
import {
|
||||
CounterfactualAnalysis,
|
||||
PlanOutcome,
|
||||
PlanningCalculationContext,
|
||||
PlanningConflictOutcome,
|
||||
PlanningExplanation,
|
||||
PlanningIncompleteOutcome,
|
||||
InvalidInputOutcome,
|
||||
PlanningOutcome,
|
||||
PlanningTrace,
|
||||
} from '../../services/leave-planner.service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
|
||||
const invalid: InvalidInputOutcome = {
|
||||
kind: 'InvalidInput',
|
||||
@@ -64,18 +75,120 @@ const plan: PlanOutcome = {
|
||||
lastCompletedStage: 'dates',
|
||||
};
|
||||
|
||||
const objectiveVector = {
|
||||
objectives: [{ id: 'non-working-day-gain' as const, direction: 'maximize' as const, value: 4 }],
|
||||
};
|
||||
|
||||
const explanation: PlanningExplanation = {
|
||||
kind: 'PlanExplanation',
|
||||
placements: plan.placements.map((placement, index) => ({
|
||||
placement,
|
||||
balance: {
|
||||
id: 'cp',
|
||||
name: 'Paid leave',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
expiryDate: '2024-12-31',
|
||||
},
|
||||
ruleIds: placement.ruleIds,
|
||||
supporting: false,
|
||||
remainingQuantity: 1 - index,
|
||||
contributions: [{ kind: 'entitlement', code: 'balance', label: 'Paid leave', value: 1 }],
|
||||
finalMetrics: plan.metrics,
|
||||
objectiveVector,
|
||||
comparison: {
|
||||
before: { objectives: [{ ...objectiveVector.objectives[0], value: 3 }] },
|
||||
after: objectiveVector,
|
||||
delta: [
|
||||
{
|
||||
id: 'non-working-day-gain',
|
||||
direction: 'maximize',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
firstDecisiveObjective: 'non-working-day-gain',
|
||||
},
|
||||
firstDecisiveObjective: 'non-working-day-gain',
|
||||
})),
|
||||
};
|
||||
|
||||
function selectedDatesForPlan(value: PlanOutcome): SelectedDates {
|
||||
const selected = new SelectedDates();
|
||||
for (const placement of value.placements) {
|
||||
const date = new Date(`${placement.date}T00:00:00`);
|
||||
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function calculationFor(
|
||||
outcome: PlanningOutcome,
|
||||
stableExplanation?: PlanningExplanation,
|
||||
trace?: PlanningTrace,
|
||||
): PlanningCalculationContext {
|
||||
const counterfactual: CounterfactualAnalysis = {
|
||||
kind: 'Counterfactual',
|
||||
calculationId: 'test',
|
||||
placement: plan.placements[0],
|
||||
hypothetical: true,
|
||||
baseline: objectiveVector,
|
||||
results: [
|
||||
{
|
||||
date: '2024-05-03',
|
||||
status: 'eligible',
|
||||
comparison:
|
||||
stableExplanation?.kind === 'PlanExplanation'
|
||||
? stableExplanation.placements[0].comparison
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
date: '2024-05-04',
|
||||
status: 'ineligible',
|
||||
reason: { code: 'non-working', message: 'Weekend' },
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
id: 'test',
|
||||
snapshot: {
|
||||
horizon: plan.horizon,
|
||||
balances: [],
|
||||
rules: [],
|
||||
identities: {},
|
||||
labels: {},
|
||||
},
|
||||
outcome: Promise.resolve(outcome),
|
||||
explanation: Promise.resolve(stableExplanation),
|
||||
explanationError: Promise.resolve(undefined),
|
||||
trace: Promise.resolve(trace),
|
||||
analyzeCounterfactual: vi.fn(async () => counterfactual),
|
||||
};
|
||||
}
|
||||
|
||||
function calendarFor(
|
||||
outcome: PlanningOutcome,
|
||||
stableExplanation?: PlanningExplanation,
|
||||
trace?: PlanningTrace,
|
||||
) {
|
||||
const calculation = calculationFor(outcome, stableExplanation, trace);
|
||||
return {
|
||||
calculation,
|
||||
calendar: {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||||
createPlanningCalculation: vi.fn(() => calculation),
|
||||
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CalendarYear planning outcome boundary', () => {
|
||||
it.each([
|
||||
['InvalidInput', invalid],
|
||||
['PlanningConflict', conflict],
|
||||
['PlanningIncomplete without incumbent', incomplete],
|
||||
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
|
||||
const calendar = {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||
planLeave: vi.fn(async () => outcome),
|
||||
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
|
||||
};
|
||||
const { calendar } = calendarFor(outcome);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
@@ -100,12 +213,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
});
|
||||
|
||||
it('keeps and summarizes a returned result', async () => {
|
||||
const calendar = {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||
planLeave: vi.fn(async () => plan),
|
||||
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
|
||||
};
|
||||
const { calendar } = calendarFor(plan, explanation);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
@@ -127,13 +235,215 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
expect(summaryValues).toEqual(['2', '4', '6']);
|
||||
});
|
||||
|
||||
it('keeps an incomplete incumbent visible with its interrupted-stage explanation', async () => {
|
||||
const outcome: PlanningIncompleteOutcome = {
|
||||
kind: 'PlanningIncomplete',
|
||||
incumbent: plan,
|
||||
completedStages: ['at-risk'],
|
||||
lastCompletedStage: 'at-risk',
|
||||
reasons: [{ code: 'deadline', message: 'Planning stopped at the deadline.' }],
|
||||
};
|
||||
const incumbentExplanation: PlanningExplanation = {
|
||||
...explanation,
|
||||
incomplete: {
|
||||
kind: 'IncompleteExplanation',
|
||||
reason: outcome.reasons[0],
|
||||
completedStages: ['at-risk'],
|
||||
uncompletedStages: ['holiday-week-value'],
|
||||
},
|
||||
};
|
||||
const { calendar } = calendarFor(outcome, incumbentExplanation);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.incomplete-incumbent').textContent).toContain(
|
||||
'holiday-week-value',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows the stable placement explanation by default and keeps the full plan visible', async () => {
|
||||
const { calendar, calculation } = calendarFor(plan, explanation);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.placement-explanation')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Paid leave');
|
||||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]);
|
||||
|
||||
const next = fixture.nativeElement.querySelector(
|
||||
'button[data-step="next"]',
|
||||
) as HTMLButtonElement;
|
||||
next.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
|
||||
'2024-05-06',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders conflict causes and replays an atomic trace decision', async () => {
|
||||
const conflictExplanation: PlanningExplanation = {
|
||||
kind: 'ConflictExplanation',
|
||||
conflictingRules: [{ id: 'rule', label: 'Required spring leave' }],
|
||||
bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' },
|
||||
};
|
||||
let setup = calendarFor(conflict, conflictExplanation);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: setup.calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
let fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
|
||||
'Required spring leave',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
|
||||
'No CP entitlement is available.',
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
const trace: PlanningTrace = {
|
||||
status: 'ready',
|
||||
initialState: {
|
||||
horizon: plan.horizon,
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
remaining: 2,
|
||||
availableFrom: '2024-01-01',
|
||||
expiryDate: '2024-12-31',
|
||||
earned: false,
|
||||
},
|
||||
],
|
||||
placements: [plan.placements[1]],
|
||||
earnedActivations: [],
|
||||
},
|
||||
phases: [
|
||||
{
|
||||
id: 'phase:1',
|
||||
name: 'at-risk',
|
||||
completed: true,
|
||||
status: 'completed',
|
||||
decisions: [
|
||||
{
|
||||
id: 'decision:1',
|
||||
action: 'add',
|
||||
dates: ['2024-05-03'],
|
||||
candidates: [
|
||||
{
|
||||
status: 'chosen',
|
||||
date: '2024-05-03',
|
||||
placement: plan.placements[0],
|
||||
},
|
||||
],
|
||||
deltas: [
|
||||
{
|
||||
kind: 'placement-added',
|
||||
date: '2024-05-03',
|
||||
balanceId: 'cp',
|
||||
placement: plan.placements[0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase:2',
|
||||
name: 'dates',
|
||||
completed: true,
|
||||
status: 'completed',
|
||||
decisions: [],
|
||||
},
|
||||
],
|
||||
completedStages: ['at-risk'],
|
||||
};
|
||||
setup = calendarFor(plan, explanation, trace);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: setup.calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const lastPhase = fixture.nativeElement.querySelector(
|
||||
'button[data-phase-step="last"]',
|
||||
) as HTMLButtonElement;
|
||||
lastPhase.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['tracePhaseIndex']()).toBe(1);
|
||||
const firstPhase = fixture.nativeElement.querySelector(
|
||||
'button[data-phase-step="first"]',
|
||||
) as HTMLButtonElement;
|
||||
firstPhase.click();
|
||||
fixture.detectChanges();
|
||||
const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0];
|
||||
beforeButton.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1);
|
||||
expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged');
|
||||
expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added');
|
||||
const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1];
|
||||
afterButton.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(2);
|
||||
const compareButton = fixture.nativeElement.querySelector(
|
||||
'.candidate-list button',
|
||||
) as HTMLButtonElement;
|
||||
compareButton.click();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible');
|
||||
const alternativeDate = fixture.nativeElement.querySelector(
|
||||
'button[data-date="2024-05-04"]',
|
||||
) as HTMLButtonElement;
|
||||
alternativeDate.click();
|
||||
fixture.detectChanges();
|
||||
expect(alternativeDate.classList).toContain('current');
|
||||
expect(alternativeDate.getAttribute('aria-current')).toBe('date');
|
||||
});
|
||||
|
||||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||||
const calendar = {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||
planLeave: vi.fn(async () => {
|
||||
throw new Error('planner exploded');
|
||||
}),
|
||||
createPlanningCalculation: vi.fn(() => ({
|
||||
...calculationFor(plan),
|
||||
outcome: Promise.reject(new Error('planner exploded')),
|
||||
})),
|
||||
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<div class="calendar-year">
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage() }}</p>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
@@ -50,6 +52,73 @@
|
||||
<app-calendar-settings></app-calendar-settings>
|
||||
</header>
|
||||
|
||||
@if (planningOutcome(); as outcome) {
|
||||
@if (outcome.kind === 'InvalidInput') {
|
||||
<ul class="reason-list" role="alert">
|
||||
@for (reason of outcome.reasons; track reason.code + reason.message) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(reason.code) }}:</strong> {{ reason.message }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@if (planningExplanation(); as outcomeExplanation) {
|
||||
@if (outcomeExplanation.kind === 'ConflictExplanation') {
|
||||
<section
|
||||
class="outcome-explanation conflict-explanation"
|
||||
aria-labelledby="conflict-title"
|
||||
>
|
||||
<h3 id="conflict-title">{{ tr().planning.conflictDetails }}</h3>
|
||||
<p>
|
||||
<strong>{{ tr().planning.conflictingRules }}:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
@for (rule of outcomeExplanation.conflictingRules; track rule.id) {
|
||||
<li>
|
||||
{{ rule.label }} <code>{{ rule.id }}</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p>
|
||||
<strong>{{ tr().planning.bottleneck }}:</strong>
|
||||
{{ outcomeExplanation.bottleneck.message }}
|
||||
</p>
|
||||
</section>
|
||||
} @else if (outcomeExplanation.kind === 'IncompleteExplanation') {
|
||||
<section
|
||||
class="outcome-explanation incomplete-explanation"
|
||||
aria-labelledby="incomplete-title"
|
||||
>
|
||||
<h3 id="incomplete-title">{{ tr().planning.incompleteDetails }}</h3>
|
||||
<p>{{ outcomeExplanation.reason.message }}</p>
|
||||
<dl class="compact-facts">
|
||||
<div>
|
||||
<dt>{{ tr().planning.completedStages }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
outcomeExplanation.completedStages.length
|
||||
? outcomeExplanation.completedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.uncompletedStages }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
outcomeExplanation.uncompletedStages.length
|
||||
? outcomeExplanation.uncompletedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (displayedPlan(); as plan) {
|
||||
<dl class="plan-summary">
|
||||
<div>
|
||||
@@ -67,6 +136,329 @@
|
||||
</dl>
|
||||
}
|
||||
|
||||
@if (selectedExplanation(); as selected) {
|
||||
<section class="placement-explanation" aria-labelledby="placement-explanation-title">
|
||||
<header class="explanation-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{ tr().planning.whyPlacement }}</p>
|
||||
<h3 id="placement-explanation-title">
|
||||
{{ selected.placement.date }} · {{ balanceLabel(selected) }}
|
||||
</h3>
|
||||
</div>
|
||||
<nav
|
||||
class="placement-stepper"
|
||||
[attr.aria-label]="tr().planning.placementNavigation"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-step="first"
|
||||
[disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.first"
|
||||
(click)="stepPlacement('first')"
|
||||
>
|
||||
<span aria-hidden="true">⏮</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-step="previous"
|
||||
[disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previous"
|
||||
(click)="stepPlacement('previous')"
|
||||
>
|
||||
<span aria-hidden="true">◀</span>
|
||||
</button>
|
||||
<output>
|
||||
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
|
||||
{{ tr().planning.of }} {{ planExplanation()!.placements.length }}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
data-step="next"
|
||||
[disabled]="
|
||||
selectedPlacementIndex() === planExplanation()!.placements.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.next"
|
||||
(click)="stepPlacement('next')"
|
||||
>
|
||||
<span aria-hidden="true">▶</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-step="last"
|
||||
[disabled]="
|
||||
selectedPlacementIndex() === planExplanation()!.placements.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.last"
|
||||
(click)="stepPlacement('last')"
|
||||
>
|
||||
<span aria-hidden="true">⏭</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
@if (planExplanation()!.incomplete; as interrupted) {
|
||||
<div class="incomplete-incumbent" role="status">
|
||||
<strong>{{ tr().planning.incumbentExplanation }}</strong>
|
||||
<p>{{ interrupted.reason.message }}</p>
|
||||
<p>
|
||||
{{ tr().planning.completedStages }}:
|
||||
{{
|
||||
interrupted.completedStages.length
|
||||
? interrupted.completedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{ tr().planning.uncompletedStages }}:
|
||||
{{
|
||||
interrupted.uncompletedStages.length
|
||||
? interrupted.uncompletedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<dl class="explanation-facts compact-facts">
|
||||
<div>
|
||||
<dt>{{ tr().planning.balance }}</dt>
|
||||
<dd>
|
||||
{{ balanceLabel(selected) }} <code>{{ selected.placement.balanceId }}</code>
|
||||
@if (selected.balance) {
|
||||
<br />{{ planningDate(selected.balance.availableFrom) }} –
|
||||
{{ planningDate(selected.balance.expiryDate) }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.rules }}</dt>
|
||||
<dd>
|
||||
@if (selected.ruleIds.length) {
|
||||
@for (ruleId of selected.ruleIds; track ruleId; let last = $last) {
|
||||
{{ ruleLabel(ruleId) }} <code>{{ ruleId }}</code
|
||||
>{{ last ? '' : ', ' }}
|
||||
}
|
||||
} @else {
|
||||
{{ tr().planning.noRules }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.supporting }}</dt>
|
||||
<dd>{{ selected.supporting ? tr().planning.yes : tr().planning.no }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.remaining }}</dt>
|
||||
<dd>
|
||||
{{ selected.remainingQuantity }}
|
||||
@if (selected.remainingBalance) {
|
||||
<br /><small>
|
||||
{{ formatIdentifier(selected.remainingBalance.classification) }} ·
|
||||
{{ selected.remainingBalance.reason.message }}
|
||||
</small>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
@if (selected.timeOffPeriod; as period) {
|
||||
<div>
|
||||
<dt>{{ tr().planning.timeOffPeriod }}</dt>
|
||||
<dd>{{ period.start }} – {{ period.end }} ({{ period.duration }})</dd>
|
||||
</div>
|
||||
}
|
||||
@if (selected.firstDecisiveObjective; as decisive) {
|
||||
<div>
|
||||
<dt>{{ tr().planning.firstDecisive }}</dt>
|
||||
<dd>{{ formatIdentifier(decisive) }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<div class="explanation-columns">
|
||||
<section>
|
||||
<h4>{{ tr().planning.contributions }}</h4>
|
||||
<ul>
|
||||
@for (
|
||||
contribution of selected.contributions;
|
||||
track contribution.kind + contribution.code
|
||||
) {
|
||||
<li>
|
||||
<strong>{{ contribution.label }}</strong>
|
||||
@if (contribution.value !== undefined) {
|
||||
<span> · {{ contribution.value }}</span>
|
||||
}
|
||||
@if (contribution.dates?.length) {
|
||||
<span> · {{ contribution.dates!.join(', ') }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>{{ tr().planning.objectiveVector }}</h4>
|
||||
<ol class="objective-vector">
|
||||
@for (
|
||||
objective of selected.objectiveVector.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
<span>{{ objectiveLabel(objective) }}</span>
|
||||
<strong>{{ objective.value }}</strong>
|
||||
<small>{{ formatIdentifier(objective.direction) }}</small>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h4>{{ tr().planning.finalMetrics }}</h4>
|
||||
<dl class="metric-list">
|
||||
<div>
|
||||
<dt>{{ tr().planning.unplacedAtRisk }}</dt>
|
||||
<dd>{{ selected.finalMetrics.unplacedAtRisk }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.holidayWeekValue }}</dt>
|
||||
<dd>{{ selected.finalMetrics.holidayWeekValue }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.daysGained }}</dt>
|
||||
<dd>{{ selected.finalMetrics.nonWorkingDayGain }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.longestWorkSequence }}</dt>
|
||||
<dd>{{ selected.finalMetrics.longestWorkSequence }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.shortSequenceDeficit }}</dt>
|
||||
<dd>{{ selected.finalMetrics.shortSequenceDeficit }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.supportingPlacements }}</dt>
|
||||
<dd>{{ selected.finalMetrics.supportingPlacements }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.preferenceSatisfaction }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
selected.finalMetrics.preferenceSatisfaction.join(', ') ||
|
||||
tr().planning.none
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<details class="comparison-details">
|
||||
<summary>{{ tr().planning.removedRestoredComparison }}</summary>
|
||||
<p>{{ tr().planning.comparisonHelp }}</p>
|
||||
<ul>
|
||||
@for (delta of selected.comparison.delta; track delta.id) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(delta.id) }}:</strong> {{ delta.value }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<section class="counterfactual-panel" aria-labelledby="counterfactual-title">
|
||||
<h4 id="counterfactual-title">{{ tr().planning.counterfactual }}</h4>
|
||||
<p>{{ tr().planning.hypothetical }}</p>
|
||||
@if (counterfactual(); as analysis) {
|
||||
<p>
|
||||
<strong
|
||||
>{{ analysis.placement.date }} ·
|
||||
{{ analysis.placement.balanceId }}</strong
|
||||
>
|
||||
</p>
|
||||
}
|
||||
@if (counterfactualLoading()) {
|
||||
<p role="status">{{ tr().planning.counterfactualLoading }}</p>
|
||||
} @else if (counterfactualError()) {
|
||||
<p class="inline-error" role="alert">
|
||||
{{ tr().planning.counterfactualError }} {{ counterfactualError() }}
|
||||
</p>
|
||||
} @else if (selectedCounterfactualResult(); as result) {
|
||||
<div class="counterfactual-result">
|
||||
<h5>{{ result.date }}</h5>
|
||||
<p class="status-badge" [class.ineligible]="result.status === 'ineligible'">
|
||||
{{
|
||||
result.status === 'eligible'
|
||||
? tr().planning.eligible
|
||||
: tr().planning.ineligible
|
||||
}}
|
||||
</p>
|
||||
@if (result.historicalStatus) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.historical }}:</strong>
|
||||
{{ traceStatusLabel(result.historicalStatus) }}
|
||||
</p>
|
||||
}
|
||||
@if (result.reason) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.reason }}:</strong>
|
||||
{{ result.reason.message }}
|
||||
</p>
|
||||
}
|
||||
@if (result.comparison; as comparison) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.firstDecisive }}:</strong>
|
||||
{{
|
||||
comparison.firstDecisiveObjective
|
||||
? formatIdentifier(comparison.firstDecisiveObjective)
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
<ul>
|
||||
@for (delta of comparison.delta; track delta.id) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(delta.id) }}:</strong>
|
||||
{{ delta.value }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<details>
|
||||
<summary>{{ tr().planning.fullComparison }}</summary>
|
||||
<div class="comparison-vectors">
|
||||
<div>
|
||||
<strong>{{ tr().planning.before }}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.before.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{ objectiveLabel(objective) }}:
|
||||
{{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ tr().planning.after }}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.after.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{ objectiveLabel(objective) }}:
|
||||
{{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
} @else if (explanationError()) {
|
||||
<p class="inline-error" role="alert">
|
||||
{{ tr().planning.explanationError }} {{ explanationError()!.message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
|
||||
<strong>{{ tr().planning.legend }}</strong>
|
||||
<ul>
|
||||
@@ -90,20 +482,406 @@
|
||||
<span class="legend-swatch today" aria-hidden="true"></span
|
||||
>{{ tr().dayTypes.today }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">◎</span
|
||||
>{{ tr().planning.currentPlacement }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">✓</span>{{ tr().planning.eligible }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">×</span
|
||||
>{{ tr().planning.ineligible }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="month-grid">
|
||||
@for (month of months; track $index) {
|
||||
@for (month of months(); track month.year + '-' + month.monthIndex) {
|
||||
<mat-card class="month-card">
|
||||
<mat-card-content>
|
||||
<app-calendar-month
|
||||
[monthIndex]="month"
|
||||
[year]="getYear(month)"
|
||||
[selectedDates]="selectedDates"
|
||||
[monthIndex]="month.monthIndex"
|
||||
[year]="month.year"
|
||||
[selectedDates]="calendarSelectedDates()"
|
||||
[horizonStart]="planningHorizon()?.start"
|
||||
[horizonEnd]="planningHorizon()?.end"
|
||||
[currentDate]="currentPlacementDate()"
|
||||
[dateStatuses]="dateStatuses()"
|
||||
(dateSelected)="selectCalendarDate($event)"
|
||||
></app-calendar-month>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') {
|
||||
<details class="trace-explorer">
|
||||
<summary>{{ tr().planning.traceTitle }}</summary>
|
||||
@if (traceLoading()) {
|
||||
<p role="status">{{ tr().planning.traceLoading }}</p>
|
||||
} @else if (planningTrace(); as trace) {
|
||||
@if (trace.error) {
|
||||
<p class="inline-error" role="alert">{{ trace.error.message }}</p>
|
||||
}
|
||||
<div class="trace-layout">
|
||||
<nav class="trace-phases" [attr.aria-label]="tr().planning.phases">
|
||||
<div class="trace-phase-stepper">
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="first"
|
||||
[disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.firstPhase"
|
||||
(click)="stepTracePhase('first')"
|
||||
>
|
||||
<span aria-hidden="true">⏮</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="previous"
|
||||
[disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previousPhase"
|
||||
(click)="stepTracePhase('previous')"
|
||||
>
|
||||
<span aria-hidden="true">◀</span>
|
||||
</button>
|
||||
<output
|
||||
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
|
||||
{{ trace.phases.length }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="next"
|
||||
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
|
||||
[attr.aria-label]="tr().planning.nextPhase"
|
||||
(click)="stepTracePhase('next')"
|
||||
>
|
||||
<span aria-hidden="true">▶</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="last"
|
||||
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
|
||||
[attr.aria-label]="tr().planning.lastPhase"
|
||||
(click)="stepTracePhase('last')"
|
||||
>
|
||||
<span aria-hidden="true">⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
@for (phase of trace.phases; track phase.id; let index = $index) {
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="index === tracePhaseIndex()"
|
||||
[attr.aria-pressed]="index === tracePhaseIndex()"
|
||||
(click)="selectTracePhase(index)"
|
||||
>
|
||||
<span>{{ formatIdentifier(phase.name) }}</span>
|
||||
<small>{{ tracePhaseStatusLabel(phase.status) }}</small>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@if (selectedTracePhase(); as phase) {
|
||||
<section class="trace-decisions" aria-labelledby="trace-phase-title">
|
||||
<h3 id="trace-phase-title">{{ formatIdentifier(phase.name) }}</h3>
|
||||
@if (phase.decisions.length) {
|
||||
<nav [attr.aria-label]="tr().planning.decisions">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.firstDecision"
|
||||
(click)="stepTraceDecision('first')"
|
||||
>
|
||||
<span aria-hidden="true">⏮</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previousDecision"
|
||||
(click)="stepTraceDecision('previous')"
|
||||
>
|
||||
<span aria-hidden="true">◀</span>
|
||||
</button>
|
||||
<output
|
||||
>{{ tr().planning.decision }} {{ traceDecisionIndex() + 1 }}
|
||||
{{ tr().planning.of }} {{ phase.decisions.length }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
traceDecisionIndex() === phase.decisions.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextDecision"
|
||||
(click)="stepTraceDecision('next')"
|
||||
>
|
||||
<span aria-hidden="true">▶</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
traceDecisionIndex() === phase.decisions.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.lastDecision"
|
||||
(click)="stepTraceDecision('last')"
|
||||
>
|
||||
<span aria-hidden="true">⏭</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (selectedTraceDecision(); as decision) {
|
||||
<div
|
||||
class="trace-toolbar"
|
||||
role="group"
|
||||
[attr.aria-label]="tr().planning.replay"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="
|
||||
traceReplay() && traceMoment() === 'before'
|
||||
"
|
||||
(click)="showTraceMoment('before')"
|
||||
>
|
||||
{{ tr().planning.before }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="
|
||||
traceReplay() && traceMoment() === 'after'
|
||||
"
|
||||
(click)="showTraceMoment('after')"
|
||||
>
|
||||
{{ tr().planning.after }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="!traceReplay()"
|
||||
(click)="showFinalPlan()"
|
||||
>
|
||||
{{ tr().planning.finalPlan }}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{{ tr().planning.dates }}:</strong>
|
||||
{{
|
||||
decision.dates.length
|
||||
? decision.dates.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
@if (decision.reason) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.reason }}:</strong>
|
||||
{{ decision.reason.message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<h4>{{ tr().planning.candidates }}</h4>
|
||||
@if (decision.candidates.length > 50) {
|
||||
<nav
|
||||
class="candidate-pagination"
|
||||
[attr.aria-label]="tr().planning.candidates"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceCandidatePage() === 0"
|
||||
[attr.aria-label]="tr().planning.previousCandidates"
|
||||
(click)="stepTraceCandidatePage(-1)"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<output
|
||||
>{{ tr().planning.page }}
|
||||
{{ traceCandidatePage() + 1 }}
|
||||
{{ tr().planning.of }}
|
||||
{{ traceCandidatePages() }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
traceCandidatePage() ===
|
||||
traceCandidatePages() - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextCandidates"
|
||||
(click)="stepTraceCandidatePage(1)"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
<div class="candidate-list">
|
||||
@for (candidate of visibleTraceCandidates(); track $index) {
|
||||
<details>
|
||||
<summary>
|
||||
<span
|
||||
class="status-symbol"
|
||||
aria-hidden="true"
|
||||
>{{
|
||||
candidate.status === 'chosen'
|
||||
? '✓'
|
||||
: candidate.status === 'ineligible'
|
||||
? '×'
|
||||
: '•'
|
||||
}}</span
|
||||
>
|
||||
{{ traceStatusLabel(candidate.status) }} ·
|
||||
@if (
|
||||
candidate.sourceDate &&
|
||||
candidate.sourceDate !== candidate.date
|
||||
) {
|
||||
{{ candidate.sourceDate }} →
|
||||
}
|
||||
{{
|
||||
candidate.dates?.join(', ') ||
|
||||
candidate.date ||
|
||||
tr().planning.none
|
||||
}}
|
||||
</summary>
|
||||
@if (candidate.reason) {
|
||||
<p>{{ candidate.reason.message }}</p>
|
||||
}
|
||||
@if (candidate.comparison?.firstDecisiveObjective) {
|
||||
<p>
|
||||
<strong
|
||||
>{{
|
||||
tr().planning.firstDecisive
|
||||
}}:</strong
|
||||
>
|
||||
{{
|
||||
formatIdentifier(
|
||||
candidate.comparison!
|
||||
.firstDecisiveObjective!
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
@if (candidate.comparison; as comparison) {
|
||||
@if (comparison.tieBreak) {
|
||||
<p>
|
||||
<strong
|
||||
>{{
|
||||
tr().planning.tieBreak
|
||||
}}:</strong
|
||||
>
|
||||
{{
|
||||
formatIdentifier(
|
||||
comparison.tieBreak
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<ul>
|
||||
@for (
|
||||
delta of comparison.delta;
|
||||
track delta.id
|
||||
) {
|
||||
<li>
|
||||
<strong
|
||||
>{{
|
||||
formatIdentifier(delta.id)
|
||||
}}:</strong
|
||||
>
|
||||
{{ delta.value }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="comparison-vectors">
|
||||
<div>
|
||||
<strong>{{
|
||||
tr().planning.before
|
||||
}}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.before
|
||||
.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{
|
||||
objectiveLabel(
|
||||
objective
|
||||
)
|
||||
}}: {{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{
|
||||
tr().planning.after
|
||||
}}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.after
|
||||
.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{
|
||||
objectiveLabel(
|
||||
objective
|
||||
)
|
||||
}}: {{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (candidate.placement) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="analyzeTraceCandidate(candidate)"
|
||||
>
|
||||
{{ tr().planning.compareAlternatives }}
|
||||
</button>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4>{{ tr().planning.deltas }}</h4>
|
||||
<ul>
|
||||
@for (delta of decision.deltas; track $index) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(delta.kind) }}</strong>
|
||||
@if (delta.fromDate || delta.date) {
|
||||
· {{ delta.fromDate || delta.date }}
|
||||
}
|
||||
@if (delta.toDate) {
|
||||
→ {{ delta.toDate }}
|
||||
}
|
||||
@if (delta.balanceId) {
|
||||
· {{ delta.balanceId }}
|
||||
}
|
||||
@if (delta.remainingAfter !== undefined) {
|
||||
· {{ delta.remainingBefore }} →
|
||||
{{ delta.remainingAfter }}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (traceState(); as state) {
|
||||
<p class="trace-state-summary" role="status">
|
||||
{{
|
||||
traceMoment() === 'before'
|
||||
? tr().planning.before
|
||||
: tr().planning.after
|
||||
}}: {{ state.placements.length }}
|
||||
{{ tr().planning.placements }},
|
||||
{{ state.earnedActivations.length }}
|
||||
{{ tr().planning.activations }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<p>{{ tr().planning.noDecisions }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else if (traceResolved()) {
|
||||
<p>{{ tr().planning.traceUnavailable }}</p>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
@@ -3,6 +3,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendar-year {
|
||||
display: grid;
|
||||
container: calendar-year / inline-size;
|
||||
@@ -146,6 +158,261 @@
|
||||
}
|
||||
}
|
||||
|
||||
.reason-list,
|
||||
.outcome-explanation,
|
||||
.placement-explanation,
|
||||
.trace-explorer {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ui-border, #c7c7d0);
|
||||
border-radius: var(--ui-radius-md, 0.75rem);
|
||||
background: var(--ui-card-bg, #fff);
|
||||
}
|
||||
|
||||
.outcome-explanation,
|
||||
.placement-explanation {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
p,
|
||||
ul,
|
||||
ol {
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.conflict-explanation {
|
||||
border-inline-start: 0.35rem solid var(--ui-error-text, #751b1b);
|
||||
}
|
||||
|
||||
.incomplete-explanation {
|
||||
border-inline-start: 0.35rem solid var(--ui-warning-text, #4f3800);
|
||||
}
|
||||
|
||||
.incomplete-incumbent {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-inline-start: 0.35rem solid var(--ui-warning-text, #4f3800);
|
||||
background: var(--ui-warning-bg, #fff4d6);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.explanation-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.placement-stepper,
|
||||
.trace-toolbar,
|
||||
.trace-decisions nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
button,
|
||||
output {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--ui-border, #777984);
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
background: var(--ui-card-bg, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-pressed='true'] {
|
||||
background: var(--ui-panel-bg, #ececf2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid var(--today-border-color, #1976d2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
background: var(--ui-panel-bg, #f7f7fa);
|
||||
}
|
||||
|
||||
dt {
|
||||
margin-block-end: 0.25rem;
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.explanation-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
background: var(--ui-panel-bg, #f7f7fa);
|
||||
}
|
||||
}
|
||||
|
||||
.objective-vector {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0 0.75rem;
|
||||
}
|
||||
|
||||
small {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem 1rem;
|
||||
margin: 0;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.comparison-details,
|
||||
.counterfactual-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
background: var(--ui-panel-bg, #f7f7fa);
|
||||
}
|
||||
|
||||
.comparison-details summary,
|
||||
.trace-explorer > summary,
|
||||
.candidate-list summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.counterfactual-result {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.comparison-vectors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-block-start: 0.5rem;
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin-block: 0.35rem 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 2px solid var(--ui-success-text, #174d26);
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
|
||||
&.ineligible {
|
||||
border-color: var(--ui-error-text, #751b1b);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 0.35rem solid var(--ui-error-text, #751b1b);
|
||||
background: var(--ui-error-bg, #ffe0e0);
|
||||
color: var(--ui-error-text, #751b1b);
|
||||
}
|
||||
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -212,15 +479,124 @@
|
||||
}
|
||||
}
|
||||
|
||||
.legend-symbol {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.month-card {
|
||||
margin: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
summary {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.trace-phases {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.35rem;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--ui-border, #c7c7d0);
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-inline-start: 0.35rem solid var(--today-border-color, #1976d2);
|
||||
background: var(--ui-panel-bg, #f7f7fa);
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trace-phase-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
output {
|
||||
min-width: 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-decisions {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
ul {
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
|
||||
details {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--ui-border, #c7c7d0);
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.status-symbol {
|
||||
display: inline-block;
|
||||
min-width: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.trace-state-summary {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--today-border-color, #1976d2);
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
}
|
||||
|
||||
@media (max-width: 52rem) {
|
||||
.trace-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trace-phases {
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,37 @@
|
||||
import { Component, computed, effect, signal, inject, untracked } from '@angular/core';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
|
||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||
import { CalendarMonth } from '../calendar-month/calendar-month';
|
||||
import { CalendarService, SelectedDates } from '../../services/calendar-service';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
|
||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
import {
|
||||
CalendarService,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
} from '../../services/calendar-service';
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
|
||||
import { SelectableDayType, VacDay } from '../../services/calendar-service';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import {
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
LeavePlacement,
|
||||
PlanExplanation,
|
||||
PlanOutcome,
|
||||
PlanningCalculationContext,
|
||||
PlanningExplanation,
|
||||
PlanningObjective,
|
||||
PlanningOutcome,
|
||||
PlanningReason,
|
||||
PlanningTrace,
|
||||
PlanningTraceCandidate,
|
||||
PlanningTraceDecision,
|
||||
PlanningTraceState,
|
||||
} from '../../services/leave-planner.service';
|
||||
|
||||
type PlacementExplanation = PlanExplanation['placements'][number];
|
||||
type TraceMoment = 'before' | 'after';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-year',
|
||||
@@ -26,21 +47,42 @@ import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
styleUrl: './calendar-year.scss',
|
||||
})
|
||||
export class CalendarYear {
|
||||
private dateCache = inject(DateCacheService);
|
||||
private userInput = inject(UserInputService);
|
||||
private calendarService = inject(CalendarService);
|
||||
private ruleService = inject(RuleService);
|
||||
private readonly userInput = inject(UserInputService);
|
||||
private readonly calendarService = inject(CalendarService);
|
||||
private readonly ruleService = inject(RuleService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
protected readonly isLoading = signal(true);
|
||||
protected readonly loadingProgress = signal(0);
|
||||
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
|
||||
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
||||
protected readonly explanationError = signal<PlanningReason | null>(null);
|
||||
protected readonly planningTrace = signal<PlanningTrace | null>(null);
|
||||
protected readonly traceLoading = signal(false);
|
||||
protected readonly traceResolved = signal(false);
|
||||
protected readonly selectedPlacementIndex = signal(0);
|
||||
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
|
||||
protected readonly selectedCounterfactualDate = signal<string | null>(null);
|
||||
protected readonly counterfactualLoading = signal(false);
|
||||
protected readonly counterfactualError = signal<string | null>(null);
|
||||
protected readonly tracePhaseIndex = signal(0);
|
||||
protected readonly traceDecisionIndex = signal(0);
|
||||
protected readonly traceCandidatePage = signal(0);
|
||||
protected readonly traceMoment = signal<TraceMoment>('before');
|
||||
protected readonly traceReplay = signal(false);
|
||||
protected readonly liveMessage = signal('');
|
||||
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
|
||||
protected readonly selectedDates = signal(new SelectedDates());
|
||||
|
||||
private calculationVersion = 0;
|
||||
private counterfactualVersion = 0;
|
||||
private activeCalculation: PlanningCalculationContext | null = null;
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
protected isLoading = signal(true);
|
||||
|
||||
protected loadingProgress = signal(0);
|
||||
protected planningOutcome = signal<PlanningOutcome | null>(null);
|
||||
protected displayedPlan = computed(() => {
|
||||
protected readonly displayedPlan = computed(() => {
|
||||
const outcome = this.planningOutcome();
|
||||
return outcome?.kind === 'Plan'
|
||||
? outcome
|
||||
@@ -48,7 +90,557 @@ export class CalendarYear {
|
||||
? outcome.incumbent
|
||||
: undefined;
|
||||
});
|
||||
private calculationVersion = 0;
|
||||
|
||||
protected readonly planExplanation = computed(() => {
|
||||
const explanation = this.planningExplanation();
|
||||
return explanation?.kind === 'PlanExplanation' ? explanation : undefined;
|
||||
});
|
||||
|
||||
protected readonly selectedExplanation = computed<PlacementExplanation | undefined>(
|
||||
() => this.planExplanation()?.placements[this.selectedPlacementIndex()],
|
||||
);
|
||||
|
||||
protected readonly selectedTracePhase = computed(
|
||||
() => this.planningTrace()?.phases[this.tracePhaseIndex()],
|
||||
);
|
||||
|
||||
protected readonly selectedTraceDecision = computed(
|
||||
() => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()],
|
||||
);
|
||||
|
||||
protected readonly visibleTraceCandidates = computed(() => {
|
||||
const start = this.traceCandidatePage() * 50;
|
||||
return this.selectedTraceDecision()?.candidates.slice(start, start + 50) ?? [];
|
||||
});
|
||||
|
||||
protected readonly traceCandidatePages = computed(() =>
|
||||
Math.max(1, Math.ceil((this.selectedTraceDecision()?.candidates.length ?? 0) / 50)),
|
||||
);
|
||||
|
||||
protected readonly selectedCounterfactualResult = computed(() =>
|
||||
this.counterfactual()?.results.find(
|
||||
(result) => result.date === this.selectedCounterfactualDate(),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly traceState = computed(() => {
|
||||
if (!this.traceReplay()) return undefined;
|
||||
return this.replayTraceState(
|
||||
this.planningTrace(),
|
||||
this.selectedTraceDecision()?.id,
|
||||
this.traceMoment(),
|
||||
);
|
||||
});
|
||||
|
||||
protected readonly calendarSelectedDates = computed(() => {
|
||||
const state = this.traceState();
|
||||
return state
|
||||
? this.calendarService.selectedDatesForPlan({
|
||||
placements: [...state.placements],
|
||||
horizon: { ...state.horizon },
|
||||
})
|
||||
: this.selectedDates();
|
||||
});
|
||||
|
||||
protected readonly currentPlacementDate = computed(() => {
|
||||
if (this.counterfactual())
|
||||
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
|
||||
if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null;
|
||||
return this.selectedExplanation()?.placement.date ?? null;
|
||||
});
|
||||
|
||||
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
|
||||
const statuses: Record<string, CalendarDayStatus> = {};
|
||||
const trace = this.planningTrace();
|
||||
if (!this.counterfactual() && this.traceReplay() && trace) {
|
||||
const priority = new Map([
|
||||
['ineligible', 0],
|
||||
['not-examined', 1],
|
||||
['pruned', 2],
|
||||
['evaluated-rejected', 3],
|
||||
['chosen', 4],
|
||||
]);
|
||||
for (
|
||||
let timestamp = Date.parse(`${trace.initialState.horizon.start}T00:00:00Z`),
|
||||
end = Date.parse(`${trace.initialState.horizon.end}T00:00:00Z`);
|
||||
timestamp < end;
|
||||
timestamp += 86_400_000
|
||||
) {
|
||||
const date = new Date(timestamp).toISOString().slice(0, 10);
|
||||
statuses[date] = {
|
||||
kind: 'not-examined',
|
||||
label: this.traceStatusLabel('not-examined'),
|
||||
};
|
||||
}
|
||||
for (const candidate of trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) => decision.candidates),
|
||||
)) {
|
||||
if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working')
|
||||
continue;
|
||||
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : []))
|
||||
statuses[date] = {
|
||||
kind: candidate.status,
|
||||
label: this.traceStatusLabel(candidate.status),
|
||||
reason: candidate.reason.message,
|
||||
};
|
||||
}
|
||||
const explicitPriorities = new Map<string, number>();
|
||||
for (const candidate of this.selectedTraceDecision()?.candidates ?? []) {
|
||||
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
|
||||
const candidatePriority = priority.get(candidate.status)!;
|
||||
if (candidatePriority > (explicitPriorities.get(date) ?? -1)) {
|
||||
statuses[date] = {
|
||||
kind: candidate.status,
|
||||
label: this.traceStatusLabel(candidate.status),
|
||||
reason: candidate.reason?.message,
|
||||
};
|
||||
explicitPriorities.set(date, candidatePriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
const decision = this.selectedTraceDecision();
|
||||
if (decision) {
|
||||
const changedDates = new Set<string>();
|
||||
const setChange = (
|
||||
date: string | undefined,
|
||||
change: NonNullable<CalendarDayStatus['change']>,
|
||||
) => {
|
||||
if (!date) return;
|
||||
changedDates.add(date);
|
||||
statuses[date] = {
|
||||
...(statuses[date] ?? {
|
||||
kind: 'not-examined',
|
||||
label: this.traceStatusLabel('not-examined'),
|
||||
}),
|
||||
change,
|
||||
changeLabel: this.traceChangeLabel(change),
|
||||
};
|
||||
};
|
||||
for (const delta of 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') {
|
||||
setChange(delta.fromDate, 'moved-from');
|
||||
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');
|
||||
for (const placement of [
|
||||
...(before?.placements ?? []),
|
||||
...(after?.placements ?? []),
|
||||
]) {
|
||||
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
for (const result of this.counterfactual()?.results ?? [])
|
||||
statuses[result.date] = {
|
||||
kind: result.status,
|
||||
label:
|
||||
result.status === 'eligible'
|
||||
? this.tr().planning.eligible
|
||||
: this.tr().planning.ineligible,
|
||||
reason: result.reason?.message,
|
||||
historicalLabel: result.historicalStatus
|
||||
? this.traceStatusLabel(result.historicalStatus)
|
||||
: undefined,
|
||||
};
|
||||
return statuses;
|
||||
});
|
||||
|
||||
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`);
|
||||
last.setDate(last.getDate() - 1);
|
||||
const result: { monthIndex: number; year: number }[] = [];
|
||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
while (cursor <= last) {
|
||||
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
this.resetCalculationState();
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
const horizon = this.calendarService.getDefaultPlanningHorizon();
|
||||
this.planningHorizon.set(horizon);
|
||||
const version = ++this.calculationVersion;
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
queueMicrotask(() => {
|
||||
let calculation: PlanningCalculationContext;
|
||||
try {
|
||||
calculation = this.calendarService.createPlanningCalculation({
|
||||
horizon,
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
label: this.translationService.getDayTypeLabel(balance.type),
|
||||
availableFrom: balance.availableDate,
|
||||
expiryDate: balance.expiryDate,
|
||||
})),
|
||||
rules: currentRules,
|
||||
labels: Object.fromEntries(
|
||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||
),
|
||||
progress: (stage) => {
|
||||
if (version === this.calculationVersion)
|
||||
this.loadingProgress.set(this.stageProgress(stage));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.failCalculation(error, version);
|
||||
return;
|
||||
}
|
||||
this.activeCalculation = calculation;
|
||||
this.loadArtifacts(calculation, version);
|
||||
void calculation.outcome
|
||||
.then((outcome) => {
|
||||
if (version === this.calculationVersion) this.applyOutcome(outcome);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [
|
||||
{
|
||||
code: 'planner-error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The planner failed unexpectedly.',
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private failCalculation(error: unknown, version: number): void {
|
||||
if (version !== this.calculationVersion) return;
|
||||
const reason = {
|
||||
code: 'planner-error',
|
||||
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
|
||||
};
|
||||
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
|
||||
this.explanationError.set(reason);
|
||||
this.traceLoading.set(false);
|
||||
this.traceResolved.set(true);
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
|
||||
private resetCalculationState(): void {
|
||||
this.activeCalculation = null;
|
||||
this.planningOutcome.set(null);
|
||||
this.planningExplanation.set(null);
|
||||
this.explanationError.set(null);
|
||||
this.planningTrace.set(null);
|
||||
this.traceLoading.set(true);
|
||||
this.traceResolved.set(false);
|
||||
this.selectedPlacementIndex.set(0);
|
||||
this.clearCounterfactual();
|
||||
this.tracePhaseIndex.set(0);
|
||||
this.traceDecisionIndex.set(0);
|
||||
this.traceCandidatePage.set(0);
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(false);
|
||||
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' && explanation.placements.length)
|
||||
this.selectPlacement(0);
|
||||
});
|
||||
void calculation.explanationError.then((reason) => {
|
||||
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
|
||||
});
|
||||
void calculation.trace
|
||||
.then((trace) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.planningTrace.set(trace ?? null);
|
||||
this.traceResolved.set(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.planningTrace.set({
|
||||
status: 'failed',
|
||||
initialState: {
|
||||
horizon: calculation.snapshot.horizon,
|
||||
balances: [],
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
phases: [],
|
||||
completedStages: [],
|
||||
error: {
|
||||
code: 'trace-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
this.traceResolved.set(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === this.calculationVersion) this.traceLoading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
private applyOutcome(outcome: PlanningOutcome): void {
|
||||
this.planningOutcome.set(outcome);
|
||||
this.liveMessage.set(this.outcomeLabel(outcome));
|
||||
const plan = this.displayedPlan();
|
||||
if (plan) {
|
||||
this.planningHorizon.set(plan.horizon);
|
||||
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
|
||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
||||
return;
|
||||
}
|
||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
protected selectPlacement(index: number): void {
|
||||
const explanation = this.planExplanation();
|
||||
if (!explanation?.placements.length) return;
|
||||
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
|
||||
this.selectedPlacementIndex.set(selected);
|
||||
this.traceReplay.set(false);
|
||||
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
|
||||
);
|
||||
void this.analyzeSelection(explanation.placements[selected].placement);
|
||||
}
|
||||
|
||||
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
|
||||
const current = this.selectedPlacementIndex();
|
||||
this.selectPlacement(
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
}
|
||||
|
||||
protected selectCalendarDate(date: string): void {
|
||||
if (!this.traceReplay()) {
|
||||
const index = this.planExplanation()?.placements.findIndex(
|
||||
(explanation) => explanation.placement.date === date,
|
||||
);
|
||||
if (index !== undefined && index >= 0) {
|
||||
this.selectPlacement(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.counterfactual()?.results.some((result) => result.date === date)) {
|
||||
this.selectedCounterfactualDate.set(date);
|
||||
this.liveMessage.set(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected selectTracePhase(index: number): void {
|
||||
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
|
||||
index = Math.max(0, Math.min(index, last));
|
||||
this.tracePhaseIndex.set(index);
|
||||
this.traceDecisionIndex.set(0);
|
||||
this.traceCandidatePage.set(0);
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(false);
|
||||
this.clearCounterfactual();
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
|
||||
);
|
||||
}
|
||||
|
||||
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
|
||||
const current = this.tracePhaseIndex();
|
||||
this.selectTracePhase(
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
}
|
||||
|
||||
protected selectTraceDecision(index: number): void {
|
||||
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
|
||||
index = Math.max(0, Math.min(index, last));
|
||||
this.traceDecisionIndex.set(index);
|
||||
this.traceCandidatePage.set(0);
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(true);
|
||||
this.clearCounterfactual();
|
||||
this.liveMessage.set(
|
||||
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
|
||||
);
|
||||
}
|
||||
|
||||
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
|
||||
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
|
||||
const current = this.traceDecisionIndex();
|
||||
this.selectTraceDecision(
|
||||
step === 'first'
|
||||
? 0
|
||||
: step === 'last'
|
||||
? last
|
||||
: step === 'previous'
|
||||
? current - 1
|
||||
: current + 1,
|
||||
);
|
||||
}
|
||||
|
||||
protected stepTraceCandidatePage(direction: -1 | 1): void {
|
||||
this.traceCandidatePage.update((page) =>
|
||||
Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
protected showTraceMoment(moment: TraceMoment): void {
|
||||
this.clearCounterfactual();
|
||||
this.traceMoment.set(moment);
|
||||
this.traceReplay.set(true);
|
||||
}
|
||||
|
||||
protected showFinalPlan(): void {
|
||||
this.traceReplay.set(false);
|
||||
this.clearCounterfactual();
|
||||
}
|
||||
|
||||
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
|
||||
const decision = this.selectedTraceDecision();
|
||||
if (!candidate.placement || !decision) return;
|
||||
this.traceMoment.set('before');
|
||||
this.traceReplay.set(true);
|
||||
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
|
||||
}
|
||||
|
||||
private async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
|
||||
const calculation = this.activeCalculation;
|
||||
if (!calculation) return;
|
||||
const version = ++this.counterfactualVersion;
|
||||
this.counterfactual.set(null);
|
||||
this.counterfactualError.set(null);
|
||||
this.counterfactualLoading.set(true);
|
||||
try {
|
||||
const result = await calculation.analyzeCounterfactual(selection);
|
||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
||||
return;
|
||||
if (result.kind === 'Counterfactual') {
|
||||
this.counterfactual.set(result);
|
||||
this.selectedCounterfactualDate.set(result.placement.date);
|
||||
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
||||
}
|
||||
} catch (error) {
|
||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
||||
return;
|
||||
this.counterfactualError.set(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private clearCounterfactual(): void {
|
||||
this.counterfactualVersion++;
|
||||
this.counterfactual.set(null);
|
||||
this.selectedCounterfactualDate.set(null);
|
||||
this.counterfactualLoading.set(false);
|
||||
this.counterfactualError.set(null);
|
||||
}
|
||||
|
||||
private replayTraceState(
|
||||
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],
|
||||
}));
|
||||
const snapshot = (): PlanningTraceState => ({
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
balances,
|
||||
placements: [...placements.values()],
|
||||
earnedActivations,
|
||||
});
|
||||
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();
|
||||
}
|
||||
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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected hasPlannerError(): boolean {
|
||||
const outcome = this.planningOutcome();
|
||||
@@ -57,6 +649,53 @@ export class CalendarYear {
|
||||
);
|
||||
}
|
||||
|
||||
protected ruleLabel(ruleId: string): string {
|
||||
const rule = this.activeCalculation?.snapshot.rules.find(
|
||||
(candidate) => candidate.id === ruleId,
|
||||
);
|
||||
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
|
||||
}
|
||||
|
||||
protected balanceLabel(explanation: PlacementExplanation): string {
|
||||
return (
|
||||
explanation.balance?.name ??
|
||||
explanation.balance?.label ??
|
||||
explanation.placement.balanceId
|
||||
);
|
||||
}
|
||||
|
||||
protected objectiveLabel(objective: PlanningObjective): string {
|
||||
return objective.label ?? objective.id;
|
||||
}
|
||||
|
||||
protected planningDate(value: string | Date | undefined): string {
|
||||
if (!value) return '';
|
||||
return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10);
|
||||
}
|
||||
|
||||
protected traceStatusLabel(status: PlanningTraceCandidate['status']): string {
|
||||
return this.tr().planning.traceStatuses[status];
|
||||
}
|
||||
|
||||
protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
|
||||
return this.tr().planning.tracePhaseStatuses[status];
|
||||
}
|
||||
|
||||
protected traceChangeLabel(change: NonNullable<CalendarDayStatus['change']>): string {
|
||||
return this.tr().planning.traceChanges[change];
|
||||
}
|
||||
|
||||
protected formatIdentifier(value: string): string {
|
||||
return value.replaceAll('-', ' ').replaceAll(':', ': ');
|
||||
}
|
||||
|
||||
private outcomeLabel(outcome: PlanningOutcome): string {
|
||||
if (outcome.kind === 'Plan') return this.tr().planning.complete;
|
||||
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
|
||||
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
|
||||
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
||||
}
|
||||
|
||||
private stageProgress(stage: string): number {
|
||||
if (stage === 'complete') return 100;
|
||||
if (stage === 'validation') return 5;
|
||||
@@ -73,87 +712,6 @@ export class CalendarYear {
|
||||
return this.loadingProgress();
|
||||
}
|
||||
|
||||
// Use DateCacheService instead of creating Date objects
|
||||
private get currentMonth() {
|
||||
return this.dateCache.getCurrentMonth();
|
||||
}
|
||||
|
||||
private get currentYear() {
|
||||
return this.dateCache.getCurrentYear();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
this.planningOutcome.set(null);
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
const version = ++this.calculationVersion;
|
||||
// Defer execution to allow UI to update
|
||||
queueMicrotask(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const outcome = await this.calendarService.planLeave({
|
||||
horizon: this.calendarService.getDefaultPlanningHorizon(),
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
availableFrom: balance.availableDate,
|
||||
expiryDate: balance.expiryDate,
|
||||
})),
|
||||
rules: currentRules,
|
||||
progress: (stage) => {
|
||||
if (version === this.calculationVersion)
|
||||
this.loadingProgress.set(this.stageProgress(stage));
|
||||
},
|
||||
});
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome(outcome);
|
||||
} catch (error) {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [
|
||||
{
|
||||
code: 'planner-error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The planner failed unexpectedly.',
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
if (version === this.calculationVersion) {
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private applyOutcome(outcome: PlanningOutcome): void {
|
||||
this.planningOutcome.set(outcome);
|
||||
const plan = this.displayedPlan();
|
||||
if (plan) {
|
||||
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
|
||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
||||
return;
|
||||
}
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
|
||||
return plan.remainingBalances.map((balance) => ({
|
||||
id: balance.id,
|
||||
@@ -165,14 +723,4 @@ export class CalendarYear {
|
||||
reason: balance.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
protected selectedDates: SelectedDates = new SelectedDates();
|
||||
|
||||
getYear(monthIndex: number): number {
|
||||
const offset = this.currentMonth <= monthIndex ? 0 : 1;
|
||||
return this.currentYear + offset;
|
||||
}
|
||||
|
||||
// months starting from actual date - cache this array
|
||||
protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12);
|
||||
}
|
||||
@@ -105,6 +105,16 @@ describe('CalendarService', () => {
|
||||
);
|
||||
expect(hasToday).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the final partial month of a half-open one-year horizon', () => {
|
||||
const selectedDates = service.getSelectedDatesForHorizon({
|
||||
start: '2024-01-15',
|
||||
end: '2025-01-15',
|
||||
});
|
||||
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
|
||||
DayType.WEEKEND,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWeekendSelection', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import { DateCacheService } from './date-cache.service';
|
||||
import {
|
||||
createPlanningCalculation as createPlanningCalculationOperation,
|
||||
planLeave as planLeaveOperation,
|
||||
LeaveType,
|
||||
PlanOutcome,
|
||||
@@ -30,14 +31,19 @@ export class CalendarService {
|
||||
private dateCache = inject(DateCacheService);
|
||||
|
||||
readonly planLeave = planLeaveOperation;
|
||||
readonly createPlanningCalculation = createPlanningCalculationOperation;
|
||||
|
||||
getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } {
|
||||
const start = planningDateInParis(now);
|
||||
return { start, end: addPlanningYears(start, 1) };
|
||||
}
|
||||
|
||||
selectedDatesForPlan(plan: PlanOutcome): SelectedDates {
|
||||
const selected = this.getSelectedDatesForYear();
|
||||
selectedDatesForPlan(
|
||||
plan: Pick<PlanOutcome, 'placements'> & { horizon?: { start: string; end: string } },
|
||||
): SelectedDates {
|
||||
const selected = plan.horizon
|
||||
? this.getSelectedDatesForHorizon(plan.horizon)
|
||||
: this.getSelectedDatesForYear();
|
||||
for (const placement of plan.placements) {
|
||||
selected.push(
|
||||
new SelectedDate(
|
||||
@@ -65,8 +71,22 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
getSelectedDatesForYear(): SelectedDates {
|
||||
const holidays = this.getHolidays();
|
||||
const monthInAYear = this.monthsInAYearFromNow();
|
||||
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
|
||||
}
|
||||
|
||||
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
|
||||
const start = new Date(`${horizon.start}T00:00:00`);
|
||||
const last = new Date(`${horizon.end}T00:00:00`);
|
||||
last.setDate(last.getDate() - 1);
|
||||
const holidays = this.getHolidays(start.getFullYear());
|
||||
const monthInHorizon: Year = [];
|
||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
while (cursor <= last) {
|
||||
monthInHorizon.push(
|
||||
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
|
||||
);
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
const selectedDates: SelectedDates = new SelectedDates();
|
||||
|
||||
// Use DateCacheService to get today's date components
|
||||
@@ -79,7 +99,7 @@ export class CalendarService {
|
||||
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
|
||||
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
|
||||
|
||||
for (const weeksInAMonth of monthInAYear) {
|
||||
for (const weeksInAMonth of monthInHorizon) {
|
||||
for (const daysInAWeek of weeksInAMonth) {
|
||||
for (const day of daysInAWeek) {
|
||||
if (day) {
|
||||
@@ -131,10 +151,9 @@ export class CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
getHolidays(): Date[] {
|
||||
getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] {
|
||||
// Cache holidays to avoid recalculation
|
||||
const currentYear = this.dateCache.getCurrentYear();
|
||||
if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) {
|
||||
if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) {
|
||||
// Return timestamps directly for efficient lookup
|
||||
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
|
||||
}
|
||||
@@ -142,10 +161,10 @@ export class CalendarService {
|
||||
// Logic to return holidays for the given year
|
||||
const hd = new Holidays('FR', allTypesOptions);
|
||||
const holidays = hd
|
||||
.getHolidays(currentYear, lang)
|
||||
.getHolidays(startYear, lang)
|
||||
.filter((holiday) => holiday.type === 'public');
|
||||
const holidaysPlusOne = hd
|
||||
.getHolidays(currentYear + 1, lang)
|
||||
.getHolidays(startYear + 1, lang)
|
||||
.filter((holiday) => holiday.type === 'public');
|
||||
holidays.push(...holidaysPlusOne);
|
||||
const holidaysDate: Date[] = holidays.map((holiday) => {
|
||||
@@ -155,7 +174,7 @@ export class CalendarService {
|
||||
|
||||
// Update cache
|
||||
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
||||
this.holidayCacheYear = currentYear;
|
||||
this.holidayCacheYear = startYear;
|
||||
|
||||
return holidaysDate;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,10 @@
|
||||
import { vi } from 'vitest';
|
||||
import { PlanningOutcome, PlanningRequest, planLeave } from './leave-planner.service';
|
||||
import {
|
||||
PlanningOutcome,
|
||||
PlanningRequest,
|
||||
createPlanningCalculation,
|
||||
planLeave,
|
||||
} from './leave-planner.service';
|
||||
|
||||
const horizon = { start: '2024-01-01', end: '2025-01-01' };
|
||||
const balance = (
|
||||
@@ -1113,6 +1118,43 @@ describe('planLeave transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not charge reported trace instrumentation time to the planning deadline', async () => {
|
||||
const expected = isPlan(
|
||||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||||
);
|
||||
class TraceAwareWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
postMessage(): void {
|
||||
this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent);
|
||||
setTimeout(() => {
|
||||
this.onmessage?.({
|
||||
data: { type: 'result', outcome: expected },
|
||||
} as MessageEvent);
|
||||
this.onmessage?.({
|
||||
data: { type: 'artifacts', artifacts: {} },
|
||||
} as MessageEvent);
|
||||
}, 15);
|
||||
}
|
||||
|
||||
terminate(): void {}
|
||||
}
|
||||
vi.stubGlobal('Worker', TraceAwareWorker);
|
||||
try {
|
||||
expect(
|
||||
await planLeave({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
deadlineMs: 10,
|
||||
}),
|
||||
).toEqual(expected);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends a detached, callback-free request to the worker transport', async () => {
|
||||
const sourceDate = new Date('2024-01-01T00:00:00Z');
|
||||
const request = {
|
||||
@@ -1197,3 +1239,602 @@ describe('planLeave transport', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planning calculation context', () => {
|
||||
it('keeps the planning outcome identical when trace instrumentation is enabled', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 3, '2024-01-31')],
|
||||
rules: [],
|
||||
};
|
||||
const withoutTrace = core.calculatePlanningInternal(request);
|
||||
let traceStatus: string | undefined;
|
||||
const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => {
|
||||
traceStatus = artifacts.trace?.status;
|
||||
});
|
||||
|
||||
expect(withTrace).toEqual(withoutTrace);
|
||||
expect(traceStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('keeps an immutable submitted snapshot beside the outcome', async () => {
|
||||
const request = {
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
labels: { cp: 'Paid leave' },
|
||||
};
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation(request);
|
||||
request.balances[0].quantity = 99;
|
||||
request.labels.cp = 'Changed later';
|
||||
|
||||
expect(calculation.snapshot.balances[0].quantity).toBe(1);
|
||||
expect(calculation.snapshot.labels['cp']).toBe('Paid leave');
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('materializes a chronological explanation after the unchanged outcome', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-15' },
|
||||
balances: [
|
||||
{
|
||||
...balance('cp', 'cp', 1, '2026-01-01'),
|
||||
name: 'Paid leave entitlement',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'winter',
|
||||
name: 'Winter break',
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-12',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
labels: { winter: 'Winter break' },
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
expect('explanation' in outcome).toBe(false);
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(explanation.placements).toHaveLength(outcome.placements.length);
|
||||
expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement');
|
||||
expect(explanation.placements[0].ruleIds).toContain('winter');
|
||||
expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1);
|
||||
expect('score' in explanation.placements[0].objectiveVector).toBe(false);
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe(
|
||||
explanation.placements[0].comparison.firstDecisiveObjective,
|
||||
);
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter');
|
||||
const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
expect(alternatives.kind).toBe('Counterfactual');
|
||||
if (alternatives.kind === 'Counterfactual')
|
||||
expect(
|
||||
alternatives.results.find(
|
||||
(result) => result.date === outcome.placements[0].date,
|
||||
)?.comparison?.firstDecisiveObjective,
|
||||
).toBe('preference:winter');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports every rule contribution when one placement is shared', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-06' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [
|
||||
{
|
||||
id: 'wide',
|
||||
name: 'Wide window',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'exact',
|
||||
name: 'Exact day',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-05',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(
|
||||
explanation.placements[0].contributions
|
||||
.filter((contribution) => contribution.kind === 'rule')
|
||||
.map((contribution) => contribution.ruleId),
|
||||
).toEqual(['exact', 'wide']);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('compares the selected placement across the half-open horizon without changing the plan', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-12-31')],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const before = structuredClone(outcome);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(analysis.results).toHaveLength(9);
|
||||
expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
analysis.results.some(
|
||||
(result) => result.status === 'eligible' && result.comparison,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(outcome).toEqual(before);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves supporting provenance in counterfactual placements', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const selected = outcome.placements.find((placement) => placement.supporting);
|
||||
expect(selected).toBeDefined();
|
||||
if (!selected) return;
|
||||
|
||||
const analysis = await calculation.analyzeCounterfactual(selected);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
const restored = analysis.results.find((result) => result.date === selected.date);
|
||||
expect(
|
||||
restored?.comparison?.after.objectives.find(
|
||||
(objective) => objective.id === 'supporting-placements',
|
||||
)?.value,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('discards counterfactual work from an older calculation', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const older = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
});
|
||||
const olderOutcome = await older.outcome;
|
||||
expect(olderOutcome.kind).toBe('Plan');
|
||||
if (olderOutcome.kind !== 'Plan') return;
|
||||
createPlanningCalculation({ horizon, balances: [], rules: [] });
|
||||
|
||||
const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]);
|
||||
expect(analysis.kind).toBe('CounterfactualStale');
|
||||
expect(analysis.results).toEqual([]);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps earned-entitlement availability and provenance in counterfactuals', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||||
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')],
|
||||
rules: [
|
||||
{
|
||||
id: 'wednesday-holidays',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
{
|
||||
id: 'thursday-consumer',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 4,
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
placement: 'before',
|
||||
rewardDays: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const earned = outcome.placements.find((placement) =>
|
||||
placement.balanceId.startsWith('earned:'),
|
||||
);
|
||||
expect(earned).toBeDefined();
|
||||
if (!earned) return;
|
||||
const analysis = await calculation.analyzeCounterfactual(earned);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(analysis.placement).toEqual(earned);
|
||||
expect(
|
||||
analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code,
|
||||
).toBe('unavailable');
|
||||
const trace = await calculation.trace;
|
||||
const earnedBalanceDelta = trace?.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.deltas)
|
||||
.find((delta) => delta.balance?.id === earned.balanceId);
|
||||
expect(earnedBalanceDelta?.balance).toMatchObject({
|
||||
id: earned.balanceId,
|
||||
type: earned.type,
|
||||
earned: true,
|
||||
});
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('replays a trace decision from its pre-decision state', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'wide',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
const decision = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.find((candidate) =>
|
||||
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
|
||||
);
|
||||
const placement = decision?.deltas.find(
|
||||
(delta) => delta.kind === 'placement-added',
|
||||
)?.placement;
|
||||
expect(decision).toBeDefined();
|
||||
expect(placement).toBeDefined();
|
||||
if (!decision || !placement) return;
|
||||
const laterPlacement = outcome.placements.find(
|
||||
(candidate) => candidate.date !== placement.date,
|
||||
);
|
||||
expect(laterPlacement).toBeDefined();
|
||||
if (!laterPlacement) return;
|
||||
|
||||
const analysis = await calculation.analyzeCounterfactual({
|
||||
decisionId: decision.id,
|
||||
placement,
|
||||
});
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(
|
||||
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
|
||||
).toBe('eligible');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the returned plan from the original trace deltas', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 4, '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 placements = new Map(
|
||||
trace.initialState.placements.map((placement) => [placement.date, placement]),
|
||||
);
|
||||
for (const delta of trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) => decision.deltas),
|
||||
)) {
|
||||
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
|
||||
if (delta.placement) placements.set(delta.placement.date, delta.placement);
|
||||
}
|
||||
expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual(
|
||||
outcome.placements,
|
||||
);
|
||||
expect(trace.phases.map((phase) => phase.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'obligations',
|
||||
'at-risk',
|
||||
'holiday-week-value',
|
||||
'non-working-day-gain',
|
||||
'longest-work-sequence',
|
||||
'short-sequence-deficit',
|
||||
'supporting-placements',
|
||||
'expiry',
|
||||
'dates',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.candidates).length,
|
||||
).toBeGreaterThan(20);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('records loaded-plan pruning and full quality comparisons without truncation', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-04-01' },
|
||||
balances: [balance('cp', 'cp', 25, '2024-03-31')],
|
||||
rules: [],
|
||||
});
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
const trace = await calculation.trace;
|
||||
const candidates =
|
||||
trace?.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) => decision.candidates),
|
||||
) ?? [];
|
||||
expect(
|
||||
candidates.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'not-examined' &&
|
||||
candidate.reason?.code === 'loaded-plan-sampling',
|
||||
),
|
||||
).toBe(true);
|
||||
const comparison = candidates.find((candidate) => candidate.comparison)?.comparison;
|
||||
expect(comparison?.before.objectives.length).toBeGreaterThan(1);
|
||||
expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length);
|
||||
expect(
|
||||
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers no explanation trace for invalid input and explains conflicts', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const invalidCalculation = createPlanningCalculation({
|
||||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
});
|
||||
expect((await invalidCalculation.outcome).kind).toBe('InvalidInput');
|
||||
expect(await invalidCalculation.explanation).toBeUndefined();
|
||||
expect(await invalidCalculation.trace).toBeUndefined();
|
||||
|
||||
const conflictCalculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('rtt', 'rtt', 1)],
|
||||
rules: [
|
||||
{
|
||||
id: 'cp-only',
|
||||
name: 'Paid leave only',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-04-01',
|
||||
endDate: '2024-04-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict');
|
||||
const explanation = await conflictCalculation.explanation;
|
||||
expect(explanation?.kind).toBe('ConflictExplanation');
|
||||
if (explanation?.kind === 'ConflictExplanation')
|
||||
expect(explanation.conflictingRules).toEqual([
|
||||
{ id: 'cp-only', label: 'Paid leave only' },
|
||||
]);
|
||||
const trace = await conflictCalculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks interrupted and unexecuted phases when no incumbent exists', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 5)],
|
||||
rules: [],
|
||||
deadlineMs: 0,
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('IncompleteExplanation');
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true);
|
||||
expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon,
|
||||
balances: Array.from({ length: 12 }, (_, index) =>
|
||||
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
|
||||
),
|
||||
rules: [],
|
||||
deadlineMs: 20,
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.incumbent).toBeDefined();
|
||||
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
|
||||
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
|
||||
expect(artifacts.explanation.placements).toHaveLength(
|
||||
outcome.incumbent?.placements.length ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes the worker outcome before delayed explanation artifacts', async () => {
|
||||
const expected = isPlan(
|
||||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||||
);
|
||||
const trace = {
|
||||
status: 'ready' as const,
|
||||
initialState: {
|
||||
horizon,
|
||||
balances: [],
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
phases: [],
|
||||
completedStages: [],
|
||||
};
|
||||
class DelayedArtifactsWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
postMessage(): void {
|
||||
queueMicrotask(() =>
|
||||
this.onmessage?.({
|
||||
data: { type: 'result', outcome: expected },
|
||||
} as MessageEvent),
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
this.onmessage?.({
|
||||
data: { type: 'artifacts', artifacts: { trace } },
|
||||
} as MessageEvent),
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
terminate(): void {}
|
||||
}
|
||||
vi.stubGlobal('Worker', DelayedArtifactsWorker);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
});
|
||||
let traceReady = false;
|
||||
void calculation.trace.then(() => (traceReady = true));
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
expect(traceReady).toBe(false);
|
||||
expect((await calculation.trace)?.status).toBe('ready');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a valid outcome when trace delivery fails afterward', async () => {
|
||||
const expected = isPlan(
|
||||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||||
);
|
||||
class FailingArtifactsWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
postMessage(): void {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: { type: 'result', outcome: expected },
|
||||
} as MessageEvent);
|
||||
queueMicrotask(() => this.onerror?.());
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): void {}
|
||||
}
|
||||
vi.stubGlobal('Worker', FailingArtifactsWorker);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
});
|
||||
expect(await calculation.outcome).toEqual(expected);
|
||||
expect((await calculation.trace)?.status).toBe('failed');
|
||||
expect((await calculation.explanationError)?.code).toBe('worker-error');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,71 @@
|
||||
import type {
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualBaselineOptions,
|
||||
CounterfactualDateResult,
|
||||
CounterfactualSelection,
|
||||
InvalidInputOutcome,
|
||||
LeaveBalanceInput,
|
||||
LeavePlacement,
|
||||
LeaveType,
|
||||
PlanOutcome,
|
||||
PlanningArtifacts,
|
||||
PlanningExplanation,
|
||||
PlanningIncompleteOutcome,
|
||||
PlanningOutcome,
|
||||
PlanningRequest,
|
||||
PlanningSnapshot,
|
||||
PlanningTrace,
|
||||
PlanningReason,
|
||||
PlanningTraceState,
|
||||
StaleCounterfactualAnalysis,
|
||||
TraceCounterfactualSelection,
|
||||
} from './leave-planner.core';
|
||||
|
||||
export type {
|
||||
IfThenPlanningRule,
|
||||
ConflictExplanation,
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
CounterfactualDateResult,
|
||||
CounterfactualReasonCode,
|
||||
InvalidInputOutcome,
|
||||
IncompleteExplanation,
|
||||
LeaveBalanceInput,
|
||||
LeavePlacement,
|
||||
LeaveSelector,
|
||||
LeaveType,
|
||||
PlanOutcome,
|
||||
PlanExplanation,
|
||||
PlacementMode,
|
||||
PlanningConflictOutcome,
|
||||
PlanningDate,
|
||||
PlanningHorizon,
|
||||
PlanningIncompleteOutcome,
|
||||
PlanningExplanation,
|
||||
PlanningMetrics,
|
||||
PlanningObjective,
|
||||
PlanningObjectiveComparison,
|
||||
PlanningObjectiveDelta,
|
||||
PlanningObjectiveId,
|
||||
PlanningObjectiveVector,
|
||||
PlanningOutcome,
|
||||
PlanningReason,
|
||||
PlanningRequest,
|
||||
PlanningRule,
|
||||
PlanningSnapshot,
|
||||
PlanningTrace,
|
||||
PlanningTraceCandidate,
|
||||
PlanningTraceDecision,
|
||||
PlanningTraceDelta,
|
||||
PlanningTracePhase,
|
||||
PlanningTraceState,
|
||||
PreferenceResult,
|
||||
RemainingBalance,
|
||||
RemainingClassification,
|
||||
RuleForce,
|
||||
TimeOffPeriod,
|
||||
TimeWindowPlanningRule,
|
||||
TraceCounterfactualSelection,
|
||||
} from './leave-planner.core';
|
||||
|
||||
const MAX_PLANNING_MS = 10_000;
|
||||
@@ -40,12 +76,15 @@ interface UntrustedRecord {
|
||||
end?: unknown;
|
||||
balances?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
deadlineMs?: unknown;
|
||||
progress?: unknown;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
quantity?: unknown;
|
||||
label?: unknown;
|
||||
availableFrom?: unknown;
|
||||
expiryDate?: unknown;
|
||||
enabled?: unknown;
|
||||
@@ -60,10 +99,12 @@ interface UntrustedRecord {
|
||||
rewardType?: unknown;
|
||||
}
|
||||
|
||||
interface PlanningSnapshot {
|
||||
interface TransportSnapshot {
|
||||
horizon?: { start?: unknown; end?: unknown };
|
||||
balances?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
deadlineMs?: unknown;
|
||||
progress?: unknown;
|
||||
}
|
||||
@@ -76,7 +117,71 @@ function copyDate(value: unknown): unknown {
|
||||
return value instanceof Date ? new Date(value.getTime()) : value;
|
||||
}
|
||||
|
||||
function snapshotRequest(request: unknown): PlanningSnapshot {
|
||||
function cloneUnknown(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
|
||||
if (value instanceof Date) return new Date(value.getTime());
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
const previous = seen.get(value);
|
||||
if (previous) return previous;
|
||||
if (Array.isArray(value)) {
|
||||
const result: unknown[] = [];
|
||||
seen.set(value, result);
|
||||
for (const item of value) result.push(cloneUnknown(item, seen));
|
||||
return result;
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
seen.set(value, result);
|
||||
for (const [key, item] of Object.entries(value)) result[key] = cloneUnknown(item, seen);
|
||||
return result;
|
||||
}
|
||||
|
||||
function snapshotValue(value: unknown): unknown {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) return value.map(snapshotValue);
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) result[key] = snapshotValue(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (typeof value !== 'object' || value === null || seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child, seen);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function stringMap(value: unknown): Readonly<Record<string, string>> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
typeof item === 'string' ? item : String(item),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
|
||||
const horizon = request.horizon ?? {};
|
||||
const balances = Array.isArray(request.balances)
|
||||
? request.balances.map((value) => snapshotValue(value) as LeaveBalanceInput)
|
||||
: [];
|
||||
const rules = Array.isArray(request.rules)
|
||||
? request.rules.map((value) => snapshotValue(value) as PlanningRequest['rules'][number])
|
||||
: [];
|
||||
const labels = stringMap(request.labels);
|
||||
return deepFreeze({
|
||||
horizon: {
|
||||
start: String(snapshotValue(horizon.start) ?? ''),
|
||||
end: String(snapshotValue(horizon.end) ?? ''),
|
||||
},
|
||||
balances,
|
||||
rules,
|
||||
identities: stringMap(request.identities),
|
||||
labels,
|
||||
}) as PlanningSnapshot;
|
||||
}
|
||||
|
||||
function snapshotRequest(request: unknown): TransportSnapshot {
|
||||
const source = asRecord(request) ?? {};
|
||||
const horizon = asRecord(source.horizon);
|
||||
const balances = Array.isArray(source.balances)
|
||||
@@ -86,6 +191,8 @@ function snapshotRequest(request: unknown): PlanningSnapshot {
|
||||
id: value?.id,
|
||||
type: value?.type,
|
||||
quantity: value?.quantity,
|
||||
name: value?.name,
|
||||
label: value?.label,
|
||||
availableFrom:
|
||||
value?.availableFrom === undefined
|
||||
? undefined
|
||||
@@ -133,17 +240,19 @@ function snapshotRequest(request: unknown): PlanningSnapshot {
|
||||
: undefined,
|
||||
balances,
|
||||
rules,
|
||||
identities: cloneUnknown(source.identities),
|
||||
labels: cloneUnknown(source.labels),
|
||||
deadlineMs: source.deadlineMs,
|
||||
progress: typeof source.progress === 'function' ? source.progress : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function deadlineFor(request: PlanningSnapshot): number {
|
||||
function deadlineFor(request: TransportSnapshot): number {
|
||||
const configured = typeof request.deadlineMs === 'number' ? request.deadlineMs : undefined;
|
||||
return Math.min(Math.max(0, configured ?? MAX_PLANNING_MS), MAX_PLANNING_MS);
|
||||
}
|
||||
|
||||
function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undefined {
|
||||
function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined {
|
||||
const deadline = request.deadlineMs;
|
||||
if (
|
||||
deadline !== undefined &&
|
||||
@@ -163,10 +272,56 @@ function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undef
|
||||
|
||||
type PlannerWorkerResponse =
|
||||
| { type: 'progress'; stage: string }
|
||||
| { type: 'trace-overhead'; totalMs: number }
|
||||
| { type: 'checkpoint'; plan: PlanOutcome }
|
||||
| { type: 'result'; outcome: PlanningOutcome }
|
||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
interface PlanningRun {
|
||||
outcome: Promise<PlanningOutcome>;
|
||||
artifacts: Promise<PlanningArtifacts>;
|
||||
}
|
||||
|
||||
function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
|
||||
const horizon = snapshot.horizon ?? {};
|
||||
const balances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
|
||||
return {
|
||||
horizon: {
|
||||
start: String(horizon.start ?? ''),
|
||||
end: String(horizon.end ?? ''),
|
||||
},
|
||||
balances: balances.map((item) => {
|
||||
const balance = asRecord(item);
|
||||
return {
|
||||
id: String(balance?.id ?? ''),
|
||||
type: String(balance?.type ?? 'other') as LeaveType,
|
||||
quantity: typeof balance?.quantity === 'number' ? balance.quantity : 0,
|
||||
remaining: typeof balance?.quantity === 'number' ? balance.quantity : 0,
|
||||
availableFrom: String(balance?.availableFrom ?? horizon.start ?? ''),
|
||||
expiryDate: String(balance?.expiryDate ?? ''),
|
||||
earned: false,
|
||||
};
|
||||
}),
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackTrace(
|
||||
snapshot: TransportSnapshot,
|
||||
status: PlanningTrace['status'],
|
||||
error?: PlanningReason,
|
||||
): PlanningTrace {
|
||||
return {
|
||||
status,
|
||||
initialState: traceStateFor(snapshot),
|
||||
phases: [],
|
||||
completedStages: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function interruptedOutcome(
|
||||
checkpoint: PlanOutcome | undefined,
|
||||
deadline: number,
|
||||
@@ -182,40 +337,71 @@ function interruptedOutcome(
|
||||
};
|
||||
}
|
||||
|
||||
function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise<PlanningOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
} catch {
|
||||
resolve(
|
||||
interruptedOutcome(
|
||||
undefined,
|
||||
deadline,
|
||||
'worker-unavailable',
|
||||
'Planning could not start because isolated execution is unavailable.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
|
||||
let resolveOutcome!: (outcome: PlanningOutcome) => void;
|
||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
||||
resolveOutcome = resolve;
|
||||
});
|
||||
const artifacts = new Promise<PlanningArtifacts>((resolve) => {
|
||||
resolveArtifacts = resolve;
|
||||
});
|
||||
try {
|
||||
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let outcomeSettled = false;
|
||||
let artifactsSettled = false;
|
||||
let checkpoint: PlanOutcome | undefined;
|
||||
const finish = (outcome: PlanningOutcome) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
let traceAllowance = 0;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const timeoutStarted = performance.now();
|
||||
const terminate = () => {
|
||||
clearTimeout(timeout);
|
||||
worker.onmessage = null;
|
||||
worker.onerror = null;
|
||||
worker.terminate();
|
||||
resolve(outcome);
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => finish(interruptedOutcome(checkpoint, deadline)),
|
||||
Math.max(0, deadline - 1),
|
||||
);
|
||||
const finishOutcome = (result: PlanningOutcome) => {
|
||||
if (!outcomeSettled) {
|
||||
outcomeSettled = true;
|
||||
clearTimeout(timeout);
|
||||
resolveOutcome(result);
|
||||
}
|
||||
};
|
||||
const finishArtifacts = (result: PlanningArtifacts) => {
|
||||
if (!artifactsSettled) {
|
||||
artifactsSettled = true;
|
||||
resolveArtifacts(result);
|
||||
terminate();
|
||||
}
|
||||
};
|
||||
const fail = (code: string, message: string) => {
|
||||
const reason = { code, message };
|
||||
if (!outcomeSettled)
|
||||
finishOutcome(interruptedOutcome(checkpoint, deadline, code, message));
|
||||
finishArtifacts({
|
||||
trace: fallbackTrace(
|
||||
snapshot,
|
||||
code === 'deadline' ? 'incomplete' : 'failed',
|
||||
reason,
|
||||
),
|
||||
explanationError: reason,
|
||||
});
|
||||
};
|
||||
const scheduleTimeout = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
fail(
|
||||
'deadline',
|
||||
`Planning stopped after ${deadline}ms before all quality stages completed.`,
|
||||
),
|
||||
Math.max(0, deadline + traceAllowance - (performance.now() - timeoutStarted)),
|
||||
);
|
||||
};
|
||||
scheduleTimeout();
|
||||
|
||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
||||
const message = event.data;
|
||||
@@ -224,65 +410,489 @@ function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise<Pla
|
||||
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'trace-overhead') {
|
||||
traceAllowance = Math.max(traceAllowance, message.totalMs);
|
||||
scheduleTimeout();
|
||||
return;
|
||||
}
|
||||
if (message.type === 'checkpoint') {
|
||||
checkpoint = message.plan;
|
||||
return;
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
finish(message.outcome);
|
||||
finishOutcome(message.outcome);
|
||||
if (message.outcome.kind === 'InvalidInput') finishArtifacts({});
|
||||
return;
|
||||
}
|
||||
finish(interruptedOutcome(checkpoint, deadline, 'worker-error', message.message));
|
||||
if (message.type === 'artifacts') {
|
||||
finishArtifacts(message.artifacts);
|
||||
return;
|
||||
}
|
||||
fail('worker-error', message.message);
|
||||
};
|
||||
worker.onerror = () =>
|
||||
finish(
|
||||
interruptedOutcome(
|
||||
checkpoint,
|
||||
deadline,
|
||||
'worker-error',
|
||||
'Planning worker failed before returning an outcome.',
|
||||
),
|
||||
);
|
||||
fail('worker-error', 'Planning worker failed before returning explanation data.');
|
||||
try {
|
||||
worker.postMessage({
|
||||
type: 'plan',
|
||||
request: { ...snapshot, deadlineMs: deadline, progress: undefined },
|
||||
});
|
||||
} catch {
|
||||
finish(
|
||||
interruptedOutcome(
|
||||
checkpoint,
|
||||
deadline,
|
||||
'worker-error',
|
||||
'Planning input could not be sent to the isolated worker.',
|
||||
),
|
||||
);
|
||||
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
|
||||
const snapshot = snapshotRequest(request);
|
||||
const deadlineError = invalidDeadline(snapshot);
|
||||
if (deadlineError) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
return deadlineError;
|
||||
}
|
||||
const deadline = deadlineFor(snapshot);
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof Worker !== 'undefined') return planInWorker(snapshot, deadline);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
const core = await import('./leave-planner.core');
|
||||
const validation = core.invalidInputFor(snapshot);
|
||||
if (validation) return validation;
|
||||
return interruptedOutcome(
|
||||
} catch {
|
||||
const result = interruptedOutcome(
|
||||
undefined,
|
||||
deadline,
|
||||
'worker-unavailable',
|
||||
'Planning could not start because isolated execution is unavailable.',
|
||||
);
|
||||
resolveOutcome(result);
|
||||
resolveArtifacts({
|
||||
trace: fallbackTrace(snapshot, 'failed', result.reasons[0]),
|
||||
explanationError: result.reasons[0],
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
return { outcome, artifacts };
|
||||
}
|
||||
|
||||
let calculationSequence = 0;
|
||||
let latestCalculationId = '';
|
||||
|
||||
function selectionFor(
|
||||
plan: PlanOutcome,
|
||||
selection: CounterfactualSelection,
|
||||
): LeavePlacement | undefined {
|
||||
if (typeof selection === 'object' && 'decisionId' in selection) return selection.placement;
|
||||
if (typeof selection !== 'string')
|
||||
return plan.placements.find(
|
||||
(candidate) =>
|
||||
candidate.date === selection.date && candidate.balanceId === selection.balanceId,
|
||||
);
|
||||
return plan.placements.find(
|
||||
(candidate) => candidate.date === selection || candidate.balanceId === selection,
|
||||
);
|
||||
}
|
||||
|
||||
function traceSelection(
|
||||
selection: CounterfactualSelection,
|
||||
): selection is TraceCounterfactualSelection {
|
||||
return typeof selection === 'object' && 'decisionId' in selection;
|
||||
}
|
||||
|
||||
function stateBeforeDecision(
|
||||
trace: PlanningTrace,
|
||||
decisionId: string,
|
||||
finalState?: PlanningTraceState,
|
||||
): PlanningTraceState | 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],
|
||||
}));
|
||||
for (const decision of trace.phases.flatMap((phase) => phase.decisions)) {
|
||||
if (decision.id === decisionId)
|
||||
return {
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
balances,
|
||||
placements: [...placements.values()],
|
||||
earnedActivations,
|
||||
};
|
||||
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],
|
||||
});
|
||||
if (delta.kind === 'balance-changed' && delta.balanceId) {
|
||||
let balance = balances.find((candidate) => candidate.id === delta.balanceId);
|
||||
if (!balance && delta.balance) {
|
||||
balance = { ...delta.balance };
|
||||
balances.push(balance);
|
||||
}
|
||||
if (!balance) {
|
||||
const source = finalState?.balances.find(
|
||||
(candidate) => candidate.id === delta.balanceId,
|
||||
);
|
||||
if (source) {
|
||||
balance = { ...source };
|
||||
balances.push(balance);
|
||||
}
|
||||
}
|
||||
if (balance && delta.remainingAfter !== undefined)
|
||||
balance.remaining = delta.remainingAfter;
|
||||
}
|
||||
if (
|
||||
delta.kind === 'earned-activated' &&
|
||||
delta.activation &&
|
||||
!earnedActivations.some((activation) => activation.id === delta.activation!.id)
|
||||
)
|
||||
earnedActivations.push({
|
||||
...delta.activation,
|
||||
dates: [...delta.activation.dates],
|
||||
});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function decisionBaseline(
|
||||
trace: PlanningTrace,
|
||||
selection: TraceCounterfactualSelection,
|
||||
finalState?: PlanningTraceState,
|
||||
): { state: PlanningTraceState; options: CounterfactualBaselineOptions } | undefined {
|
||||
const decision = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.find((candidate) => candidate.id === selection.decisionId);
|
||||
const state = stateBeforeDecision(trace, selection.decisionId, finalState);
|
||||
if (!decision || !state) return undefined;
|
||||
const replacement = decision.deltas.find(
|
||||
(delta) =>
|
||||
(delta.kind === 'placement-moved' || delta.kind === 'placement-updated') &&
|
||||
delta.placement?.date === selection.placement.date &&
|
||||
delta.placement.balanceId === selection.placement.balanceId,
|
||||
);
|
||||
return {
|
||||
state,
|
||||
options: {
|
||||
removeDate:
|
||||
replacement?.kind === 'placement-moved'
|
||||
? (replacement.fromDate ?? null)
|
||||
: (replacement?.date ?? null),
|
||||
compareOriginalState: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withHistoricalStatuses(
|
||||
analysis: CounterfactualAnalysis,
|
||||
trace?: PlanningTrace,
|
||||
decisionId?: string,
|
||||
): CounterfactualAnalysis {
|
||||
if (!trace) return analysis;
|
||||
const priority = new Map([
|
||||
['ineligible', 0],
|
||||
['not-examined', 1],
|
||||
['pruned', 2],
|
||||
['evaluated-rejected', 3],
|
||||
['chosen', 4],
|
||||
]);
|
||||
const statuses = new Map<string, CounterfactualDateResult['historicalStatus']>();
|
||||
for (const candidate of trace.phases.flatMap((phase) =>
|
||||
phase.decisions
|
||||
.filter((decision) => !decisionId || decision.id === decisionId)
|
||||
.flatMap((decision) => decision.candidates),
|
||||
)) {
|
||||
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
|
||||
const previous = statuses.get(date);
|
||||
if (!previous || priority.get(candidate.status)! > priority.get(previous)!)
|
||||
statuses.set(date, candidate.status);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...analysis,
|
||||
results: analysis.results.map((result) => ({
|
||||
...result,
|
||||
historicalStatus:
|
||||
statuses.get(result.date) ?? (decisionId ? 'not-examined' : undefined),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCounterfactual(
|
||||
context: PlanningCalculationContext,
|
||||
selection: CounterfactualSelection,
|
||||
artifacts: Promise<PlanningArtifacts>,
|
||||
): Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis> {
|
||||
if (context.id !== latestCalculationId) {
|
||||
const plan = await context.outcome;
|
||||
const finalPlan =
|
||||
plan.kind === 'Plan'
|
||||
? plan
|
||||
: plan.kind === 'PlanningIncomplete'
|
||||
? plan.incumbent
|
||||
: undefined;
|
||||
const placement = finalPlan ? selectionFor(finalPlan, selection) : undefined;
|
||||
if (!placement)
|
||||
throw new Error('The selected placement is not present in the calculation.');
|
||||
return {
|
||||
kind: 'CounterfactualStale',
|
||||
calculationId: context.id,
|
||||
placement,
|
||||
hypothetical: true,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
const outcome = await context.outcome;
|
||||
if (context.id !== latestCalculationId) {
|
||||
const plan =
|
||||
outcome.kind === 'Plan'
|
||||
? outcome
|
||||
: outcome.kind === 'PlanningIncomplete'
|
||||
? outcome.incumbent
|
||||
: undefined;
|
||||
const placement = plan ? selectionFor(plan, selection) : undefined;
|
||||
if (!placement)
|
||||
throw new Error('The selected placement is not present in the calculation.');
|
||||
return {
|
||||
kind: 'CounterfactualStale',
|
||||
calculationId: context.id,
|
||||
placement,
|
||||
hypothetical: true,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
const plan =
|
||||
outcome.kind === 'Plan'
|
||||
? outcome
|
||||
: outcome.kind === 'PlanningIncomplete'
|
||||
? outcome.incumbent
|
||||
: undefined;
|
||||
if (!plan) throw new Error('Counterfactual analysis requires a plan outcome.');
|
||||
const placement = selectionFor(plan, selection);
|
||||
if (!placement) throw new Error('The selected placement is not present in the calculation.');
|
||||
const completedArtifacts = await artifacts;
|
||||
const selectedDecision = traceSelection(selection)
|
||||
? completedArtifacts.trace &&
|
||||
decisionBaseline(completedArtifacts.trace, selection, completedArtifacts.finalState)
|
||||
: undefined;
|
||||
if (traceSelection(selection) && !selectedDecision)
|
||||
throw new Error('The selected trace decision is unavailable.');
|
||||
const analysisState = selectedDecision?.state ?? completedArtifacts.finalState;
|
||||
const options = selectedDecision?.options;
|
||||
if (context.id !== latestCalculationId)
|
||||
return {
|
||||
kind: 'CounterfactualStale',
|
||||
calculationId: context.id,
|
||||
placement,
|
||||
hypothetical: true,
|
||||
results: [],
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof Worker !== 'undefined') {
|
||||
const result = await counterfactualInWorker(
|
||||
context.snapshot,
|
||||
plan,
|
||||
placement,
|
||||
analysisState,
|
||||
options,
|
||||
);
|
||||
return context.id === latestCalculationId
|
||||
? withHistoricalStatuses(
|
||||
{ ...result, calculationId: context.id },
|
||||
completedArtifacts.trace,
|
||||
traceSelection(selection) ? selection.decisionId : undefined,
|
||||
)
|
||||
: {
|
||||
kind: 'CounterfactualStale',
|
||||
calculationId: context.id,
|
||||
placement,
|
||||
hypothetical: true,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
const core = await import('./leave-planner.core');
|
||||
return core.calculatePlanningInternal(snapshot);
|
||||
const result = core.analyzeCounterfactualInternal(
|
||||
context.snapshot,
|
||||
plan,
|
||||
placement,
|
||||
analysisState,
|
||||
options,
|
||||
);
|
||||
return context.id === latestCalculationId
|
||||
? withHistoricalStatuses(
|
||||
{ ...result, calculationId: context.id },
|
||||
completedArtifacts.trace,
|
||||
traceSelection(selection) ? selection.decisionId : undefined,
|
||||
)
|
||||
: {
|
||||
kind: 'CounterfactualStale',
|
||||
calculationId: context.id,
|
||||
placement,
|
||||
hypothetical: true,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
function counterfactualInWorker(
|
||||
snapshot: PlanningSnapshot,
|
||||
plan: PlanOutcome,
|
||||
placement: LeavePlacement,
|
||||
state?: PlanningTraceState,
|
||||
options?: CounterfactualBaselineOptions,
|
||||
): Promise<CounterfactualAnalysis> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const finish = (callback: () => void) => {
|
||||
worker.onmessage = null;
|
||||
worker.onerror = null;
|
||||
worker.terminate();
|
||||
callback();
|
||||
};
|
||||
worker.onmessage = (
|
||||
event: MessageEvent<{
|
||||
type: string;
|
||||
analysis?: CounterfactualAnalysis;
|
||||
message?: string;
|
||||
}>,
|
||||
) => {
|
||||
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
|
||||
finish(() => resolve(event.data.analysis!));
|
||||
else if (event.data?.type === 'error')
|
||||
finish(() =>
|
||||
reject(new Error(event.data.message ?? 'Counterfactual analysis failed.')),
|
||||
);
|
||||
};
|
||||
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
|
||||
try {
|
||||
worker.postMessage({
|
||||
type: 'counterfactual',
|
||||
request: snapshot,
|
||||
plan,
|
||||
placement,
|
||||
state,
|
||||
options,
|
||||
});
|
||||
} catch (error) {
|
||||
finish(() => reject(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface PlanningCalculationContext {
|
||||
readonly id: string;
|
||||
readonly snapshot: PlanningSnapshot;
|
||||
readonly outcome: Promise<PlanningOutcome>;
|
||||
readonly explanation: Promise<PlanningExplanation | undefined>;
|
||||
readonly explanationError: Promise<PlanningReason | undefined>;
|
||||
readonly trace: Promise<PlanningTrace | undefined>;
|
||||
readonly analyzeCounterfactual: (
|
||||
selection: CounterfactualSelection,
|
||||
) => Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis>;
|
||||
}
|
||||
|
||||
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
|
||||
const transport = snapshotRequest(request);
|
||||
const snapshot = publicSnapshot(transport);
|
||||
const id = `calculation:${++calculationSequence}`;
|
||||
latestCalculationId = id;
|
||||
const deadlineError = invalidDeadline(transport);
|
||||
let run: PlanningRun;
|
||||
if (deadlineError) {
|
||||
run = {
|
||||
outcome: new Promise((resolve) => queueMicrotask(() => resolve(deadlineError))),
|
||||
artifacts: Promise.resolve({}),
|
||||
};
|
||||
} else {
|
||||
const deadline = deadlineFor(transport);
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
run = planInWorker(transport, deadline);
|
||||
} else {
|
||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||
const artifacts = new Promise<PlanningArtifacts>(
|
||||
(resolve) => (resolveArtifacts = resolve),
|
||||
);
|
||||
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
||||
queueMicrotask(async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
const validation = core.invalidInputFor(transport);
|
||||
if (validation) {
|
||||
resolve(validation);
|
||||
resolveArtifacts({});
|
||||
return;
|
||||
}
|
||||
const result = interruptedOutcome(
|
||||
undefined,
|
||||
deadline,
|
||||
'worker-unavailable',
|
||||
'Planning could not start because isolated execution is unavailable.',
|
||||
);
|
||||
resolve(result);
|
||||
resolveArtifacts({
|
||||
explanation: {
|
||||
kind: 'IncompleteExplanation',
|
||||
reason: result.reasons[0],
|
||||
completedStages: [],
|
||||
uncompletedStages: [],
|
||||
},
|
||||
trace: fallbackTrace(transport, 'failed', result.reasons[0]),
|
||||
});
|
||||
});
|
||||
});
|
||||
run = { outcome, artifacts };
|
||||
}
|
||||
} else {
|
||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||
const artifacts = new Promise<PlanningArtifacts>(
|
||||
(resolve) => (resolveArtifacts = resolve),
|
||||
);
|
||||
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
const core = await import('./leave-planner.core');
|
||||
let completedArtifacts: PlanningArtifacts = {};
|
||||
const result = core.calculatePlanningInternal(
|
||||
transport,
|
||||
undefined,
|
||||
(value) => (completedArtifacts = value),
|
||||
);
|
||||
resolve(result);
|
||||
queueMicrotask(() => resolveArtifacts(completedArtifacts));
|
||||
} catch (error) {
|
||||
const reason: PlanningReason = {
|
||||
code: 'planner-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
resolve({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [reason],
|
||||
});
|
||||
resolveArtifacts({
|
||||
trace: fallbackTrace(transport, 'failed', reason),
|
||||
explanationError: reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
run = { outcome, artifacts };
|
||||
}
|
||||
}
|
||||
const retainedArtifacts = run.artifacts.then((artifacts) => ({
|
||||
...artifacts,
|
||||
trace:
|
||||
id === latestCalculationId
|
||||
? artifacts.trace
|
||||
: fallbackTrace(transport, 'failed', {
|
||||
code: 'stale-calculation',
|
||||
message: 'This trace belongs to an older calculation and was discarded.',
|
||||
}),
|
||||
}));
|
||||
const context: PlanningCalculationContext = {
|
||||
id,
|
||||
snapshot,
|
||||
outcome: run.outcome,
|
||||
explanation: retainedArtifacts.then((artifacts) => artifacts.explanation),
|
||||
explanationError: retainedArtifacts.then((artifacts) => artifacts.explanationError),
|
||||
trace: retainedArtifacts.then((artifacts) => artifacts.trace),
|
||||
analyzeCounterfactual: (selection) =>
|
||||
runCounterfactual(context, selection, retainedArtifacts),
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
|
||||
return createPlanningCalculation(request).outcome;
|
||||
}
|
||||
@@ -1,41 +1,92 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { PlanningOutcome, PlanOutcome } from './leave-planner.service';
|
||||
import { calculatePlanningInternal } from './leave-planner.core';
|
||||
import type {
|
||||
CounterfactualAnalysis,
|
||||
LeavePlacement,
|
||||
PlanOutcome,
|
||||
PlanningOutcome,
|
||||
PlanningSnapshot,
|
||||
PlanningTraceState,
|
||||
} from './leave-planner.service';
|
||||
import {
|
||||
analyzeCounterfactualInternal,
|
||||
calculatePlanningInternal,
|
||||
type CounterfactualBaselineOptions,
|
||||
type PlanningArtifacts,
|
||||
} from './leave-planner.core';
|
||||
|
||||
type WorkerPlanningRequest = {
|
||||
horizon?: { start?: unknown; end?: unknown };
|
||||
balances?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
deadlineMs?: unknown;
|
||||
progress?: unknown;
|
||||
};
|
||||
|
||||
type WorkerRequest = {
|
||||
type: 'plan';
|
||||
request: WorkerPlanningRequest;
|
||||
};
|
||||
type WorkerRequest =
|
||||
| {
|
||||
type: 'plan';
|
||||
request: WorkerPlanningRequest;
|
||||
}
|
||||
| {
|
||||
type: 'counterfactual';
|
||||
request: PlanningSnapshot;
|
||||
plan: PlanOutcome;
|
||||
placement: LeavePlacement;
|
||||
state?: PlanningTraceState;
|
||||
options?: CounterfactualBaselineOptions;
|
||||
};
|
||||
|
||||
type WorkerResponse =
|
||||
| { type: 'progress'; stage: string }
|
||||
| { type: 'trace-overhead'; totalMs: number }
|
||||
| { type: 'checkpoint'; plan: PlanOutcome }
|
||||
| { type: 'result'; outcome: PlanningOutcome }
|
||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
||||
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
const worker = self as DedicatedWorkerGlobalScope;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
if (event.data?.type !== 'plan') return;
|
||||
try {
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
...event.data.request,
|
||||
progress: (stage: string) =>
|
||||
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse),
|
||||
},
|
||||
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse),
|
||||
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;
|
||||
}
|
||||
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: 'result', outcome } satisfies WorkerResponse);
|
||||
worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse);
|
||||
} catch (error) {
|
||||
worker.postMessage({
|
||||
type: 'error',
|
||||
|
||||
@@ -109,6 +109,103 @@ export interface AppTranslations {
|
||||
incomplete: string;
|
||||
error: string;
|
||||
complete: string;
|
||||
whyPlacement: string;
|
||||
placementNavigation: string;
|
||||
placement: string;
|
||||
placements: string;
|
||||
decision: string;
|
||||
of: string;
|
||||
first: string;
|
||||
previous: string;
|
||||
next: string;
|
||||
last: string;
|
||||
balance: string;
|
||||
rules: string;
|
||||
noRules: string;
|
||||
supporting: string;
|
||||
yes: string;
|
||||
no: string;
|
||||
remaining: string;
|
||||
timeOffPeriod: string;
|
||||
firstDecisive: string;
|
||||
contributions: string;
|
||||
objectiveVector: string;
|
||||
finalMetrics: string;
|
||||
unplacedAtRisk: string;
|
||||
holidayWeekValue: string;
|
||||
shortSequenceDeficit: string;
|
||||
supportingPlacements: string;
|
||||
preferenceSatisfaction: string;
|
||||
removedRestoredComparison: string;
|
||||
fullComparison: string;
|
||||
comparisonHelp: string;
|
||||
counterfactual: string;
|
||||
hypothetical: string;
|
||||
counterfactualLoading: string;
|
||||
counterfactualReady: string;
|
||||
counterfactualError: string;
|
||||
eligible: string;
|
||||
ineligible: string;
|
||||
historical: string;
|
||||
reason: string;
|
||||
explanationError: string;
|
||||
conflictDetails: string;
|
||||
conflictingRules: string;
|
||||
bottleneck: string;
|
||||
incompleteDetails: string;
|
||||
incumbentExplanation: string;
|
||||
completedStages: string;
|
||||
uncompletedStages: string;
|
||||
none: string;
|
||||
currentPlacement: string;
|
||||
outsideHorizon: string;
|
||||
traceTitle: string;
|
||||
traceLoading: string;
|
||||
traceUnavailable: string;
|
||||
phases: string;
|
||||
firstPhase: string;
|
||||
previousPhase: string;
|
||||
nextPhase: string;
|
||||
lastPhase: string;
|
||||
decisions: string;
|
||||
firstDecision: string;
|
||||
previousDecision: string;
|
||||
nextDecision: string;
|
||||
lastDecision: string;
|
||||
replay: string;
|
||||
before: string;
|
||||
after: string;
|
||||
finalPlan: string;
|
||||
dates: string;
|
||||
candidates: string;
|
||||
previousCandidates: string;
|
||||
nextCandidates: string;
|
||||
page: string;
|
||||
compareAlternatives: string;
|
||||
tieBreak: string;
|
||||
deltas: string;
|
||||
activations: string;
|
||||
noDecisions: string;
|
||||
traceStatuses: {
|
||||
chosen: string;
|
||||
'evaluated-rejected': string;
|
||||
pruned: string;
|
||||
'not-examined': string;
|
||||
ineligible: string;
|
||||
};
|
||||
tracePhaseStatuses: {
|
||||
completed: string;
|
||||
interrupted: string;
|
||||
'not-run': string;
|
||||
};
|
||||
traceChanges: {
|
||||
added: string;
|
||||
'moved-from': string;
|
||||
'moved-to': string;
|
||||
removed: string;
|
||||
shared: string;
|
||||
unchanged: string;
|
||||
};
|
||||
};
|
||||
benchmark: {
|
||||
title: string;
|
||||
|
||||
+98
-1
@@ -104,7 +104,104 @@
|
||||
"conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.",
|
||||
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
|
||||
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
|
||||
"complete": "Plan berechnet."
|
||||
"complete": "Plan berechnet.",
|
||||
"whyPlacement": "Warum diese Platzierung",
|
||||
"placementNavigation": "Navigation der Platzierungserklärungen",
|
||||
"placement": "Platzierung",
|
||||
"placements": "Platzierungen",
|
||||
"decision": "Entscheidung",
|
||||
"of": "von",
|
||||
"first": "Erste Platzierung",
|
||||
"previous": "Vorherige Platzierung",
|
||||
"next": "Nächste Platzierung",
|
||||
"last": "Letzte Platzierung",
|
||||
"balance": "Anspruch",
|
||||
"rules": "Regeln",
|
||||
"noRules": "Keine Regel",
|
||||
"supporting": "Unterstützende Platzierung",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"remaining": "Verbleibender Anspruch nach der Platzierung",
|
||||
"timeOffPeriod": "Freizeitzeitraum",
|
||||
"firstDecisive": "Erstes entscheidendes Ziel",
|
||||
"contributions": "Beiträge",
|
||||
"objectiveVector": "Vollständiger Zielvektor",
|
||||
"finalMetrics": "Kennzahlen des endgültigen Plans",
|
||||
"unplacedAtRisk": "Nicht platzierter gefährdeter Urlaub",
|
||||
"holidayWeekValue": "Wert der Feiertagswochen",
|
||||
"shortSequenceDeficit": "Defizit kurzer Arbeitsfolgen",
|
||||
"supportingPlacements": "Unterstützende Platzierungen",
|
||||
"preferenceSatisfaction": "Erfüllung der Präferenzen",
|
||||
"removedRestoredComparison": "Platzierung entfernt und wiederhergestellt",
|
||||
"fullComparison": "Vollständige Zielvektoren vorher und nachher",
|
||||
"comparisonHelp": "Vergleich des endgültigen Plans ohne diese Platzierung und nach ihrer Wiederherstellung.",
|
||||
"counterfactual": "Alternative Termine",
|
||||
"hypothetical": "Nur hypothetisch: Der optimierte Plan bleibt unverändert. Wählen Sie ein Datum im Horizont aus.",
|
||||
"counterfactualLoading": "Alle Daten im Horizont werden analysiert…",
|
||||
"counterfactualReady": "Alternative Termine sind bereit.",
|
||||
"counterfactualError": "Alternative Termine konnten nicht berechnet werden.",
|
||||
"eligible": "Geeignet",
|
||||
"ineligible": "Nicht geeignet",
|
||||
"historical": "Status der ursprünglichen Suche",
|
||||
"reason": "Grund",
|
||||
"explanationError": "Die Erklärung konnte nicht erstellt werden.",
|
||||
"conflictDetails": "Warum kein Plan die Verpflichtungen erfüllt",
|
||||
"conflictingRules": "Konfligierende Regeln",
|
||||
"bottleneck": "Engpass",
|
||||
"incompleteDetails": "Vor dem Abbruch abgeschlossene Schritte",
|
||||
"incumbentExplanation": "Dies erklärt den besten gültigen Zwischenstand; spätere Qualitätsstufen wurden nicht abgeschlossen.",
|
||||
"completedStages": "Abgeschlossene Schritte",
|
||||
"uncompletedStages": "Unterbrochene oder nicht gestartete Schritte",
|
||||
"none": "Keine",
|
||||
"currentPlacement": "Aktuell erklärte Platzierung",
|
||||
"outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts",
|
||||
"traceTitle": "Erweitert: ursprüngliche Entscheidungsspur",
|
||||
"traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…",
|
||||
"traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.",
|
||||
"phases": "Spurphasen",
|
||||
"firstPhase": "Erste Phase",
|
||||
"previousPhase": "Vorherige Phase",
|
||||
"nextPhase": "Nächste Phase",
|
||||
"lastPhase": "Letzte Phase",
|
||||
"decisions": "Atomare Entscheidungen",
|
||||
"firstDecision": "Erste Entscheidung",
|
||||
"previousDecision": "Vorherige Entscheidung",
|
||||
"nextDecision": "Nächste Entscheidung",
|
||||
"lastDecision": "Letzte Entscheidung",
|
||||
"replay": "Entscheidungszustand",
|
||||
"before": "Vorher",
|
||||
"after": "Nachher",
|
||||
"finalPlan": "Endgültiger Plan",
|
||||
"dates": "Daten",
|
||||
"candidates": "Kandidaten",
|
||||
"previousCandidates": "Vorherige Kandidatenseite",
|
||||
"nextCandidates": "Nächste Kandidatenseite",
|
||||
"page": "Seite",
|
||||
"compareAlternatives": "Diesen Kandidaten im gesamten Horizont vergleichen",
|
||||
"tieBreak": "Entscheidungskriterium",
|
||||
"deltas": "Zustandsänderungen",
|
||||
"activations": "erworbene Aktivierungen",
|
||||
"noDecisions": "Diese Phase enthält keine atomare Entscheidung.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Ausgewählt",
|
||||
"evaluated-rejected": "Bewertet und abgelehnt",
|
||||
"pruned": "Verworfen",
|
||||
"not-examined": "Nicht untersucht",
|
||||
"ineligible": "Nicht geeignet"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Abgeschlossen",
|
||||
"interrupted": "Unterbrochen",
|
||||
"not-run": "Nicht ausgeführt"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Durch diese Entscheidung hinzugefügt",
|
||||
"moved-from": "Von diesem Datum verschoben",
|
||||
"moved-to": "Auf dieses Datum verschoben",
|
||||
"removed": "Durch diese Entscheidung entfernt",
|
||||
"shared": "Durch diese Entscheidung geteilt",
|
||||
"unchanged": "Durch diese Entscheidung unverändert"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
"title": "Benchmark der Urlaubsplanung",
|
||||
|
||||
+98
-1
@@ -104,7 +104,104 @@
|
||||
"conflict": "The obligations cannot be satisfied together.",
|
||||
"incomplete": "Calculation stopped before all quality stages completed.",
|
||||
"error": "The planner encountered an unexpected error.",
|
||||
"complete": "Plan calculated."
|
||||
"complete": "Plan calculated.",
|
||||
"whyPlacement": "Why this placement",
|
||||
"placementNavigation": "Placement explanation navigation",
|
||||
"placement": "Placement",
|
||||
"placements": "placements",
|
||||
"decision": "Decision",
|
||||
"of": "of",
|
||||
"first": "First placement",
|
||||
"previous": "Previous placement",
|
||||
"next": "Next placement",
|
||||
"last": "Last placement",
|
||||
"balance": "Entitlement",
|
||||
"rules": "Rules",
|
||||
"noRules": "No rule",
|
||||
"supporting": "Supporting placement",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"remaining": "Remaining entitlement after placement",
|
||||
"timeOffPeriod": "Time-off period",
|
||||
"firstDecisive": "First decisive objective",
|
||||
"contributions": "Contributions",
|
||||
"objectiveVector": "Full objective vector",
|
||||
"finalMetrics": "Final plan metrics",
|
||||
"unplacedAtRisk": "Unplaced at-risk leave",
|
||||
"holidayWeekValue": "Public-holiday week value",
|
||||
"shortSequenceDeficit": "Short work-sequence deficit",
|
||||
"supportingPlacements": "Supporting placements",
|
||||
"preferenceSatisfaction": "Preference satisfaction",
|
||||
"removedRestoredComparison": "Placement removed and restored",
|
||||
"fullComparison": "Full before-and-after objective vectors",
|
||||
"comparisonHelp": "This compares the final plan without this placement with the final plan after restoring it.",
|
||||
"counterfactual": "Date alternatives",
|
||||
"hypothetical": "Hypothetical only: the optimized plan is unchanged. Select any date in the horizon to inspect it.",
|
||||
"counterfactualLoading": "Analyzing every date in the horizon…",
|
||||
"counterfactualReady": "Date alternatives are ready.",
|
||||
"counterfactualError": "Date alternatives could not be calculated.",
|
||||
"eligible": "Eligible",
|
||||
"ineligible": "Ineligible",
|
||||
"historical": "Original search status",
|
||||
"reason": "Reason",
|
||||
"explanationError": "The explanation could not be produced.",
|
||||
"conflictDetails": "Why no plan can satisfy the obligations",
|
||||
"conflictingRules": "Conflicting rules",
|
||||
"bottleneck": "Bottleneck",
|
||||
"incompleteDetails": "What completed before calculation stopped",
|
||||
"incumbentExplanation": "This explains the best valid incumbent; later quality stages did not complete.",
|
||||
"completedStages": "Completed stages",
|
||||
"uncompletedStages": "Interrupted or unstarted stages",
|
||||
"none": "None",
|
||||
"currentPlacement": "Current explained placement",
|
||||
"outsideHorizon": "Context date outside the planning horizon",
|
||||
"traceTitle": "Advanced: original decision trace",
|
||||
"traceLoading": "The plan is ready; its decision trace is still loading…",
|
||||
"traceUnavailable": "No decision trace is available for this result.",
|
||||
"phases": "Trace phases",
|
||||
"firstPhase": "First phase",
|
||||
"previousPhase": "Previous phase",
|
||||
"nextPhase": "Next phase",
|
||||
"lastPhase": "Last phase",
|
||||
"decisions": "Atomic decisions",
|
||||
"firstDecision": "First decision",
|
||||
"previousDecision": "Previous decision",
|
||||
"nextDecision": "Next decision",
|
||||
"lastDecision": "Last decision",
|
||||
"replay": "Decision state",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"finalPlan": "Final plan",
|
||||
"dates": "Dates",
|
||||
"candidates": "Candidates",
|
||||
"previousCandidates": "Previous candidate page",
|
||||
"nextCandidates": "Next candidate page",
|
||||
"page": "Page",
|
||||
"compareAlternatives": "Compare this candidate across the horizon",
|
||||
"tieBreak": "Tie-break",
|
||||
"deltas": "State changes",
|
||||
"activations": "earned activations",
|
||||
"noDecisions": "This phase recorded no atomic decision.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Chosen",
|
||||
"evaluated-rejected": "Evaluated and rejected",
|
||||
"pruned": "Pruned",
|
||||
"not-examined": "Not examined",
|
||||
"ineligible": "Ineligible"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Completed",
|
||||
"interrupted": "Interrupted",
|
||||
"not-run": "Not run"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Added by this decision",
|
||||
"moved-from": "Moved from this date",
|
||||
"moved-to": "Moved to this date",
|
||||
"removed": "Removed by this decision",
|
||||
"shared": "Shared by this decision",
|
||||
"unchanged": "Unchanged by this decision"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
"title": "Leave planner benchmark",
|
||||
|
||||
+98
-1
@@ -104,7 +104,104 @@
|
||||
"conflict": "Las obligaciones no se pueden satisfacer juntas.",
|
||||
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
|
||||
"error": "El planificador encontró un error inesperado.",
|
||||
"complete": "Plan calculado."
|
||||
"complete": "Plan calculado.",
|
||||
"whyPlacement": "Por qué esta asignación",
|
||||
"placementNavigation": "Navegación por las explicaciones de las asignaciones",
|
||||
"placement": "Asignación",
|
||||
"placements": "asignaciones",
|
||||
"decision": "Decisión",
|
||||
"of": "de",
|
||||
"first": "Primera asignación",
|
||||
"previous": "Asignación anterior",
|
||||
"next": "Asignación siguiente",
|
||||
"last": "Última asignación",
|
||||
"balance": "Derecho",
|
||||
"rules": "Reglas",
|
||||
"noRules": "Sin regla",
|
||||
"supporting": "Asignación de apoyo",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"remaining": "Derecho restante tras la asignación",
|
||||
"timeOffPeriod": "Período de descanso",
|
||||
"firstDecisive": "Primer objetivo decisivo",
|
||||
"contributions": "Contribuciones",
|
||||
"objectiveVector": "Vector completo de objetivos",
|
||||
"finalMetrics": "Métricas del plan final",
|
||||
"unplacedAtRisk": "Vacaciones en riesgo sin asignar",
|
||||
"holidayWeekValue": "Valor de semanas con festivos",
|
||||
"shortSequenceDeficit": "Déficit de secuencias cortas de trabajo",
|
||||
"supportingPlacements": "Asignaciones de apoyo",
|
||||
"preferenceSatisfaction": "Satisfacción de preferencias",
|
||||
"removedRestoredComparison": "Asignación eliminada y restaurada",
|
||||
"fullComparison": "Vectores de objetivos completos antes y después",
|
||||
"comparisonHelp": "Compara el plan final sin esta asignación y después de restaurarla.",
|
||||
"counterfactual": "Fechas alternativas",
|
||||
"hypothetical": "Solo hipotético: el plan optimizado no cambia. Selecciona una fecha del horizonte para inspeccionarla.",
|
||||
"counterfactualLoading": "Analizando cada fecha del horizonte…",
|
||||
"counterfactualReady": "Las fechas alternativas están listas.",
|
||||
"counterfactualError": "No se pudieron calcular las fechas alternativas.",
|
||||
"eligible": "Válida",
|
||||
"ineligible": "No válida",
|
||||
"historical": "Estado de la búsqueda original",
|
||||
"reason": "Motivo",
|
||||
"explanationError": "No se pudo generar la explicación.",
|
||||
"conflictDetails": "Por qué ningún plan satisface las obligaciones",
|
||||
"conflictingRules": "Reglas en conflicto",
|
||||
"bottleneck": "Cuello de botella",
|
||||
"incompleteDetails": "Lo completado antes de detener el cálculo",
|
||||
"incumbentExplanation": "Esto explica el mejor plan válido encontrado; las etapas de calidad posteriores no se completaron.",
|
||||
"completedStages": "Etapas completadas",
|
||||
"uncompletedStages": "Etapas interrumpidas o no iniciadas",
|
||||
"none": "Ninguno",
|
||||
"currentPlacement": "Asignación explicada actualmente",
|
||||
"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…",
|
||||
"traceUnavailable": "No hay una traza de decisiones disponible para este resultado.",
|
||||
"phases": "Fases de la traza",
|
||||
"firstPhase": "Primera fase",
|
||||
"previousPhase": "Fase anterior",
|
||||
"nextPhase": "Fase siguiente",
|
||||
"lastPhase": "Última fase",
|
||||
"decisions": "Decisiones atómicas",
|
||||
"firstDecision": "Primera decisión",
|
||||
"previousDecision": "Decisión anterior",
|
||||
"nextDecision": "Decisión siguiente",
|
||||
"lastDecision": "Última decisión",
|
||||
"replay": "Estado de la decisión",
|
||||
"before": "Antes",
|
||||
"after": "Después",
|
||||
"finalPlan": "Plan final",
|
||||
"dates": "Fechas",
|
||||
"candidates": "Candidatos",
|
||||
"previousCandidates": "Página de candidatos anterior",
|
||||
"nextCandidates": "Página de candidatos siguiente",
|
||||
"page": "Página",
|
||||
"compareAlternatives": "Comparar este candidato en todo el horizonte",
|
||||
"tieBreak": "Desempate",
|
||||
"deltas": "Cambios de estado",
|
||||
"activations": "activaciones obtenidas",
|
||||
"noDecisions": "Esta fase no registró ninguna decisión atómica.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Elegido",
|
||||
"evaluated-rejected": "Evaluado y rechazado",
|
||||
"pruned": "Podado",
|
||||
"not-examined": "No examinado",
|
||||
"ineligible": "No válido"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Completada",
|
||||
"interrupted": "Interrumpida",
|
||||
"not-run": "No ejecutada"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Añadido por esta decisión",
|
||||
"moved-from": "Movido desde esta fecha",
|
||||
"moved-to": "Movido a esta fecha",
|
||||
"removed": "Eliminado por esta decisión",
|
||||
"shared": "Compartido por esta decisión",
|
||||
"unchanged": "Sin cambios por esta decisión"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
"title": "Prueba del planificador de vacaciones",
|
||||
|
||||
+98
-1
@@ -104,7 +104,104 @@
|
||||
"conflict": "Les obligations ne peuvent pas être satisfaites ensemble.",
|
||||
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
|
||||
"error": "Le planificateur a rencontré une erreur inattendue.",
|
||||
"complete": "Plan calculé."
|
||||
"complete": "Plan calculé.",
|
||||
"whyPlacement": "Pourquoi ce placement",
|
||||
"placementNavigation": "Navigation entre les explications des placements",
|
||||
"placement": "Placement",
|
||||
"placements": "placements",
|
||||
"decision": "Décision",
|
||||
"of": "sur",
|
||||
"first": "Premier placement",
|
||||
"previous": "Placement précédent",
|
||||
"next": "Placement suivant",
|
||||
"last": "Dernier placement",
|
||||
"balance": "Droit",
|
||||
"rules": "Règles",
|
||||
"noRules": "Aucune règle",
|
||||
"supporting": "Placement de soutien",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"remaining": "Droit restant après placement",
|
||||
"timeOffPeriod": "Période de repos",
|
||||
"firstDecisive": "Premier objectif décisif",
|
||||
"contributions": "Contributions",
|
||||
"objectiveVector": "Vecteur d’objectifs complet",
|
||||
"finalMetrics": "Métriques du plan final",
|
||||
"unplacedAtRisk": "Congés à risque non placés",
|
||||
"holidayWeekValue": "Valeur des semaines fériées",
|
||||
"shortSequenceDeficit": "Déficit des courtes séquences travaillées",
|
||||
"supportingPlacements": "Placements de soutien",
|
||||
"preferenceSatisfaction": "Satisfaction des préférences",
|
||||
"removedRestoredComparison": "Placement retiré puis rétabli",
|
||||
"fullComparison": "Vecteurs d’objectifs complets avant et après",
|
||||
"comparisonHelp": "Comparaison du plan final sans ce placement puis après son rétablissement.",
|
||||
"counterfactual": "Dates alternatives",
|
||||
"hypothetical": "Hypothèse uniquement : le plan optimisé ne change pas. Sélectionnez une date de l’horizon pour l’inspecter.",
|
||||
"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.",
|
||||
"eligible": "Éligible",
|
||||
"ineligible": "Inéligible",
|
||||
"historical": "Statut de la recherche originale",
|
||||
"reason": "Raison",
|
||||
"explanationError": "L’explication n’a pas pu être produite.",
|
||||
"conflictDetails": "Pourquoi aucun plan ne satisfait les obligations",
|
||||
"conflictingRules": "Règles en conflit",
|
||||
"bottleneck": "Goulot d’étranglement",
|
||||
"incompleteDetails": "Étapes terminées avant l’arrêt du calcul",
|
||||
"incumbentExplanation": "Cette explication concerne le meilleur plan valide trouvé ; les étapes de qualité suivantes ne sont pas terminées.",
|
||||
"completedStages": "Étapes terminées",
|
||||
"uncompletedStages": "Étapes interrompues ou non démarrées",
|
||||
"none": "Aucun",
|
||||
"currentPlacement": "Placement expliqué actuellement",
|
||||
"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…",
|
||||
"traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.",
|
||||
"phases": "Phases de la trace",
|
||||
"firstPhase": "Première phase",
|
||||
"previousPhase": "Phase précédente",
|
||||
"nextPhase": "Phase suivante",
|
||||
"lastPhase": "Dernière phase",
|
||||
"decisions": "Décisions atomiques",
|
||||
"firstDecision": "Première décision",
|
||||
"previousDecision": "Décision précédente",
|
||||
"nextDecision": "Décision suivante",
|
||||
"lastDecision": "Dernière décision",
|
||||
"replay": "État de la décision",
|
||||
"before": "Avant",
|
||||
"after": "Après",
|
||||
"finalPlan": "Plan final",
|
||||
"dates": "Dates",
|
||||
"candidates": "Candidats",
|
||||
"previousCandidates": "Page de candidats précédente",
|
||||
"nextCandidates": "Page de candidats suivante",
|
||||
"page": "Page",
|
||||
"compareAlternatives": "Comparer ce candidat sur tout l’horizon",
|
||||
"tieBreak": "Critère de départage",
|
||||
"deltas": "Changements d’état",
|
||||
"activations": "activations acquises",
|
||||
"noDecisions": "Cette phase n’a enregistré aucune décision atomique.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Choisi",
|
||||
"evaluated-rejected": "Évalué et rejeté",
|
||||
"pruned": "Élagué",
|
||||
"not-examined": "Non examiné",
|
||||
"ineligible": "Inéligible"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Terminée",
|
||||
"interrupted": "Interrompue",
|
||||
"not-run": "Non exécutée"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Ajouté par cette décision",
|
||||
"moved-from": "Déplacé depuis cette date",
|
||||
"moved-to": "Déplacé vers cette date",
|
||||
"removed": "Retiré par cette décision",
|
||||
"shared": "Partagé par cette décision",
|
||||
"unchanged": "Inchangé par cette décision"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
"title": "Benchmark du planificateur de congés",
|
||||
|
||||
Reference in new issue
Block a user