- 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.
7.9 KiB
Cancelling a planning calculation
Issue #58 implements the cancellation portion of ADR 0006. Issue #59 removes the automatic deadline while keeping the same cancellation and original-trace protocol. The duration estimate remains in issue #60.
PlanningCalculationContext.cancel() requests user cancellation once while its outcome is pending. CalendarYear keeps an accessible Cancel button visible during preparation, obligation search, and optimization. It announces the request, disables duplicate cancellation, and moves keyboard focus to the result heading when the outcome arrives. Cancellation never triggers Calculate. A subsequent explicit Calculate creates a fresh immutable calculation snapshot.
Stopping CPU work
The core exposes resumable generator steps. calculatePlanningAsyncInternal drives those same steps in roughly eight-millisecond slices and yields to the worker event loop at phase boundaries and between preparation years. A cancel message sets the active run's cancellation flag; the next resume throws the cancellation signal into the suspended calculation. Recursive obligation and preference searches, quality passes, and calendar preparation unwind through their existing cleanup blocks. An individual synchronous operation, such as the holiday library's work for one year, finishes before the next pause.
The synchronous calculatePlanningInternal adapter drains the same steps without pauses for existing direct core callers. The public non-browser calculation context uses the asynchronous driver so its cancellation method also works.
User cancellation is distinct from replacement by another calculation (stale-calculation) and isolated worker failure (worker-error or worker-unavailable). Replacement retains the existing termination and stale-response protection. User cancellation leaves the producing worker alive until its explanation and trace have been delivered.
Retaining a valid plan
The asynchronous calculation retains a copied incumbent as soon as the obligation search finds a jointly valid solution, before that entire stage has completed. It also considers accepted placements and every feasible evaluated quality candidate, including candidates rejected by a stage's narrower comparator. Each candidate is reconciled for earned entitlement availability and checked against all obligation rules before retention. The existing full lexicographic comparator chooses the best available incumbent; its comparisons are recorded in the original trace.
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone, with the actual short-circuited comparison recorded in the trace. This avoids rescoring dominated states during large obligation previews without skipping any produced history or a candidate that could improve the retained plan.
Preference occurrence dates and patterns are calculated once for the run's immutable calendar and rules. Each comparison still checks the candidate's actual placements against those patterns; candidate scores and trace records are not cached.
On cancellation, the core materializes the retained state with consistent placements, balances, periods, rule provenance, and preference satisfaction. It returns PlanningIncomplete with reason user-cancelled. With an incumbent the French status is Calcul annulé — plan partiel; without one it is Calcul annulé, without a plan or an obligation conflict. Temporary search states never become the displayed incumbent.
Preserving the original trace
Cancellation follows the core's normal finish path. Quality evaluations are recorded before their incumbent comparisons, and each greedy placement attempt starts its own atomic decision. The recorder closes interrupted and unexecuted phases and drains every remaining operation from that run. The worker retains staged updates, including ones not yet posted, and continues its bounded, acknowledged trace delivery after optimization has stopped. It sends artifacts-complete only when all queued updates have been sent and acknowledged. The facade drains its own received update queue before resolving the trace. Neither layer replays planning to reconstruct this history.
The stable explanation is derived from the retained state. The outcome, explanation, and trace have separate promises: delayed or failed artifact delivery cannot replace or remove an already settled valid partial plan. Repeated and late outcome messages cannot change that outcome, and calculation identities protect the UI against obsolete results and artifacts.
If Cancel is accepted just before a queued natural result reaches the facade, the pending outcome still becomes a user cancellation. An already computed valid plan can be retained, while a queued conflict cannot become the cancellation result. Its existing explanation receives interruption metadata, and the original phase history is preserved even when the worker had already completed those phases.
Verification
Run the focused checks with the project's installed Bun and Node:
bunx vitest run src/app/services/leave-planner-cancellation.spec.ts
bunx vitest run src/app/services/leave-planner.worker.spec.ts
bunx vitest run src/app/components/calendar-year/calendar-year-planning.spec.ts
bunx tsc --noEmit -p tsconfig.app.json
bunx tsc --noEmit -p tsconfig.spec.json
The public cancellation tests cover immediate cancellation, queued trace updates on both sides of the request, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. A core regression compares the retained plan's full objective vector with every valid candidate actually evaluated before cancellation. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete trace delivery.
The real-worker suite bundles the production worker source with Bun and runs it on a separate Node worker thread. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and acknowledged trace protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and complete trace delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
The production build was checked in native Chromium for all three interruption points, including keyboard cancellation during a 375px dark-theme obligation search and desktop light-theme checks. An editable input could still receive focus during CPU work, and focus moved to the result heading after keyboard cancellation. Each run sent one planning request and one cancellation request, displayed the appropriate French status, and completed acknowledged artifact delivery. The Cancel control was 44px high and the page had no horizontal overflow. In these sample runs, cancellation through artifact completion took under 200ms; this is measured evidence, not a latency guarantee.