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

+38 -7
View File
@@ -11,6 +11,9 @@ describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
let afterCheckpoint: (() => void) | undefined;
let heartbeats = 0;
let simulateLongCalculation = false;
let simulatedElapsed = 0;
const threads = new Set<NodeWorker>();
beforeAll(async () => {
@@ -19,8 +22,16 @@ describe('real planning worker cancellation', () => {
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort } from 'node:worker_threads';
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
`import { parentPort, workerData } from 'node:worker_threads';
let elapsed = 0;
const originalNow = performance.now.bind(performance);
if (workerData.simulateLongCalculation)
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
globalThis.self = { postMessage: (message) => {
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'at-risk')
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
parentPort.postMessage(message);
} };
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
@@ -30,13 +41,21 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
beforeEach(() => {
afterCheckpoint = undefined;
heartbeats = 0;
simulateLongCalculation = false;
simulatedElapsed = 0;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private thread = new NodeWorker(bundle);
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => {
if (data.type === 'test-clock') {
simulatedElapsed = data.elapsed;
return;
}
if (data.type === 'heartbeat') heartbeats++;
this.onmessage?.({ data } as MessageEvent);
if (data.type === 'checkpoint') afterCheckpoint?.();
});
@@ -63,7 +82,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const stages: string[] = [];
let preparationUpdates = 0;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
horizon: { start: '2024-01-01', end: '2027-01-01' },
balances: [],
rules: [],
progress: (stage) => {
@@ -82,6 +101,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(stages).not.toContain('obligations');
expect((await calculation.trace)?.status).toBe('incomplete');
expect(await calculation.explanationError).toBeUndefined();
expect(heartbeats).toBeGreaterThan(0);
});
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
@@ -219,16 +239,27 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(await calculation.outcome).toEqual(frozenOutcome);
});
it('completes the same deterministic plan as the synchronous adapter when not cancelled', async () => {
it('completes a three-year calculation after twenty simulated seconds with the same deterministic plan', async () => {
const submitted = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
horizon: { start: '2024-01-01', end: '2027-01-01' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 1,
availableFrom: '2026-12-24',
expiryDate: '2026-12-24',
},
],
rules: [],
};
const { calculatePlanningInternal } = await import('./leave-planner.core');
const expected = calculatePlanningInternal(submitted);
expect(expected).toMatchObject({ kind: 'Plan', lastCompletedStage: 'dates' });
simulateLongCalculation = true;
const calculation = createPlanningCalculation(submitted);
expect(await calculation.outcome).toEqual(expected);
expect(simulatedElapsed).toBe(20_000);
expect((await calculation.trace)?.status).toBe('ready');
expect(await calculation.explanationError).toBeUndefined();
});