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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user