feat(planning): explain and trace planning decisions

This commit is contained in:
nessar committed 2026-08-31 00:02:45 +02:00
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);
}
+36 -8
View File
@@ -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));
}
}
+658 -110
View File
@@ -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);
}
+10
View File
@@ -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', () => {
+30 -11
View File
@@ -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
+642 -1
View File
@@ -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();
}
});
});
+679 -69
View File
@@ -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;
}
+66 -15
View File
@@ -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',
+97
View File
@@ -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;