feat(planning): explain and trace planning decisions

This commit is contained in:
nessar committed 2026-08-31 00:02:45 +02:00
1 parent 609b3dece9
commit d7a13b0b1e
24 files changed
+6073 -342

No files matched your search

@@ -26,21 +26,45 @@
<p class="day empty"></p>
} @else {
@let dayTooltip = getDayTooltip(day);
<p
@let dateKey = getDateKey(day);
@let status = getStatus(day);
<button
type="button"
[attr.data-date]="dateKey"
[class.past]="day < getNow()"
[class.start]="selectedDates.isStart(day)"
[class.end]="selectedDates.isEnd(day)"
[class.selected]="selectedDates.isSelected(day)"
[class.current]="dateKey === currentDate"
[class.context-only]="!isInHorizon(day)"
[class.eligible]="status?.kind === 'eligible'"
[class.ineligible]="status?.kind === 'ineligible'"
[class.chosen]="status?.kind === 'chosen'"
[class.evaluated-rejected]="status?.kind === 'evaluated-rejected'"
[class.pruned]="status?.kind === 'pruned'"
[class.not-examined]="status?.kind === 'not-examined'"
[class.change-added]="status?.change === 'added'"
[class.change-moved-from]="status?.change === 'moved-from'"
[class.change-moved-to]="status?.change === 'moved-to'"
[class.change-removed]="status?.change === 'removed'"
[class.change-shared]="status?.change === 'shared'"
[class.change-unchanged]="status?.change === 'unchanged'"
[class]="selectedDates.getClasses(day)"
class="day number"
[disabled]="!isInHorizon(day)"
[matTooltip]="dayTooltip"
[matTooltipShowDelay]="500"
[attr.aria-label]="
dayTooltip ? day.getDate() + ': ' + dayTooltip : null
"
[attr.aria-label]="getDayAriaLabel(day)"
[attr.aria-current]="dateKey === currentDate ? 'date' : null"
(click)="dateSelected.emit(dateKey)"
>
{{ day.getDate() }}
</p>
<span>{{ day.getDate() }}</span>
@if (status) {
<small class="status-marker" aria-hidden="true">{{
getStatusMarker(day)
}}</small>
}
</button>
}
}
</div>
@@ -22,6 +22,7 @@
}
.day {
position: relative;
text-align: center;
display: flex;
align-items: center;
@@ -32,7 +33,10 @@
margin-top: 2px;
margin-bottom: 2px;
border-radius: 30%;
border: 1px solid transparent;
color: var(--number-color);
font: inherit;
padding: 0;
&.empty {
visibility: hidden;
@@ -41,7 +45,17 @@
&.number:hover {
background-color: var(--number-bg-hover);
color: var(--number-color-hover);
cursor: default;
cursor: pointer;
}
&.number:focus-visible {
outline: 3px solid var(--today-border-color, #1976d2);
outline-offset: 2px;
}
&.context-only {
opacity: 0.38;
cursor: not-allowed;
}
&.past {
@@ -49,8 +63,66 @@
}
}
.status-marker {
position: absolute;
inset-block-start: -0.35rem;
inset-inline-end: -0.2rem;
min-width: 0.85rem;
font-size: 0.65rem;
font-weight: 800;
line-height: 1;
background: var(--ui-card-bg, #fff);
border-radius: 999px;
}
.current {
outline: 3px double var(--today-border-color, #1976d2);
outline-offset: 1px;
}
.eligible,
.chosen {
border-block-end: 3px solid var(--ui-success-text, #174d26);
}
.ineligible,
.evaluated-rejected {
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.pruned {
border: 2px dotted currentColor;
}
.not-examined {
border: 2px dashed currentColor;
}
.change-added,
.change-moved-to {
box-shadow: inset 0 -3px var(--ui-success-text, #174d26);
}
.change-moved-from,
.change-removed {
box-shadow: inset 0 3px var(--ui-error-text, #8b1a1a);
}
.change-shared {
box-shadow: inset 3px 0 var(--today-border-color, #1976d2);
}
.change-unchanged {
box-shadow: inset 0 0 0 1px currentColor;
}
.selected {
cursor: help !important;
cursor: pointer;
}
.context-only.selected {
cursor: not-allowed;
}
.selected:not(.start):not(.end) {
@@ -1,10 +1,19 @@
import { Component, Input, OnInit, inject } from '@angular/core';
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
import { MatTooltip } from '@angular/material/tooltip';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { DateCacheService } from '../../services/date-cache.service';
import { TranslationService } from '../../services/translation-service';
export interface CalendarDayStatus {
kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined';
label: string;
reason?: string;
historicalLabel?: string;
change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged';
changeLabel?: string;
}
@Component({
selector: 'app-calendar-month',
imports: [MatTooltip],
@@ -38,6 +47,89 @@ export class CalendarMonth implements OnInit {
@Input({ required: true })
selectedDates!: SelectedDates;
@Input()
horizonStart?: string;
@Input()
horizonEnd?: string;
@Input()
currentDate: string | null = null;
@Input()
dateStatuses: Readonly<Record<string, CalendarDayStatus>> = {};
@Output()
readonly dateSelected = new EventEmitter<string>();
getDateKey(day: Date): string {
return [
day.getFullYear(),
String(day.getMonth() + 1).padStart(2, '0'),
String(day.getDate()).padStart(2, '0'),
].join('-');
}
isInHorizon(day: Date): boolean {
const date = this.getDateKey(day);
return (
(!this.horizonStart || date >= this.horizonStart) &&
(!this.horizonEnd || date < this.horizonEnd)
);
}
getStatus(day: Date): CalendarDayStatus | undefined {
return this.dateStatuses[this.getDateKey(day)];
}
getStatusMarker(day: Date): string {
switch (this.getStatus(day)?.change) {
case 'added':
return '+';
case 'moved-from':
return '←';
case 'moved-to':
return '→';
case 'removed':
return '−';
case 'shared':
return '≈';
case 'unchanged':
return '=';
}
switch (this.getStatus(day)?.kind) {
case 'chosen':
case 'eligible':
return '✓';
case 'ineligible':
return '×';
case 'evaluated-rejected':
return '−';
case 'pruned':
return '⌁';
case 'not-examined':
return '?';
default:
return '';
}
}
getDayAriaLabel(day: Date): string {
const parts = [
day.toLocaleDateString(this.translationService.locale(), {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
];
const tooltip = this.getDayTooltip(day);
if (tooltip) parts.push(tooltip);
if (!this.isInHorizon(day))
parts.push(this.translationService.tr().planning.outsideHorizon);
return parts.join('. ');
}
getMonthName(): string {
const locale = this.translationService.locale();
const tempDate = new Date(this.year, this.monthIndex, 1);
@@ -69,9 +161,19 @@ export class CalendarMonth implements OnInit {
}
getDayTooltip(day: Date): string | null {
const tooltips = this.selectedDates.datesSelected
const tooltips: string[] = this.selectedDates.datesSelected
.filter((dateSelected) => dateSelected.isSelected(day) && dateSelected.type)
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
const status = this.getStatus(day);
if (status) {
tooltips.push(status.label);
if (status.changeLabel) tooltips.push(status.changeLabel);
if (status.historicalLabel)
tooltips.push(
`${this.translationService.tr().planning.historical}: ${status.historicalLabel}`,
);
if (status.reason) tooltips.push(status.reason);
}
return tooltips.length > 0 ? tooltips.join(' + ') : null;
}
@@ -2,15 +2,26 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarYear } from './calendar-year';
import { CalendarService, SelectedDates, SelectableDayType } from '../../services/calendar-service';
import {
CalendarService,
DayType,
SelectedDate,
SelectedDates,
SelectableDayType,
} from '../../services/calendar-service';
import {
CounterfactualAnalysis,
PlanOutcome,
PlanningCalculationContext,
PlanningConflictOutcome,
PlanningExplanation,
PlanningIncompleteOutcome,
InvalidInputOutcome,
PlanningOutcome,
PlanningTrace,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
kind: 'InvalidInput',
@@ -64,18 +75,120 @@ const plan: PlanOutcome = {
lastCompletedStage: 'dates',
};
const objectiveVector = {
objectives: [{ id: 'non-working-day-gain' as const, direction: 'maximize' as const, value: 4 }],
};
const explanation: PlanningExplanation = {
kind: 'PlanExplanation',
placements: plan.placements.map((placement, index) => ({
placement,
balance: {
id: 'cp',
name: 'Paid leave',
type: 'cp',
quantity: 2,
expiryDate: '2024-12-31',
},
ruleIds: placement.ruleIds,
supporting: false,
remainingQuantity: 1 - index,
contributions: [{ kind: 'entitlement', code: 'balance', label: 'Paid leave', value: 1 }],
finalMetrics: plan.metrics,
objectiveVector,
comparison: {
before: { objectives: [{ ...objectiveVector.objectives[0], value: 3 }] },
after: objectiveVector,
delta: [
{
id: 'non-working-day-gain',
direction: 'maximize',
value: 1,
},
],
firstDecisiveObjective: 'non-working-day-gain',
},
firstDecisiveObjective: 'non-working-day-gain',
})),
};
function selectedDatesForPlan(value: PlanOutcome): SelectedDates {
const selected = new SelectedDates();
for (const placement of value.placements) {
const date = new Date(`${placement.date}T00:00:00`);
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
}
return selected;
}
function calculationFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
): PlanningCalculationContext {
const counterfactual: CounterfactualAnalysis = {
kind: 'Counterfactual',
calculationId: 'test',
placement: plan.placements[0],
hypothetical: true,
baseline: objectiveVector,
results: [
{
date: '2024-05-03',
status: 'eligible',
comparison:
stableExplanation?.kind === 'PlanExplanation'
? stableExplanation.placements[0].comparison
: undefined,
},
{
date: '2024-05-04',
status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' },
},
],
};
return {
id: 'test',
snapshot: {
horizon: plan.horizon,
balances: [],
rules: [],
identities: {},
labels: {},
},
outcome: Promise.resolve(outcome),
explanation: Promise.resolve(stableExplanation),
explanationError: Promise.resolve(undefined),
trace: Promise.resolve(trace),
analyzeCounterfactual: vi.fn(async () => counterfactual),
};
}
function calendarFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
) {
const calculation = calculationFor(outcome, stableExplanation, trace);
return {
calculation,
calendar: {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
createPlanningCalculation: vi.fn(() => calculation),
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
},
};
}
describe('CalendarYear planning outcome boundary', () => {
it.each([
['InvalidInput', invalid],
['PlanningConflict', conflict],
['PlanningIncomplete without incumbent', incomplete],
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => outcome),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
const { calendar } = calendarFor(outcome);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -100,12 +213,7 @@ describe('CalendarYear planning outcome boundary', () => {
});
it('keeps and summarizes a returned result', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => plan),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
const { calendar } = calendarFor(plan, explanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -127,13 +235,215 @@ describe('CalendarYear planning outcome boundary', () => {
expect(summaryValues).toEqual(['2', '4', '6']);
});
it('keeps an incomplete incumbent visible with its interrupted-stage explanation', async () => {
const outcome: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: ['at-risk'],
lastCompletedStage: 'at-risk',
reasons: [{ code: 'deadline', message: 'Planning stopped at the deadline.' }],
};
const incumbentExplanation: PlanningExplanation = {
...explanation,
incomplete: {
kind: 'IncompleteExplanation',
reason: outcome.reasons[0],
completedStages: ['at-risk'],
uncompletedStages: ['holiday-week-value'],
},
};
const { calendar } = calendarFor(outcome, incumbentExplanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.incomplete-incumbent').textContent).toContain(
'holiday-week-value',
);
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
it('shows the stable placement explanation by default and keeps the full plan visible', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.placement-explanation')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Paid leave');
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]);
const next = fixture.nativeElement.querySelector(
'button[data-step="next"]',
) as HTMLButtonElement;
next.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
'2024-05-06',
);
});
it('renders conflict causes and replays an atomic trace decision', async () => {
const conflictExplanation: PlanningExplanation = {
kind: 'ConflictExplanation',
conflictingRules: [{ id: 'rule', label: 'Required spring leave' }],
bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' },
};
let setup = calendarFor(conflict, conflictExplanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: setup.calendar },
],
}).compileComponents();
let fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
'Required spring leave',
);
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
'No CP entitlement is available.',
);
TestBed.resetTestingModule();
const trace: PlanningTrace = {
status: 'ready',
initialState: {
horizon: plan.horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
remaining: 2,
availableFrom: '2024-01-01',
expiryDate: '2024-12-31',
earned: false,
},
],
placements: [plan.placements[1]],
earnedActivations: [],
},
phases: [
{
id: 'phase:1',
name: 'at-risk',
completed: true,
status: 'completed',
decisions: [
{
id: 'decision:1',
action: 'add',
dates: ['2024-05-03'],
candidates: [
{
status: 'chosen',
date: '2024-05-03',
placement: plan.placements[0],
},
],
deltas: [
{
kind: 'placement-added',
date: '2024-05-03',
balanceId: 'cp',
placement: plan.placements[0],
},
],
},
],
},
{
id: 'phase:2',
name: 'dates',
completed: true,
status: 'completed',
decisions: [],
},
],
completedStages: ['at-risk'],
};
setup = calendarFor(plan, explanation, trace);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: setup.calendar },
],
}).compileComponents();
fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const lastPhase = fixture.nativeElement.querySelector(
'button[data-phase-step="last"]',
) as HTMLButtonElement;
lastPhase.click();
fixture.detectChanges();
expect(fixture.componentInstance['tracePhaseIndex']()).toBe(1);
const firstPhase = fixture.nativeElement.querySelector(
'button[data-phase-step="first"]',
) as HTMLButtonElement;
firstPhase.click();
fixture.detectChanges();
const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0];
beforeButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1);
expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged');
expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added');
const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1];
afterButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(2);
const compareButton = fixture.nativeElement.querySelector(
'.candidate-list button',
) as HTMLButtonElement;
compareButton.click();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible');
const alternativeDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]',
) as HTMLButtonElement;
alternativeDate.click();
fixture.detectChanges();
expect(alternativeDate.classList).toContain('current');
expect(alternativeDate.getAttribute('aria-current')).toBe('date');
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => {
throw new Error('planner exploded');
}),
createPlanningCalculation: vi.fn(() => ({
...calculationFor(plan),
outcome: Promise.reject(new Error('planner exploded')),
})),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
@@ -1,4 +1,6 @@
<div class="calendar-year">
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage() }}</p>
@if (isLoading()) {
<div class="loading-overlay">
<div class="loading-spinner">
@@ -50,6 +52,73 @@
<app-calendar-settings></app-calendar-settings>
</header>
@if (planningOutcome(); as outcome) {
@if (outcome.kind === 'InvalidInput') {
<ul class="reason-list" role="alert">
@for (reason of outcome.reasons; track reason.code + reason.message) {
<li>
<strong>{{ formatIdentifier(reason.code) }}:</strong> {{ reason.message }}
</li>
}
</ul>
}
}
@if (planningExplanation(); as outcomeExplanation) {
@if (outcomeExplanation.kind === 'ConflictExplanation') {
<section
class="outcome-explanation conflict-explanation"
aria-labelledby="conflict-title"
>
<h3 id="conflict-title">{{ tr().planning.conflictDetails }}</h3>
<p>
<strong>{{ tr().planning.conflictingRules }}:</strong>
</p>
<ul>
@for (rule of outcomeExplanation.conflictingRules; track rule.id) {
<li>
{{ rule.label }} <code>{{ rule.id }}</code>
</li>
}
</ul>
<p>
<strong>{{ tr().planning.bottleneck }}:</strong>
{{ outcomeExplanation.bottleneck.message }}
</p>
</section>
} @else if (outcomeExplanation.kind === 'IncompleteExplanation') {
<section
class="outcome-explanation incomplete-explanation"
aria-labelledby="incomplete-title"
>
<h3 id="incomplete-title">{{ tr().planning.incompleteDetails }}</h3>
<p>{{ outcomeExplanation.reason.message }}</p>
<dl class="compact-facts">
<div>
<dt>{{ tr().planning.completedStages }}</dt>
<dd>
{{
outcomeExplanation.completedStages.length
? outcomeExplanation.completedStages.join(', ')
: tr().planning.none
}}
</dd>
</div>
<div>
<dt>{{ tr().planning.uncompletedStages }}</dt>
<dd>
{{
outcomeExplanation.uncompletedStages.length
? outcomeExplanation.uncompletedStages.join(', ')
: tr().planning.none
}}
</dd>
</div>
</dl>
</section>
}
}
@if (displayedPlan(); as plan) {
<dl class="plan-summary">
<div>
@@ -67,6 +136,329 @@
</dl>
}
@if (selectedExplanation(); as selected) {
<section class="placement-explanation" aria-labelledby="placement-explanation-title">
<header class="explanation-heading">
<div>
<p class="eyebrow">{{ tr().planning.whyPlacement }}</p>
<h3 id="placement-explanation-title">
{{ selected.placement.date }} · {{ balanceLabel(selected) }}
</h3>
</div>
<nav
class="placement-stepper"
[attr.aria-label]="tr().planning.placementNavigation"
>
<button
type="button"
data-step="first"
[disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.first"
(click)="stepPlacement('first')"
>
<span aria-hidden="true">⏮</span>
</button>
<button
type="button"
data-step="previous"
[disabled]="selectedPlacementIndex() === 0"
[attr.aria-label]="tr().planning.previous"
(click)="stepPlacement('previous')"
>
<span aria-hidden="true">◀</span>
</button>
<output>
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
{{ tr().planning.of }} {{ planExplanation()!.placements.length }}
</output>
<button
type="button"
data-step="next"
[disabled]="
selectedPlacementIndex() === planExplanation()!.placements.length - 1
"
[attr.aria-label]="tr().planning.next"
(click)="stepPlacement('next')"
>
<span aria-hidden="true">▶</span>
</button>
<button
type="button"
data-step="last"
[disabled]="
selectedPlacementIndex() === planExplanation()!.placements.length - 1
"
[attr.aria-label]="tr().planning.last"
(click)="stepPlacement('last')"
>
<span aria-hidden="true">⏭</span>
</button>
</nav>
</header>
@if (planExplanation()!.incomplete; as interrupted) {
<div class="incomplete-incumbent" role="status">
<strong>{{ tr().planning.incumbentExplanation }}</strong>
<p>{{ interrupted.reason.message }}</p>
<p>
{{ tr().planning.completedStages }}:
{{
interrupted.completedStages.length
? interrupted.completedStages.join(', ')
: tr().planning.none
}}
</p>
<p>
{{ tr().planning.uncompletedStages }}:
{{
interrupted.uncompletedStages.length
? interrupted.uncompletedStages.join(', ')
: tr().planning.none
}}
</p>
</div>
}
<dl class="explanation-facts compact-facts">
<div>
<dt>{{ tr().planning.balance }}</dt>
<dd>
{{ balanceLabel(selected) }} <code>{{ selected.placement.balanceId }}</code>
@if (selected.balance) {
<br />{{ planningDate(selected.balance.availableFrom) }} –
{{ planningDate(selected.balance.expiryDate) }}
}
</dd>
</div>
<div>
<dt>{{ tr().planning.rules }}</dt>
<dd>
@if (selected.ruleIds.length) {
@for (ruleId of selected.ruleIds; track ruleId; let last = $last) {
{{ ruleLabel(ruleId) }} <code>{{ ruleId }}</code
>{{ last ? '' : ', ' }}
}
} @else {
{{ tr().planning.noRules }}
}
</dd>
</div>
<div>
<dt>{{ tr().planning.supporting }}</dt>
<dd>{{ selected.supporting ? tr().planning.yes : tr().planning.no }}</dd>
</div>
<div>
<dt>{{ tr().planning.remaining }}</dt>
<dd>
{{ selected.remainingQuantity }}
@if (selected.remainingBalance) {
<br /><small>
{{ formatIdentifier(selected.remainingBalance.classification) }} ·
{{ selected.remainingBalance.reason.message }}
</small>
}
</dd>
</div>
@if (selected.timeOffPeriod; as period) {
<div>
<dt>{{ tr().planning.timeOffPeriod }}</dt>
<dd>{{ period.start }} – {{ period.end }} ({{ period.duration }})</dd>
</div>
}
@if (selected.firstDecisiveObjective; as decisive) {
<div>
<dt>{{ tr().planning.firstDecisive }}</dt>
<dd>{{ formatIdentifier(decisive) }}</dd>
</div>
}
</dl>
<div class="explanation-columns">
<section>
<h4>{{ tr().planning.contributions }}</h4>
<ul>
@for (
contribution of selected.contributions;
track contribution.kind + contribution.code
) {
<li>
<strong>{{ contribution.label }}</strong>
@if (contribution.value !== undefined) {
<span> · {{ contribution.value }}</span>
}
@if (contribution.dates?.length) {
<span> · {{ contribution.dates!.join(', ') }}</span>
}
</li>
}
</ul>
</section>
<section>
<h4>{{ tr().planning.objectiveVector }}</h4>
<ol class="objective-vector">
@for (
objective of selected.objectiveVector.objectives;
track objective.id
) {
<li>
<span>{{ objectiveLabel(objective) }}</span>
<strong>{{ objective.value }}</strong>
<small>{{ formatIdentifier(objective.direction) }}</small>
</li>
}
</ol>
</section>
<section>
<h4>{{ tr().planning.finalMetrics }}</h4>
<dl class="metric-list">
<div>
<dt>{{ tr().planning.unplacedAtRisk }}</dt>
<dd>{{ selected.finalMetrics.unplacedAtRisk }}</dd>
</div>
<div>
<dt>{{ tr().planning.holidayWeekValue }}</dt>
<dd>{{ selected.finalMetrics.holidayWeekValue }}</dd>
</div>
<div>
<dt>{{ tr().planning.daysGained }}</dt>
<dd>{{ selected.finalMetrics.nonWorkingDayGain }}</dd>
</div>
<div>
<dt>{{ tr().planning.longestWorkSequence }}</dt>
<dd>{{ selected.finalMetrics.longestWorkSequence }}</dd>
</div>
<div>
<dt>{{ tr().planning.shortSequenceDeficit }}</dt>
<dd>{{ selected.finalMetrics.shortSequenceDeficit }}</dd>
</div>
<div>
<dt>{{ tr().planning.supportingPlacements }}</dt>
<dd>{{ selected.finalMetrics.supportingPlacements }}</dd>
</div>
<div>
<dt>{{ tr().planning.preferenceSatisfaction }}</dt>
<dd>
{{
selected.finalMetrics.preferenceSatisfaction.join(', ') ||
tr().planning.none
}}
</dd>
</div>
</dl>
</section>
</div>
<details class="comparison-details">
<summary>{{ tr().planning.removedRestoredComparison }}</summary>
<p>{{ tr().planning.comparisonHelp }}</p>
<ul>
@for (delta of selected.comparison.delta; track delta.id) {
<li>
<strong>{{ formatIdentifier(delta.id) }}:</strong> {{ delta.value }}
</li>
}
</ul>
</details>
<section class="counterfactual-panel" aria-labelledby="counterfactual-title">
<h4 id="counterfactual-title">{{ tr().planning.counterfactual }}</h4>
<p>{{ tr().planning.hypothetical }}</p>
@if (counterfactual(); as analysis) {
<p>
<strong
>{{ analysis.placement.date }} ·
{{ analysis.placement.balanceId }}</strong
>
</p>
}
@if (counterfactualLoading()) {
<p role="status">{{ tr().planning.counterfactualLoading }}</p>
} @else if (counterfactualError()) {
<p class="inline-error" role="alert">
{{ tr().planning.counterfactualError }} {{ counterfactualError() }}
</p>
} @else if (selectedCounterfactualResult(); as result) {
<div class="counterfactual-result">
<h5>{{ result.date }}</h5>
<p class="status-badge" [class.ineligible]="result.status === 'ineligible'">
{{
result.status === 'eligible'
? tr().planning.eligible
: tr().planning.ineligible
}}
</p>
@if (result.historicalStatus) {
<p>
<strong>{{ tr().planning.historical }}:</strong>
{{ traceStatusLabel(result.historicalStatus) }}
</p>
}
@if (result.reason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ result.reason.message }}
</p>
}
@if (result.comparison; as comparison) {
<p>
<strong>{{ tr().planning.firstDecisive }}:</strong>
{{
comparison.firstDecisiveObjective
? formatIdentifier(comparison.firstDecisiveObjective)
: tr().planning.none
}}
</p>
<ul>
@for (delta of comparison.delta; track delta.id) {
<li>
<strong>{{ formatIdentifier(delta.id) }}:</strong>
{{ delta.value }}
</li>
}
</ul>
<details>
<summary>{{ tr().planning.fullComparison }}</summary>
<div class="comparison-vectors">
<div>
<strong>{{ tr().planning.before }}</strong>
<ol>
@for (
objective of comparison.before.objectives;
track objective.id
) {
<li>
{{ objectiveLabel(objective) }}:
{{ objective.value }}
</li>
}
</ol>
</div>
<div>
<strong>{{ tr().planning.after }}</strong>
<ol>
@for (
objective of comparison.after.objectives;
track objective.id
) {
<li>
{{ objectiveLabel(objective) }}:
{{ objective.value }}
</li>
}
</ol>
</div>
</div>
</details>
}
</div>
}
</section>
</section>
} @else if (explanationError()) {
<p class="inline-error" role="alert">
{{ tr().planning.explanationError }} {{ explanationError()!.message }}
</p>
}
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
<strong>{{ tr().planning.legend }}</strong>
<ul>
@@ -90,20 +482,406 @@
<span class="legend-swatch today" aria-hidden="true"></span
>{{ tr().dayTypes.today }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">◎</span
>{{ tr().planning.currentPlacement }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">✓</span>{{ tr().planning.eligible }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">×</span
>{{ tr().planning.ineligible }}
</li>
</ul>
</div>
<div class="month-grid">
@for (month of months; track $index) {
@for (month of months(); track month.year + '-' + month.monthIndex) {
<mat-card class="month-card">
<mat-card-content>
<app-calendar-month
[monthIndex]="month"
[year]="getYear(month)"
[selectedDates]="selectedDates"
[monthIndex]="month.monthIndex"
[year]="month.year"
[selectedDates]="calendarSelectedDates()"
[horizonStart]="planningHorizon()?.start"
[horizonEnd]="planningHorizon()?.end"
[currentDate]="currentPlacementDate()"
[dateStatuses]="dateStatuses()"
(dateSelected)="selectCalendarDate($event)"
></app-calendar-month>
</mat-card-content>
</mat-card>
}
</div>
@if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') {
<details class="trace-explorer">
<summary>{{ tr().planning.traceTitle }}</summary>
@if (traceLoading()) {
<p role="status">{{ tr().planning.traceLoading }}</p>
} @else if (planningTrace(); as trace) {
@if (trace.error) {
<p class="inline-error" role="alert">{{ trace.error.message }}</p>
}
<div class="trace-layout">
<nav class="trace-phases" [attr.aria-label]="tr().planning.phases">
<div class="trace-phase-stepper">
<button
type="button"
data-phase-step="first"
[disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.firstPhase"
(click)="stepTracePhase('first')"
>
<span aria-hidden="true">⏮</span>
</button>
<button
type="button"
data-phase-step="previous"
[disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.previousPhase"
(click)="stepTracePhase('previous')"
>
<span aria-hidden="true">◀</span>
</button>
<output
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
{{ trace.phases.length }}</output
>
<button
type="button"
data-phase-step="next"
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
[attr.aria-label]="tr().planning.nextPhase"
(click)="stepTracePhase('next')"
>
<span aria-hidden="true">▶</span>
</button>
<button
type="button"
data-phase-step="last"
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
[attr.aria-label]="tr().planning.lastPhase"
(click)="stepTracePhase('last')"
>
<span aria-hidden="true">⏭</span>
</button>
</div>
@for (phase of trace.phases; track phase.id; let index = $index) {
<button
type="button"
[class.active]="index === tracePhaseIndex()"
[attr.aria-pressed]="index === tracePhaseIndex()"
(click)="selectTracePhase(index)"
>
<span>{{ formatIdentifier(phase.name) }}</span>
<small>{{ tracePhaseStatusLabel(phase.status) }}</small>
</button>
}
</nav>
@if (selectedTracePhase(); as phase) {
<section class="trace-decisions" aria-labelledby="trace-phase-title">
<h3 id="trace-phase-title">{{ formatIdentifier(phase.name) }}</h3>
@if (phase.decisions.length) {
<nav [attr.aria-label]="tr().planning.decisions">
<button
type="button"
[disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.firstDecision"
(click)="stepTraceDecision('first')"
>
<span aria-hidden="true">⏮</span>
</button>
<button
type="button"
[disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.previousDecision"
(click)="stepTraceDecision('previous')"
>
<span aria-hidden="true">◀</span>
</button>
<output
>{{ tr().planning.decision }} {{ traceDecisionIndex() + 1 }}
{{ tr().planning.of }} {{ phase.decisions.length }}</output
>
<button
type="button"
[disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.nextDecision"
(click)="stepTraceDecision('next')"
>
<span aria-hidden="true">▶</span>
</button>
<button
type="button"
[disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.lastDecision"
(click)="stepTraceDecision('last')"
>
<span aria-hidden="true">⏭</span>
</button>
</nav>
@if (selectedTraceDecision(); as decision) {
<div
class="trace-toolbar"
role="group"
[attr.aria-label]="tr().planning.replay"
>
<button
type="button"
[attr.aria-pressed]="
traceReplay() && traceMoment() === 'before'
"
(click)="showTraceMoment('before')"
>
{{ tr().planning.before }}
</button>
<button
type="button"
[attr.aria-pressed]="
traceReplay() && traceMoment() === 'after'
"
(click)="showTraceMoment('after')"
>
{{ tr().planning.after }}
</button>
<button
type="button"
[attr.aria-pressed]="!traceReplay()"
(click)="showFinalPlan()"
>
{{ tr().planning.finalPlan }}
</button>
</div>
<p>
<strong>{{ tr().planning.dates }}:</strong>
{{
decision.dates.length
? decision.dates.join(', ')
: tr().planning.none
}}
</p>
@if (decision.reason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ decision.reason.message }}
</p>
}
<h4>{{ tr().planning.candidates }}</h4>
@if (decision.candidates.length > 50) {
<nav
class="candidate-pagination"
[attr.aria-label]="tr().planning.candidates"
>
<button
type="button"
[disabled]="traceCandidatePage() === 0"
[attr.aria-label]="tr().planning.previousCandidates"
(click)="stepTraceCandidatePage(-1)"
>
◀
</button>
<output
>{{ tr().planning.page }}
{{ traceCandidatePage() + 1 }}
{{ tr().planning.of }}
{{ traceCandidatePages() }}</output
>
<button
type="button"
[disabled]="
traceCandidatePage() ===
traceCandidatePages() - 1
"
[attr.aria-label]="tr().planning.nextCandidates"
(click)="stepTraceCandidatePage(1)"
>
▶
</button>
</nav>
}
<div class="candidate-list">
@for (candidate of visibleTraceCandidates(); track $index) {
<details>
<summary>
<span
class="status-symbol"
aria-hidden="true"
>{{
candidate.status === 'chosen'
? '✓'
: candidate.status === 'ineligible'
? '×'
: '•'
}}</span
>
{{ traceStatusLabel(candidate.status) }} ·
@if (
candidate.sourceDate &&
candidate.sourceDate !== candidate.date
) {
{{ candidate.sourceDate }} →
}
{{
candidate.dates?.join(', ') ||
candidate.date ||
tr().planning.none
}}
</summary>
@if (candidate.reason) {
<p>{{ candidate.reason.message }}</p>
}
@if (candidate.comparison?.firstDecisiveObjective) {
<p>
<strong
>{{
tr().planning.firstDecisive
}}:</strong
>
{{
formatIdentifier(
candidate.comparison!
.firstDecisiveObjective!
)
}}
</p>
}
@if (candidate.comparison; as comparison) {
@if (comparison.tieBreak) {
<p>
<strong
>{{
tr().planning.tieBreak
}}:</strong
>
{{
formatIdentifier(
comparison.tieBreak
)
}}
</p>
}
<ul>
@for (
delta of comparison.delta;
track delta.id
) {
<li>
<strong
>{{
formatIdentifier(delta.id)
}}:</strong
>
{{ delta.value }}
</li>
}
</ul>
<div class="comparison-vectors">
<div>
<strong>{{
tr().planning.before
}}</strong>
<ol>
@for (
objective of comparison.before
.objectives;
track objective.id
) {
<li>
{{
objectiveLabel(
objective
)
}}: {{ objective.value }}
</li>
}
</ol>
</div>
<div>
<strong>{{
tr().planning.after
}}</strong>
<ol>
@for (
objective of comparison.after
.objectives;
track objective.id
) {
<li>
{{
objectiveLabel(
objective
)
}}: {{ objective.value }}
</li>
}
</ol>
</div>
</div>
}
@if (candidate.placement) {
<button
type="button"
(click)="analyzeTraceCandidate(candidate)"
>
{{ tr().planning.compareAlternatives }}
</button>
}
</details>
}
</div>
<h4>{{ tr().planning.deltas }}</h4>
<ul>
@for (delta of decision.deltas; track $index) {
<li>
<strong>{{ formatIdentifier(delta.kind) }}</strong>
@if (delta.fromDate || delta.date) {
· {{ delta.fromDate || delta.date }}
}
@if (delta.toDate) {
→ {{ delta.toDate }}
}
@if (delta.balanceId) {
· {{ delta.balanceId }}
}
@if (delta.remainingAfter !== undefined) {
· {{ delta.remainingBefore }} →
{{ delta.remainingAfter }}
}
</li>
}
</ul>
@if (traceState(); as state) {
<p class="trace-state-summary" role="status">
{{
traceMoment() === 'before'
? tr().planning.before
: tr().planning.after
}}: {{ state.placements.length }}
{{ tr().planning.placements }},
{{ state.earnedActivations.length }}
{{ tr().planning.activations }}
</p>
}
}
} @else {
<p>{{ tr().planning.noDecisions }}</p>
}
</section>
}
</div>
} @else if (traceResolved()) {
<p>{{ tr().planning.traceUnavailable }}</p>
}
</details>
}
</div>
@@ -3,6 +3,18 @@
min-width: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.calendar-year {
display: grid;
container: calendar-year / inline-size;
@@ -146,6 +158,261 @@
}
}
.reason-list,
.outcome-explanation,
.placement-explanation,
.trace-explorer {
margin: 0;
padding: 1rem;
border: 1px solid var(--ui-border, #c7c7d0);
border-radius: var(--ui-radius-md, 0.75rem);
background: var(--ui-card-bg, #fff);
}
.outcome-explanation,
.placement-explanation {
display: grid;
gap: 1rem;
h3,
h4,
h5,
p,
ul,
ol {
margin-block: 0;
}
}
.conflict-explanation {
border-inline-start: 0.35rem solid var(--ui-error-text, #751b1b);
}
.incomplete-explanation {
border-inline-start: 0.35rem solid var(--ui-warning-text, #4f3800);
}
.incomplete-incumbent {
display: grid;
gap: 0.35rem;
padding: 0.75rem 1rem;
border-inline-start: 0.35rem solid var(--ui-warning-text, #4f3800);
background: var(--ui-warning-bg, #fff4d6);
p {
margin: 0;
}
}
.explanation-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.eyebrow {
color: var(--ui-text-secondary, #45464f);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.placement-stepper,
.trace-toolbar,
.trace-decisions nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
button,
output {
min-height: 2.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--ui-border, #777984);
border-radius: var(--ui-radius-sm, 0.5rem);
background: var(--ui-card-bg, #fff);
color: inherit;
font: inherit;
}
button {
cursor: pointer;
&:hover:not(:disabled),
&[aria-pressed='true'] {
background: var(--ui-panel-bg, #ececf2);
}
&:focus-visible {
outline: 3px solid var(--today-border-color, #1976d2);
outline-offset: 2px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
output {
display: inline-flex;
align-items: center;
font-variant-numeric: tabular-nums;
}
}
.compact-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 0.75rem;
margin: 0;
div {
min-width: 0;
padding: 0.75rem;
border-radius: var(--ui-radius-sm, 0.5rem);
background: var(--ui-panel-bg, #f7f7fa);
}
dt {
margin-block-end: 0.25rem;
color: var(--ui-text-secondary, #45464f);
font-size: 0.8rem;
font-weight: 700;
}
dd {
margin: 0;
line-height: 1.5;
overflow-wrap: anywhere;
}
}
code {
font-size: 0.8em;
overflow-wrap: anywhere;
}
.explanation-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 1rem;
section {
display: grid;
align-content: start;
gap: 0.75rem;
padding: 1rem;
border-radius: var(--ui-radius-sm, 0.5rem);
background: var(--ui-panel-bg, #f7f7fa);
}
}
.objective-vector {
display: grid;
gap: 0.4rem;
padding-inline-start: 1.5rem;
li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0 0.75rem;
}
small {
grid-column: 1 / -1;
color: var(--ui-text-secondary, #45464f);
}
}
.metric-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.4rem 1rem;
margin: 0;
div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
dt {
color: var(--ui-text-secondary, #45464f);
}
dd {
margin: 0;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
}
.comparison-details,
.counterfactual-panel {
padding: 0.75rem 1rem;
border-radius: var(--ui-radius-sm, 0.5rem);
background: var(--ui-panel-bg, #f7f7fa);
}
.comparison-details summary,
.trace-explorer > summary,
.candidate-list summary {
cursor: pointer;
font-weight: 700;
}
.counterfactual-result {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 1rem;
}
.comparison-vectors {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: 0.75rem;
margin-block-start: 0.5rem;
div {
min-width: 0;
}
ol {
margin-block: 0.35rem 0;
padding-inline-start: 1.25rem;
}
li {
overflow-wrap: anywhere;
}
}
.status-badge {
padding: 0.2rem 0.5rem;
border: 2px solid var(--ui-success-text, #174d26);
border-radius: 999px;
font-weight: 700;
&.ineligible {
border-color: var(--ui-error-text, #751b1b);
text-decoration: line-through;
}
}
.inline-error {
margin: 0;
padding: 0.75rem;
border-inline-start: 0.35rem solid var(--ui-error-text, #751b1b);
background: var(--ui-error-bg, #ffe0e0);
color: var(--ui-error-text, #751b1b);
}
.calendar-legend {
display: flex;
flex-wrap: wrap;
@@ -212,15 +479,124 @@
}
}
.legend-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
font-weight: 800;
}
.month-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
gap: 1rem;
width: 100%;
max-width: 100%;
}
.month-card {
margin: 16px;
min-width: 0;
}
.trace-explorer {
summary {
font-size: 1.05rem;
}
}
.trace-layout {
display: grid;
grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr);
gap: 1rem;
margin-block-start: 1rem;
}
.trace-phases {
display: grid;
align-content: start;
gap: 0.35rem;
button {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--ui-border, #c7c7d0);
border-radius: var(--ui-radius-sm, 0.5rem);
background: transparent;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
&.active {
border-inline-start: 0.35rem solid var(--today-border-color, #1976d2);
background: var(--ui-panel-bg, #f7f7fa);
}
small {
color: var(--ui-text-secondary, #45464f);
}
}
}
.trace-phase-stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
output {
min-width: 4rem;
text-align: center;
}
}
.trace-decisions {
display: grid;
align-content: start;
gap: 0.85rem;
min-width: 0;
h3,
h4,
p,
ul {
margin-block: 0;
}
}
.candidate-list {
display: grid;
gap: 0.4rem;
details {
padding: 0.55rem 0.75rem;
border: 1px solid var(--ui-border, #c7c7d0);
border-radius: var(--ui-radius-sm, 0.5rem);
}
}
.status-symbol {
display: inline-block;
min-width: 1rem;
font-weight: 800;
}
.trace-state-summary {
padding: 0.75rem;
border: 2px solid var(--today-border-color, #1976d2);
border-radius: var(--ui-radius-sm, 0.5rem);
}
@media (max-width: 52rem) {
.trace-layout {
grid-template-columns: 1fr;
}
.trace-phases {
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
}
+658 -110
View File
@@ -1,16 +1,37 @@
import { Component, computed, effect, signal, inject, untracked } from '@angular/core';
import { UserInputService } from '../../services/user-input-service';
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
import { MatCard, MatCardContent } from '@angular/material/card';
import { CalendarMonth } from '../calendar-month/calendar-month';
import { CalendarService, SelectedDates } from '../../services/calendar-service';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { DateCacheService } from '../../services/date-cache.service';
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
CalendarService,
SelectedDates,
SelectableDayType,
VacDay,
} from '../../services/calendar-service';
import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
import { SelectableDayType, VacDay } from '../../services/calendar-service';
import { MatIcon } from '@angular/material/icon';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import { UserInputService } from '../../services/user-input-service';
import {
CounterfactualAnalysis,
CounterfactualSelection,
LeavePlacement,
PlanExplanation,
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceCandidate,
PlanningTraceDecision,
PlanningTraceState,
} from '../../services/leave-planner.service';
type PlacementExplanation = PlanExplanation['placements'][number];
type TraceMoment = 'before' | 'after';
@Component({
selector: 'app-calendar-year',
@@ -26,21 +47,42 @@ import { CalendarSettings } from '../calendar-settings/calendar-settings';
styleUrl: './calendar-year.scss',
})
export class CalendarYear {
private dateCache = inject(DateCacheService);
private userInput = inject(UserInputService);
private calendarService = inject(CalendarService);
private ruleService = inject(RuleService);
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly ruleService = inject(RuleService);
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(true);
protected readonly loadingProgress = signal(0);
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
protected readonly explanationError = signal<PlanningReason | null>(null);
protected readonly planningTrace = signal<PlanningTrace | null>(null);
protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0);
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false);
protected readonly counterfactualError = signal<string | null>(null);
protected readonly tracePhaseIndex = signal(0);
protected readonly traceDecisionIndex = signal(0);
protected readonly traceCandidatePage = signal(0);
protected readonly traceMoment = signal<TraceMoment>('before');
protected readonly traceReplay = signal(false);
protected readonly liveMessage = signal('');
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
private calculationVersion = 0;
private counterfactualVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
get tr() {
return this.translationService.tr;
}
protected isLoading = signal(true);
protected loadingProgress = signal(0);
protected planningOutcome = signal<PlanningOutcome | null>(null);
protected displayedPlan = computed(() => {
protected readonly displayedPlan = computed(() => {
const outcome = this.planningOutcome();
return outcome?.kind === 'Plan'
? outcome
@@ -48,7 +90,557 @@ export class CalendarYear {
? outcome.incumbent
: undefined;
});
private calculationVersion = 0;
protected readonly planExplanation = computed(() => {
const explanation = this.planningExplanation();
return explanation?.kind === 'PlanExplanation' ? explanation : undefined;
});
protected readonly selectedExplanation = computed<PlacementExplanation | undefined>(
() => this.planExplanation()?.placements[this.selectedPlacementIndex()],
);
protected readonly selectedTracePhase = computed(
() => this.planningTrace()?.phases[this.tracePhaseIndex()],
);
protected readonly selectedTraceDecision = computed(
() => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()],
);
protected readonly visibleTraceCandidates = computed(() => {
const start = this.traceCandidatePage() * 50;
return this.selectedTraceDecision()?.candidates.slice(start, start + 50) ?? [];
});
protected readonly traceCandidatePages = computed(() =>
Math.max(1, Math.ceil((this.selectedTraceDecision()?.candidates.length ?? 0) / 50)),
);
protected readonly selectedCounterfactualResult = computed(() =>
this.counterfactual()?.results.find(
(result) => result.date === this.selectedCounterfactualDate(),
),
);
protected readonly traceState = computed(() => {
if (!this.traceReplay()) return undefined;
return this.replayTraceState(
this.planningTrace(),
this.selectedTraceDecision()?.id,
this.traceMoment(),
);
});
protected readonly calendarSelectedDates = computed(() => {
const state = this.traceState();
return state
? this.calendarService.selectedDatesForPlan({
placements: [...state.placements],
horizon: { ...state.horizon },
})
: this.selectedDates();
});
protected readonly currentPlacementDate = computed(() => {
if (this.counterfactual())
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null;
return this.selectedExplanation()?.placement.date ?? null;
});
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
const statuses: Record<string, CalendarDayStatus> = {};
const trace = this.planningTrace();
if (!this.counterfactual() && this.traceReplay() && trace) {
const priority = new Map([
['ineligible', 0],
['not-examined', 1],
['pruned', 2],
['evaluated-rejected', 3],
['chosen', 4],
]);
for (
let timestamp = Date.parse(`${trace.initialState.horizon.start}T00:00:00Z`),
end = Date.parse(`${trace.initialState.horizon.end}T00:00:00Z`);
timestamp < end;
timestamp += 86_400_000
) {
const date = new Date(timestamp).toISOString().slice(0, 10);
statuses[date] = {
kind: 'not-examined',
label: this.traceStatusLabel('not-examined'),
};
}
for (const candidate of trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.candidates),
)) {
if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working')
continue;
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : []))
statuses[date] = {
kind: candidate.status,
label: this.traceStatusLabel(candidate.status),
reason: candidate.reason.message,
};
}
const explicitPriorities = new Map<string, number>();
for (const candidate of this.selectedTraceDecision()?.candidates ?? []) {
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
const candidatePriority = priority.get(candidate.status)!;
if (candidatePriority > (explicitPriorities.get(date) ?? -1)) {
statuses[date] = {
kind: candidate.status,
label: this.traceStatusLabel(candidate.status),
reason: candidate.reason?.message,
};
explicitPriorities.set(date, candidatePriority);
}
}
}
const decision = this.selectedTraceDecision();
if (decision) {
const changedDates = new Set<string>();
const setChange = (
date: string | undefined,
change: NonNullable<CalendarDayStatus['change']>,
) => {
if (!date) return;
changedDates.add(date);
statuses[date] = {
...(statuses[date] ?? {
kind: 'not-examined',
label: this.traceStatusLabel('not-examined'),
}),
change,
changeLabel: this.traceChangeLabel(change),
};
};
for (const delta of decision.deltas) {
if (delta.kind === 'placement-added') setChange(delta.date, 'added');
else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed');
else if (delta.kind === 'placement-moved') {
setChange(delta.fromDate, 'moved-from');
setChange(delta.toDate, 'moved-to');
} else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
}
const before = this.replayTraceState(trace, decision.id, 'before');
const after = this.replayTraceState(trace, decision.id, 'after');
for (const placement of [
...(before?.placements ?? []),
...(after?.placements ?? []),
]) {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
}
}
return statuses;
}
for (const result of this.counterfactual()?.results ?? [])
statuses[result.date] = {
kind: result.status,
label:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
reason: result.reason?.message,
historicalLabel: result.historicalStatus
? this.traceStatusLabel(result.historicalStatus)
: undefined,
};
return statuses;
});
protected readonly months = computed(() => {
const horizon = this.planningHorizon();
if (!horizon) return [];
const start = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
}
return result;
});
constructor() {
effect(() => {
this.userInput.computeTriggerSignal();
this.resetCalculationState();
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
const horizon = this.calendarService.getDefaultPlanningHorizon();
this.planningHorizon.set(horizon);
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.loadingProgress.set(0);
queueMicrotask(() => {
let calculation: PlanningCalculationContext;
try {
calculation = this.calendarService.createPlanningCalculation({
horizon,
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
label: this.translationService.getDayTypeLabel(balance.type),
availableFrom: balance.availableDate,
expiryDate: balance.expiryDate,
})),
rules: currentRules,
labels: Object.fromEntries(
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
} catch (error) {
this.failCalculation(error, version);
return;
}
this.activeCalculation = calculation;
this.loadArtifacts(calculation, version);
void calculation.outcome
.then((outcome) => {
if (version === this.calculationVersion) this.applyOutcome(outcome);
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [
{
code: 'planner-error',
message:
error instanceof Error
? error.message
: 'The planner failed unexpectedly.',
},
],
});
})
.finally(() => {
if (version !== this.calculationVersion) return;
this.loadingProgress.set(100);
this.isLoading.set(false);
});
});
});
}
private failCalculation(error: unknown, version: number): void {
if (version !== this.calculationVersion) return;
const reason = {
code: 'planner-error',
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
};
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
this.explanationError.set(reason);
this.traceLoading.set(false);
this.traceResolved.set(true);
this.loadingProgress.set(100);
this.isLoading.set(false);
}
private resetCalculationState(): void {
this.activeCalculation = null;
this.planningOutcome.set(null);
this.planningExplanation.set(null);
this.explanationError.set(null);
this.planningTrace.set(null);
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]);
}
private loadArtifacts(calculation: PlanningCalculationContext, version: number): void {
void calculation.explanation.then((explanation) => {
if (version !== this.calculationVersion) return;
this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation' && explanation.placements.length)
this.selectPlacement(0);
});
void calculation.explanationError.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
});
void calculation.trace
.then((trace) => {
if (version !== this.calculationVersion) return;
this.planningTrace.set(trace ?? null);
this.traceResolved.set(true);
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
this.planningTrace.set({
status: 'failed',
initialState: {
horizon: calculation.snapshot.horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
error: {
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
},
});
this.traceResolved.set(true);
})
.finally(() => {
if (version === this.calculationVersion) this.traceLoading.set(false);
});
}
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
this.liveMessage.set(this.outcomeLabel(outcome));
const plan = this.displayedPlan();
if (plan) {
this.planningHorizon.set(plan.horizon);
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]);
}
protected selectPlacement(index: number): void {
const explanation = this.planExplanation();
if (!explanation?.placements.length) return;
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
this.selectedPlacementIndex.set(selected);
this.traceReplay.set(false);
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
this.liveMessage.set(
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
);
void this.analyzeSelection(explanation.placements[selected].placement);
}
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex();
this.selectPlacement(
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1,
);
}
protected selectCalendarDate(date: string): void {
if (!this.traceReplay()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.selectPlacement(index);
return;
}
}
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.selectedCounterfactualDate.set(date);
this.liveMessage.set(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`);
}
}
protected selectTracePhase(index: number): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last));
this.tracePhaseIndex.set(index);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.clearCounterfactual();
this.liveMessage.set(
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
);
}
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
const current = this.tracePhaseIndex();
this.selectTracePhase(
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1,
);
}
protected selectTraceDecision(index: number): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last));
this.traceDecisionIndex.set(index);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(true);
this.clearCounterfactual();
this.liveMessage.set(
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
);
}
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
const current = this.traceDecisionIndex();
this.selectTraceDecision(
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1,
);
}
protected stepTraceCandidatePage(direction: -1 | 1): void {
this.traceCandidatePage.update((page) =>
Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)),
);
}
protected showTraceMoment(moment: TraceMoment): void {
this.clearCounterfactual();
this.traceMoment.set(moment);
this.traceReplay.set(true);
}
protected showFinalPlan(): void {
this.traceReplay.set(false);
this.clearCounterfactual();
}
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
const decision = this.selectedTraceDecision();
if (!candidate.placement || !decision) return;
this.traceMoment.set('before');
this.traceReplay.set(true);
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
}
private async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
const calculation = this.activeCalculation;
if (!calculation) return;
const version = ++this.counterfactualVersion;
this.counterfactual.set(null);
this.counterfactualError.set(null);
this.counterfactualLoading.set(true);
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
this.selectedCounterfactualDate.set(result.placement.date);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
this.counterfactualError.set(error instanceof Error ? error.message : String(error));
} finally {
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
}
}
private clearCounterfactual(): void {
this.counterfactualVersion++;
this.counterfactual.set(null);
this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false);
this.counterfactualError.set(null);
}
private replayTraceState(
trace: PlanningTrace | null,
decisionId: string | undefined,
moment: TraceMoment,
): PlanningTraceState | undefined {
if (!trace || !decisionId) return undefined;
const balances = trace.initialState.balances.map((balance) => ({ ...balance }));
const placements = new Map(
trace.initialState.placements.map((placement) => [placement.date, { ...placement }]),
);
const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
}));
const snapshot = (): PlanningTraceState => ({
horizon: { ...trace.initialState.horizon },
balances,
placements: [...placements.values()],
earnedActivations,
});
for (const decision of trace.phases.flatMap((phase) => phase.decisions)) {
if (decision.id === decisionId && moment === 'before') return snapshot();
this.applyTraceDecision(decision, placements, balances, earnedActivations);
if (decision.id === decisionId) return snapshot();
}
return undefined;
}
private applyTraceDecision(
decision: PlanningTraceDecision,
placements: Map<string, LeavePlacement>,
balances: PlanningTraceState['balances'][number][],
earnedActivations: Array<{
id: string;
ruleId: string;
occurrence: string;
dates: string[];
}>,
): void {
for (const delta of decision.deltas) {
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
if (delta.placement)
placements.set(delta.placement.date, {
...delta.placement,
ruleIds: [...delta.placement.ruleIds],
});
const balanceIndex = balances.findIndex(
(candidate) => candidate.id === delta.balanceId,
);
if (balanceIndex >= 0 && delta.remainingAfter !== undefined)
balances[balanceIndex] = {
...balances[balanceIndex],
remaining: delta.remainingAfter,
};
else if (balanceIndex < 0 && delta.balance) balances.push({ ...delta.balance });
if (
delta.activation &&
!earnedActivations.some((activation) => activation.id === delta.activation!.id)
)
earnedActivations.push({
...delta.activation,
dates: [...delta.activation.dates],
});
}
}
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
@@ -57,6 +649,53 @@ export class CalendarYear {
);
}
protected ruleLabel(ruleId: string): string {
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
}
protected balanceLabel(explanation: PlacementExplanation): string {
return (
explanation.balance?.name ??
explanation.balance?.label ??
explanation.placement.balanceId
);
}
protected objectiveLabel(objective: PlanningObjective): string {
return objective.label ?? objective.id;
}
protected planningDate(value: string | Date | undefined): string {
if (!value) return '';
return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10);
}
protected traceStatusLabel(status: PlanningTraceCandidate['status']): string {
return this.tr().planning.traceStatuses[status];
}
protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
return this.tr().planning.tracePhaseStatuses[status];
}
protected traceChangeLabel(change: NonNullable<CalendarDayStatus['change']>): string {
return this.tr().planning.traceChanges[change];
}
protected formatIdentifier(value: string): string {
return value.replaceAll('-', ' ').replaceAll(':', ': ');
}
private outcomeLabel(outcome: PlanningOutcome): string {
if (outcome.kind === 'Plan') return this.tr().planning.complete;
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
}
private stageProgress(stage: string): number {
if (stage === 'complete') return 100;
if (stage === 'validation') return 5;
@@ -73,87 +712,6 @@ export class CalendarYear {
return this.loadingProgress();
}
// Use DateCacheService instead of creating Date objects
private get currentMonth() {
return this.dateCache.getCurrentMonth();
}
private get currentYear() {
return this.dateCache.getCurrentYear();
}
constructor() {
effect(() => {
this.userInput.computeTriggerSignal();
this.selectedDates = this.calendarService.getSelectedDatesForYear();
this.planningOutcome.set(null);
this.userInput.remainingVacationDaysSignal.set([]);
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
this.isLoading.set(true);
this.loadingProgress.set(0);
const version = ++this.calculationVersion;
// Defer execution to allow UI to update
queueMicrotask(() => {
void (async () => {
try {
const outcome = await this.calendarService.planLeave({
horizon: this.calendarService.getDefaultPlanningHorizon(),
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate,
expiryDate: balance.expiryDate,
})),
rules: currentRules,
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
if (version !== this.calculationVersion) return;
this.applyOutcome(outcome);
} catch (error) {
if (version !== this.calculationVersion) return;
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [
{
code: 'planner-error',
message:
error instanceof Error
? error.message
: 'The planner failed unexpectedly.',
},
],
});
} finally {
if (version === this.calculationVersion) {
this.loadingProgress.set(100);
this.isLoading.set(false);
}
}
})();
});
});
}
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
const plan = this.displayedPlan();
if (plan) {
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates = this.calendarService.getSelectedDatesForYear();
this.userInput.remainingVacationDaysSignal.set([]);
}
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
return plan.remainingBalances.map((balance) => ({
id: balance.id,
@@ -165,14 +723,4 @@ export class CalendarYear {
reason: balance.reason,
}));
}
protected selectedDates: SelectedDates = new SelectedDates();
getYear(monthIndex: number): number {
const offset = this.currentMonth <= monthIndex ? 0 : 1;
return this.currentYear + offset;
}
// months starting from actual date - cache this array
protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12);
}