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
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user