From bc8570d8caa415400d02d56a7e577d9ae4d24d63 Mon Sep 17 00:00:00 2001 From: Nessar Date: Mon, 5 Oct 2026 17:06:17 +0200 Subject: [PATCH] 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. --- README.md | 12 +- ...constraint-first-lexicographic-planning.md | 2 +- ...plan-explanation-from-calculation-trace.md | 2 +- ...planation-original-trace-counterfactual.md | 2 +- ...d-horizons-and-cancellable-calculations.md | 4 +- docs/benchmark/README.md | 156 ++++++++++++- docs/planner-cancellation.md | 2 +- docs/planning-duration.md | 109 +++++++++ .../benchmark/leave-planner-benchmark.spec.ts | 32 ++- src/app/benchmark/leave-planner-benchmark.ts | 31 ++- .../calendar-year-planning.spec.ts | 213 +++++++++++++++++- .../components/calendar-year/calendar-year.ts | 48 ++-- src/app/components/user-input/user-input.html | 7 +- src/app/components/user-input/user-input.scss | 8 + .../components/user-input/user-input.spec.ts | 14 ++ src/app/components/user-input/user-input.ts | 12 + src/app/services/leave-planner.core.ts | 16 +- .../services/leave-planner.service.spec.ts | 191 ++++++++++++---- src/app/services/leave-planner.service.ts | 35 ++- src/app/services/leave-planner.worker.spec.ts | 45 +++- src/app/services/leave-planner.worker.ts | 7 + .../planning-duration.service.spec.ts | 125 ++++++++++ src/app/services/planning-duration.service.ts | 102 +++++++++ src/app/services/translation-service.ts | 2 + src/app/services/user-input-service.ts | 2 + src/i18n/de.json | 2 + src/i18n/en.json | 2 + src/i18n/es.json | 2 + src/i18n/fr.json | 2 + 29 files changed, 1064 insertions(+), 123 deletions(-) create mode 100644 docs/planning-duration.md create mode 100644 src/app/services/planning-duration.service.spec.ts create mode 100644 src/app/services/planning-duration.service.ts diff --git a/README.md b/README.md index 1718099..757f12b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ Leave calculation stays in the browser. The production Express process serves th 4. `leave-planner.worker.ts` runs the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and original trace. 5. `CalendarYear` ignores stale calculations, renders the selected dates, and reports remaining balances or errors. -Planning work has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict. Explanation and trace delivery happen after the outcome and do not consume that planning budget. During preparation and optimization, Cancel interrupts the worker cooperatively and preserves its best jointly valid candidate as a cancelled, incomplete calculation. A cancellation before a valid candidate reports no plan. The original trace continues draining after optimization stops, and a late artifact error never removes the retained plan. See [the cancellation mechanism and checks](docs/planner-cancellation.md). +Calculations run until all objectives complete or the user cancels; there is no automatic elapsed-time cutoff. During preparation and optimization, Cancel interrupts the worker cooperatively and preserves its best jointly valid candidate as a cancelled, incomplete calculation. A cancellation before a valid candidate reports no plan. The final calendar is delivered before the explanation and complete original trace, whose later arrival or failure cannot change the retained plan. Worker heartbeats distinguish responsive long phases from a worker that stops responding. See [the cancellation mechanism and checks](docs/planner-cancellation.md). + +Technical callers may explicitly supply `deadlineMs`, a finite non-negative planning-work budget of any size. Omission means no deadline; the calendar and benchmark never supply one. This opt-in budget starts after calendar preparation and excludes trace capture, observer callbacks, and post-outcome artifact work. Expiry returns `PlanningIncomplete` with the latest valid checkpoint, if any, rather than a conflict. It is independent of worker liveness monitoring. ## Planning model @@ -54,7 +56,7 @@ A request contains: - a user-selected half-open planning horizon, including past dates and multiple years; - CP, RTT, or Other leave balances with quantities, availability dates, and expiry dates; - ordered `TIME_WINDOW` and `IF_THEN` rules; -- an optional deadline and progress callback. +- an optional explicit technical deadline and progress callback. Rules are either: @@ -76,6 +78,8 @@ All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/ Settings save a fixed start date and an inclusive last planning date. The planner's exclusive end is the following calendar date, so matching settings dates cover one day. Without saved dates, the default is today in Europe/Paris through the same date twelve months later, exclusive (February 29 uses February 28 in the following year). Invalid edits prevent calculation and preserve the last valid saved dates. Changes to dates, balances, and rules apply to the displayed calendar on the next explicit Calculate action. +An indicative range beside Calculate follows the current dates, balances, and rules. Completed plans refine it locally for the current browser session using request-to-rendered-calendar time; later trace delivery is excluded. The range never stops a calculation. See [the duration model and measurement contract](docs/planning-duration.md). + ## Optimization pipeline The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization: @@ -155,7 +159,7 @@ The `/benchmark` route and planner core are lazy-loaded, so the normal applicati ```text src/app/ -├── benchmark/ # Lazy five-scenario performance harness +├── benchmark/ # Lazy seven-scenario performance harness ├── components/ # Calendar, inputs, rules, and settings UI ├── services/ │ ├── leave-planner.service.ts # Canonical async planning boundary @@ -197,7 +201,7 @@ Open . The development server reloads when source files c ## Performance benchmark -Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, and a loaded year. It reports preparation time, planning latency, separate trace-delivery latency, total wall time, outcome, last completed stage, placement count, determinism, and trace size/status. +Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, separate trace-delivery latency, total wall time, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, determinism, and trace size/status. The release protocol and reference-phone thresholds are documented in [docs/benchmark/README.md](docs/benchmark/README.md). Phone timings are release evidence, not CI assertions. diff --git a/docs/adr/0003-constraint-first-lexicographic-planning.md b/docs/adr/0003-constraint-first-lexicographic-planning.md index 98dcbff..175b752 100644 --- a/docs/adr/0003-constraint-first-lexicographic-planning.md +++ b/docs/adr/0003-constraint-first-lexicographic-planning.md @@ -1,5 +1,5 @@ # Constraint-First Lexicographic Planning Without a Solver Dependency -The ten-second automatic deadline is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), accepted for issue #12 and awaiting implementation. +The ten-second automatic deadline is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff. VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001. diff --git a/docs/adr/0004-separate-plan-explanation-from-calculation-trace.md b/docs/adr/0004-separate-plan-explanation-from-calculation-trace.md index cc7ca9b..f5badba 100644 --- a/docs/adr/0004-separate-plan-explanation-from-calculation-trace.md +++ b/docs/adr/0004-separate-plan-explanation-from-calculation-trace.md @@ -1,5 +1,5 @@ # Separate Plan Explanations from Calculation Traces -The automatic planning deadline referenced below is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), accepted for issue #12 and awaiting implementation. +The automatic planning deadline referenced below is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff. VacOptimizer provides a stable plan explanation separately from the complete, untruncated calculation trace captured during the same planning run; on-demand counterfactual date analysis is identified as hypothetical and never presented as trace history. Explanation and tracing must not alter the planning outcome, so trace instrumentation is excluded from the planner deadline and the result may appear before the latest run's in-memory trace finishes loading; this narrows ADR 0003's ten-second hard limit to planning work rather than end-to-end trace delivery. This accepts additional transient memory and wall-clock time instead of inventing scalar day scores, truncating history, or reconstructing a trace by replay. diff --git a/docs/adr/0004-stable-explanation-original-trace-counterfactual.md b/docs/adr/0004-stable-explanation-original-trace-counterfactual.md index 91ffaa1..a937272 100644 --- a/docs/adr/0004-stable-explanation-original-trace-counterfactual.md +++ b/docs/adr/0004-stable-explanation-original-trace-counterfactual.md @@ -1,6 +1,6 @@ # Stable Explanation, Original Trace, and Counterfactuals -The automatic planning deadline referenced below is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), accepted for issue #12 and awaiting implementation. +The automatic planning deadline referenced below is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff. VacOptimizer exposes one client-side calculation context. Its immutable submitted snapshot and planning outcome are independent from three explanation concerns: diff --git a/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md b/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md index defd431..5014cd6 100644 --- a/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md +++ b/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md @@ -1,6 +1,8 @@ # User-Selected Horizons and Cancellable Calculations -Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58). The duration estimate and removal of the automatic calculation deadline remain pending. +Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58); removal of the automatic calculation deadline and heartbeat-based worker liveness are implemented by [issue #59](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/59). The indicative duration estimate is implemented by [issue #60](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/60); its initial model, session-local refinement and calendar-rendering measurement are documented in [planning-duration.md](../planning-duration.md). + +`deadlineMs` remains an opt-in contract for technical callers: a finite non-negative planning-work budget with no ten-second cap. An omitted value is unlimited. It starts after calendar preparation and excludes trace capture, observer callbacks, and post-outcome artifact work. The calendar and benchmark omit it. A one-second worker heartbeat renews the coordinator's thirty-second inactivity timer during cooperative preparation and search; total elapsed time never triggers that timer while activity continues. Worker errors and unavailable execution retain their explicit outcomes, and monitoring ends when the outcome arrives so delayed trace delivery cannot invalidate it. VacOptimizer accepts explicitly selected, fixed planning horizons whose start may be past and whose duration may exceed twelve months, retaining today through the same date twelve months later as the default. Calculations remain in the browser and run to completion without an automatic elapsed-time cutoff, with an indicative duration estimate and an explicit Cancel action; this accepts variable waiting time and resource use to make longer horizons usable without silently changing the user's dates. This supersedes ADR 0003's ten-second automatic planning limit and its retention in both ADR 0004 documents, while preserving the separation between the planning outcome, stable explanation, and original trace. diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index cf85234..7e9cd42 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -1,7 +1,7 @@ # Mobile leave-planner benchmark Open the deployed application at `/benchmark` on the reference phone. Tap -**Run benchmark**, wait for the five scenarios to finish, then tap **Copy +**Run benchmark**, wait for the seven scenarios to finish, then tap **Copy report** and save the tab-separated output with the date and build identifier. The harness measures preparation, planning-to-outcome latency, later trace @@ -22,18 +22,18 @@ stage and incumbent are retained. warm-up, then records three measured repetitions per scenario. Do not interact with the page while a run is active. The first measured result is the determinism baseline; later repetitions report whether they match it. -- The original target was below five seconds for representative and loaded - scenarios, with a ten-second planning ceiling for every scenario. The owner - accepted six to seven seconds for the loaded scenario on 2026-09-25. These - are release measurements, not CI timing assertions. +- The scenarios include two and three calendar years with separately dated + annual CP and RTT balances. Let these runs finish or cancel explicitly; + there is no automatic calculation cutoff and no validated duration ceiling + for multi-year horizons. The historical annual targets and release exception + below do not establish multi-year performance. - Keep the copied report unchanged, including any incomplete outcomes or hardware limitations. No mobile-device measurement is claimed by the source tree until a human records it here. The browser page uses the same `createPlanningCalculation(request)` context as -the calendar and adds no solver or backend dependency. The ten-second release -ceiling applies to planning work; trace delivery is reported separately and -cannot replace a valid planning outcome. +the calendar and adds no solver or backend dependency. It omits `deadlineMs`. +Trace delivery is reported separately and cannot replace a valid planning outcome. `planning_ms` is the observed latency until the outcome is usable; `planning_work_ms` excludes trace capture. `trace_capture_ms` accounts for @@ -42,7 +42,24 @@ building the stable explanation and finishing the trace after the outcome. `trace_transfer_ms` measures worker transfer and receiver assembly work; `trace_delivery_ms` measures elapsed time until the complete trace is ready. `max_ui_delay_ms` includes planning, trace delivery, and report preparation. Trace readiness has no -ten-second ceiling and never permits truncating actual candidate evaluations. +elapsed-time ceiling and never permits truncating actual candidate evaluations. + +`peak_main_thread_heap_bytes` is Chromium's available `performance.memory` +sample, taken alongside the 50 ms UI heartbeat. A blank value means this API is +unavailable. This measures the page's JS heap, excludes the worker heap, and is +not a claim about total application memory. For a browser profile, record the +isolated Chrome process-tree RSS separately during planning and subsequent +trace delivery, including the worker and renderer. State the sampling interval, +CPU throttling, viewport, and whether a warm-up preceded each measurement. + +For cancellation measurements, use the normal calendar with the same saved +two- or three-year dates and balances. Cancel during preparation and an active +optimization phase, using the keyboard in one run. Record request-to-outcome +latency separately from request-to-artifacts latency; verify the partial-plan +status or absence of a plan, one planning request, no placements before the +outcome, and continued acknowledged delivery of the original trace. Confirm +that editing an input remains possible during active worker CPU work. These +measurements describe that device and workload; they impose no latency limit. ## Hardware status @@ -52,6 +69,127 @@ six-to-seven-second loaded planning time and the observed post-result trace UI pause. This is an explicit release exception, not a reference-phone measurement or proof of physical-device responsiveness. +## Long-horizon calendar verification — 2026-10-05 + +Working implementation of issue #59 based on +`2edf2004f56f8a453a691846f3c0e6d960e27911`, using the production browser build, +native Chromium 149.0.7827.55 Web Workers, and an AMD Ryzen 7 7800X3D desktop. +The host ran Debian 13 under WSL2 (kernel 6.18.33.2-microsoft-standard-WSL2). +Headless browser emulation used 412×915 pixels, scale 2.625, touch enabled, +reduced motion, and 4× CPU throttling. Each scenario used a fresh browser and +one cold run, with no warm-up. Other validation commands were kept out of these +runs. This is desktop evidence, not a reference-phone performance claim. + +The saved dates covered 2024-01-01 through 2025-12-31 or 2026-12-31 inclusive. +Each year supplied 20 CP and 10 RTT days available January 1 and expiring +December 31, matching the new benchmark scenarios. No technical deadline was +supplied. Both completed plans finished through the `dates` objective, consumed +all balances, and displayed no intermediate placements. The calendar appeared +before the full trace; its placement dates were identical after trace readiness, +and each flow sent exactly one planning request. All 1,949 and 3,956 trace +packets respectively were acknowledged, carrying 122,621 and 249,234 candidate +records without truncation. Heartbeats continued during the producing runs. + +| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace | +| --- | --- | --- | --- | --- | --- | --- | +| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB | +| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB | + +A 50 ms page timer measured extra event-loop delay and sampled available +main-thread JS heap. RSS was sampled every 500 ms across that isolated Chrome +process tree, including browser, renderer, worker, and auxiliary processes; +it is resident process memory, not worker JS heap. The timer delays include +initial multi-year calendar rendering and later artifact work. These pauses +and memory costs remain visible limitations; no duration or responsiveness +threshold is inferred from these samples. + +Two additional cold three-year runs used the same balances in dark mode. +Cancellation during preparation returned no plan and no obligation conflict. +Keyboard cancellation during the holiday-week pass retained a valid 90-placement +incumbent marked **Calcul annulé — plan partiel**, with `at-risk` as the last +completed stage. An editable input accepted focus while the worker remained +active. Both flows kept a 44 px Cancel control, fit the viewport without +horizontal overflow, and delivered the original trace after their outcome. + +| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements | +| --- | --- | --- | --- | +| Calendar preparation | 21.9 ms | 874.8 ms | 0 | +| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 | + +No browser errors occurred. Cancellation issued one cancel request and no +additional calculation. Separate tests cover late artifact errors and stale +outcomes, explanations, and traces, preserving the latest displayed plan. +Elapsed-time tests advance clocks beyond ten seconds without waiting in CI, +including inside the real bundled worker. The seven-scenario benchmark also +checks complete traces and repeated outcome determinism for both longer horizons. + +## Indicative wait and complete horizon flow — 2026-10-05 + +Working implementation of issue #60, preserving the existing #59 worktree +changes, based on `2edf2004f56f8a453a691846f3c0e6d960e27911`. Production build, +native Chromium 149.0.7827.55 workers, AMD Ryzen 7 7800X3D, Debian 13.6 under +WSL2 (kernel 6.18.33.2-microsoft-standard-WSL2). Headless emulation used 412×915 +pixels, scale 2.625, touch, reduced motion and 4× CPU throttling. External font +downloads were blocked. No other build or test workload ran during measurement. +These are desktop samples, not physical-phone results or accuracy guarantees. + +Each scenario used a fresh browser. The application's initial calculation +covered one saved day with no balances or rules; that small completed plan was +the session's sole prior calibration sample. No target-scenario warm-up was +performed. Through the actual settings and balance controls, the run then +selected January 1, 2024 through the relevant December 31 inclusive and supplied +20 CP plus 10 RTT days per year, with annual availability and expiry dates. +The saved dates and balances were checked. All edits retained the initial +calendar and issued no calculation; one explicit Calculate click submitted the +complete target configuration. + +Calendar wait was measured from the captured Calculate click to DOM observation +of the displayed final placements with the loading overlay removed. This +includes request scheduling, preparation and rendering, unlike a core-work +measurement. The later trace interval starts at that calendar endpoint and ends +at the worker's terminal artifact delivery. The application's own calibration +uses its request timestamp and `afterNextRender` endpoint as described in +[planning-duration.md](../planning-duration.md). + +| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements | +| --- | --- | --- | --- | --- | --- | +| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 | +| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 | +| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 | + +All three outcomes completed through `dates`, with no intermediate placements, +implicit deadline, extra request or date movement. The three-year result +finished after ten seconds. Calendars and estimates remained identical during +the later trace interval. All 674, 1,948 and 3,959 trace packets respectively +were acknowledged. These isolated samples establish behavior, not a validated +numerical prediction error or duration ceiling. The rough model before any +local sample is separately documented; it does not assume linear growth with +the number of years. + +Two dark-mode native-worker cancellation flows covered the same manually +submitted three-year configuration. Holding the new worker's script download +until the explicit Cancel click made cancellation before any valid plan +reproducible: no plan or conflict was presented, and the indicative 7–46 s range +was unchanged. Its empty original trace was already available when the calendar +rendered. A keyboard cancellation in active holiday-week optimization retained +90 placements, displayed **Calculation cancelled — partial plan**, and left its +7–44 s range unchanged through later trace delivery. Both Cancel controls were +44 px high, and balance inputs accepted focus while the calculation was active. +The controlled startup download is not cancellation-latency evidence. + +A separate three-year run completed in 10.96 s while native trace-update events +were held back. The calendar was usable with an explicitly loading trace; +injecting a subsequent worker transport error preserved all 90 placements and +the newly refined 6–38 s estimate. Controlled-clock tests additionally keep a +trace unresolved for five minutes after a twenty-second calendar wait, verifying +that subsequent delivery/error time contributes no calibration sample. + +No browser errors or horizontal overflow occurred. The annual flow also fit +375, 768 and 1440 px widths. The estimate uses the existing secondary-text +theme token and is associated with Calculate through `aria-describedby`. +Settings restoration, stale calculations, rule/balance edits, long-worker +liveness, and cancellation history are also exercised by the integration suite. + ## Emulated browser run — 2026-09-25 Chromium 149.0.7827.55 on an AMD Ryzen 7 7800X3D (x86_64), headless at diff --git a/docs/planner-cancellation.md b/docs/planner-cancellation.md index 1a0afd0..6945daf 100644 --- a/docs/planner-cancellation.md +++ b/docs/planner-cancellation.md @@ -1,6 +1,6 @@ # Cancelling a planning calculation -Issue [#58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58) implements the cancellation portion of ADR 0006. The duration estimate and removal of the existing automatic deadline belong to the remaining work for issue #12. +Issue [#58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/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. diff --git a/docs/planning-duration.md b/docs/planning-duration.md new file mode 100644 index 0000000..c9e1f54 --- /dev/null +++ b/docs/planning-duration.md @@ -0,0 +1,109 @@ +# Indicative wait before Calculate + +The small range beside **Calculate** describes the configuration currently in +the form. Editing dates, balances or rules updates it without requesting a plan +or changing the previously displayed calendar. It is an approximation, with no +accuracy guarantee or automatic stopping deadline. + +## Initial estimate + +`PlanningDurationService` shares the date-only request with `CalendarYear` so +the estimate and calculation use the same horizon, balances and rules. Invalid +horizon dates hide the estimate and retain the existing disabled Calculate +control. Other malformed inputs keep a rough fallback estimate; the planner +still performs its own validation. + +The initial model is a deliberately rough engineering prior grounded in the +[browser reference measurements](benchmark/README.md): the 2026-10-03 annual +30-day scenario returned its outcome in about 0.6 seconds, and the 2026-10-05 +two- and three-year calendar runs with 30 days per year took 5.08 and 10.86 +seconds. These are throttled desktop measurements, not phone measurements or a +validated prediction model. The annual outcome timing does not include final +calendar rendering. The new rendered-calendar measurements below supplement +those references. + +For a half-open horizon of `D` calendar days, the model uses: + +```text +initial_ms = 300 + 600 × (D / 365.25)^1.2 × (max(1, P) / 30)^1.5 × R +``` + +`P` approximates placement load. Each non-negative finite balance contributes +at most five sevenths of its calendar-day overlap with the horizon, availability +and inclusive expiry. An at-risk balance has weight 1; a deferred balance has +weight 0.25 to account for possible supporting placements. Already expired and +not-yet-available balances contribute nothing. Each enabled IF_THEN rule adds +its non-negative earned quantity times `D / 365.25` as a rough allowance. + +`R` starts at 1. Each enabled rule adds `max(1, placeDays / 3)` times 0.5 for an +obligation or 0.15 for a preference. TIME_WINDOW rules wholly outside the +horizon contribute nothing. These weights approximate extra search, rather than +solving constraints or counting holiday occurrences in the form. Typed +obligations, date distribution, interacting rules, earned entitlements, device +load and browser differences can all change the actual wait substantially. + +The powers and weights are implementation choices, not complexity guarantees +or fitted accuracy claims. With 30 available days per year, the model's search +term grows as the number of years to the power 2.7, rather than assuming a +linear increase. Horizons beyond the references are unvalidated extrapolation. +The estimate performs one pass over balances and rules and runs no planner. + +## Local refinement and precision + +After each successfully rendered `Plan`, the service retains the ratio of its +observed elapsed time to the initial model for that calculation's immutable +submitted snapshot. The median of the most recent eight ratios scales the +initial estimate for the current form. A changed form therefore does not +relabel the completed measurement as a calculation of the new configuration. +The median limits the effect of isolated unusually slow runs; it cannot remove +systematic model error or predict arbitrary obligation searches. + +Only eight numeric ratios are retained in memory for the current application +session. Reloading restores the initial estimate. Nothing is persisted or sent +to a remote service, so there is no saved-measurement restoration dependency. +Storage failure does not affect this service. + +The displayed lower bound is half the estimated duration, rounded down; the +upper bound is three times that duration, rounded up. Values are whole seconds, +or whole minutes when the lower bound is at least one minute. The minimum +displayed value is 1 and the upper bound is at least one unit higher. Internal +duration rounding to whole milliseconds prevents floating-point noise at unit +boundaries. Very fast runs can finish before the displayed lower bound. The +range is explicitly indicative, not a confidence interval or a promise. + +## What is timed + +Manual requests capture `performance.now()` inside +`UserInputService.requestComputation`, before updating the calculation trigger. +The existing initial calculation falls back to the start of `startCalculation`. +The endpoint is Angular's `afterNextRender` callback after the complete plan has +been projected into the calendar, its months and placements have been rendered, +and the loading overlay has been removed. This measures request scheduling, +calendar preparation, worker startup, search, trace capture that delays the +outcome, outcome transport, projection and Angular rendering. It is a DOM +readiness endpoint, rather than a claim to measure a physical screen's paint. + +Neither core `planningWorkMs` nor artifact timings supply the calibration +measurement. The callback does not wait for the explanation or trace promises; +later delivery, navigation and trace errors never add a second measurement. +Cancellation (including a retained incumbent), incomplete outcomes, invalid +inputs, conflicts and calculation errors do not calibrate successful-plan +waiting time. Superseded calculations and callbacks for destroyed calendars +are ignored. Zero, negative and non-finite elapsed values are discarded. + +Exceeding the range has no side effects. The estimate supplies no deadline, +timer, date adjustment or new calculation. **Cancel** remains explicit. + +## Verification + +Controlled-clock tests at the existing calendar and input seams cover initial +estimation, unsubmitted edits, minute formatting, local refinement, request +scheduling, a twenty-second calculation exceeding its initial range, a trace +that fails five minutes after calendar readiness, cancellation with and without +an incumbent, incomplete/error outcomes and superseded calculations. No timing +assertion depends on CI hardware speed. Existing settings, planner and worker +tests cover saved horizons, long calculations and original interrupted traces. + +Rendered annual, two-year and three-year references, with the actual displayed +range and separate later trace wait, are recorded in +[the benchmark report](benchmark/README.md). diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts index 2bbaf7b..1ef97f7 100644 --- a/src/app/benchmark/leave-planner-benchmark.spec.ts +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -8,9 +8,22 @@ import { describe('leave planner benchmark protocol', () => { // Warm-up and two repetitions include complete trace delivery after planning. - it('keeps five deterministic scenarios and exercises a later earned consumer', async () => { + it('keeps seven deterministic scenarios including two and three years and a later earned consumer', async () => { const scenarios = benchmarkScenarios(); - expect(scenarios).toHaveLength(5); + expect(scenarios).toHaveLength(7); + for (const [name, end] of [ + ['two years', '2026-01-01'], + ['three years', '2027-01-01'], + ]) { + const scenario = scenarios.find((item) => item.name === name)!; + expect(scenario.request.horizon).toEqual({ start: '2024-01-01', end }); + expect( + scenario.request.balances.some( + (item) => String(item.availableFrom) >= '2025-01-01', + ), + ).toBe(true); + expect(scenario.request.deadlineMs).toBeUndefined(); + } const typedObligations = scenarios.find( (scenario) => scenario.name === 'typed obligations', ); @@ -49,6 +62,19 @@ describe('leave planner benchmark protocol', () => { ).toHaveLength(2); } const measurements = await runLeavePlannerBenchmark(2); + for (const [name, placements] of [ + ['two years', 60], + ['three years', 90], + ] as const) { + const longer = measurements.filter((measurement) => measurement.scenario === name); + expect(longer).toHaveLength(2); + expect(longer.map((item) => item.outcome)).toEqual(['Plan', 'Plan']); + expect(longer.map((item) => item.lastCompletedStage)).toEqual(['dates', 'dates']); + expect(longer.map((item) => item.placements)).toEqual([placements, placements]); + expect(longer.map((item) => item.deterministic)).toEqual(['baseline', 'yes']); + expect(longer.map((item) => item.traceStatus)).toEqual(['ready', 'ready']); + expect(longer[0].traceCandidates).toBeGreaterThan(0); + } const chained = measurements.find( (measurement) => measurement.scenario === 'chained earned entitlements', ); @@ -74,7 +100,7 @@ describe('leave planner benchmark protocol', () => { expect(loaded[0].traceStatus).toBe('ready'); expect(loaded[0].traceCandidates).toBeGreaterThan(0); const report = benchmarkReport(measurements); - expect(report.split('\n')).toHaveLength(11); + expect(report.split('\n')).toHaveLength(15); expect(report).toContain('planning_ms\ttrace_delivery_ms'); expect(report).toContain('planning_work_ms\ttrace_capture_ms'); expect(report).toContain('\tbaseline\t'); diff --git a/src/app/benchmark/leave-planner-benchmark.ts b/src/app/benchmark/leave-planner-benchmark.ts index 1e61ab5..e7e06a7 100644 --- a/src/app/benchmark/leave-planner-benchmark.ts +++ b/src/app/benchmark/leave-planner-benchmark.ts @@ -17,6 +17,8 @@ export interface BenchmarkMeasurement { planningMs: number; traceDeliveryMs: number; maxUiDelayMs: number; + /** Chromium's main-thread heap only; worker memory needs a separate browser profile. */ + peakMainThreadHeapBytes?: number; planningWorkMs?: number; traceCaptureMs?: number; traceMaterializationMs?: number; @@ -147,6 +149,25 @@ export function benchmarkScenarios(): BenchmarkScenario[] { })), }, }, + ...[2, 3].map( + (years): BenchmarkScenario => ({ + name: years === 2 ? 'two years' : 'three years', + request: { + horizon: { start: '2024-01-01', end: `${2024 + years}-01-01` }, + balances: Array.from({ length: years }, (_, index) => 2024 + index).flatMap( + (year) => + (['cp', 'rtt'] as const).map((type) => ({ + id: `${type}-${year}`, + type, + quantity: type === 'cp' ? 20 : 10, + availableFrom: `${year}-01-01`, + expiryDate: `${year}-12-31`, + })), + ), + rules: [], + }, + }), + ), ]; } @@ -200,11 +221,17 @@ async function measureRepetition( const wallStart = performance.now(); let lastTick = wallStart; let maxUiDelayMs = 0; + let peakMainThreadHeapBytes: number | undefined; const measureDelay = () => { const current = performance.now(); maxUiDelayMs = Math.max(maxUiDelayMs, current - lastTick - 50); lastTick = current; + const heap = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory + ?.usedJSHeapSize; + if (heap !== undefined) + peakMainThreadHeapBytes = Math.max(peakMainThreadHeapBytes ?? 0, heap); }; + measureDelay(); const heartbeat = setInterval(measureDelay, 50); try { const preparationStart = performance.now(); @@ -242,6 +269,7 @@ async function measureRepetition( planningMs, traceDeliveryMs, maxUiDelayMs, + peakMainThreadHeapBytes, ...timings, wallMs, ...summary, @@ -258,7 +286,7 @@ async function measureRepetition( export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { const header = - 'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms'; + 'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms\tpeak_main_thread_heap_bytes'; return [ header, ...measurements.map((item) => @@ -280,6 +308,7 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { item.traceCaptureMs?.toFixed(1) ?? '', item.traceMaterializationMs?.toFixed(1) ?? '', item.traceTransferMs?.toFixed(1) ?? '', + item.peakMainThreadHeapBytes ?? '', ].join('\t'), ), ].join('\n'); diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 55dfbc9..5726263 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -28,6 +28,7 @@ import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { RuleService, RuleType } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; +import { PlanningDurationService } from '../../services/planning-duration.service'; import { DateRange } from '@angular/material/datepicker'; const invalid: InvalidInputOutcome = { @@ -404,6 +405,192 @@ describe('CalendarYear planning outcome boundary', () => { beforeEach(() => localStorage.clear()); afterEach(() => localStorage.clear()); + it('calibrates the wait through calendar rendering while a long trace remains pending', async () => { + let now = 1_000; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); + try { + localStorage.setItem('vacationRules', '[]'); + const pending = deferred(); + const trace = deferred(); + const { calendar, calculation } = calendarFor( + plan, + undefined, + undefined, + trace.promise, + ); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + 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'), + }, + ]); + const duration = TestBed.inject(PlanningDurationService); + const submitted = duration.request()!; + calendar.createPlanningCalculation.mockReturnValue({ + ...calculation, + outcome: pending.promise, + snapshot: { ...calculation.snapshot, ...submitted }, + }); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + now = 21_000; + expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' }); + expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull(); + expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(0); + pending.resolve(plan); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull(); + expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); + expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); + now = 321_000; + trace.reject(new Error('Late trace delivery failed')); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); + expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); + expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); + } finally { + clock.mockRestore(); + } + }); + + it.each(['cancel before a plan', 'cancel with a plan', 'incomplete', 'error'])( + 'excludes the unfinished duration after %s', + async (mode) => { + let now = 1_000; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); + try { + const pending = deferred(); + const outcome: PlanningIncompleteOutcome = { + ...incomplete, + incumbent: mode === 'cancel with a plan' ? plan : undefined, + reasons: [ + { + code: mode.startsWith('cancel') ? 'user-cancelled' : 'deadline', + message: mode, + }, + ], + }; + const { calendar, calculation } = calendarFor(outcome); + calendar.createPlanningCalculation.mockReturnValue({ + ...calculation, + outcome: pending.promise, + }); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const duration = TestBed.inject(PlanningDurationService); + const initial = duration.estimate(); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + now = 61_000; + if (mode.startsWith('cancel')) + fixture.nativeElement.querySelector('.cancel-calculation').click(); + if (mode === 'error') pending.reject(new Error('Worker failed')); + else pending.resolve(outcome); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(duration.estimate()).toEqual(initial); + expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull(); + expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); + } finally { + clock.mockRestore(); + } + }, + ); + + it('ignores a replaced calculation duration and measures the latest submitted horizon', async () => { + let now = 1_000; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); + try { + localStorage.setItem('vacationRules', '[]'); + const old = deferred(); + const latest = deferred(); + const { calendar, calculation } = calendarFor(plan); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const settings = TestBed.inject(CalendarSettingsService); + settings.setPlanningDates('2024-01-01', '2024-12-31'); + const input = TestBed.inject(UserInputService); + const duration = TestBed.inject(PlanningDurationService); + calendar.createPlanningCalculation.mockImplementation(() => ({ + ...calculation, + snapshot: { ...calculation.snapshot, ...duration.request()! }, + outcome: + calendar.createPlanningCalculation.mock.calls.length === 1 + ? old.promise + : latest.promise, + })); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + now = 11_000; + settings.setPlanningDates('2024-01-01', '2026-12-31'); + input.requestComputation(); + now = 12_000; // Rendering/scheduling after the request is part of the wait. + fixture.detectChanges(); + const initial = duration.estimate(); + now = 31_000; + old.resolve(plan); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(duration.estimate()).toEqual(initial); + expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull(); + latest.resolve({ ...plan, horizon: { start: '2024-01-01', end: '2027-01-01' } }); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); + expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(36); + expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2); + fixture.destroy(); + const revisitedOutcome = deferred(); + calendar.createPlanningCalculation.mockReturnValueOnce({ + ...calculation, + snapshot: { ...calculation.snapshot, ...duration.request()! }, + outcome: revisitedOutcome.promise, + }); + now = 631_000; // Returning to the calendar is a fresh initial calculation. + const revisited = TestBed.createComponent(CalendarYear); + revisited.detectChanges(); + now = 651_000; + revisitedOutcome.resolve({ + ...plan, + horizon: { start: '2024-01-01', end: '2027-01-01' }, + }); + await revisited.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + revisited.detectChanges(); + expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); + } finally { + clock.mockRestore(); + } + }); + it.each([false, true])( 'cancels accessibly with a valid incumbent: %s, then allows a new calculation', async (withIncumbent) => { @@ -1361,8 +1548,18 @@ describe('CalendarYear planning outcome boundary', () => { }); it('announces trace loading and completion while keeping the completed plan available', async () => { + const outcomeDelivery = deferred(); const traceDelivery = deferred(); - const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise); + const { calendar, calculation } = calendarFor( + plan, + explanation, + undefined, + traceDelivery.promise, + ); + calendar.createPlanningCalculation.mockReturnValue({ + ...calculation, + outcome: outcomeDelivery.promise, + }); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -1378,6 +1575,17 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('.loading-overlay')).not.toBeNull(); + expect(root.querySelectorAll('button.selected')).toHaveLength(0); + expect(calendar.selectedDatesForPlan).not.toHaveBeenCalled(); + expect(root.querySelector('.cancel-calculation')).not.toBeNull(); + outcomeDelivery.resolve(plan); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + const plannedButtons = Array.from(root.querySelectorAll('button.selected')); + expect(plannedButtons).toHaveLength(2); + expect(root.querySelector('.loading-overlay')).toBeNull(); const liveMessage = root.querySelector('.calendar-year > p[aria-live]')!; const traceDetails = root.querySelector('.trace-explorer')!; activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); @@ -1392,6 +1600,9 @@ describe('CalendarYear planning outcome boundary', () => { await fixture.whenStable(); fixture.detectChanges(); expect(liveMessage.textContent).toContain('decision trace has loaded'); + expect(Array.from(root.querySelectorAll('button.selected'))).toEqual(plannedButtons); + expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); + expect(calendar.selectedDatesForPlan).toHaveBeenCalledOnce(); await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull()); root.querySelector( '.trace-decisions nav button[aria-label="Next decision"]', diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 8fb9eed..959af00 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -1,10 +1,12 @@ import { + afterNextRender, Component, computed, DestroyRef, effect, ElementRef, inject, + Injector, signal, TemplateRef, untracked, @@ -18,7 +20,6 @@ import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButton } from '@angular/material/button'; -import dayjs from 'dayjs'; import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; @@ -30,10 +31,9 @@ import { SelectableDayType, VacDay, } from '../../services/calendar-service'; -import { RuleService } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; -import { CalendarSettingsService } from '../../services/calendar-settings-service'; +import { PlanningDurationService } from '../../services/planning-duration.service'; import { applyReplayDeltas, CounterfactualAnalysis, @@ -80,8 +80,8 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra export class CalendarYear { private readonly userInput = inject(UserInputService); private readonly calendarService = inject(CalendarService); - private readonly ruleService = inject(RuleService); - private readonly calendarSettings = inject(CalendarSettingsService); + private readonly duration = inject(PlanningDurationService); + private readonly injector = inject(Injector); private readonly dialog = inject(MatDialog); private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); @@ -453,32 +453,27 @@ export class CalendarYear { } private startCalculation(): void { - const horizon = this.calendarSettings.planningHorizon(); - if (!horizon) return; + const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now(); + this.userInput.computationRequestedAtMs.set(null); + const request = this.duration.request(); + if (!request) return; + const { horizon, rules: currentRules } = request; this.planningHorizon.set(horizon); this.resetCalculationState(horizon); - const vacationData = this.userInput.vacationNumberSignal(); - const currentRules = this.ruleService.rules; const version = ++this.calculationVersion; this.isLoading.set(true); this.isCancelling.set(false); this.loadingProgress.set(0); this.liveMessage.set(this.tr().loading); let calculation: PlanningCalculationContext; + let completed = false; try { calculation = this.calendarService.createPlanningCalculation({ - horizon, - balances: vacationData.map((balance) => ({ - id: balance.id, - type: balance.type, - quantity: balance.numberOfDays, + ...request, + balances: request.balances.map((balance) => ({ + ...balance, label: this.translationService.getDayTypeLabel(balance.type), - availableFrom: balance.availableDate - ? dayjs(balance.availableDate).format('YYYY-MM-DD') - : undefined, - expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'), })), - rules: currentRules, labels: Object.fromEntries( currentRules.map((rule) => [rule.id, rule.name ?? rule.id]), ), @@ -495,7 +490,9 @@ export class CalendarYear { this.loadArtifacts(calculation, version); void calculation.outcome .then((outcome) => { - if (version === this.calculationVersion) this.applyOutcome(outcome); + if (version !== this.calculationVersion) return; + this.applyOutcome(outcome); + completed = outcome.kind === 'Plan' && !this.isCancelling(); }) .catch((error: unknown) => { if (version !== this.calculationVersion) return; @@ -519,6 +516,17 @@ export class CalendarYear { this.isLoading.set(false); if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus(); this.isCancelling.set(false); + if (completed && !this.destroyRef.destroyed) + afterNextRender( + () => { + if (version === this.calculationVersion && !this.destroyRef.destroyed) + this.duration.recordCompletion( + calculation.snapshot, + performance.now() - requestedAt, + ); + }, + { injector: this.injector }, + ); }); } diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 84ae6fb..06c6a66 100644 --- a/src/app/components/user-input/user-input.html +++ b/src/app/components/user-input/user-input.html @@ -25,6 +25,9 @@ + @if (duration.estimate()) { +

{{ durationLabel() }}

+ }