feat(planning): follow the indicative wait in calculation progress

The overlay percentage now rises linearly to 95% at the expected wait
captured at Calculate, holds there, and shows 100% only when the planner
completes. Beneath the status, one line names the current planning stage
(with the rule name for rule stages) and another shows the approximate
remaining time, or "Taking longer than expected" once the wait is
exceeded, announced once through the live status. Cancel freezes the
percentage and hides both lines.

PlanningCalculationService reports the stage and submitted request
instead of fixed per-stage percentages; PlanningDurationService owns the
wait progress on a 250 ms tick.

Closes #300

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-19 10:45:28 +02:00
1 parent 35a63d24c4
commit 58be749949
14 files changed
+529 -45

No files matched your search

@@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { vi } from '../../testing/vitest-compat';
import { CalendarSettingsService } from './calendar-settings-service';
import { SelectableDayType } from './calendar-service';
import { CalendarService, SelectableDayType } from './calendar-service';
import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { createPlanningCalculation, type PlanningOutcome } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
@@ -145,3 +146,142 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(initial);
});
});
describe('Indicative wait progress', () => {
let now: number;
let startedAt: number;
let progress: (stage: string) => void;
let resolve: (outcome: PlanningOutcome) => void;
let reject: (error: Error) => void;
let duration: PlanningDurationService;
let calculations: PlanningCalculationService;
/** Starts a calculation whose expected wait is exactly `expectedMs`. */
function start(expectedMs: number): void {
duration['calibration'].set([]);
duration.recordCompletion(calculations.request()!, expectedMs);
startedAt = now;
calculations.start();
TestBed.tick();
}
function at(elapsedMs: number) {
now = startedAt + elapsedMs;
vi.advanceTimersByTime(250);
return duration.wait();
}
beforeEach(() => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.useFakeTimers();
now = 1_000;
vi.spyOn(performance, 'now').mockImplementation(() => now);
TestBed.configureTestingModule({
providers: [
{
provide: CalendarService,
useValue: {
createPlanningCalculation: (request: { progress: typeof progress }) => {
progress = request.progress;
return {
cancel: () => undefined,
snapshot: { rules: [], labels: {}, declaredDays: [] },
outcome: new Promise<PlanningOutcome>((res, rej) => {
resolve = res;
reject = rej;
}),
explanation: new Promise(() => undefined),
explanationError: new Promise(() => undefined),
};
},
},
},
],
});
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
duration = TestBed.inject(PlanningDurationService);
calculations = TestBed.inject(PlanningCalculationService);
});
afterEach(() => {
TestBed.resetTestingModule();
vi.useRealTimers();
vi.restoreAllMocks();
localStorage.clear();
});
it('climbs linearly to 95% at the expected wait, holds, then shows 100% on completion', () => {
start(20_000);
expect(at(0)).toEqual({
percent: 0,
remaining: { value: 20, unit: 'seconds' },
overrun: false,
});
expect(at(10_000)).toMatchObject({ percent: 47, overrun: false });
expect(at(19_999)).toMatchObject({ percent: 94, overrun: false });
expect(at(20_000)).toEqual({ percent: 95, remaining: null, overrun: true });
expect(at(600_000)).toEqual({ percent: 95, remaining: null, overrun: true });
progress('complete');
expect(duration.wait()).toEqual({ percent: 100, remaining: null, overrun: false });
});
it('shows the remaining time only for expected waits of at least 3 seconds', () => {
start(2_999);
expect(at(1_000)).toEqual({ percent: 31, remaining: null, overrun: false });
expect(at(2_999)).toEqual({ percent: 95, remaining: null, overrun: true });
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
});
it('rounds the remaining time to seconds, then 5-second steps, then minutes', () => {
start(3_000);
expect(at(0)!.remaining).toEqual({ value: 3, unit: 'seconds' });
expect(at(2_100)!.remaining).toEqual({ value: 1, unit: 'seconds' });
start(300_000);
const left = (seconds: number) => at(300_000 - seconds * 1_000)!.remaining;
expect(left(9.2)).toEqual({ value: 10, unit: 'seconds' });
expect(left(12.4)).toEqual({ value: 10, unit: 'seconds' });
expect(left(12.6)).toEqual({ value: 15, unit: 'seconds' });
expect(left(57.4)).toEqual({ value: 55, unit: 'seconds' });
expect(left(57.6)).toEqual({ value: 1, unit: 'minutes' });
expect(left(149)).toEqual({ value: 2, unit: 'minutes' });
expect(left(300)).toEqual({ value: 5, unit: 'minutes' });
});
it('keeps the expected wait frozen when the form or calibration change mid-calculation', () => {
start(20_000);
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2028-12-31');
duration.recordCompletion(calculations.request()!, 900_000);
expect(at(10_000)).toMatchObject({ percent: 47, remaining: { value: 10 } });
});
it('never moves backwards when the planner repeats stages', () => {
start(20_000);
const percents = ['obligations', 'preference:a', 'dates', 'preference:a', 'dates'].map(
(stage, index) => {
progress(stage);
return at(index * 5_000)!.percent;
},
);
expect(percents).toEqual([...percents].sort((a, b) => a - b));
expect(percents.at(-1)).toBe(95);
});
it('freezes on Cancel and never shows 100% after cancellation, incomplete outcomes or errors', async () => {
start(20_000);
at(10_000);
calculations.cancel();
TestBed.tick();
progress('complete');
expect(at(30_000)).toMatchObject({ percent: 47 });
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
await Promise.resolve();
expect(duration.wait()).toBeNull();
start(20_000);
at(25_000);
reject(new Error('Worker failed'));
await Promise.resolve();
await Promise.resolve();
expect(duration.wait()).toBeNull();
});
});