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:
1 parent
2edf2004f5
commit
bc8570d8ca
29 files changed
+1064
-123
No files matched your search
@@ -28,6 +28,7 @@ import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { RuleService, RuleType } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
|
||||
const invalid: InvalidInputOutcome = {
|
||||
@@ -404,6 +405,192 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('calibrates the wait through calendar rendering while a long trace remains pending', async () => {
|
||||
let now = 1_000;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||
try {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
const pending = deferred<PlanningOutcome>();
|
||||
const trace = deferred<PlanningTrace | undefined>();
|
||||
const { calendar, calculation } = calendarFor(
|
||||
plan,
|
||||
undefined,
|
||||
undefined,
|
||||
trace.promise,
|
||||
);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
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'),
|
||||
},
|
||||
]);
|
||||
const duration = TestBed.inject(PlanningDurationService);
|
||||
const submitted = duration.request()!;
|
||||
calendar.createPlanningCalculation.mockReturnValue({
|
||||
...calculation,
|
||||
outcome: pending.promise,
|
||||
snapshot: { ...calculation.snapshot, ...submitted },
|
||||
});
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
now = 21_000;
|
||||
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
|
||||
expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(0);
|
||||
pending.resolve(plan);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||
now = 321_000;
|
||||
trace.reject(new Error('Late trace delivery failed'));
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['cancel before a plan', 'cancel with a plan', 'incomplete', 'error'])(
|
||||
'excludes the unfinished duration after %s',
|
||||
async (mode) => {
|
||||
let now = 1_000;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||
try {
|
||||
const pending = deferred<PlanningOutcome>();
|
||||
const outcome: PlanningIncompleteOutcome = {
|
||||
...incomplete,
|
||||
incumbent: mode === 'cancel with a plan' ? plan : undefined,
|
||||
reasons: [
|
||||
{
|
||||
code: mode.startsWith('cancel') ? 'user-cancelled' : 'deadline',
|
||||
message: mode,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { calendar, calculation } = calendarFor(outcome);
|
||||
calendar.createPlanningCalculation.mockReturnValue({
|
||||
...calculation,
|
||||
outcome: pending.promise,
|
||||
});
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
const duration = TestBed.inject(PlanningDurationService);
|
||||
const initial = duration.estimate();
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
now = 61_000;
|
||||
if (mode.startsWith('cancel'))
|
||||
fixture.nativeElement.querySelector('.cancel-calculation').click();
|
||||
if (mode === 'error') pending.reject(new Error('Worker failed'));
|
||||
else pending.resolve(outcome);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(duration.estimate()).toEqual(initial);
|
||||
expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull();
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores a replaced calculation duration and measures the latest submitted horizon', async () => {
|
||||
let now = 1_000;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||
try {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
const old = deferred<PlanningOutcome>();
|
||||
const latest = deferred<PlanningOutcome>();
|
||||
const { calendar, calculation } = calendarFor(plan);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
const duration = TestBed.inject(PlanningDurationService);
|
||||
calendar.createPlanningCalculation.mockImplementation(() => ({
|
||||
...calculation,
|
||||
snapshot: { ...calculation.snapshot, ...duration.request()! },
|
||||
outcome:
|
||||
calendar.createPlanningCalculation.mock.calls.length === 1
|
||||
? old.promise
|
||||
: latest.promise,
|
||||
}));
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
now = 11_000;
|
||||
settings.setPlanningDates('2024-01-01', '2026-12-31');
|
||||
input.requestComputation();
|
||||
now = 12_000; // Rendering/scheduling after the request is part of the wait.
|
||||
fixture.detectChanges();
|
||||
const initial = duration.estimate();
|
||||
now = 31_000;
|
||||
old.resolve(plan);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(duration.estimate()).toEqual(initial);
|
||||
expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull();
|
||||
latest.resolve({ ...plan, horizon: { start: '2024-01-01', end: '2027-01-01' } });
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(36);
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
|
||||
fixture.destroy();
|
||||
const revisitedOutcome = deferred<PlanningOutcome>();
|
||||
calendar.createPlanningCalculation.mockReturnValueOnce({
|
||||
...calculation,
|
||||
snapshot: { ...calculation.snapshot, ...duration.request()! },
|
||||
outcome: revisitedOutcome.promise,
|
||||
});
|
||||
now = 631_000; // Returning to the calendar is a fresh initial calculation.
|
||||
const revisited = TestBed.createComponent(CalendarYear);
|
||||
revisited.detectChanges();
|
||||
now = 651_000;
|
||||
revisitedOutcome.resolve({
|
||||
...plan,
|
||||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||
});
|
||||
await revisited.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
revisited.detectChanges();
|
||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||||
async (withIncumbent) => {
|
||||
@@ -1361,8 +1548,18 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
});
|
||||
|
||||
it('announces trace loading and completion while keeping the completed plan available', async () => {
|
||||
const outcomeDelivery = deferred<PlanningOutcome>();
|
||||
const traceDelivery = deferred<PlanningTrace | undefined>();
|
||||
const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise);
|
||||
const { calendar, calculation } = calendarFor(
|
||||
plan,
|
||||
explanation,
|
||||
undefined,
|
||||
traceDelivery.promise,
|
||||
);
|
||||
calendar.createPlanningCalculation.mockReturnValue({
|
||||
...calculation,
|
||||
outcome: outcomeDelivery.promise,
|
||||
});
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
@@ -1378,6 +1575,17 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('.loading-overlay')).not.toBeNull();
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
|
||||
expect(calendar.selectedDatesForPlan).not.toHaveBeenCalled();
|
||||
expect(root.querySelector('.cancel-calculation')).not.toBeNull();
|
||||
outcomeDelivery.resolve(plan);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const plannedButtons = Array.from(root.querySelectorAll('button.selected'));
|
||||
expect(plannedButtons).toHaveLength(2);
|
||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||
const liveMessage = root.querySelector<HTMLElement>('.calendar-year > p[aria-live]')!;
|
||||
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
||||
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
|
||||
@@ -1392,6 +1600,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(liveMessage.textContent).toContain('decision trace has loaded');
|
||||
expect(Array.from(root.querySelectorAll('button.selected'))).toEqual(plannedButtons);
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||
expect(calendar.selectedDatesForPlan).toHaveBeenCalledOnce();
|
||||
await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull());
|
||||
root.querySelector<HTMLButtonElement>(
|
||||
'.trace-decisions nav button[aria-label="Next decision"]',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
@@ -18,7 +20,6 @@ import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import dayjs from 'dayjs';
|
||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
||||
@@ -30,10 +31,9 @@ import {
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
} from '../../services/calendar-service';
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||
import {
|
||||
applyReplayDeltas,
|
||||
CounterfactualAnalysis,
|
||||
@@ -80,8 +80,8 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
|
||||
export class CalendarYear {
|
||||
private readonly userInput = inject(UserInputService);
|
||||
private readonly calendarService = inject(CalendarService);
|
||||
private readonly ruleService = inject(RuleService);
|
||||
private readonly calendarSettings = inject(CalendarSettingsService);
|
||||
private readonly duration = inject(PlanningDurationService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -453,32 +453,27 @@ export class CalendarYear {
|
||||
}
|
||||
|
||||
private startCalculation(): void {
|
||||
const horizon = this.calendarSettings.planningHorizon();
|
||||
if (!horizon) return;
|
||||
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
|
||||
this.userInput.computationRequestedAtMs.set(null);
|
||||
const request = this.duration.request();
|
||||
if (!request) return;
|
||||
const { horizon, rules: currentRules } = request;
|
||||
this.planningHorizon.set(horizon);
|
||||
this.resetCalculationState(horizon);
|
||||
const vacationData = this.userInput.vacationNumberSignal();
|
||||
const currentRules = this.ruleService.rules;
|
||||
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({
|
||||
horizon,
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
...request,
|
||||
balances: request.balances.map((balance) => ({
|
||||
...balance,
|
||||
label: this.translationService.getDayTypeLabel(balance.type),
|
||||
availableFrom: balance.availableDate
|
||||
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||
: undefined,
|
||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||
})),
|
||||
rules: currentRules,
|
||||
labels: Object.fromEntries(
|
||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||
),
|
||||
@@ -495,7 +490,9 @@ export class CalendarYear {
|
||||
this.loadArtifacts(calculation, version);
|
||||
void calculation.outcome
|
||||
.then((outcome) => {
|
||||
if (version === this.calculationVersion) this.applyOutcome(outcome);
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome(outcome);
|
||||
completed = outcome.kind === 'Plan' && !this.isCancelling();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
@@ -519,6 +516,17 @@ export class CalendarYear {
|
||||
this.isLoading.set(false);
|
||||
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
||||
this.isCancelling.set(false);
|
||||
if (completed && !this.destroyRef.destroyed)
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (version === this.calculationVersion && !this.destroyRef.destroyed)
|
||||
this.duration.recordCompletion(
|
||||
calculation.snapshot,
|
||||
performance.now() - requestedAt,
|
||||
);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user