refactor(planning): remove the original decision trace
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s

This commit is contained in:
nessar committed 2026-10-15 20:42:33 +02:00
1 parent ea4256107f
commit d9d9befa12
43 files changed
+729 -8880

No files matched your search

@@ -14,7 +14,6 @@ import {
import {
CounterfactualAnalysis,
CounterfactualSelection,
HistoricalEligibilityReason,
LeavePlacement,
PlanOutcome,
PlanExplanation,
@@ -25,8 +24,6 @@ import {
InvalidInputOutcome,
PlanningOutcome,
PlanningObjectiveVector,
PlanningTrace,
PlanningTraceUpdate,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
@@ -147,14 +144,12 @@ function placementForSelection(selection: CounterfactualSelection): LeavePlaceme
(candidate) => candidate.date === selection || candidate.balanceId === selection,
) ?? plan.placements[0]
);
return 'decisionId' in selection ? selection.placement : selection;
return selection;
}
function calculationFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
tracePromise: Promise<PlanningTrace | undefined> = Promise.resolve(trace),
): PlanningCalculationContext {
const counterfactual: CounterfactualAnalysis = {
kind: 'Counterfactual',
@@ -175,7 +170,6 @@ function calculationFor(
date: '2024-05-04',
status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' },
historicalStatus: 'ineligible',
},
{
date: '2024-05-07',
@@ -186,7 +180,6 @@ function calculationFor(
date: '2024-05-08',
status: 'ineligible',
reason: { code: 'non-working', message: 'Public Holiday' },
historicalStatus: 'ineligible',
},
],
};
@@ -203,27 +196,15 @@ function calculationFor(
outcome: Promise.resolve(outcome),
explanation: Promise.resolve(stableExplanation),
explanationError: Promise.resolve(undefined),
trace: tracePromise,
analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({
...counterfactual,
placement: placementForSelection(selection),
})),
historicalEligibility: vi.fn(async () => [] as readonly HistoricalEligibilityReason[]),
};
}
function calendarFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
tracePromise?: Promise<PlanningTrace | undefined>,
) {
const calculation = calculationFor(
outcome,
stableExplanation,
trace,
tracePromise ?? Promise.resolve(trace),
);
function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExplanation) {
const calculation = calculationFor(outcome, stableExplanation);
return {
calculation,
calendar: {
@@ -253,161 +234,6 @@ function activateWithKeyboard(element: HTMLElement, key: 'Enter' | ' ' = 'Enter'
element.click();
}
function jointTrace(): PlanningTrace {
const placementComparison =
explanation.kind === 'PlanExplanation' ? explanation.placements[0].comparison : undefined;
return {
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,
},
{
id: 'earned:spring:2024',
type: 'rtt',
quantity: 2,
remaining: 2,
availableFrom: '2024-05-04',
expiryDate: '2025-05-04',
earned: true,
sourceRuleId: 'spring',
},
],
placements: [plan.placements[1]],
earnedActivations: [
{
id: 'earned:spring:2024',
ruleId: 'spring',
occurrence: '2024',
dates: ['2024-05-03', '2024-05-06'],
},
],
},
phases: [
{
id: 'phase:obligations',
name: 'obligations',
completed: true,
status: 'completed',
decisions: [
{
id: 'decision:prior',
action: 'validate',
dates: ['2024-05-01'],
candidates: [],
deltas: [
{
kind: 'balance-changed',
balanceId: 'cp',
remainingBefore: 2,
remainingAfter: 1,
},
],
},
{
id: 'decision:joint',
action: 'choose',
dates: ['2024-05-03', '2024-05-06'],
branch: {
parentDecisionId: 'decision:prior',
deltas: [
{
kind: 'placement-added',
date: '2024-05-03',
balanceId: 'cp',
remainingBefore: 1,
remainingAfter: 0,
placement: plan.placements[0],
},
{
kind: 'balance-changed',
balanceId: 'earned:spring:2024',
remainingBefore: 2,
remainingAfter: 2,
balance: {
id: 'earned:spring:2024',
type: 'rtt',
quantity: 2,
remaining: 2,
availableFrom: '2024-05-07',
expiryDate: '2025-05-07',
earned: true,
sourceRuleId: 'spring',
},
},
],
},
candidates: [
{
status: 'evaluated-rejected',
dates: ['2024-05-10', '2024-05-13'],
reason: {
code: 'joint-date-conflict',
message: 'This branch cannot satisfy both required dates.',
},
technicalComparison: [
{
name: 'unplaced-at-risk',
direction: 'minimize',
before: 2,
after: 2,
},
{
name: 'preference:spring',
direction: 'maximize',
before: 1,
after: 0,
},
],
firstDecisiveComparator: 'preference:spring',
comparison: placementComparison,
},
{
status: 'chosen',
dates: ['2024-05-03', '2024-05-06'],
comparison: placementComparison,
},
],
deltas: [
{
kind: 'placement-added',
date: '2024-05-03',
balanceId: 'cp',
remainingBefore: 1,
remainingAfter: 0,
placement: plan.placements[0],
},
],
},
{
id: 'decision:later',
action: 'reject',
dates: ['2024-05-20'],
candidates: [],
deltas: [],
},
],
},
{
id: 'phase:dates',
name: 'dates',
completed: true,
status: 'completed',
decisions: [],
},
],
completedStages: ['obligations', 'dates'],
};
}
const presets = createOptimizationPresetRules();
describe('CalendarYear planning outcome boundary', () => {
@@ -577,10 +403,9 @@ describe('CalendarYear planning outcome boundary', () => {
{ partial: false, placements: 0 },
{ partial: true, placements: 0 },
])(
'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
'opens calculated statistics for the displayed plan (partial: $partial, placements: $placements)',
async ({ partial, placements }) => {
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
const pendingTrace = deferred<PlanningTrace | undefined>();
const validPlan: PlanOutcome = {
...plan,
calculationDate: '2024-05-01',
@@ -632,12 +457,7 @@ describe('CalendarYear planning outcome boundary', () => {
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
}
: validPlan;
const { calendar, calculation } = calendarFor(
outcome,
undefined,
undefined,
pendingTrace.promise,
);
const { calendar, calculation } = calendarFor(outcome, undefined);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -693,7 +513,6 @@ describe('CalendarYear planning outcome boundary', () => {
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
pendingTrace.reject(new Error('Trace delivery failed'));
await fixture.whenStable();
fixture.detectChanges();
expect(statistics()).toEqual(expectedStatistics);
@@ -1002,14 +821,12 @@ describe('CalendarYear planning outcome boundary', () => {
);
});
it('shows the retained earnings when cancellation interrupts a different generating candidate', async () => {
it('shows the retained earnings after cancelling with a valid generating plan', async () => {
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
// Flush the original trace deterministically so cancellation occurs at the observed branch.
// Yield between work slices so cancellation can follow a delivered valid checkpoint.
let elapsed = 0;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30));
let checkpoint: PlanOutcome | undefined;
let interrupted = false;
let latestGenerator: string | undefined;
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
try {
const core = await import('../../services/leave-planner.core');
@@ -1045,24 +862,11 @@ describe('CalendarYear planning outcome boundary', () => {
rewardType: 'other',
})),
],
traceProgress: (_: number, update: PlanningTraceUpdate) => {
if (interrupted) return;
for (const operation of update.operations) {
const deltas =
operation.kind === 'delta'
? [operation.delta]
: operation.kind === 'decision'
? (operation.decision.branch?.deltas ?? [])
: [];
for (const delta of deltas) {
if (delta.kind !== 'earned-activated') continue;
latestGenerator = delta.activation?.ruleId;
if (checkpoint && latestGenerator === 'before') interrupted = true;
}
}
},
},
(value) => (checkpoint = value),
(value) => {
if (value.ifThenContributions?.some(({ ruleId }) => ruleId === 'after'))
interrupted = true;
},
(value) => (artifacts = value),
undefined,
undefined,
@@ -1072,7 +876,6 @@ describe('CalendarYear planning outcome boundary', () => {
if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
throw new Error('Expected a real cancellation with a retained valid plan.');
expect(interrupted).toBe(true);
expect(latestGenerator).toBe('before');
expect(outcome.incumbent.ifThenContributions).toMatchObject([
{ ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } },
]);
@@ -1081,20 +884,7 @@ describe('CalendarYear planning outcome boundary', () => {
'2024-05-02',
'2024-05-10',
]);
const latestBranch = artifacts.trace?.phases
.flatMap(({ decisions }) => decisions)
.filter(({ branch }) => branch)
.at(-1);
expect(latestBranch?.candidates).toMatchObject([
{ status: 'evaluated-rejected', dates: ['2024-04-29', '2024-04-30'] },
]);
expect(latestBranch?.branch?.deltas).toContainEqual(
expect.objectContaining({
kind: 'earned-activated',
activation: expect.objectContaining({ ruleId: 'before' }),
}),
);
const { calendar } = calendarFor(outcome, artifacts.explanation, artifacts.trace);
const { calendar } = calendarFor(outcome, artifacts.explanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -1242,12 +1032,11 @@ describe('CalendarYear planning outcome boundary', () => {
'replaces open statistics after %s and ignores an older valid outcome',
async (_, replacement: PlanningOutcome, focusMoved) => {
const oldOutcome = deferred<PlanningOutcome>();
const oldTrace = deferred<PlanningTrace | undefined>();
const { calendar } = calendarFor(plan);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan))
.mockReturnValueOnce({
...calculationFor(plan, undefined, undefined, oldTrace.promise),
...calculationFor(plan, undefined),
outcome: oldOutcome.promise,
})
.mockReturnValueOnce(calculationFor(replacement));
@@ -1280,7 +1069,6 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
oldOutcome.resolve(plan);
oldTrace.resolve(jointTrace());
await fixture.whenStable();
fixture.detectChanges();
expect(input.planningOutcomeSignal()).toBe(replacement);
@@ -1353,7 +1141,6 @@ describe('CalendarYear planning outcome boundary', () => {
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
});
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-08', '2024-01-12');
@@ -1432,7 +1219,6 @@ describe('CalendarYear planning outcome boundary', () => {
const calculation = {
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
};
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
@@ -1484,7 +1270,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
it.each([false, true])(
'keeps adjacent context declarations attached to the historical anchors (fixed anchor: %s)',
'keeps adjacent context declarations attached to displayed anchors (fixed anchor: %s)',
async (fixedAnchor) => {
const core = await import('../../services/leave-planner.core');
const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31';
@@ -1511,28 +1297,14 @@ describe('CalendarYear planning outcome boundary', () => {
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a plan and its original trace.');
const trace = artifacts.trace;
const phaseIndex = trace.phases.findIndex((phase) =>
phase.decisions.some((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
),
);
const decisionIndex = trace.phases[phaseIndex].decisions.findIndex((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
);
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculation = calculationFor(outcome, artifacts.explanation, trace);
const calculation = calculationFor(outcome, artifacts.explanation);
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
);
@@ -1543,29 +1315,10 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-explorer summary')!);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
activateWithKeyboard(
root.querySelectorAll<HTMLElement>('.trace-phases > button')[phaseIndex],
);
fixture.detectChanges();
for (let index = 0; index < decisionIndex; index++) {
activateWithKeyboard(
root.querySelector<HTMLElement>('[aria-label="Next decision"]')!,
);
fixture.detectChanges();
}
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-toolbar button')!);
fixture.detectChanges();
const before = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]');
if (fixedAnchor) {
expect(before?.classList).toContain('selected');
expect(before?.classList).toContain('cp');
} else expect(before).toBeNull();
activateWithKeyboard(root.querySelectorAll<HTMLElement>('.trace-toolbar button')[1]);
fixture.detectChanges();
const after = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]')!;
expect(after.classList).toContain('selected');
@@ -1573,7 +1326,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(after.classList).toContain('context-only');
expect(after.disabled).toBe(true);
expect(after.getAttribute('aria-label')).toContain('Already booked');
expect(fixture.componentInstance['traceState']()?.placements).toEqual(
expect(fixture.componentInstance['displayedPlan']()?.placements).toEqual(
outcome.placements,
);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt');
@@ -1871,11 +1624,9 @@ describe('CalendarYear planning outcome boundary', () => {
],
};
const lateExplanation = deferred<PlanningExplanation | undefined>();
const lateTrace = deferred<PlanningTrace | undefined>();
const initialCalculation = {
...calculationFor(initial, oldExplanation),
explanation: lateExplanation.promise,
trace: lateTrace.promise,
};
const rule = {
id: 'linked',
@@ -1946,26 +1697,10 @@ describe('CalendarYear planning outcome boundary', () => {
horizon: submitted.horizon,
declaredDays: submitted.declaredDays,
});
const originalTrace: PlanningTrace = {
status: 'ready',
initialState: {
horizon: initial.horizon,
declaredDays: initial.declaredDays,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: initial.completedStages,
};
lateExplanation.resolve(oldExplanation);
lateTrace.resolve(originalTrace);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['planningTrace']()).toBe(originalTrace);
expect(fixture.componentInstance['traceLoading']()).toBe(false);
expect(fixture.componentInstance['traceResolved']()).toBe(true);
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
details.open = true;
details.dispatchEvent(new Event('toggle'));
@@ -1986,19 +1721,14 @@ describe('CalendarYear planning outcome boundary', () => {
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'other' }]);
});
it('calibrates the wait through calendar rendering while a long trace remains pending', async () => {
it('calibrates the wait through calendar rendering independently of explanation delivery', async () => {
let now = 1_000;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
try {
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
const pending = deferred<PlanningOutcome>();
const trace = deferred<PlanningTrace | undefined>();
const { calendar, calculation } = calendarFor(
plan,
undefined,
undefined,
trace.promise,
);
const delayedExplanation = deferred<PlanningExplanation | undefined>();
const { calendar, calculation } = calendarFor(plan, undefined);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2021,6 +1751,7 @@ describe('CalendarYear planning outcome boundary', () => {
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: pending.promise,
explanation: delayedExplanation.promise,
snapshot: { ...calculation.snapshot, ...submitted },
});
const fixture = TestBed.createComponent(CalendarYear);
@@ -2036,11 +1767,13 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull();
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
now = 321_000;
trace.reject(new Error('Late trace delivery failed'));
delayedExplanation.resolve(explanation);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['planningExplanation']()).toBe(explanation);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
@@ -2176,19 +1909,13 @@ describe('CalendarYear planning outcome boundary', () => {
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
async (withIncumbent) => {
const pending = deferred<PlanningOutcome>();
const delayedTrace = deferred<PlanningTrace | undefined>();
const cancelled: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
incumbent: withIncumbent ? plan : undefined,
completedStages: ['validation'],
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
};
const { calendar, calculation } = calendarFor(
cancelled,
undefined,
undefined,
delayedTrace.promise,
);
const { calendar, calculation } = calendarFor(cancelled, undefined);
calendar.createPlanningCalculation
.mockReturnValueOnce({ ...calculation, outcome: pending.promise })
.mockReturnValueOnce(calculationFor(plan, explanation));
@@ -2226,7 +1953,6 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
delayedTrace.resolve({ ...jointTrace(), status: 'incomplete' });
await fixture.whenStable();
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
},
@@ -2304,14 +2030,13 @@ describe('CalendarYear planning outcome boundary', () => {
},
);
it('ignores an older outcome, explanation and trace after a new horizon is submitted', async () => {
it('ignores an older outcome and explanation after a new horizon is submitted', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const oldOutcome = deferred<PlanningOutcome>();
const oldExplanation = deferred<PlanningExplanation | undefined>();
const oldTrace = deferred<PlanningTrace | undefined>();
const calendar = TestBed.inject(CalendarService);
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
@@ -2319,7 +2044,6 @@ describe('CalendarYear planning outcome boundary', () => {
...calculationFor(plan),
outcome: oldOutcome.promise,
explanation: oldExplanation.promise,
trace: oldTrace.promise,
})
.mockReturnValueOnce(calculationFor(conflict));
const settings = TestBed.inject(CalendarSettingsService);
@@ -2332,14 +2056,12 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable();
oldOutcome.resolve(plan);
oldExplanation.resolve(explanation);
oldTrace.resolve(jointTrace());
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(calculate).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance['planningOutcome']()).toBe(conflict);
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
expect(fixture.componentInstance['planningTrace']()).toBeNull();
expect(fixture.componentInstance['planningHorizon']()).toEqual({
start: '2021-05-14',
end: '2021-05-15',
@@ -2636,9 +2358,7 @@ describe('CalendarYear planning outcome boundary', () => {
const root = fixture.nativeElement as HTMLElement;
const planDetails = root.querySelector<HTMLDetailsElement>('.plan-details')!;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
expect(planDetails.open).toBe(false);
expect(traceDetails.open).toBe(false);
activateWithKeyboard(planDetails.querySelector<HTMLElement>('summary')!);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
@@ -3128,108 +2848,6 @@ describe('CalendarYear planning outcome boundary', () => {
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it('announces trace loading and completion while keeping the completed plan available', async () => {
const outcomeDelivery = deferred<PlanningOutcome>();
const traceDelivery = deferred<PlanningTrace | undefined>();
const { calendar, calculation } = calendarFor(
plan,
explanation,
undefined,
traceDelivery.promise,
);
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: outcomeDelivery.promise,
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.loading-overlay')).not.toBeNull();
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
expect(calendar.selectedDatesForPlan).not.toHaveBeenCalled();
expect(root.querySelector('.cancel-calculation')).not.toBeNull();
outcomeDelivery.resolve(plan);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const plannedButtons = Array.from(root.querySelectorAll('button.selected'));
expect(plannedButtons).toHaveLength(2);
expect(root.querySelector('.loading-overlay')).toBeNull();
const liveMessage = root.querySelector<HTMLElement>('.calendar-year > p[aria-live]')!;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
fixture.detectChanges();
expect(traceDetails.querySelector('[role="status"]')?.textContent).toContain(
'decision trace is still loading',
);
expect(liveMessage.getAttribute('aria-atomic')).toBe('true');
expect(root.querySelector('.plan-summary')).not.toBeNull();
traceDelivery.resolve(jointTrace());
await fixture.whenStable();
fixture.detectChanges();
expect(liveMessage.textContent).toContain('decision trace has loaded');
expect(Array.from(root.querySelectorAll('button.selected'))).toEqual(plannedButtons);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
expect(calendar.selectedDatesForPlan).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull());
root.querySelector<HTMLButtonElement>(
'.trace-decisions nav button[aria-label="Next decision"]',
)!.click();
fixture.detectChanges();
expect(root.querySelector('.candidate-list details')).not.toBeNull();
});
it('announces trace delivery failure without discarding the completed plan', async () => {
const traceDelivery = deferred<PlanningTrace | undefined>();
const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
fixture.detectChanges();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
traceDelivery.reject(new Error('trace transfer failed'));
await vi.waitFor(() => {
fixture.detectChanges();
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
'No decision trace is available',
);
});
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
'No decision trace is available',
);
expect(traceDetails.querySelector('[role="alert"]')?.textContent).toContain(
'trace transfer failed',
);
expect(root.querySelector('.plan-summary')).not.toBeNull();
});
it('announces an explicit comparison failure and retries inside the dialog', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
const firstAttempt = deferred<CounterfactualAnalysis>();
@@ -3304,13 +2922,13 @@ describe('CalendarYear planning outcome boundary', () => {
expect(document.activeElement).toBe(focusedBeforeResult);
});
it('renders conflict causes and replays an atomic trace decision', async () => {
it('renders conflict causes with their reported obligations', 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);
const setup = calendarFor(conflict, conflictExplanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -3319,7 +2937,7 @@ describe('CalendarYear planning outcome boundary', () => {
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
let fixture = TestBed.createComponent(CalendarYear);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -3330,208 +2948,6 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
'No CP entitlement is available.',
);
TestBed.resetTestingModule();
const trace = jointTrace();
setup = calendarFor(plan, explanation, trace);
vi.mocked(setup.calculation.historicalEligibility).mockResolvedValue([
{
date: '2024-01-02',
reason: {
code: 'unavailable',
message: 'This balance is not available until January 4.',
},
},
{
date: '2024-01-08',
reason: { code: 'expired', message: 'This balance expired on January 7.' },
},
]);
const eligibilityRetry = deferred<readonly HistoricalEligibilityReason[]>();
vi.mocked(setup.calculation.historicalEligibility)
.mockRejectedValueOnce(new Error('Historical eligibility unavailable.'))
.mockImplementationOnce(() => eligibilityRetry.promise);
setup.calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: setup.calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(traceDetails.open).toBe(true);
const nextDecision = root.querySelector<HTMLButtonElement>(
'.trace-decisions nav button[aria-label="Next decision"]',
)!;
activateWithKeyboard(nextDecision);
await vi.waitFor(() => {
expect(fixture.componentInstance['historicalEligibilityError']()).toBe(
'Historical eligibility unavailable.',
);
});
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(true);
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'Historical eligibility unavailable.',
);
expect(root.querySelector('.sr-only[role="status"]')?.textContent).toContain(
'Original date eligibility could not be checked.',
);
const retryEligibility = Array.from(root.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Retry date eligibility check'),
)!;
activateWithKeyboard(retryEligibility);
fixture.detectChanges();
expect(root.querySelector('[role="status"]')?.textContent).toContain(
'Checking which dates this leave could use',
);
eligibilityRetry.resolve([
{
date: '2024-01-02',
reason: {
code: 'unavailable',
message: 'This balance is not available until January 4.',
},
},
{
date: '2024-01-08',
reason: { code: 'expired', message: 'This balance expired on January 7.' },
},
]);
await vi.waitFor(() => {
expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([
'2024-01-02',
'2024-01-08',
]);
});
fixture.detectChanges();
expect(setup.calculation.historicalEligibility).toHaveBeenCalledWith({
decisionId: 'decision:joint',
placement: plan.placements[0],
});
expect(fixture.componentInstance['traceReplay']()).toBe(true);
expect(fixture.componentInstance['historicalEligibilityError']()).toBeNull();
expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([
'2024-01-02',
'2024-01-08',
]);
const historicalAvailability = fixture.componentInstance['dateStatuses']();
expect(historicalAvailability['2024-01-02']).toMatchObject({
kind: 'ineligible',
reason: 'This leave balance cannot be used on this date.',
});
expect(historicalAvailability['2024-01-08']).toMatchObject({
kind: 'ineligible',
reason: 'This date is after the leave balance expires.',
});
let earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) =>
item.textContent?.includes('earned:spring:2024'),
)!;
expect(earnedBalance.textContent).toContain('Available from 4 May 2024');
expect(earnedBalance.textContent).toContain('Expires on 4 May 2025');
const jointAfterButton =
root.querySelectorAll<HTMLButtonElement>('.trace-toolbar button')[1]!;
activateWithKeyboard(jointAfterButton);
fixture.detectChanges();
earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) =>
item.textContent?.includes('earned:spring:2024'),
)!;
expect(earnedBalance.textContent).toContain('Available from 7 May 2024');
expect(earnedBalance.textContent).toContain('Expires on 7 May 2025');
expect(
root
.querySelector('.trace-decisions output[aria-current="step"]')
?.getAttribute('aria-label'),
).toContain('2024-05-03, 2024-05-06');
const candidates = Array.from(
root.querySelectorAll<HTMLDetailsElement>('.candidate-list > details'),
);
expect(candidates).toHaveLength(2);
expect(candidates[0].querySelector('summary')?.textContent).toContain(
'2024-05-10, 2024-05-13',
);
expect(candidates[1].querySelector('summary')?.textContent).toContain(
'2024-05-03, 2024-05-06',
);
expect(candidates.every((candidate) => !candidate.open)).toBe(true);
const technicalComparison = candidates[0].querySelector('.technical-comparison')!;
expect(technicalComparison.closest('details')).toBe(candidates[0]);
expect(candidates[0].open).toBe(false);
activateWithKeyboard(candidates[0].querySelector('summary')!, 'Enter');
fixture.detectChanges();
expect(candidates[0].open).toBe(true);
expect(candidates[0].textContent).toContain(
'This branch cannot satisfy both required dates.',
);
expect(technicalComparison.textContent).toContain('Actual stage comparison');
const traceImpact = candidates[0].querySelector('.criteria-detail')!;
expect(traceImpact.textContent).toContain('Full-plan effect of this branch');
expect(traceImpact.querySelectorAll('.decisive-row')).toHaveLength(0);
expect(traceImpact.textContent).not.toContain('First difference');
const decisiveComparator = candidates[0].querySelector(
'.technical-comparison tbody tr.decisive-row',
)!;
expect(decisiveComparator.textContent).toContain('preference:spring');
expect(decisiveComparator.textContent).toContain('1');
expect(decisiveComparator.textContent).toContain('0');
expect(
candidates[0].textContent!.indexOf('This branch cannot satisfy both required dates.'),
).toBeLessThan(candidates[0].textContent!.indexOf('preference:spring'));
expect(document.activeElement).toBe(candidates[0].querySelector('summary'));
const beforeButton = root.querySelector('.trace-toolbar button') as HTMLButtonElement;
const afterButton = root.querySelectorAll('.trace-toolbar button')[1] as HTMLButtonElement;
activateWithKeyboard(beforeButton, ' ');
fixture.detectChanges();
expect(beforeButton.getAttribute('aria-pressed')).toBe('true');
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'1 earned activations',
);
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'cp: 1 remaining',
);
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('1 placements');
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(['2', '4', '6', '6', '—', '—', '0', '0']);
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
fixture.detectChanges();
expect(afterButton.getAttribute('aria-pressed')).toBe('true');
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('2 placements');
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'1 earned activations',
);
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'cp: 0 remaining',
);
const weekend = root.querySelector<HTMLButtonElement>('button[data-date="2024-05-04"]')!;
expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
expect(weekend.querySelector('.status-marker')?.textContent).toBe('×');
const legend = root.querySelector('.calendar-legend')?.textContent ?? '';
expect(legend).toContain('Evaluated and rejected');
expect(legend).toContain('Not examined');
expect(root.querySelector('.counterfactual-result')).toBeNull();
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
@@ -533,476 +533,8 @@
{{ tr().planning.explanationError }} {{ explanationError()!.message }}
</p>
}
@if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') {
<details class="trace-explorer" [open]="traceOpen()" (toggle)="toggleTrace($event)">
<summary>
<mat-icon aria-hidden="true">account_tree</mat-icon>
<span
>{{ tr().planning.traceTitle
}}<small>{{ tr().planning.traceHelp }}</small></span
>
<mat-icon class="disclosure-chevron" aria-hidden="true">expand_more</mat-icon>
</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"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.firstPhase"
(click)="stepTracePhase('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
data-phase-step="previous"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.previousPhase"
(click)="stepTracePhase('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="tracePhaseAnnouncement()"
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
{{ trace.phases.length }}</output
>
<button
type="button"
data-phase-step="next"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.nextPhase"
(click)="stepTracePhase('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
data-phase-step="last"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.lastPhase"
(click)="stepTracePhase('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</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>{{ phaseLabel(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">{{ phaseLabel(phase.name) }}</h3>
@if (phase.decisions.length) {
<nav [attr.aria-label]="tr().planning.decisions">
<button
type="button"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.firstDecision"
(click)="stepTraceDecision('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.previousDecision"
(click)="stepTraceDecision('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="traceDecisionAnnouncement()"
>{{ tr().planning.decision }}
{{ traceDecisionIndex() + 1 }} {{ tr().planning.of }}
{{ phase.decisions.length }}</output
>
<button
type="button"
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.nextDecision"
(click)="stepTraceDecision('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.lastDecision"
(click)="stepTraceDecision('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</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>
@if (historicalEligibilityLoading()) {
<p role="status">
{{ tr().planning.historicalEligibilityLoading }}
</p>
}
@if (historicalEligibilityError(); as error) {
<p role="alert">
{{ tr().planning.historicalEligibilityError }}
{{ error }}
<button
type="button"
(click)="retryHistoricalEligibility()"
>
{{ tr().planning.retryHistoricalEligibility }}
</button>
</p>
}
<p>
<strong>{{ tr().planning.dates }}:</strong>
{{
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"
[attr.aria-disabled]="
traceCandidatePage() === 0
"
[attr.aria-label]="
tr().planning.previousCandidates
"
(click)="stepTraceCandidatePage(-1)"
>
<mat-icon aria-hidden="true"
>chevron_left</mat-icon
>
</button>
<output
>{{ tr().planning.page }}
{{ traceCandidatePage() + 1 }}
{{ tr().planning.of }}
{{ traceCandidatePages() }}</output
>
<button
type="button"
[attr.aria-disabled]="
traceCandidatePage() ===
traceCandidatePages() - 1
"
[attr.aria-label]="tr().planning.nextCandidates"
(click)="stepTraceCandidatePage(1)"
>
<mat-icon aria-hidden="true"
>chevron_right</mat-icon
>
</button>
</nav>
}
<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.technicalComparison?.length) {
<section class="technical-comparison">
<h5>
{{
tr().planning.details
.traceStageTitle
}}
</h5>
<p class="calculation-intro">
{{
tr().planning.details
.traceStageHelp
}}
</p>
<table class="impact-table">
<thead>
<tr>
<th scope="col">
{{
tr().planning
.explanation
.criterion
}}
</th>
<th scope="col">
{{
tr().planning.before
}}
</th>
<th scope="col">
{{
tr().planning.after
}}
</th>
</tr>
</thead>
<tbody>
@for (
comparator of candidate.technicalComparison;
track $index
) {
<tr
[class.decisive-row]="
comparator.name ===
candidate.firstDecisiveComparator
"
>
<th scope="row">
<code>{{
comparator.name
}}</code>
<small>{{
comparator.direction ===
'maximize'
? tr()
.planning
.details
.higher
: tr()
.planning
.details
.lower
}}</small>
@if (
comparator.name ===
candidate.firstDecisiveComparator
) {
<span
class="decisive-label"
>{{
tr()
.planning
.details
.firstDifference
}}</span
>
}
</th>
<td>
{{
comparator.before
}}
</td>
<td>
{{
comparator.after
}}
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (candidate.comparison; as comparison) {
<div class="calculation-content">
<ng-container
[ngTemplateOutlet]="
calculationTable
"
[ngTemplateOutletContext]="{
comparison,
beforeLabel:
tr().planning.before,
afterLabel: tr().planning.after,
audit: comparison,
traceEffect:
!!candidate
.technicalComparison
?.length,
}"
/>
</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 }}
@for (balance of state.balances; track balance.id) {
<span class="trace-state-balance"
>{{ balance.id }}: {{ balance.remaining }}
{{ tr().planning.balanceRemaining }}
<small
>{{ tr().planning.details.available }}
{{ displayDate(balance.availableFrom) }}
·
{{ tr().planning.details.expires }}
{{
displayDate(balance.expiryDate)
}}</small
></span
>
}
</p>
}
}
} @else {
<p>{{ tr().planning.noDecisions }}</p>
}
</section>
}
</div>
} @else if (traceResolved()) {
<p role="status">{{ tr().planning.traceUnavailable }}</p>
}
</details>
}
</div>
@if (traceReplay()) {
<div class="replay-notice" role="status">
<span>{{ tr().planning.replayNotice }}</span>
<button type="button" class="quiet-button" (click)="showFinalPlan()">
{{ tr().planning.finalPlan }}
</button>
</div>
}
<p class="calendar-help">
<mat-icon aria-hidden="true">touch_app</mat-icon
>{{ declarationEntry() ? tr().bookedDays.help : tr().planning.calendarHelp }}
@@ -1037,7 +569,7 @@
<span class="legend-swatch today" aria-hidden="true"></span
>{{ tr().dayTypes.today }}
</li>
@if (planDetailsOpen() || traceReplay()) {
@if (planDetailsOpen()) {
<li>
<span class="legend-swatch current" aria-hidden="true"></span
>{{ tr().planning.currentPlacement }}
@@ -1049,62 +581,6 @@
>{{ tr().planning.explainedPeriod }}
</li>
}
@if (traceReplay() && counterfactual()) {
<li>
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~✓</span
>{{ tr().planning.eligible }}
</li>
<li>
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~×</span
>{{ tr().planning.ineligible }}
</li>
}
@if (traceReplay()) {
<li>
<span class="legend-symbol" aria-hidden="true">✓</span
>{{ traceStatusLabel('chosen') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceStatusLabel('evaluated-rejected') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">⌁</span
>{{ traceStatusLabel('pruned') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">?</span
>{{ traceStatusLabel('not-examined') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">×</span
>{{ traceStatusLabel('ineligible') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">+</span
>{{ traceChangeLabel('added') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">←</span
>{{ traceChangeLabel('moved-from') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">→</span
>{{ traceChangeLabel('moved-to') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceChangeLabel('removed') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">≈</span
>{{ traceChangeLabel('shared') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">=</span
>{{ traceChangeLabel('unchanged') }}
</li>
}
</ul>
</div>
@@ -1129,7 +605,6 @@
[declarationPreview]="declarationPreview()"
[currentDate]="declarationEntry() ? null : currentPlacementDate()"
[explainedPeriod]="declarationEntry() ? undefined : explainedPeriod()"
[dateStatuses]="!declarationEntry() && traceReplay() ? dateStatuses() : {}"
(dateSelected)="!declarationEntry() && selectCalendarDate($event)"
></app-calendar-month>
</mat-card-content>
@@ -1144,22 +619,13 @@
let-beforeLabel="beforeLabel"
let-afterLabel="afterLabel"
let-audit="audit"
let-traceEffect="traceEffect"
>
<section class="criteria-detail">
<h4>
{{
traceEffect
? tr().planning.details.traceImpactTitle
: tr().planning.details.criteria
}}
{{ tr().planning.details.criteria }}
</h4>
<p class="calculation-intro">
{{
traceEffect
? tr().planning.details.traceImpactHelp
: tr().planning.details.criteriaHelp
}}
{{ tr().planning.details.criteriaHelp }}
</p>
<table class="impact-table criteria-table">
<thead>
@@ -1178,7 +644,7 @@
@if (objective.numeric) {
<tr
[class.decisive-row]="
!traceEffect && objective.id === comparison.firstDecisiveObjective
objective.id === comparison.firstDecisiveObjective
"
>
<th scope="row">
@@ -1191,10 +657,7 @@
? tr().planning.details.higher
: tr().planning.details.lower
}}</small>
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
@if (objective.id === comparison.firstDecisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
@@ -1218,10 +681,7 @@
@if (!objective.numeric) {
<li>
{{ objective.label }}
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
@if (objective.id === comparison.firstDecisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
@@ -1347,11 +807,7 @@
{{ tr().planning.comparison.title }}
</h2>
<p id="comparison-intro">
{{
traceReplay()
? tr().planning.explanation.traceHelp
: tr().planning.explanation.moveHelp
}}
{{ tr().planning.explanation.moveHelp }}
</p>
</div>
<button
@@ -1376,11 +832,7 @@
} @else if (counterfactual(); as analysis) {
<div class="comparison-date-pair">
<div class="original-date">
<span>{{
traceReplay()
? tr().planning.comparison.candidate
: tr().planning.explanation.plannedDate
}}</span>
<span>{{ tr().planning.explanation.plannedDate }}</span>
<strong>{{ displayDate(analysis.placement.date) }}</strong>
</div>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
@@ -1469,7 +921,7 @@
}
</div>
</div>
} @else if (!traceReplay() && result.date === analysis.placement.date) {
} @else if (result.date === analysis.placement.date) {
<p class="comparison-empty">{{ tr().planning.explanation.sameDate }}</p>
} @else {
<div class="comparison-result-heading">
@@ -1488,20 +940,10 @@
{{ tr().planning.explanation.criterion }}
</th>
<th scope="col">
{{
traceReplay()
? tr().planning.explanation
.beforeDecision
: tr().planning.explanation.currentPlan
}}
{{ tr().planning.explanation.currentPlan }}
</th>
<th scope="col">
{{
traceReplay()
? tr().planning.explanation
.withCandidate
: tr().planning.explanation.alternative
}}
{{ tr().planning.explanation.alternative }}
</th>
</tr>
</thead>
@@ -1529,34 +971,14 @@
<details class="technical-details date-calculation-details">
<summary>{{ tr().planning.technicalDetails }}</summary>
<div class="calculation-content">
<p>
<strong>{{ tr().planning.historical }}:</strong>
{{
dateStatuses()[result.date]?.label ??
traceStatusLabel(
result.historicalStatus ?? 'not-examined'
)
}}
</p>
@if (dateStatuses()[result.date]?.reason; as historicalReason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ historicalReason }}
</p>
}
@if (dateComparison(); as comparison) {
<ng-container
[ngTemplateOutlet]="calculationTable"
[ngTemplateOutletContext]="{
comparison,
beforeLabel: traceReplay()
? tr().planning.explanation.beforeDecision
: tr().planning.explanation.currentPlan,
afterLabel: traceReplay()
? tr().planning.explanation.withCandidate
: tr().planning.explanation.alternative,
beforeLabel: tr().planning.explanation.currentPlan,
afterLabel: tr().planning.explanation.alternative,
audit: result,
traceEffect: traceReplay(),
}"
/>
} @else {
@@ -1583,9 +1005,7 @@
{{
counterfactualError()
? tr().planning.retryCounterfactual
: traceReplay()
? tr().planning.comparison.backToTrace
: tr().planning.comparison.backToPlan
: tr().planning.comparison.backToPlan
}}
</button>
</footer>
@@ -85,8 +85,7 @@ summary:focus-visible {
}
.results-header,
.explanation-heading,
.replay-notice {
.explanation-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -249,14 +248,12 @@ summary:focus-visible {
}
.placement-explanation,
.outcome-explanation,
.trace-decisions {
.outcome-explanation {
display: grid;
gap: 1rem;
min-width: 0;
}
.placement-explanation,
.trace-layout {
.placement-explanation {
padding: 1.25rem;
}
.placement-explanation ul {
@@ -264,8 +261,7 @@ summary:focus-visible {
padding-inline-start: 1.25rem;
}
.break-story,
.outcome-explanation,
.replay-notice {
.outcome-explanation {
padding: 1rem;
border-radius: var(--ui-radius-sm);
background: var(--ui-panel-bg);
@@ -339,17 +335,13 @@ summary:focus-visible {
.compare-dates-button {
justify-self: start;
}
.placement-stepper,
.trace-toolbar,
.trace-decisions nav,
.trace-phase-stepper {
.placement-stepper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.placement-stepper button,
.trace-phase-stepper button {
.placement-stepper button {
padding: 0.375rem;
}
output {
@@ -499,52 +491,6 @@ output {
box-shadow: none;
}
.trace-layout {
display: grid;
grid-template-columns: minmax(0, 0.35fr) minmax(0, 1fr);
gap: 1rem;
}
.trace-phases {
display: grid;
align-content: start;
gap: 0.5rem;
> button {
justify-content: space-between;
text-align: start;
overflow-wrap: anywhere;
}
.active {
border-inline-start: 4px solid var(--mat-sys-primary);
}
small {
color: var(--ui-text-secondary);
flex-shrink: 0;
white-space: nowrap;
}
}
.trace-phase-stepper {
justify-content: center;
}
.trace-decisions {
align-content: start;
}
.candidate-list {
display: grid;
gap: 0.5rem;
details {
padding: 0.5rem;
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius-sm);
overflow-wrap: anywhere;
}
}
.trace-state-summary,
.replay-notice {
padding: 0.75rem;
border: 1px solid var(--mat-sys-primary);
border-radius: var(--ui-radius-sm);
}
.loading-overlay {
position: absolute;
inset: 0;
@@ -576,18 +522,6 @@ output {
font-weight: 600;
}
@container calendar-year (max-width: 48rem) {
.trace-layout {
grid-template-columns: minmax(0, 1fr);
}
.trace-phases {
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.trace-phase-stepper {
grid-column: 1 / -1;
}
}
@container calendar-year (max-width: 35rem) {
.plan-insights {
grid-template-columns: minmax(0, 1fr);
@@ -604,8 +538,7 @@ output {
font-size: 0.75rem;
}
}
.placement-explanation,
.trace-layout {
.placement-explanation {
padding: 0.875rem;
}
.placement-stepper {
+20 -615
View File
@@ -30,17 +30,12 @@ import {
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import {
CalendarMonth,
CalendarDayStatus,
DeclarationPreview,
} from '../calendar-month/calendar-month';
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
calendarProjectionHorizon,
DayType,
SelectedDates,
SelectableDayType,
VacDay,
@@ -50,10 +45,8 @@ import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import {
applyReplayDeltas,
CounterfactualAnalysis,
CounterfactualSelection,
LeavePlacement,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
@@ -64,15 +57,9 @@ import {
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceCandidate,
PlanningTraceDecision,
PlanningTraceState,
} from '../../services/leave-planner.service';
type PlacementExplanation = PlanExplanation['placements'][number];
type TraceMoment = 'before' | 'after';
type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
@Component({
selector: 'app-calendar-year',
@@ -121,12 +108,8 @@ export class CalendarYear {
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
protected readonly explanationError = signal<PlanningReason | null>(null);
protected readonly planningTrace = this.userInput.planningTraceSignal;
protected readonly traceLoading = signal(false);
protected readonly traceResolved = this.userInput.traceResolvedSignal;
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
protected readonly traceOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
@@ -134,18 +117,7 @@ export class CalendarYear {
protected readonly counterfactualError = signal<string | null>(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | 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 selectedTraceDate = signal<string | null>(null);
protected readonly liveMessage = signal('');
private readonly historicalIneligibility = signal<ReadonlyMap<string, PlanningReason>>(
new Map(),
);
protected readonly historicalEligibilityLoading = signal(false);
protected readonly historicalEligibilityError = signal<string | null>(null);
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
@@ -179,21 +151,14 @@ export class CalendarYear {
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
: (this.traceState()?.declaredDays ??
this.displayedPlan()?.declaredDays ??
this.resultDeclaredDays());
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
private readonly traceDecisionIndices = new WeakMap<
PlanningTrace,
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
>();
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -245,28 +210,6 @@ export class CalendarYear {
() => 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 selectedTraceTransition = computed(() =>
this.traceReplay()
? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision())
: undefined,
);
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(),
@@ -314,12 +257,9 @@ export class CalendarYear {
const analysis = this.counterfactual();
const comparison = this.selectedCounterfactualResult()?.comparison;
if (!analysis || !comparison) return undefined;
// Final-plan alternatives share a removed-day baseline. Compare their restored
// plans; a trace candidate instead uses the exact state before its decision.
const before = this.traceReplay()
? comparison.before
: analysis.results.find((result) => result.date === analysis.placement.date)?.comparison
?.after;
// Compare restored alternatives against the displayed plan.
const before = analysis.results.find((result) => result.date === analysis.placement.date)
?.comparison?.after;
return before
? {
before,
@@ -337,10 +277,6 @@ export class CalendarYear {
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly traceState = computed(() => {
return this.selectedTraceTransition()?.[this.traceMoment()];
});
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
@@ -352,176 +288,22 @@ export class CalendarYear {
})
: new SelectedDates();
}
const state = this.traceState();
return state
? this.calendarService.selectedDatesForPlan({
placements: [...state.placements],
declaredDays: state.declaredDays,
horizon: { ...state.horizon },
})
: this.selectedDates();
return this.selectedDates();
});
protected readonly currentPlacementDate = computed(() => {
if (this.traceReplay())
return (
this.selectedCounterfactualDate() ??
this.selectedTraceDate() ??
this.selectedTraceDecision()?.dates[0] ??
null
);
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
});
protected readonly explainedPeriod = computed(() =>
this.planDetailsOpen() && !this.traceReplay()
? this.selectedExplanation()?.timeOffPeriod
: undefined,
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
);
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
const statuses: Record<string, CalendarDayStatus> = {};
const trace = this.planningTrace();
const closedDayDates = new Set<string>();
for (const selected of this.selectedDates().datesSelected) {
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
const day = selected.range.start;
closedDayDates.add(
`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
);
}
if (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 date of Object.keys(statuses)) {
const reason = this.nonWorkingDateReason(date, closedDayDates);
if (reason)
statuses[date] = {
kind: 'ineligible',
label: this.traceStatusLabel('ineligible'),
reason,
};
}
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);
}
}
}
for (const [date, reason] of this.historicalIneligibility()) {
if (statuses[date]?.kind !== 'not-examined') continue;
statuses[date] = {
kind: 'ineligible',
label: this.traceStatusLabel('ineligible'),
reason: this.reasonLabel(reason),
};
}
const decision = this.selectedTraceDecision();
if (decision) {
const changedDates = new Set<string>();
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.branch?.deltas ?? decision.deltas) {
if (delta.kind === 'placement-added') setChange(delta.date, 'added');
else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed');
else if (delta.kind === 'placement-moved') {
setChange(delta.fromDate, 'moved-from');
setChange(delta.toDate, 'moved-to');
} else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
}
const transition = this.selectedTraceTransition();
for (const placement of [
...(transition?.before.placements ?? []),
...(transition?.after.placements ?? []),
]) {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
}
}
for (const result of this.counterfactual()?.results ?? []) {
const status = statuses[result.date];
const historicalStatus =
result.historicalStatus ??
(status && status.kind !== 'eligible' ? status.kind : 'not-examined');
statuses[result.date] = {
...status,
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: status?.reason,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
hypotheticalReason: result.reason?.message,
};
}
return statuses;
}
for (const result of this.counterfactual()?.results ?? []) {
const nonWorkingReason = this.nonWorkingDateReason(result.date, closedDayDates);
const historicalStatus =
result.historicalStatus ?? (nonWorkingReason ? 'ineligible' : 'not-examined');
statuses[result.date] = {
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: historicalStatus === 'ineligible'
? nonWorkingReason
: undefined,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
hypotheticalReason: result.reason?.message,
};
}
return statuses;
});
protected readonly months = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return [];
const plan = this.traceState() ?? this.displayedPlan();
const plan = this.displayedPlan();
const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
@@ -555,7 +337,7 @@ export class CalendarYear {
effect(() => {
if (this.declarationEntry())
untracked(() => {
this.showFinalPlan();
this.clearCounterfactual();
this.liveMessage.set(this.tr().bookedDays.enter);
});
});
@@ -670,8 +452,6 @@ export class CalendarYear {
};
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);
}
@@ -683,20 +463,9 @@ export class CalendarYear {
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.planDetailsOpen.set(false);
this.traceOpen.set(false);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -736,44 +505,6 @@ export class CalendarYear {
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
void calculation.trace
.then((trace) => {
if (calculation !== this.activeCalculation) return;
this.planningTrace.set(trace ?? null);
this.traceResolved.set(true);
this.liveMessage.set(
trace?.status === 'failed'
? this.tr().planning.traceUnavailable
: this.tr().planning.traceReady,
);
})
.catch((error: unknown) => {
if (calculation !== this.activeCalculation) return;
this.planningTrace.set({
status: 'failed',
initialState: {
calculationDate: calculation.snapshot.calculationDate,
horizon: calculation.snapshot.horizon,
...(calculation.snapshot.declaredDays
? { declaredDays: calculation.snapshot.declaredDays }
: {}),
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
error: {
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
},
});
this.traceResolved.set(true);
this.liveMessage.set(this.tr().planning.traceUnavailable);
})
.finally(() => {
if (calculation === this.activeCalculation) this.traceLoading.set(false);
});
}
private applyOutcome(outcome: PlanningOutcome): void {
@@ -833,7 +564,6 @@ export class CalendarYear {
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.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
if (announce) this.liveMessage.set(this.placementAnnouncement());
@@ -863,19 +593,10 @@ export class CalendarYear {
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) {
this.traceOpen.set(false);
this.selectPlacement(this.selectedPlacementIndex());
} else this.clearCounterfactual();
}
protected toggleTrace(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.traceOpen()) return;
this.traceOpen.set(open);
if (open) this.planDetailsOpen.set(false);
this.showFinalPlan();
}
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex();
@@ -1064,7 +785,7 @@ export class CalendarYear {
return;
}
const declaredType = this.calendarDeclaredDays()[date];
if (declaredType && !this.traceReplay() && !this.counterfactual()) {
if (declaredType && !this.counterfactual()) {
this.liveMessage.set(
`${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
);
@@ -1074,212 +795,34 @@ export class CalendarYear {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
}
if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
if (!this.counterfactual() && !this.counterfactualLoading()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
return;
}
}
if (this.traceReplay()) {
this.selectedTraceDate.set(date);
if (this.counterfactual()?.results.some((result) => result.date === date))
this.selectedCounterfactualDate.set(date);
this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
return;
}
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.selectedCounterfactualDate.set(date);
this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
this.liveMessage.set(this.dateStatusAnnouncement(date));
}
}
private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string {
private dateStatusAnnouncement(date: string): string {
const result = this.counterfactual()?.results.find((result) => result.date === date);
return [
date,
status?.label,
status?.reason,
status?.hypotheticalLabel
? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}`
: undefined,
status?.hypotheticalReason
? `${this.tr().planning.reason}: ${status.hypotheticalReason}`
: undefined,
status?.changeLabel,
this.tr().planning.counterfactual,
result ? this.tr().planning[result.status] : undefined,
result?.reason ? this.reasonLabel(result.reason) : undefined,
]
.filter(Boolean)
.join('. ');
}
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.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.clearCounterfactual();
this.liveMessage.set(this.tracePhaseAnnouncement());
}
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
const current = this.tracePhaseIndex();
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1;
if (target !== current) this.selectTracePhase(target);
}
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.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
this.clearCounterfactual();
this.liveMessage.set(this.traceDecisionAnnouncement());
void this.loadHistoricalEligibility();
}
private selectedDecisionPlacement(decision: PlanningTraceDecision): LeavePlacement | undefined {
const placements = [
...decision.candidates.flatMap((candidate) =>
candidate.placement ? [candidate.placement] : [],
),
...(decision.branch?.deltas ?? decision.deltas).flatMap((delta) =>
delta.placement ? [delta.placement] : [],
),
];
const selectedDate = this.selectedTraceDate() ?? decision.dates[0];
return placements.find((placement) => placement.date === selectedDate) ?? placements[0];
}
private clearHistoricalEligibility(): void {
this.historicalEligibilityVersion++;
this.historicalIneligibility.set(new Map());
this.historicalEligibilityLoading.set(false);
this.historicalEligibilityError.set(null);
}
private async loadHistoricalEligibility(): Promise<void> {
const version = ++this.historicalEligibilityVersion;
this.historicalIneligibility.set(new Map());
this.historicalEligibilityLoading.set(false);
this.historicalEligibilityError.set(null);
const calculation = this.activeCalculation;
const decision = this.selectedTraceDecision();
const placement = decision && this.selectedDecisionPlacement(decision);
if (!this.traceReplay() || !calculation || !decision || !placement) return;
this.historicalEligibilityLoading.set(true);
this.liveMessage.set(this.tr().planning.historicalEligibilityLoading);
try {
const reasons = await calculation.historicalEligibility({
decisionId: decision.id,
placement,
});
if (
version !== this.historicalEligibilityVersion ||
calculation !== this.activeCalculation
)
return;
this.historicalIneligibility.set(
new Map(reasons.map(({ date, reason }) => [date, reason])),
);
this.liveMessage.set(this.tr().planning.historicalEligibilityReady);
} catch (error) {
if (version !== this.historicalEligibilityVersion) return;
this.historicalIneligibility.set(new Map());
const message = error instanceof Error ? error.message : String(error);
this.historicalEligibilityError.set(message);
this.liveMessage.set(`${this.tr().planning.historicalEligibilityError} ${message}`);
} finally {
if (version === this.historicalEligibilityVersion)
this.historicalEligibilityLoading.set(false);
}
}
protected retryHistoricalEligibility(): void {
void this.loadHistoricalEligibility();
}
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
const current = this.traceDecisionIndex();
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1;
if (target !== current) this.selectTraceDecision(target);
}
protected stepTraceCandidatePage(direction: -1 | 1): void {
const page = Math.max(
0,
Math.min(this.traceCandidatePage() + direction, this.traceCandidatePages() - 1),
);
if (page === this.traceCandidatePage()) return;
this.traceCandidatePage.set(page);
this.liveMessage.set(
`${this.tr().planning.page} ${this.traceCandidatePage() + 1} ${this.tr().planning.of} ${this.traceCandidatePages()}`,
);
}
protected showTraceMoment(moment: TraceMoment): void {
this.clearCounterfactual();
this.traceMoment.set(moment);
this.traceReplay.set(true);
this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`);
void this.loadHistoricalEligibility();
}
protected showFinalPlan(): void {
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.clearCounterfactual();
this.liveMessage.set(this.tr().planning.finalPlan);
}
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
const decision = this.selectedTraceDecision();
if (!candidate.placement || !decision) return;
this.traceMoment.set('before');
this.traceReplay.set(true);
this.selectedTraceDate.set(candidate.placement.date);
void this.analyzeSelection(
{ decisionId: decision.id, placement: candidate.placement },
true,
);
}
private nonWorkingDateReason(
date: string,
closedDayDates: ReadonlySet<string>,
): string | undefined {
const day = new Date(`${date}T00:00:00`);
if (day.getDay() === 0 || day.getDay() === 6) return this.tr().dayTypes.weekend;
if (closedDayDates.has(date)) return this.tr().dayTypes.closedDay;
return undefined;
}
private counterfactualSelection: CounterfactualSelection | null = null;
private async analyzeSelection(
@@ -1295,12 +838,7 @@ export class CalendarYear {
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (openDialog) this.openComparisonDialog();
const placement =
typeof selection === 'string'
? selection
: 'placement' in selection
? selection.placement
: selection;
const placement = selection;
this.liveMessage.set(
`${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
);
@@ -1312,9 +850,7 @@ export class CalendarYear {
this.counterfactual.set(result);
if (openDialog || this.comparisonDialog)
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
this.nearbyComparisonDates()[0]?.date ?? result.placement.date,
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
@@ -1362,113 +898,6 @@ export class CalendarYear {
this.comparisonDateInput.set(null);
}
private replayTraceTransition(
trace: PlanningTrace | null,
decision: PlanningTraceDecision | undefined,
): TraceTransition | undefined {
if (!trace || !decision) return undefined;
const state = {
balances: trace.initialState.balances.map((balance) => ({ ...balance })),
placements: new Map(
trace.initialState.placements.map((placement) => [
placement.date,
{ ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
};
const snapshot = (): PlanningTraceState => ({
calculationDate: trace.initialState.calculationDate,
horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays }
: {}),
balances: state.balances.map((balance) => ({ ...balance })),
placements: [...state.placements.values()].map((placement) => ({
...placement,
ruleIds: [...placement.ruleIds],
})),
earnedActivations: state.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
});
if (decision.branch) {
const decisionIndex = this.getTraceDecisionIndex(trace);
const target = decisionIndex.get(decision.id);
if (!target) return undefined;
const branchAncestors: PlanningTraceDecision[] = [];
const visited = new Set([decision.id]);
let current = decision;
let committedParentOrdinal: number | undefined;
while (current.branch) {
branchAncestors.unshift(current);
const parentId = current.branch.parentDecisionId;
if (!parentId) break;
if (visited.has(parentId)) return undefined;
visited.add(parentId);
const parent = decisionIndex.get(parentId);
if (!parent) return undefined;
if (!parent.decision.branch) {
committedParentOrdinal = parent.ordinal;
break;
}
current = parent.decision;
}
const branchRoot = branchAncestors[0];
const branchRootIndex = branchRoot && decisionIndex.get(branchRoot.id)?.ordinal;
if (branchRootIndex === undefined || target.ordinal < branchRootIndex) return undefined;
const replayThrough = committedParentOrdinal ?? branchRootIndex - 1;
if (replayThrough >= branchRootIndex) return undefined;
let ordinal = 0;
for (const phase of trace.phases) {
for (const prior of phase.decisions) {
if (ordinal++ > replayThrough) break;
applyReplayDeltas(state, prior.deltas);
}
if (ordinal > replayThrough) break;
}
for (const ancestor of branchAncestors.slice(0, -1))
applyReplayDeltas(state, ancestor.branch!.deltas);
const before = snapshot();
applyReplayDeltas(state, decision.branch.deltas);
return { before, after: snapshot() };
}
for (const phase of trace.phases) {
for (const prior of phase.decisions) {
if (prior.id === decision.id) {
const before = snapshot();
applyReplayDeltas(state, prior.deltas);
return { before, after: snapshot() };
}
applyReplayDeltas(state, prior.deltas);
}
}
return undefined;
}
private getTraceDecisionIndex(
trace: PlanningTrace,
): ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }> {
let index = this.traceDecisionIndices.get(trace);
if (!index) {
const decisions = new Map<
string,
{ decision: PlanningTraceDecision; ordinal: number }
>();
let ordinal = 0;
for (const phase of trace.phases)
for (const decision of phase.decisions)
decisions.set(decision.id, { decision, ordinal: ordinal++ });
index = decisions;
this.traceDecisionIndices.set(trace, index);
}
return index;
}
protected placementAnnouncement(): string {
const selected = this.selectedExplanation();
const count = this.planExplanation()?.placements.length ?? 0;
@@ -1480,18 +909,6 @@ export class CalendarYear {
return explanation.contributions.some((contribution) => contribution.kind === 'at-risk');
}
protected tracePhaseAnnouncement(): string {
const phases = this.planningTrace()?.phases ?? [];
const phase = phases[this.tracePhaseIndex()];
return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`;
}
protected traceDecisionAnnouncement(): string {
const decisions = this.selectedTracePhase()?.decisions ?? [];
const decision = decisions[this.traceDecisionIndex()];
return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`;
}
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
return (
@@ -1627,22 +1044,10 @@ export class CalendarYear {
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 reasonLabel(reason: PlanningReason): string {
return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message;
}
protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
return this.tr().planning.tracePhaseStatuses[status];
}
protected traceChangeLabel(change: NonNullable<CalendarDayStatus['change']>): string {
return this.tr().planning.traceChanges[change];
}
protected formatIdentifier(value: string): string {
return value.replaceAll('-', ' ').replaceAll(':', ': ');
}