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

+4 -12
View File
@@ -59,7 +59,7 @@ export interface PlanningRequest {
identities?: unknown;
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
labels?: unknown;
/** A testable deadline in milliseconds. The production default is ten seconds. */
/** Opt-in planning-work budget in milliseconds; omitted means run until completion or cancellation. */
deadlineMs?: number;
progress?: (stage: string) => void;
}
@@ -1235,8 +1235,6 @@ class UserCancelled extends Error {}
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
const DAY_MS = 86_400_000;
const MAX_PLANNING_MS = 10_000;
const CORE_DEADLINE_MARGIN_MS = 50;
// ponytail: cap the number of feasible obligation branches previewed for a
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
// that this ceiling misses a better lexicographic assignment.
@@ -5735,14 +5733,8 @@ function* calculatePlanningSteps(
calendar;
const started =
typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
const configuredLimit =
typeof source.deadlineMs === 'number' ? source.deadlineMs : MAX_PLANNING_MS;
const deadline = Math.min(Math.max(0, configuredLimit), MAX_PLANNING_MS);
// Leave a small scheduling margin so the synchronous SSR/test fallback also
// observes the public ten-second wall-clock ceiling; browser workers have a
// separate watchdog in planInWorker.
const coreDeadline =
deadline === MAX_PLANNING_MS ? deadline - CORE_DEADLINE_MARGIN_MS : deadline;
const deadline =
typeof source.deadlineMs === 'number' ? source.deadlineMs : Number.POSITIVE_INFINITY;
const context = {
horizon,
holidays,
@@ -5754,7 +5746,7 @@ function* calculatePlanningSteps(
workingDays,
initialBalances: normalized.balances,
rules: normalized.rules,
deadline: coreDeadline,
deadline,
started,
request: source,
nodes: 0,
+153 -38
View File
@@ -1079,6 +1079,63 @@ describe('planLeave', () => {
}
});
it.each([
['2026-01-01', undefined],
['2027-01-01', undefined],
['2027-01-01', 25_000],
] as const)(
'completes every objective on a horizon ending %s after more than ten seconds (budget %s)',
async (end, deadlineMs) => {
const request: PlanningRequest = {
deadlineMs,
horizon: { start: '2024-01-01', end },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-05-06',
expiryDate: '2024-05-10',
},
],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
],
};
const expected = isPlan(await runInNonBrowser(request));
let elapsed = 0;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
try {
const outcome = await runInNonBrowser({
...request,
progress: (stage) => {
if (stage === 'at-risk') queueMicrotask(() => (elapsed = 20_000));
},
});
expect(elapsed).toBeGreaterThan(10_000);
expect(outcome).toEqual(expected);
expect(expected.lastCompletedStage).toBe('dates');
expect(expected.placements).toContainEqual({
date: '2024-05-06',
balanceId: 'cp',
type: 'cp',
ruleIds: ['fixed'],
supporting: false,
});
} finally {
clock.mockRestore();
}
},
);
it('returns an incomplete outcome instead of a false conflict at an expired deadline', async () => {
const result = await planLeave({
horizon,
@@ -1134,6 +1191,53 @@ describe('planLeave', () => {
});
describe('planLeave transport', () => {
it('keeps a responsive phase active beyond repeated liveness windows and delivers its trace later', async () => {
const request = { horizon, balances: [], rules: [] };
const expected = isPlan(await runInNonBrowser(request));
const workers: ResponsiveWorker[] = [];
class ResponsiveWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage = vi.fn();
terminate = vi.fn();
constructor() {
workers.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
vi.stubGlobal('Worker', ResponsiveWorker);
vi.useFakeTimers();
try {
const calculation = createPlanningCalculation(request);
const worker = workers[0];
let outcomeReady = false;
let traceReady = false;
void calculation.outcome.then(() => (outcomeReady = true));
void calculation.trace.then(() => (traceReady = true));
for (let tick = 0; tick < 10; tick++) {
await vi.advanceTimersByTimeAsync(10_000);
worker.emit({ type: 'heartbeat' });
}
expect(outcomeReady).toBe(false);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit({ type: 'result', outcome: expected });
expect(await calculation.outcome).toEqual(expected);
await vi.advanceTimersByTimeAsync(60_000);
expect(traceReady).toBe(false);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit({ type: 'artifacts', artifacts: {} });
await calculation.trace;
expect(await calculation.outcome).toEqual(expected);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.terminate).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => {
const incumbent = isPlan(
await runInNonBrowser({
@@ -1400,45 +1504,52 @@ describe('planLeave transport', () => {
}
});
it('sends a detached, callback-free request to the worker transport', async () => {
const sourceDate = new Date('2024-01-01T00:00:00Z');
const request = {
horizon: { start: sourceDate, end: '2025-01-01' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 0,
availableFrom: sourceDate,
expiryDate: '2025-01-01',
},
],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
let posted: { type: string; request: PlanningRequest } | undefined;
class InspectingWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
it.each([undefined, 25_000])(
'sends a detached, callback-free request with budget %s',
async (deadlineMs) => {
const sourceDate = new Date('2024-01-01T00:00:00Z');
const request = {
deadlineMs,
horizon: { start: sourceDate, end: '2025-01-01' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 0,
availableFrom: sourceDate,
expiryDate: '2025-01-01',
},
],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
let posted: { type: string; request: PlanningRequest } | undefined;
class InspectingWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(message: { type: string; request: PlanningRequest }): void {
posted = structuredClone(message);
this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
postMessage(message: { type: string; request: PlanningRequest }): void {
posted = structuredClone(message);
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
}
terminate(): void {}
}
terminate(): void {}
}
vi.stubGlobal('Worker', InspectingWorker);
try {
const result = await planLeave(request);
expect(result.kind).toBe('Plan');
expect(posted?.request).not.toBe(request);
expect(posted?.request.progress).toBeUndefined();
expect(request.horizon.start).toBe(sourceDate);
} finally {
vi.unstubAllGlobals();
}
});
vi.stubGlobal('Worker', InspectingWorker);
try {
const result = await planLeave(request);
expect(result.kind).toBe('Plan');
expect(posted?.request).not.toBe(request);
expect(posted?.request.deadlineMs).toBe(deadlineMs);
expect(posted?.request.progress).toBeUndefined();
expect(request.horizon.start).toBe(sourceDate);
} finally {
vi.unstubAllGlobals();
}
},
);
it('strips legacy aliases at the worker boundary', async () => {
const request = {
@@ -3186,6 +3297,7 @@ describe('planning calculation context', () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', SlowTraceWorker);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] });
try {
const calculation = createPlanningCalculation(request);
let traceReady = false;
@@ -3197,15 +3309,18 @@ describe('planning calculation context', () => {
expect((await calculation.explanation)?.kind).toBe('PlanExplanation');
expect(traceReady).toBe(false);
const traceStarted = performance.now();
await vi.advanceTimersByTimeAsync(10_001);
await vi.runAllTimersAsync();
const loadedTrace = await tracePromise;
expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000);
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount);
expect(loadedTrace?.status).toBe('ready');
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
}, 15_000);
});
it('drains queued trace packets in batches of at most sixteen', async () => {
const request = {
+13 -22
View File
@@ -79,8 +79,7 @@ export type {
TraceCandidateStatus,
} from './leave-planner.core';
const MAX_PLANNING_MS = 10_000;
// Core owns the work deadline; this independent timer only recovers a stuck worker.
// Responsive workers renew this inactivity timer regardless of calculation duration.
const WORKER_LIVENESS_TIMEOUT_MS = 30_000;
const MAX_TRACE_UPDATES_PER_DRAIN = 16;
const TRACE_DRAIN_TIME_BUDGET_MS = 4;
@@ -263,11 +262,6 @@ function snapshotRequest(request: unknown): TransportSnapshot {
};
}
function deadlineFor(request: TransportSnapshot): number {
const configured = typeof request.deadlineMs === 'number' ? request.deadlineMs : undefined;
return Math.min(Math.max(0, configured ?? MAX_PLANNING_MS), MAX_PLANNING_MS);
}
function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined {
const deadline = request.deadlineMs;
if (
@@ -287,6 +281,7 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
}
type PlannerWorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
@@ -389,9 +384,8 @@ function fallbackTrace(
function interruptedOutcome(
checkpoint: PlanOutcome | undefined,
deadline: number,
reasonCode = 'deadline',
message = `Planning stopped after ${deadline}ms before all quality stages completed.`,
reasonCode: string,
message: string,
): PlanningIncompleteOutcome {
return {
kind: 'PlanningIncomplete',
@@ -413,7 +407,7 @@ function cancelLatestWorkers(): void {
latestHistoricalEligibilityCancel = undefined;
}
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
function planInWorker(snapshot: TransportSnapshot): PlanningRun {
let resolveOutcome!: (outcome: PlanningOutcome) => void;
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
@@ -492,7 +486,6 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
) {
cancelledRace = interruptedOutcome(
result.kind === 'Plan' ? result : checkpoint,
deadline,
'user-cancelled',
'The calculation was cancelled by the user.',
);
@@ -649,9 +642,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
message: 'Planning stopped because a newer calculation started.',
};
if (!hadOutcome)
finishOutcome(
interruptedOutcome(checkpoint, deadline, reason.code, reason.message),
);
finishOutcome(interruptedOutcome(checkpoint, reason.code, reason.message));
finishExplanation(
receivedExplanation,
hadOutcome ? receivedExplanationError : reason,
@@ -707,8 +698,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
};
let scheduleTraceDrain: () => void = () => {};
const fail = (code: string, message: string) => {
if (!outcomeSettled)
finishOutcome(interruptedOutcome(checkpoint, deadline, code, message));
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
pendingFailure ??= { code, message };
if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
else finalizeFailure(pendingFailure);
@@ -809,6 +799,10 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
if (message.type === 'heartbeat') {
scheduleLivenessWatchdog();
return;
}
if (message.type === 'progress') {
scheduleLivenessWatchdog();
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
@@ -891,7 +885,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
try {
worker.postMessage({
type: 'plan',
request: { ...snapshot, deadlineMs: deadline, progress: undefined },
request: { ...snapshot, progress: undefined },
});
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
@@ -899,7 +893,6 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
} catch {
const result = interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
);
@@ -1571,10 +1564,9 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
timings: Promise.resolve({}),
};
} else {
const deadline = deadlineFor(transport);
if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') {
run = planInWorker(transport, deadline);
run = planInWorker(transport);
} else {
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
@@ -1601,7 +1593,6 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
}
const result = interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
);
+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();
});
+7
View File
@@ -63,6 +63,7 @@ type WorkerRequest =
};
type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
@@ -301,7 +302,12 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
calculationComplete: false,
};
activeTraceDelivery = traceDelivery;
// The cooperative driver yields during preparation and search, even within one phase.
const heartbeat = () =>
worker.postMessage({ type: 'heartbeat' } satisfies WorkerResponse);
const heartbeatTimer = setInterval(heartbeat, 1000);
try {
heartbeat();
outcome = await calculatePlanningAsyncInternal(
{
...event.data.request,
@@ -351,6 +357,7 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
} satisfies WorkerResponse);
return;
} finally {
clearInterval(heartbeatTimer);
if (activeCalculation === calculation) activeCalculation = undefined;
}
if (!outcomePosted)
@@ -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);
});
});
@@ -0,0 +1,102 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarSettingsService } from './calendar-settings-service';
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
import { RuleService } from './rule-service';
import { UserInputService } from './user-input-service';
export interface PlanningDurationRange {
minimum: number;
maximum: number;
unit: 'seconds' | 'minutes';
}
function dateTime(value: PlanningDate): number {
const key =
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
return Date.parse(`${key}T00:00:00Z`);
}
function initialDurationMs(request: PlanningRequest): number {
const start = dateTime(request.horizon.start);
const end = dateTime(request.horizon.end);
const days = Math.max(1, (end - start) / 86_400_000);
let placements = 0;
for (const balance of request.balances) {
const available = balance.availableFrom ? dateTime(balance.availableFrom) : start;
const expiry = dateTime(balance.expiryDate);
if (available >= end || expiry < start || !Number.isFinite(balance.quantity)) continue;
const overlap = Math.max(
0,
(Math.min(end, expiry + 86_400_000) - Math.max(start, available)) / 86_400_000,
);
placements +=
Math.max(0, Math.min(balance.quantity, (overlap * 5) / 7)) * (expiry < end ? 1 : 0.25);
}
let ruleWeight = 1;
for (const rule of request.rules) {
if (rule.enabled === false) continue;
if (
'startDate' in rule &&
(dateTime(rule.startDate) >= end || dateTime(rule.endDate) < start)
)
continue;
ruleWeight += (rule.force === 'obligation' ? 0.5 : 0.15) * Math.max(1, rule.placeDays / 3);
if ('rewardDays' in rule) placements += (Math.max(0, rule.rewardDays) * days) / 365.25;
}
// Rough prior from the browser references in docs/planning-duration.md, not a deadline.
const duration =
300 + 600 * (days / 365.25) ** 1.2 * (Math.max(1, placements) / 30) ** 1.5 * ruleWeight;
return Number.isFinite(duration) ? duration : 1_000;
}
@Injectable({ providedIn: 'root' })
export class PlanningDurationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calibration = signal<readonly number[]>([]);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
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>(() => {
const request = this.request();
if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
const factor = factors.length
? (factors[middle] + factors[Math.floor((factors.length - 1) / 2)]) / 2
: 1;
const duration = Math.round(initialDurationMs(request) * factor);
const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
const divisor = unit === 'minutes' ? 60_000 : 1_000;
const minimum = Math.max(1, Math.floor(duration / 2 / divisor));
return {
minimum,
maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / divisor)),
unit,
};
});
recordCompletion(request: PlanningRequest, elapsedMs: number): void {
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return;
const factor = elapsedMs / initialDurationMs(request);
this.calibration.update((factors) => [...factors.slice(-7), factor]);
}
}
+2
View File
@@ -14,6 +14,8 @@ export interface AppTranslations {
description: string;
addVacationDay: string;
calculate: string;
durationEstimate: string;
durationUnits: { seconds: string; minutes: string };
remainingDaysWarning: string;
remainingDaysManualPlace: string;
deleteEntry: string;
+2
View File
@@ -107,6 +107,7 @@ export class UserInputService {
);
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly computeTriggerSignal = signal(0);
readonly computationRequestedAtMs = signal<number | null>(null);
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
@@ -129,6 +130,7 @@ export class UserInputService {
}
public requestComputation(): void {
this.computationRequestedAtMs.set(performance.now());
this.computeTriggerSignal.update((count) => count + 1);
}
}