feat: add PlanningDurationService for estimating planning duration

- Implemented PlanningDurationService to provide an indicative wait time before calculation based on user input and calendar settings.
- Introduced duration estimation logic that considers horizon dates, balances, and rules without triggering a calculation.
- Enhanced user input component to display estimated duration using the new service.
- Updated translation files to include new duration estimate strings in multiple languages.
- Added tests for PlanningDurationService to ensure accurate duration estimation and calibration.
- Refactored leave planner service to remove hardcoded deadlines and improve worker responsiveness.
This commit is contained in:
nessar committed 2026-10-05 17:06:17 +02:00
1 parent 2edf2004f5
commit bc8570d8ca
29 files changed
+1064 -123

No files matched your search

@@ -0,0 +1,125 @@
import { TestBed } from '@angular/core/testing';
import { CalendarSettingsService } from './calendar-settings-service';
import { SelectableDayType } from './calendar-service';
import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
describe('Planning duration estimate', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem('vacationRules', '[]');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
TestBed.inject(UserInputService).updateBalances(() => [
{
id: 'cp',
type: SelectableDayType.CP,
numberOfDays: 30,
availableDate: new Date('2024-01-01T00:00:00'),
expiryDate: new Date('2024-12-31T00:00:00'),
},
]);
});
afterEach(() => {
TestBed.resetTestingModule();
localStorage.clear();
});
it('offers a coarse seconds range on the first launch without calculating', () => {
const duration = TestBed.inject(PlanningDurationService);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0);
expect(TestBed.inject(RuleService).rules).toEqual([]);
});
it('updates for dates, balances and active rules without requesting a calculation', () => {
const duration = TestBed.inject(PlanningDurationService);
const settings = TestBed.inject(CalendarSettingsService);
const input = TestBed.inject(UserInputService);
const initial = duration.estimate()!;
settings.setPlanningDates('2024-01-01', '2026-12-31');
const longer = duration.estimate()!;
expect(longer.maximum).toBeGreaterThan(initial.maximum);
input.updateBalances((balances) =>
balances.map((balance) => ({ ...balance, numberOfDays: 120 })),
);
const moreLeave = duration.estimate()!;
expect(moreLeave.maximum).toBeGreaterThan(longer.maximum);
const rules = TestBed.inject(RuleService);
rules.addRule({
id: 'window',
name: 'Spring',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'obligation',
startDate: new Date('2024-04-01T00:00:00'),
endDate: new Date('2024-04-30T00:00:00'),
placeDays: 15,
placeType: SelectableDayType.CP,
});
expect(duration.estimate()!.maximum).toBeGreaterThan(moreLeave.maximum);
rules.toggleRule('window');
expect(duration.estimate()).toEqual(moreLeave);
settings.setPlanningDates('2026-12-31', '2024-01-01');
expect(duration.estimate()).toBeNull();
expect(input.computeTriggerSignal()).toBe(0);
});
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
duration.recordCompletion(submitted, 120_000);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
});
it('calibrates against the submitted snapshot even when the form has since changed', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
duration.recordCompletion(submitted, 20_000);
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
it('calibrates the same configuration after Date rules become ISO strings in the immutable snapshot', () => {
TestBed.inject(RuleService).addRule({
id: 'future-rule',
name: 'Outside the historical horizon',
type: RuleType.TIME_WINDOW,
force: 'preference',
enabled: true,
startDate: new Date(2026, 3, 1),
endDate: new Date(2026, 3, 30),
placeDays: 60,
placeType: SelectableDayType.CP,
});
const duration = TestBed.inject(PlanningDurationService);
const calculation = createPlanningCalculation(duration.request()!);
duration.recordCompletion(calculation.snapshot, 20_000);
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', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
for (const elapsed of [20_000, 25_000, 900_000])
duration.recordCompletion(submitted, elapsed);
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
TestBed.resetTestingModule();
expect(TestBed.inject(PlanningDurationService).estimate()).toEqual({
minimum: 1,
maximum: 3,
unit: 'seconds',
});
});
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
const duration = TestBed.inject(PlanningDurationService);
const initial = duration.estimate();
duration.recordCompletion(duration.request()!, elapsed);
expect(duration.estimate()).toEqual(initial);
});
});