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
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:
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 {
|
||||
|
||||
@@ -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(':', ': ');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user