refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the pending and accepted calculation state, and delivers explanations for the accepted calculation only. The duration estimator reads the same request. The calendar and leave-balance summary consume the accepted outcome instead of coordinating writable state on the input service. Refs #275 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b69b3c3690
commit
5cb0b93339
12 files changed
+708
-322
No files matched your search
@@ -73,9 +73,8 @@ range is explicitly indicative, not a confidence interval or a promise.
|
|||||||
|
|
||||||
## What is timed
|
## What is timed
|
||||||
|
|
||||||
Manual requests capture `performance.now()` inside
|
Every request, manual or initial, captures `performance.now()` inside
|
||||||
`UserInputService.requestComputation`, before updating the calculation trigger.
|
`PlanningCalculationService.start`.
|
||||||
The existing initial calculation falls back to the start of `startCalculation`.
|
|
||||||
The endpoint is Angular's `afterNextRender` callback after the complete plan has
|
The endpoint is Angular's `afterNextRender` callback after the complete plan has
|
||||||
been projected into the calendar, its months and placements have been rendered,
|
been projected into the calendar, its months and placements have been rendered,
|
||||||
and the loading overlay has been removed. This measures request scheduling,
|
and the loading overlay has been removed. This measures request scheduling,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
|
|||||||
import { RuleService, RuleType } from '../../services/rule-service';
|
import { RuleService, RuleType } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import { DateRange } from '@angular/material/datepicker';
|
import { DateRange } from '@angular/material/datepicker';
|
||||||
|
|
||||||
const invalid: InvalidInputOutcome = {
|
const invalid: InvalidInputOutcome = {
|
||||||
@@ -474,7 +475,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const before = {
|
const before = {
|
||||||
balances: structuredClone(input.vacationNumberSignal()),
|
balances: structuredClone(input.vacationNumberSignal()),
|
||||||
displayed: structuredClone(input.displayedPlanSignal()),
|
displayed: structuredClone(
|
||||||
|
TestBed.inject(PlanningCalculationService).displayedPlan(),
|
||||||
|
),
|
||||||
declarations: structuredClone(input.declaredDaysSignal()),
|
declarations: structuredClone(input.declaredDaysSignal()),
|
||||||
rules: structuredClone(rules.rules),
|
rules: structuredClone(rules.rules),
|
||||||
};
|
};
|
||||||
@@ -506,9 +509,11 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
];
|
];
|
||||||
expect(statistics()).toEqual(expectedStatistics);
|
expect(statistics()).toEqual(expectedStatistics);
|
||||||
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
||||||
expect(input.planningOutcomeSignal()).toBe(outcome);
|
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
|
||||||
expect(input.vacationNumberSignal()).toEqual(before.balances);
|
expect(input.vacationNumberSignal()).toEqual(before.balances);
|
||||||
expect(input.displayedPlanSignal()).toEqual(before.displayed);
|
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
|
||||||
|
before.displayed,
|
||||||
|
);
|
||||||
expect(input.declaredDaysSignal()).toEqual(before.declarations);
|
expect(input.declaredDaysSignal()).toEqual(before.declarations);
|
||||||
expect(rules.rules).toEqual(before.rules);
|
expect(rules.rules).toEqual(before.rules);
|
||||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||||
@@ -525,7 +530,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(statistics()).toEqual(expectedStatistics);
|
expect(statistics()).toEqual(expectedStatistics);
|
||||||
expect(input.planningOutcomeSignal()).toBe(outcome);
|
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
|
||||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1047,7 +1052,6 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const input = TestBed.inject(UserInputService);
|
|
||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -1056,10 +1060,10 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
|
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const otherControl = document.createElement('button');
|
const otherControl = document.createElement('button');
|
||||||
otherControl.textContent = 'Another control';
|
otherControl.textContent = 'Another control';
|
||||||
@@ -1071,7 +1075,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
oldOutcome.resolve(plan);
|
oldOutcome.resolve(plan);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(input.planningOutcomeSignal()).toBe(replacement);
|
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
|
||||||
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
|
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
|
||||||
if (replacement.kind === 'Plan') {
|
if (replacement.kind === 'Plan') {
|
||||||
expect(opener).not.toBeNull();
|
expect(opener).not.toBeNull();
|
||||||
@@ -1747,7 +1751,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
calendar.createPlanningCalculation.mockReturnValue({
|
calendar.createPlanningCalculation.mockReturnValue({
|
||||||
...calculation,
|
...calculation,
|
||||||
outcome: pending.promise,
|
outcome: pending.promise,
|
||||||
@@ -1849,11 +1853,13 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const settings = TestBed.inject(CalendarSettingsService);
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
const input = TestBed.inject(UserInputService);
|
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
calendar.createPlanningCalculation.mockImplementation(() => ({
|
calendar.createPlanningCalculation.mockImplementation(() => ({
|
||||||
...calculation,
|
...calculation,
|
||||||
snapshot: { ...calculation.snapshot, ...duration.request()! },
|
snapshot: {
|
||||||
|
...calculation.snapshot,
|
||||||
|
...TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
},
|
||||||
outcome:
|
outcome:
|
||||||
calendar.createPlanningCalculation.mock.calls.length === 1
|
calendar.createPlanningCalculation.mock.calls.length === 1
|
||||||
? old.promise
|
? old.promise
|
||||||
@@ -1863,7 +1869,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
now = 11_000;
|
now = 11_000;
|
||||||
settings.setPlanningDates('2024-01-01', '2026-12-31');
|
settings.setPlanningDates('2024-01-01', '2026-12-31');
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
now = 12_000; // Rendering/scheduling after the request is part of the wait.
|
now = 12_000; // Rendering/scheduling after the request is part of the wait.
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const initial = duration.estimate();
|
const initial = duration.estimate();
|
||||||
@@ -1885,7 +1891,10 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
const revisitedOutcome = deferred<PlanningOutcome>();
|
const revisitedOutcome = deferred<PlanningOutcome>();
|
||||||
calendar.createPlanningCalculation.mockReturnValueOnce({
|
calendar.createPlanningCalculation.mockReturnValueOnce({
|
||||||
...calculation,
|
...calculation,
|
||||||
snapshot: { ...calculation.snapshot, ...duration.request()! },
|
snapshot: {
|
||||||
|
...calculation.snapshot,
|
||||||
|
...TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
},
|
||||||
outcome: revisitedOutcome.promise,
|
outcome: revisitedOutcome.promise,
|
||||||
});
|
});
|
||||||
now = 631_000; // Returning to the calendar is a fresh initial calculation.
|
now = 631_000; // Returning to the calendar is a fresh initial calculation.
|
||||||
@@ -1947,7 +1956,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
||||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||||
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
||||||
TestBed.inject(UserInputService).requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -2051,7 +2060,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||||
TestBed.inject(UserInputService).requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
oldOutcome.resolve(plan);
|
oldOutcome.resolve(plan);
|
||||||
@@ -2117,7 +2126,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
expect(calculate).toHaveBeenCalledTimes(1);
|
expect(calculate).toHaveBeenCalledTimes(1);
|
||||||
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
||||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -2163,12 +2172,11 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const userInput = TestBed.inject(UserInputService);
|
|
||||||
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
expect(userInput.displayedPlanSignal()).toBeUndefined();
|
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps and summarizes a returned result', async () => {
|
it('keeps and summarizes a returned result', async () => {
|
||||||
@@ -2180,13 +2188,14 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const userInput = TestBed.inject(UserInputService);
|
|
||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(userInput.displayedPlanSignal()?.remainingBalances).toEqual(plan.remainingBalances);
|
expect(
|
||||||
|
TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
|
||||||
|
).toEqual(plan.remainingBalances);
|
||||||
const summaryValues = Array.from(
|
const summaryValues = Array.from(
|
||||||
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
|
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
|
||||||
).map((element) => element.textContent?.trim());
|
).map((element) => element.textContent?.trim());
|
||||||
@@ -2538,7 +2547,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
);
|
);
|
||||||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
|
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
TestBed.inject(UserInputService).requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -2959,7 +2968,6 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const userInput = TestBed.inject(UserInputService);
|
|
||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -2971,6 +2979,6 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
if (outcome?.kind === 'PlanningIncomplete')
|
if (outcome?.kind === 'PlanningIncomplete')
|
||||||
expect(outcome.reasons[0].code).toBe('planner-error');
|
expect(outcome.reasons[0].code).toBe('planner-error');
|
||||||
expect(fixture.componentInstance['isLoading']()).toBe(false);
|
expect(fixture.componentInstance['isLoading']()).toBe(false);
|
||||||
expect(userInput.displayedPlanSignal()).toBeUndefined();
|
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -42,14 +42,12 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import {
|
import {
|
||||||
CounterfactualAnalysis,
|
CounterfactualAnalysis,
|
||||||
CounterfactualSelection,
|
CounterfactualSelection,
|
||||||
LeaveType,
|
LeaveType,
|
||||||
DeclaredNonWorkingDay,
|
|
||||||
PlanExplanation,
|
PlanExplanation,
|
||||||
PlanningCalculationContext,
|
|
||||||
PlanningExplanation,
|
|
||||||
PlanningObjective,
|
PlanningObjective,
|
||||||
PlanningObjectiveVector,
|
PlanningObjectiveVector,
|
||||||
PlanningOutcome,
|
PlanningOutcome,
|
||||||
@@ -86,6 +84,7 @@ export class CalendarYear {
|
|||||||
private readonly calendarService = inject(CalendarService);
|
private readonly calendarService = inject(CalendarService);
|
||||||
private readonly settings = inject(CalendarSettingsService);
|
private readonly settings = inject(CalendarSettingsService);
|
||||||
private readonly duration = inject(PlanningDurationService);
|
private readonly duration = inject(PlanningDurationService);
|
||||||
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
@@ -98,13 +97,13 @@ export class CalendarYear {
|
|||||||
private statisticsDialog: MatDialogRef<unknown> | null = null;
|
private statisticsDialog: MatDialogRef<unknown> | null = null;
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
|
|
||||||
protected readonly isLoading = signal(false);
|
protected readonly isLoading = this.calculations.isCalculating;
|
||||||
protected readonly isCancelling = signal(false);
|
protected readonly isCancelling = this.calculations.isCancelling;
|
||||||
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
||||||
protected readonly loadingProgress = signal(0);
|
protected readonly loadingProgress = this.calculations.progress;
|
||||||
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
|
protected readonly planningOutcome = this.calculations.outcome;
|
||||||
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
protected readonly planningExplanation = this.calculations.explanation;
|
||||||
protected readonly explanationError = signal<PlanningReason | null>(null);
|
protected readonly explanationError = this.calculations.explanationError;
|
||||||
protected readonly selectedPlacementIndex = signal(0);
|
protected readonly selectedPlacementIndex = signal(0);
|
||||||
protected readonly planDetailsOpen = signal(false);
|
protected readonly planDetailsOpen = signal(false);
|
||||||
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
|
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
|
||||||
@@ -115,7 +114,7 @@ export class CalendarYear {
|
|||||||
protected readonly invalidComparisonDate = signal(false);
|
protected readonly invalidComparisonDate = signal(false);
|
||||||
protected readonly comparisonDateInput = signal<Date | null>(null);
|
protected readonly comparisonDateInput = signal<Date | null>(null);
|
||||||
protected readonly liveMessage = signal('');
|
protected readonly liveMessage = signal('');
|
||||||
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
|
protected readonly planningHorizon = this.calculations.displayedHorizon;
|
||||||
protected readonly selectedDates = signal(new SelectedDates());
|
protected readonly selectedDates = signal(new SelectedDates());
|
||||||
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
|
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
|
||||||
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
|
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
|
||||||
@@ -141,21 +140,18 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
return { dates, type: this.userInput.declaredTypeSignal() };
|
return { dates, type: this.userInput.declaredTypeSignal() };
|
||||||
});
|
});
|
||||||
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
|
|
||||||
protected readonly calendarHorizon = computed(() =>
|
protected readonly calendarHorizon = computed(() =>
|
||||||
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
|
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
|
||||||
);
|
);
|
||||||
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
|
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
|
||||||
const days = this.declarationEntry()
|
const days = this.declarationEntry()
|
||||||
? this.userInput.declaredDaysSignal()
|
? this.userInput.declaredDaysSignal()
|
||||||
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
|
: (this.calculations.accepted()?.declaredDays ?? []);
|
||||||
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
|
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
|
||||||
});
|
});
|
||||||
|
|
||||||
private calculationVersion = 0;
|
|
||||||
private counterfactualVersion = 0;
|
private counterfactualVersion = 0;
|
||||||
private activeCalculation: PlanningCalculationContext | null = null;
|
private wasCancelling = false;
|
||||||
private runningCalculation: PlanningCalculationContext | null = null;
|
|
||||||
private displayDateLocale = '';
|
private displayDateLocale = '';
|
||||||
private displayDateFormatter?: Intl.DateTimeFormat;
|
private displayDateFormatter?: Intl.DateTimeFormat;
|
||||||
|
|
||||||
@@ -163,7 +159,7 @@ export class CalendarYear {
|
|||||||
return this.translationService.tr;
|
return this.translationService.tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
|
protected readonly displayedPlan = this.calculations.displayedPlan;
|
||||||
protected readonly weekendConnectedHolidays = computed(() => {
|
protected readonly weekendConnectedHolidays = computed(() => {
|
||||||
const isWeekend = (date: string) => {
|
const isWeekend = (date: string) => {
|
||||||
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||||
@@ -339,185 +335,74 @@ export class CalendarYear {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.userInput.computeTriggerSignal();
|
if (this.isLoading()) untracked(() => this.liveMessage.set(this.tr().loading));
|
||||||
untracked(() => this.startCalculation());
|
|
||||||
});
|
});
|
||||||
}
|
effect(() => {
|
||||||
|
const cancelling = this.isCancelling();
|
||||||
private startCalculation(): void {
|
untracked(() => {
|
||||||
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
|
if (this.wasCancelling && !cancelling) this.resultsTitle()?.nativeElement.focus();
|
||||||
this.userInput.computationRequestedAtMs.set(null);
|
this.wasCancelling = cancelling;
|
||||||
const request = this.duration.request();
|
|
||||||
if (!request) return;
|
|
||||||
const { horizon, rules: currentRules } = request;
|
|
||||||
this.declarationEntry.set(false);
|
|
||||||
if (!this.planningHorizon()) this.planningHorizon.set(horizon);
|
|
||||||
const version = ++this.calculationVersion;
|
|
||||||
this.isLoading.set(true);
|
|
||||||
this.isCancelling.set(false);
|
|
||||||
this.loadingProgress.set(0);
|
|
||||||
this.liveMessage.set(this.tr().loading);
|
|
||||||
let calculation: PlanningCalculationContext;
|
|
||||||
let completed = false;
|
|
||||||
try {
|
|
||||||
calculation = this.calendarService.createPlanningCalculation({
|
|
||||||
...request,
|
|
||||||
balances: request.balances.map((balance) => ({
|
|
||||||
...balance,
|
|
||||||
label: this.translationService.getDayTypeLabel(balance.type),
|
|
||||||
})),
|
|
||||||
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.resetCalculationState(horizon);
|
effect(() => {
|
||||||
this.planningHorizon.set(horizon);
|
const accepted = this.calculations.accepted();
|
||||||
this.failCalculation(error, version);
|
if (!accepted) return;
|
||||||
return;
|
untracked(() => {
|
||||||
}
|
this.closeStatistics();
|
||||||
this.runningCalculation = calculation;
|
this.selectedPlacementIndex.set(0);
|
||||||
void calculation.outcome
|
this.planDetailsOpen.set(false);
|
||||||
.then((outcome) => {
|
this.clearCounterfactual();
|
||||||
if (version !== this.calculationVersion) return;
|
const plan = this.displayedPlan();
|
||||||
this.resetCalculationState(horizon);
|
this.selectedDates.set(
|
||||||
this.activeCalculation = calculation;
|
plan
|
||||||
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
|
? this.calendarService.selectedDatesForPlan(plan)
|
||||||
this.planningHorizon.set(horizon);
|
: this.calendarService.getSelectedDatesForHorizon(accepted.horizon),
|
||||||
this.applyOutcome(outcome);
|
);
|
||||||
this.loadArtifacts(calculation);
|
this.liveMessage.set(this.outcomeLabel(accepted.outcome));
|
||||||
completed = outcome.kind === 'Plan' && !this.isCancelling();
|
if (accepted.successful && !this.destroyRef.destroyed)
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
this.resetCalculationState(horizon);
|
|
||||||
this.activeCalculation = calculation;
|
|
||||||
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
|
|
||||||
this.planningHorizon.set(horizon);
|
|
||||||
this.applyOutcome({
|
|
||||||
kind: 'PlanningIncomplete',
|
|
||||||
completedStages: [],
|
|
||||||
reasons: [
|
|
||||||
{
|
|
||||||
code: 'planner-error',
|
|
||||||
message:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'The planner failed unexpectedly.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
this.loadArtifacts(calculation);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
this.runningCalculation = null;
|
|
||||||
this.loadingProgress.set(100);
|
|
||||||
this.isLoading.set(false);
|
|
||||||
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
|
||||||
this.isCancelling.set(false);
|
|
||||||
if (completed && !this.destroyRef.destroyed)
|
|
||||||
afterNextRender(
|
afterNextRender(
|
||||||
() => {
|
() => {
|
||||||
if (version === this.calculationVersion && !this.destroyRef.destroyed)
|
if (
|
||||||
|
this.calculations.accepted() === accepted &&
|
||||||
|
!this.destroyRef.destroyed &&
|
||||||
|
accepted.snapshot
|
||||||
|
)
|
||||||
this.duration.recordCompletion(
|
this.duration.recordCompletion(
|
||||||
calculation.snapshot,
|
accepted.snapshot,
|
||||||
performance.now() - requestedAt,
|
performance.now() - accepted.requestedAt,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ injector: this.injector },
|
{ injector: this.injector },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const explanation = this.calculations.explanation();
|
||||||
|
if (explanation?.kind !== 'PlanExplanation') return;
|
||||||
|
untracked(() => {
|
||||||
|
this.selectPlacement(0, false);
|
||||||
|
const outcome = this.planningOutcome();
|
||||||
|
this.liveMessage.set(
|
||||||
|
`${outcome ? this.outcomeLabel(outcome) : ''} ${this.placementAnnouncement()}`.trim(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const reason = this.explanationError();
|
||||||
|
if (reason?.code === 'explanation-error')
|
||||||
|
untracked(() =>
|
||||||
|
this.liveMessage.set(
|
||||||
|
`${this.tr().planning.explanationError} ${reason.message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.calculations.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected cancelCalculation(): void {
|
protected cancelCalculation(): void {
|
||||||
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
|
if (!this.isLoading() || this.isCancelling()) return;
|
||||||
this.isCancelling.set(true);
|
this.calculations.cancel();
|
||||||
this.liveMessage.set(this.tr().planning.cancelling);
|
this.liveMessage.set(this.tr().planning.cancelling);
|
||||||
this.runningCalculation.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
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.loadingProgress.set(100);
|
|
||||||
this.isLoading.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetCalculationState(horizon: { start: string; end: string }): void {
|
|
||||||
this.closeStatistics();
|
|
||||||
this.activeCalculation = null;
|
|
||||||
this.userInput.planningSnapshotSignal.set(null);
|
|
||||||
this.planningOutcome.set(null);
|
|
||||||
this.planningExplanation.set(null);
|
|
||||||
this.explanationError.set(null);
|
|
||||||
this.selectedPlacementIndex.set(0);
|
|
||||||
this.planDetailsOpen.set(false);
|
|
||||||
this.clearCounterfactual();
|
|
||||||
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
|
||||||
void calculation.explanation
|
|
||||||
.then((explanation) => {
|
|
||||||
if (calculation !== this.activeCalculation) return;
|
|
||||||
this.planningExplanation.set(explanation ?? null);
|
|
||||||
if (explanation?.kind === 'PlanExplanation') {
|
|
||||||
this.selectPlacement(0, false);
|
|
||||||
this.liveMessage.set(
|
|
||||||
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (calculation !== this.activeCalculation) return;
|
|
||||||
const reason = {
|
|
||||||
code: 'explanation-error',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
this.explanationError.set(reason);
|
|
||||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
|
||||||
});
|
|
||||||
void calculation.explanationError
|
|
||||||
.then((reason) => {
|
|
||||||
if (calculation === this.activeCalculation)
|
|
||||||
this.explanationError.set(reason ?? null);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (calculation !== this.activeCalculation) return;
|
|
||||||
const reason = {
|
|
||||||
code: 'explanation-error',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
this.explanationError.set(reason);
|
|
||||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyOutcome(outcome: PlanningOutcome): void {
|
|
||||||
this.planningOutcome.set(outcome);
|
|
||||||
this.liveMessage.set(this.outcomeLabel(outcome));
|
|
||||||
const plan = this.displayedPlan();
|
|
||||||
this.resultDeclaredDays.set(
|
|
||||||
plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
|
|
||||||
);
|
|
||||||
if (plan) {
|
|
||||||
this.planningHorizon.set(plan.horizon);
|
|
||||||
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.selectedDates.set(
|
|
||||||
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected formatStatistic(value: number | null): string {
|
protected formatStatistic(value: number | null): string {
|
||||||
@@ -823,7 +708,7 @@ export class CalendarYear {
|
|||||||
selection: CounterfactualSelection,
|
selection: CounterfactualSelection,
|
||||||
openDialog = false,
|
openDialog = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const calculation = this.activeCalculation;
|
const calculation = this.calculations.accepted()?.calculation;
|
||||||
if (!calculation) return;
|
if (!calculation) return;
|
||||||
this.counterfactualSelection = selection;
|
this.counterfactualSelection = selection;
|
||||||
const version = ++this.counterfactualVersion;
|
const version = ++this.counterfactualVersion;
|
||||||
@@ -838,7 +723,10 @@ export class CalendarYear {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await calculation.analyzeCounterfactual(selection);
|
const result = await calculation.analyzeCounterfactual(selection);
|
||||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
if (
|
||||||
|
version !== this.counterfactualVersion ||
|
||||||
|
calculation !== this.calculations.accepted()?.calculation
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
if (result.kind === 'Counterfactual') {
|
if (result.kind === 'Counterfactual') {
|
||||||
this.counterfactual.set(result);
|
this.counterfactual.set(result);
|
||||||
@@ -849,7 +737,10 @@ export class CalendarYear {
|
|||||||
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
if (
|
||||||
|
version !== this.counterfactualVersion ||
|
||||||
|
calculation !== this.calculations.accepted()?.calculation
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.counterfactualError.set(message);
|
this.counterfactualError.set(message);
|
||||||
@@ -914,10 +805,10 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected ruleLabel(ruleId: string): string {
|
protected ruleLabel(ruleId: string): string {
|
||||||
const rule = this.activeCalculation?.snapshot.rules.find(
|
const rule = this.calculations
|
||||||
(candidate) => candidate.id === ruleId,
|
.snapshot()
|
||||||
);
|
?.rules.find((candidate) => candidate.id === ruleId);
|
||||||
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
|
return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected balanceLabel(explanation: PlacementExplanation): string {
|
protected balanceLabel(explanation: PlacementExplanation): string {
|
||||||
@@ -949,9 +840,9 @@ export class CalendarYear {
|
|||||||
protected objectiveCopy(id: string) {
|
protected objectiveCopy(id: string) {
|
||||||
if (id.startsWith('optimization:')) {
|
if (id.startsWith('optimization:')) {
|
||||||
const ruleId = id.slice('optimization:'.length);
|
const ruleId = id.slice('optimization:'.length);
|
||||||
const rule = this.activeCalculation?.snapshot.rules.find(
|
const rule = this.calculations
|
||||||
(candidate) => candidate.id === ruleId,
|
.snapshot()
|
||||||
);
|
?.rules.find((candidate) => candidate.id === ruleId);
|
||||||
const copy = this.tr().planning.explanation.objectives['optimization'];
|
const copy = this.tr().planning.explanation.objectives['optimization'];
|
||||||
return {
|
return {
|
||||||
...copy,
|
...copy,
|
||||||
@@ -969,9 +860,9 @@ export class CalendarYear {
|
|||||||
const copy = this.tr().planning.explanation.objectives[key];
|
const copy = this.tr().planning.explanation.objectives[key];
|
||||||
const rule =
|
const rule =
|
||||||
key === 'preference'
|
key === 'preference'
|
||||||
? this.activeCalculation?.snapshot.rules.find(
|
? this.calculations
|
||||||
(candidate) => candidate.id === id.slice('preference:'.length),
|
.snapshot()
|
||||||
)
|
?.rules.find((candidate) => candidate.id === id.slice('preference:'.length))
|
||||||
: undefined;
|
: undefined;
|
||||||
return (
|
return (
|
||||||
copy && {
|
copy && {
|
||||||
@@ -1056,20 +947,4 @@ export class CalendarYear {
|
|||||||
: this.tr().planning.cancelled;
|
: this.tr().planning.cancelled;
|
||||||
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
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;
|
|
||||||
if (stage === 'obligations') return 20;
|
|
||||||
if (stage === 'at-risk') return 35;
|
|
||||||
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
|
|
||||||
if (stage === 'holiday-week-value') return 58;
|
|
||||||
if (stage === 'non-working-day-gain') return 65;
|
|
||||||
if (stage === 'longest-work-sequence') return 72;
|
|
||||||
if (stage === 'short-sequence-deficit') return 79;
|
|
||||||
if (stage === 'supporting-placements') return 86;
|
|
||||||
if (stage === 'expiry') return 93;
|
|
||||||
if (stage === 'dates') return 98;
|
|
||||||
return this.loadingProgress();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,19 @@ import { MatDialog } from '@angular/material/dialog';
|
|||||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatSelectHarness } from '@angular/material/select/testing';
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi, type MockInstance } from 'vitest';
|
||||||
import { UserInput } from './user-input';
|
import { UserInput } from './user-input';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
|
import { CalendarService } from '../../services/calendar-service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import { UserSettings } from '../user-settings/user-settings';
|
import { UserSettings } from '../user-settings/user-settings';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
|
import {
|
||||||
|
createPlanningCalculation,
|
||||||
|
type PlanningCalculationContext,
|
||||||
|
type PlanningOutcome,
|
||||||
|
PlanningRequest,
|
||||||
|
} from '../../services/leave-planner.service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
|
|
||||||
const presets = createOptimizationPresetRules();
|
const presets = createOptimizationPresetRules();
|
||||||
@@ -21,6 +28,8 @@ describe('UserInput', () => {
|
|||||||
let component: UserInput;
|
let component: UserInput;
|
||||||
let fixture: ComponentFixture<UserInput>;
|
let fixture: ComponentFixture<UserInput>;
|
||||||
let userInputService: UserInputService;
|
let userInputService: UserInputService;
|
||||||
|
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
|
||||||
|
let calculations: PlanningCalculationService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -45,6 +54,11 @@ describe('UserInput', () => {
|
|||||||
fixture = TestBed.createComponent(UserInput);
|
fixture = TestBed.createComponent(UserInput);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
userInputService = TestBed.inject(UserInputService);
|
userInputService = TestBed.inject(UserInputService);
|
||||||
|
calculations = TestBed.inject(PlanningCalculationService);
|
||||||
|
// Unless a test supplies a result, a started calculation stays pending.
|
||||||
|
createCalculation = vi
|
||||||
|
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
|
||||||
|
.mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
}
|
}
|
||||||
@@ -91,16 +105,25 @@ describe('UserInput', () => {
|
|||||||
|
|
||||||
async function calculate(request: PlanningRequest): Promise<void> {
|
async function calculate(request: PlanningRequest): Promise<void> {
|
||||||
const browserWindow = globalThis.window;
|
const browserWindow = globalThis.window;
|
||||||
let calculation: ReturnType<typeof createPlanningCalculation>;
|
let calculation: PlanningCalculationContext;
|
||||||
try {
|
try {
|
||||||
vi.stubGlobal('window', undefined);
|
vi.stubGlobal('window', undefined);
|
||||||
calculation = createPlanningCalculation(request);
|
calculation = createPlanningCalculation(request);
|
||||||
} finally {
|
} finally {
|
||||||
vi.stubGlobal('window', browserWindow);
|
vi.stubGlobal('window', browserWindow);
|
||||||
}
|
}
|
||||||
const outcome = await calculation.outcome;
|
await show(calculation);
|
||||||
userInputService.planningSnapshotSignal.set(calculation.snapshot);
|
}
|
||||||
userInputService.planningOutcomeSignal.set(outcome);
|
|
||||||
|
async function show(
|
||||||
|
calculation: PlanningCalculationContext,
|
||||||
|
outcome?: PlanningOutcome,
|
||||||
|
): Promise<void> {
|
||||||
|
const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
|
||||||
|
createCalculation.mockReturnValueOnce(shown);
|
||||||
|
calculations.start();
|
||||||
|
await shown.outcome;
|
||||||
|
await Promise.resolve();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,19 +178,18 @@ describe('UserInput', () => {
|
|||||||
expect(card.textContent).toContain('Aucune action nécessaire');
|
expect(card.textContent).toContain('Aucune action nécessaire');
|
||||||
expect(card.textContent).not.toContain('Disponible seulement');
|
expect(card.textContent).not.toContain('Disponible seulement');
|
||||||
expect(root.querySelector('.remaining-history')).toBeNull();
|
expect(root.querySelector('.remaining-history')).toBeNull();
|
||||||
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
|
const outcome = JSON.stringify(calculations.outcome());
|
||||||
expect(card.querySelector('details')).toBeNull();
|
expect(card.querySelector('details')).toBeNull();
|
||||||
expect(card.textContent).not.toContain('trace');
|
expect(card.textContent).not.toContain('trace');
|
||||||
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
|
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
|
||||||
|
|
||||||
const plan = userInputService.displayedPlanSignal()!;
|
const plan = calculations.displayedPlan()!;
|
||||||
userInputService.planningOutcomeSignal.set({
|
await show(calculations.accepted()!.calculation!, {
|
||||||
kind: 'PlanningIncomplete',
|
kind: 'PlanningIncomplete',
|
||||||
incumbent: plan,
|
incumbent: plan,
|
||||||
completedStages: [],
|
completedStages: [],
|
||||||
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
});
|
});
|
||||||
fixture.detectChanges();
|
|
||||||
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
|
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
|
||||||
expect(card.textContent).not.toContain('Conservé pour plus tard');
|
expect(card.textContent).not.toContain('Conservé pour plus tard');
|
||||||
expect(root.querySelector('.remaining-history')).toBeNull();
|
expect(root.querySelector('.remaining-history')).toBeNull();
|
||||||
@@ -185,7 +207,7 @@ describe('UserInput', () => {
|
|||||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(estimateText()).not.toBe(initial);
|
expect(estimateText()).not.toBe(initial);
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
|
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
|
||||||
@@ -218,7 +240,7 @@ describe('UserInput', () => {
|
|||||||
start: '2024-02-27',
|
start: '2024-02-27',
|
||||||
lastPlanningDate: '2024-03-09',
|
lastPlanningDate: '2024-03-09',
|
||||||
});
|
});
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
|
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
|
||||||
@@ -233,11 +255,11 @@ describe('UserInput', () => {
|
|||||||
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
|
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
|
||||||
calculate.click();
|
calculate.click();
|
||||||
component.triggerComputation();
|
component.triggerComputation();
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
editInput('input[name="planningStart"]', '29/02/2024');
|
editInput('input[name="planningStart"]', '29/02/2024');
|
||||||
expect(calculate.disabled).toBe(false);
|
expect(calculate.disabled).toBe(false);
|
||||||
calculate.click();
|
calculate.click();
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(1);
|
expect(createCalculation).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['', '30/02/2024', 'not-a-date', '2'])(
|
it.each(['', '30/02/2024', 'not-a-date', '2'])(
|
||||||
@@ -282,7 +304,7 @@ describe('UserInput', () => {
|
|||||||
expect(last.getAttribute('aria-invalid')).toBe('false');
|
expect(last.getAttribute('aria-invalid')).toBe('false');
|
||||||
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
|
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
|
||||||
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
|
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize with vacation days from service', () => {
|
it('should initialize with vacation days from service', () => {
|
||||||
@@ -518,7 +540,7 @@ describe('UserInput', () => {
|
|||||||
fixture.nativeElement.querySelector('.calculate-button').click();
|
fixture.nativeElement.querySelector('.calculate-button').click();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(1);
|
expect(createCalculation).toHaveBeenCalledTimes(1);
|
||||||
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
|
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
|
||||||
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { VacDay } from '../../services/calendar-service';
|
|||||||
import type { LeaveType } from '../../services/leave-planner.service';
|
import type { LeaveType } from '../../services/leave-planner.service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
|
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
|
||||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||||
@@ -45,6 +46,7 @@ export class UserInput {
|
|||||||
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
|
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
|
||||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
protected readonly duration = inject(PlanningDurationService);
|
protected readonly duration = inject(PlanningDurationService);
|
||||||
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
protected readonly planningDates = computed(() => {
|
protected readonly planningDates = computed(() => {
|
||||||
@@ -72,22 +74,21 @@ export class UserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected readonly remainingDays = computed(() =>
|
protected readonly remainingDays = computed(() =>
|
||||||
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
|
(this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
|
||||||
(total, balance) => total + balance.quantity,
|
(total, balance) => total + balance.quantity,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
protected readonly hasAtRiskDays = computed(() =>
|
protected readonly hasAtRiskDays = computed(() =>
|
||||||
this.userInputService
|
this.calculations
|
||||||
.displayedPlanSignal()
|
.displayedPlan()
|
||||||
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
|
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
|
||||||
);
|
);
|
||||||
|
|
||||||
protected remainingDaysDetails = computed(() => {
|
protected remainingDaysDetails = computed(() => {
|
||||||
const plan = this.userInputService.displayedPlanSignal();
|
const plan = this.calculations.displayedPlan();
|
||||||
if (!plan) return [];
|
if (!plan) return [];
|
||||||
const incomplete =
|
const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
|
||||||
this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
|
|
||||||
const copy = this.tr().userInput;
|
const copy = this.tr().userInput;
|
||||||
return plan.remainingBalances.map((balance) => {
|
return plan.remainingBalances.map((balance) => {
|
||||||
const code =
|
const code =
|
||||||
@@ -147,10 +148,7 @@ export class UserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ruleName(id: string): string {
|
private ruleName(id: string): string {
|
||||||
return (
|
return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
|
||||||
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
|
|
||||||
?.name ?? id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
|
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
|
||||||
@@ -178,6 +176,6 @@ export class UserInput {
|
|||||||
|
|
||||||
public triggerComputation() {
|
public triggerComputation() {
|
||||||
if (this.calendarSettings.planningHorizonError()) return;
|
if (this.calendarSettings.planningHorizonError()) return;
|
||||||
this.userInputService.requestComputation();
|
this.calculations.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { CalendarService } from './calendar-service';
|
||||||
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
|
import type {
|
||||||
|
PlanOutcome,
|
||||||
|
PlanningCalculationContext,
|
||||||
|
PlanningExplanation,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningReason,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
|
import { UserInputService } from './user-input-service';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function planFor(start: string, end: string): PlanOutcome {
|
||||||
|
return {
|
||||||
|
kind: 'Plan',
|
||||||
|
horizon: { start, end },
|
||||||
|
declaredDays: [],
|
||||||
|
placements: [],
|
||||||
|
timeOffPeriods: [],
|
||||||
|
preferences: [],
|
||||||
|
remainingBalances: [],
|
||||||
|
completedStages: [],
|
||||||
|
} as unknown as PlanOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
|
||||||
|
|
||||||
|
function fakeCalculation(id: string) {
|
||||||
|
const outcome = deferred<PlanningOutcome>();
|
||||||
|
const explanation = deferred<PlanningExplanation | undefined>();
|
||||||
|
const explanationError = deferred<PlanningReason | undefined>();
|
||||||
|
const cancel = vi.fn();
|
||||||
|
const context = {
|
||||||
|
id,
|
||||||
|
cancel,
|
||||||
|
snapshot: { rules: [], labels: {}, declaredDays: [] },
|
||||||
|
outcome: outcome.promise,
|
||||||
|
explanation: explanation.promise,
|
||||||
|
explanationError: explanationError.promise,
|
||||||
|
analyzeCounterfactual: vi.fn(),
|
||||||
|
} as unknown as PlanningCalculationContext;
|
||||||
|
return { context, outcome, explanation, explanationError, cancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fake = ReturnType<typeof fakeCalculation>;
|
||||||
|
|
||||||
|
const flush = () => new Promise<void>((resolve) => setTimeout(resolve));
|
||||||
|
|
||||||
|
describe('PlanningCalculationService', () => {
|
||||||
|
let create: ReturnType<typeof vi.fn>;
|
||||||
|
let service: PlanningCalculationService;
|
||||||
|
|
||||||
|
function queue(...fakes: Fake[]): void {
|
||||||
|
for (const fake of fakes) create.mockReturnValueOnce(fake.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
create = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: { createPlanningCalculation: create } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
|
service = TestBed.inject(PlanningCalculationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not calculate until started and ignores input edits afterwards', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
expect(service.accepted()).toBeNull();
|
||||||
|
service.start();
|
||||||
|
expect(create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.isCalculating()).toBe(true);
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
const accepted = service.accepted()!;
|
||||||
|
expect(accepted.successful).toBe(true);
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
|
||||||
|
await flush();
|
||||||
|
expect(create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.accepted()).toBe(accepted);
|
||||||
|
expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the submission translated labels and leaves declaration entry', () => {
|
||||||
|
queue(fakeCalculation('first'));
|
||||||
|
const input = TestBed.inject(UserInputService);
|
||||||
|
input.declarationEntrySignal.set(true);
|
||||||
|
service.start();
|
||||||
|
expect(input.declarationEntrySignal()).toBe(false);
|
||||||
|
const submission = create.mock.calls[0][0];
|
||||||
|
expect(submission.horizon).toEqual(service.request()!.horizon);
|
||||||
|
expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
|
||||||
|
expect(submission.labels).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports progress of the latest calculation only', () => {
|
||||||
|
queue(fakeCalculation('old'), fakeCalculation('latest'));
|
||||||
|
service.start();
|
||||||
|
const oldProgress = create.mock.calls[0][0].progress;
|
||||||
|
service.start();
|
||||||
|
const latestProgress = create.mock.calls[1][0].progress;
|
||||||
|
oldProgress('obligations');
|
||||||
|
expect(service.progress()).toBe(0);
|
||||||
|
latestProgress('at-risk');
|
||||||
|
expect(service.progress()).toBe(35);
|
||||||
|
latestProgress('unknown');
|
||||||
|
expect(service.progress()).toBe(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
const stale = fakeCalculation('stale');
|
||||||
|
const latest = fakeCalculation('latest');
|
||||||
|
queue(first, stale, latest);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
const previous = service.accepted()!;
|
||||||
|
service.start();
|
||||||
|
service.start();
|
||||||
|
expect(service.accepted()).toBe(previous);
|
||||||
|
expect(service.displayedPlan()).toBe(previous.outcome);
|
||||||
|
stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.accepted()).toBe(previous);
|
||||||
|
expect(service.isCalculating()).toBe(true);
|
||||||
|
latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.accepted()!.calculation).toBe(latest.context);
|
||||||
|
expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
const second = fakeCalculation('second');
|
||||||
|
queue(first, second);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
expect(service.explanation()).toBeNull();
|
||||||
|
service.start();
|
||||||
|
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
first.explanation.resolve(explained);
|
||||||
|
await flush();
|
||||||
|
expect(service.explanation()).toBeNull();
|
||||||
|
second.explanation.resolve(undefined);
|
||||||
|
second.explanationError.resolve(undefined);
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
const second = fakeCalculation('second');
|
||||||
|
queue(first, second);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
service.start();
|
||||||
|
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
first.explanationError.resolve({ code: 'old', message: 'old' });
|
||||||
|
await flush();
|
||||||
|
expect(service.explanationError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the outcome when the explanation fails', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
first.explanation.reject(new Error('no trace'));
|
||||||
|
first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
expect(service.explanationError()?.message).toBeTruthy();
|
||||||
|
expect(service.explanation()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the outcome when the explanation error promise rejects', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
first.explanationError.reject(new Error('lost'));
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an outcome rejection into an accepted planner error', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
first.outcome.reject(new Error('worker died'));
|
||||||
|
await flush();
|
||||||
|
const accepted = service.accepted()!;
|
||||||
|
expect(accepted.successful).toBe(false);
|
||||||
|
expect(accepted.outcome).toEqual({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'planner-error', message: 'worker died' }],
|
||||||
|
});
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a construction failure into an accepted planner error with the requested horizon', () => {
|
||||||
|
create.mockImplementation(() => {
|
||||||
|
throw new Error('cannot start');
|
||||||
|
});
|
||||||
|
service.start();
|
||||||
|
const reason = { code: 'planner-error', message: 'cannot start' };
|
||||||
|
expect(service.outcome()).toEqual({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [reason],
|
||||||
|
});
|
||||||
|
expect(service.explanationError()).toEqual(reason);
|
||||||
|
expect(service.accepted()!.calculation).toBeNull();
|
||||||
|
expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels cooperatively once, and not when nothing is running', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.cancel();
|
||||||
|
expect(first.cancel).not.toHaveBeenCalled();
|
||||||
|
service.start();
|
||||||
|
service.cancel();
|
||||||
|
service.cancel();
|
||||||
|
expect(first.cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.isCancelling()).toBe(true);
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.isCancelling()).toBe(false);
|
||||||
|
expect(service.accepted()!.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
service.cancel();
|
||||||
|
first.outcome.resolve({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('PlanningIncomplete');
|
||||||
|
expect(service.displayedPlan()).toBeUndefined();
|
||||||
|
expect(service.accepted()!.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the incumbent of a cancelled calculation', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
|
||||||
|
service.start();
|
||||||
|
service.cancel();
|
||||||
|
first.outcome.resolve({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
incumbent,
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(service.displayedPlan()).toBe(incumbent);
|
||||||
|
expect(service.accepted()!.successful).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { CalendarService } from './calendar-service';
|
||||||
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
|
import type {
|
||||||
|
DeclaredNonWorkingDay,
|
||||||
|
PlanningCalculationContext,
|
||||||
|
PlanningExplanation,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningReason,
|
||||||
|
PlanningSnapshot,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
import { RuleService } from './rule-service';
|
||||||
|
import { TranslationService } from './translation-service';
|
||||||
|
import { UserInputService } from './user-input-service';
|
||||||
|
|
||||||
|
type Horizon = { start: string; end: string };
|
||||||
|
|
||||||
|
interface PendingCalculation {
|
||||||
|
horizon: Horizon;
|
||||||
|
progress: number;
|
||||||
|
cancelling: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptedCalculation {
|
||||||
|
/** Null when the planner could not even be created. */
|
||||||
|
calculation: PlanningCalculationContext | null;
|
||||||
|
outcome: PlanningOutcome;
|
||||||
|
snapshot: PlanningSnapshot | null;
|
||||||
|
horizon: Horizon;
|
||||||
|
declaredDays: readonly DeclaredNonWorkingDay[];
|
||||||
|
requestedAt: number;
|
||||||
|
successful: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageProgress(stage: string, current: number): number {
|
||||||
|
if (stage === 'complete') return 100;
|
||||||
|
if (stage === 'validation') return 5;
|
||||||
|
if (stage === 'obligations') return 20;
|
||||||
|
if (stage === 'at-risk') return 35;
|
||||||
|
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
|
||||||
|
if (stage === 'holiday-week-value') return 58;
|
||||||
|
if (stage === 'non-working-day-gain') return 65;
|
||||||
|
if (stage === 'longest-work-sequence') return 72;
|
||||||
|
if (stage === 'short-sequence-deficit') return 79;
|
||||||
|
if (stage === 'supporting-placements') return 86;
|
||||||
|
if (stage === 'expiry') return 93;
|
||||||
|
if (stage === 'dates') return 98;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerError(error: unknown): PlanningReason {
|
||||||
|
return {
|
||||||
|
code: 'planner-error',
|
||||||
|
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function explanationFailure(error: unknown): PlanningReason {
|
||||||
|
return {
|
||||||
|
code: 'explanation-error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PlanningCalculationService {
|
||||||
|
private readonly settings = inject(CalendarSettingsService);
|
||||||
|
private readonly userInput = inject(UserInputService);
|
||||||
|
private readonly rules = inject(RuleService);
|
||||||
|
private readonly calendar = inject(CalendarService);
|
||||||
|
private readonly translation = inject(TranslationService);
|
||||||
|
|
||||||
|
private version = 0;
|
||||||
|
private running: PlanningCalculationContext | null = null;
|
||||||
|
private readonly pendingState = signal<PendingCalculation | null>(null);
|
||||||
|
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
|
||||||
|
private readonly explanationState = signal<PlanningExplanation | null>(null);
|
||||||
|
private readonly explanationErrorState = signal<PlanningReason | null>(null);
|
||||||
|
|
||||||
|
readonly request = computed(() => {
|
||||||
|
const horizon = this.settings.planningHorizon();
|
||||||
|
if (!horizon) return null;
|
||||||
|
return {
|
||||||
|
horizon,
|
||||||
|
declaredDays: this.userInput.declaredDaysSignal(),
|
||||||
|
balances: this.userInput.vacationNumberSignal().map((balance) => ({
|
||||||
|
id: balance.id,
|
||||||
|
type: balance.type,
|
||||||
|
quantity: balance.numberOfDays,
|
||||||
|
availableFrom: balance.availableDate
|
||||||
|
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||||
|
})),
|
||||||
|
rules: this.rules.rules,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly accepted = this.acceptedState.asReadonly();
|
||||||
|
readonly explanation = this.explanationState.asReadonly();
|
||||||
|
readonly explanationError = this.explanationErrorState.asReadonly();
|
||||||
|
readonly isCalculating = computed(() => this.pendingState() !== null);
|
||||||
|
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
|
||||||
|
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
|
||||||
|
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
|
||||||
|
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
|
||||||
|
readonly displayedPlan = computed(() => {
|
||||||
|
const outcome = this.outcome();
|
||||||
|
return outcome?.kind === 'Plan'
|
||||||
|
? outcome
|
||||||
|
: outcome?.kind === 'PlanningIncomplete'
|
||||||
|
? outcome.incumbent
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
readonly displayedHorizon = computed(
|
||||||
|
() => this.acceptedState()?.horizon ?? this.pendingState()?.horizon ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
const request = this.request();
|
||||||
|
if (!request) return;
|
||||||
|
const requestedAt = performance.now();
|
||||||
|
const version = ++this.version;
|
||||||
|
this.userInput.declarationEntrySignal.set(false);
|
||||||
|
this.pendingState.set({ horizon: request.horizon, progress: 0, cancelling: false });
|
||||||
|
let calculation: PlanningCalculationContext;
|
||||||
|
try {
|
||||||
|
calculation = this.calendar.createPlanningCalculation({
|
||||||
|
...request,
|
||||||
|
balances: request.balances.map((balance) => ({
|
||||||
|
...balance,
|
||||||
|
label: this.translation.getDayTypeLabel(balance.type),
|
||||||
|
})),
|
||||||
|
labels: Object.fromEntries(
|
||||||
|
request.rules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||||
|
),
|
||||||
|
progress: (stage) => {
|
||||||
|
if (version === this.version)
|
||||||
|
this.pendingState.update(
|
||||||
|
(pending) =>
|
||||||
|
pending && {
|
||||||
|
...pending,
|
||||||
|
progress: stageProgress(stage, pending.progress),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = plannerError(error);
|
||||||
|
this.running = null;
|
||||||
|
this.accept(null, this.incomplete(reason), request.horizon, [], requestedAt);
|
||||||
|
this.explanationErrorState.set(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = calculation;
|
||||||
|
const accept = (outcome: PlanningOutcome) => {
|
||||||
|
if (version !== this.version) return false;
|
||||||
|
this.running = null;
|
||||||
|
this.accept(calculation, outcome, request.horizon, undefined, requestedAt);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
void calculation.outcome
|
||||||
|
.then((outcome) => accept(outcome))
|
||||||
|
.catch((error: unknown) => accept(this.incomplete(plannerError(error))))
|
||||||
|
.then((accepted) => {
|
||||||
|
if (accepted) this.loadArtifacts(calculation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
const pending = this.pendingState();
|
||||||
|
if (!pending || pending.cancelling || !this.running) return;
|
||||||
|
this.pendingState.set({ ...pending, cancelling: true });
|
||||||
|
this.running.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private incomplete(reason: PlanningReason): PlanningOutcome {
|
||||||
|
return { kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private accept(
|
||||||
|
calculation: PlanningCalculationContext | null,
|
||||||
|
outcome: PlanningOutcome,
|
||||||
|
requestHorizon: Horizon,
|
||||||
|
declaredDays: readonly DeclaredNonWorkingDay[] | undefined,
|
||||||
|
requestedAt: number,
|
||||||
|
): void {
|
||||||
|
const plan = outcome.kind === 'Plan' ? outcome : undefined;
|
||||||
|
const shown =
|
||||||
|
plan ?? (outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined);
|
||||||
|
const cancelling = this.pendingState()?.cancelling ?? false;
|
||||||
|
this.explanationState.set(null);
|
||||||
|
this.explanationErrorState.set(null);
|
||||||
|
this.pendingState.set(null);
|
||||||
|
this.acceptedState.set({
|
||||||
|
calculation,
|
||||||
|
outcome,
|
||||||
|
snapshot: calculation?.snapshot ?? null,
|
||||||
|
horizon: shown?.horizon ?? requestHorizon,
|
||||||
|
declaredDays:
|
||||||
|
shown?.declaredDays ?? declaredDays ?? calculation?.snapshot.declaredDays ?? [],
|
||||||
|
requestedAt,
|
||||||
|
successful: plan !== undefined && !cancelling,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
||||||
|
const current = () => this.acceptedState()?.calculation === calculation;
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
if (current()) this.explanationErrorState.set(explanationFailure(error));
|
||||||
|
};
|
||||||
|
calculation.explanation
|
||||||
|
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
|
||||||
|
.catch(fail);
|
||||||
|
calculation.explanationError
|
||||||
|
.then((reason) => current() && this.explanationErrorState.set(reason ?? null))
|
||||||
|
.catch(fail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { RuleService, RuleType } from './rule-service';
|
|||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
import { PlanningDurationService } from './planning-duration.service';
|
import { PlanningDurationService } from './planning-duration.service';
|
||||||
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
|
|
||||||
describe('Planning duration estimate', () => {
|
describe('Planning duration estimate', () => {
|
||||||
@@ -31,7 +32,8 @@ describe('Planning duration estimate', () => {
|
|||||||
it('offers a coarse seconds range on the first launch without calculating', () => {
|
it('offers a coarse seconds range on the first launch without calculating', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
|
||||||
expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0);
|
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
|
||||||
|
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
|
||||||
expect(TestBed.inject(RuleService).rules).toEqual([]);
|
expect(TestBed.inject(RuleService).rules).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ describe('Planning duration estimate', () => {
|
|||||||
});
|
});
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
|
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
|
||||||
duration.recordCompletion(duration.request()!, 20_000);
|
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,19 +83,20 @@ describe('Planning duration estimate', () => {
|
|||||||
expect(duration.estimate()).toEqual(moreLeave);
|
expect(duration.estimate()).toEqual(moreLeave);
|
||||||
settings.setPlanningDates('2026-12-31', '2024-01-01');
|
settings.setPlanningDates('2026-12-31', '2024-01-01');
|
||||||
expect(duration.estimate()).toBeNull();
|
expect(duration.estimate()).toBeNull();
|
||||||
expect(input.computeTriggerSignal()).toBe(0);
|
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
|
||||||
|
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
|
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
duration.recordCompletion(submitted, 120_000);
|
duration.recordCompletion(submitted, 120_000);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
|
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calibrates against the submitted snapshot even when the form has since changed', () => {
|
it('calibrates against the submitted snapshot even when the form has since changed', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
|
||||||
duration.recordCompletion(submitted, 20_000);
|
duration.recordCompletion(submitted, 20_000);
|
||||||
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
|
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
|
||||||
@@ -114,14 +117,16 @@ describe('Planning duration estimate', () => {
|
|||||||
placeType: SelectableDayType.CP,
|
placeType: SelectableDayType.CP,
|
||||||
});
|
});
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const calculation = createPlanningCalculation(duration.request()!);
|
const calculation = createPlanningCalculation(
|
||||||
|
TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
);
|
||||||
duration.recordCompletion(calculation.snapshot, 20_000);
|
duration.recordCompletion(calculation.snapshot, 20_000);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
|
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
for (const elapsed of [20_000, 25_000, 900_000])
|
for (const elapsed of [20_000, 25_000, 900_000])
|
||||||
duration.recordCompletion(submitted, elapsed);
|
duration.recordCompletion(submitted, elapsed);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
|
||||||
@@ -136,7 +141,7 @@ describe('Planning duration estimate', () => {
|
|||||||
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
|
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const initial = duration.estimate();
|
const initial = duration.estimate();
|
||||||
duration.recordCompletion(duration.request()!, elapsed);
|
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
|
||||||
expect(duration.estimate()).toEqual(initial);
|
expect(duration.estimate()).toEqual(initial);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
|
||||||
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
||||||
import { RuleService } from './rule-service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { UserInputService } from './user-input-service';
|
|
||||||
|
|
||||||
export interface PlanningDurationRange {
|
export interface PlanningDurationRange {
|
||||||
minimum: number;
|
minimum: number;
|
||||||
@@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningDurationService {
|
export class PlanningDurationService {
|
||||||
private readonly settings = inject(CalendarSettingsService);
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
private readonly userInput = inject(UserInputService);
|
|
||||||
private readonly rules = inject(RuleService);
|
|
||||||
private readonly calibration = signal<readonly number[]>([]);
|
private readonly calibration = signal<readonly number[]>([]);
|
||||||
|
|
||||||
readonly request = computed(() => {
|
|
||||||
const horizon = this.settings.planningHorizon();
|
|
||||||
if (!horizon) return null;
|
|
||||||
return {
|
|
||||||
horizon,
|
|
||||||
declaredDays: this.userInput.declaredDaysSignal(),
|
|
||||||
balances: this.userInput.vacationNumberSignal().map((balance) => ({
|
|
||||||
id: balance.id,
|
|
||||||
type: balance.type,
|
|
||||||
quantity: balance.numberOfDays,
|
|
||||||
availableFrom: balance.availableDate
|
|
||||||
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
|
||||||
: undefined,
|
|
||||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
|
||||||
})),
|
|
||||||
rules: this.rules.rules,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
||||||
const request = this.request();
|
const request = this.calculations.request();
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
const factors = [...this.calibration()].sort((a, b) => a - b);
|
const factors = [...this.calibration()].sort((a, b) => a - b);
|
||||||
const middle = Math.floor(factors.length / 2);
|
const middle = Math.floor(factors.length / 2);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { RuleService, RuleType } from './rule-service';
|
import { RuleService, RuleType } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import { PlanningDurationService } from './planning-duration.service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { SelectableDayType } from './calendar-service';
|
import { SelectableDayType } from './calendar-service';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
@@ -260,7 +260,7 @@ describe('saved rule migration', () => {
|
|||||||
expiryDate: new Date('2024-01-05'),
|
expiryDate: new Date('2024-01-05'),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const request = TestBed.inject(PlanningDurationService).request()!;
|
const request = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
expect(request.rules).toHaveLength(6);
|
expect(request.rules).toHaveLength(6);
|
||||||
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
|
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
|
||||||
vi.stubGlobal('window', undefined);
|
vi.stubGlobal('window', undefined);
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ describe('UserInputService', () => {
|
|||||||
{ date: '2024-01-02', type: 'other' },
|
{ date: '2024-01-02', type: 'other' },
|
||||||
]);
|
]);
|
||||||
expect(service.vacationNumberSignal()).toBe(balances);
|
expect(service.vacationNumberSignal()).toBe(balances);
|
||||||
expect(service.computeTriggerSignal()).toBe(0);
|
|
||||||
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +118,6 @@ describe('UserInputService', () => {
|
|||||||
]);
|
]);
|
||||||
expect(save).toHaveBeenCalledTimes(3);
|
expect(save).toHaveBeenCalledTimes(3);
|
||||||
expect(service.vacationNumberSignal()).toBe(balances);
|
expect(service.vacationNumberSignal()).toBe(balances);
|
||||||
expect(service.computeTriggerSignal()).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restores an empty list when saved declarations are unreadable', () => {
|
it('restores an empty list when saved declarations are unreadable', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { SelectableDayType, VacDay } from './calendar-service';
|
import { SelectableDayType, VacDay } from './calendar-service';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
isWorkingPlanningDate,
|
isWorkingPlanningDate,
|
||||||
parseDeclaredDays,
|
parseDeclaredDays,
|
||||||
} from './declared-non-working-days';
|
} from './declared-non-working-days';
|
||||||
import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
|
import type { LeaveType } from './leave-planner.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -161,19 +161,6 @@ export class UserInputService {
|
|||||||
readonly vacationNumberSignal = signal<VacDay[]>(
|
readonly vacationNumberSignal = signal<VacDay[]>(
|
||||||
this.getSavedValues() ?? this.getInitialValues(),
|
this.getSavedValues() ?? this.getInitialValues(),
|
||||||
);
|
);
|
||||||
readonly planningOutcomeSignal = signal<PlanningOutcome | null>(null);
|
|
||||||
readonly planningSnapshotSignal = signal<PlanningSnapshot | null>(null);
|
|
||||||
readonly displayedPlanSignal = computed(() => {
|
|
||||||
const outcome = this.planningOutcomeSignal();
|
|
||||||
return outcome?.kind === 'Plan'
|
|
||||||
? outcome
|
|
||||||
: outcome?.kind === 'PlanningIncomplete'
|
|
||||||
? outcome.incumbent
|
|
||||||
: undefined;
|
|
||||||
});
|
|
||||||
readonly computeTriggerSignal = signal(0);
|
|
||||||
readonly computationRequestedAtMs = signal<number | null>(null);
|
|
||||||
|
|
||||||
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
|
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
|
||||||
this.vacationNumberSignal.update(updater);
|
this.vacationNumberSignal.update(updater);
|
||||||
try {
|
try {
|
||||||
@@ -193,9 +180,4 @@ export class UserInputService {
|
|||||||
console.warn('Failed to save leave balances to localStorage.', error);
|
console.warn('Failed to save leave balances to localStorage.', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public requestComputation(): void {
|
|
||||||
this.computationRequestedAtMs.set(performance.now());
|
|
||||||
this.computeTriggerSignal.update((count) => count + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user