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
@@ -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.
|
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.
|
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
|
## Planning model
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ A request contains:
|
|||||||
- a user-selected half-open planning horizon, including past dates and multiple years;
|
- 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;
|
- CP, RTT, or Other leave balances with quantities, availability dates, and expiry dates;
|
||||||
- ordered `TIME_WINDOW` and `IF_THEN` rules;
|
- ordered `TIME_WINDOW` and `IF_THEN` rules;
|
||||||
- an optional deadline and progress callback.
|
- an optional explicit technical deadline and progress callback.
|
||||||
|
|
||||||
Rules are either:
|
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.
|
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
|
## 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:
|
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
|
```text
|
||||||
src/app/
|
src/app/
|
||||||
├── benchmark/ # Lazy five-scenario performance harness
|
├── benchmark/ # Lazy seven-scenario performance harness
|
||||||
├── components/ # Calendar, inputs, rules, and settings UI
|
├── components/ # Calendar, inputs, rules, and settings UI
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── leave-planner.service.ts # Canonical async planning boundary
|
│ ├── leave-planner.service.ts # Canonical async planning boundary
|
||||||
@@ -197,7 +201,7 @@ Open <http://localhost:4200>. The development server reloads when source files c
|
|||||||
|
|
||||||
## Performance benchmark
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Constraint-First Lexicographic Planning Without a Solver Dependency
|
# 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.
|
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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Separate Plan Explanations from Calculation Traces
|
# 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.
|
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Stable Explanation, Original Trace, and Counterfactuals
|
# 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:
|
VacOptimizer exposes one client-side calculation context. Its immutable submitted snapshot and planning outcome are independent from three explanation concerns:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# User-Selected Horizons and Cancellable Calculations
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+147
-9
@@ -1,7 +1,7 @@
|
|||||||
# Mobile leave-planner benchmark
|
# Mobile leave-planner benchmark
|
||||||
|
|
||||||
Open the deployed application at `/benchmark` on the reference phone. Tap
|
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.
|
report** and save the tab-separated output with the date and build identifier.
|
||||||
|
|
||||||
The harness measures preparation, planning-to-outcome latency, later trace
|
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
|
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
|
with the page while a run is active. The first measured result is the
|
||||||
determinism baseline; later repetitions report whether they match it.
|
determinism baseline; later repetitions report whether they match it.
|
||||||
- The original target was below five seconds for representative and loaded
|
- The scenarios include two and three calendar years with separately dated
|
||||||
scenarios, with a ten-second planning ceiling for every scenario. The owner
|
annual CP and RTT balances. Let these runs finish or cancel explicitly;
|
||||||
accepted six to seven seconds for the loaded scenario on 2026-09-25. These
|
there is no automatic calculation cutoff and no validated duration ceiling
|
||||||
are release measurements, not CI timing assertions.
|
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
|
- Keep the copied report unchanged, including any incomplete outcomes or
|
||||||
hardware limitations. No mobile-device measurement is claimed by the source
|
hardware limitations. No mobile-device measurement is claimed by the source
|
||||||
tree until a human records it here.
|
tree until a human records it here.
|
||||||
|
|
||||||
The browser page uses the same `createPlanningCalculation(request)` context as
|
The browser page uses the same `createPlanningCalculation(request)` context as
|
||||||
the calendar and adds no solver or backend dependency. The ten-second release
|
the calendar and adds no solver or backend dependency. It omits `deadlineMs`.
|
||||||
ceiling applies to planning work; trace delivery is reported separately and
|
Trace delivery is reported separately and cannot replace a valid planning outcome.
|
||||||
cannot replace a valid planning outcome.
|
|
||||||
|
|
||||||
`planning_ms` is the observed latency until the outcome is usable;
|
`planning_ms` is the observed latency until the outcome is usable;
|
||||||
`planning_work_ms` excludes trace capture. `trace_capture_ms` accounts for
|
`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_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
|
`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
|
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
|
## 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
|
pause. This is an explicit release exception, not a reference-phone measurement
|
||||||
or proof of physical-device responsiveness.
|
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
|
## Emulated browser run — 2026-09-25
|
||||||
|
|
||||||
Chromium 149.0.7827.55 on an AMD Ryzen 7 7800X3D (x86_64), headless at
|
Chromium 149.0.7827.55 on an AMD Ryzen 7 7800X3D (x86_64), headless at
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Cancelling a planning calculation
|
# 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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -8,9 +8,22 @@ import {
|
|||||||
|
|
||||||
describe('leave planner benchmark protocol', () => {
|
describe('leave planner benchmark protocol', () => {
|
||||||
// Warm-up and two repetitions include complete trace delivery after planning.
|
// 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();
|
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(
|
const typedObligations = scenarios.find(
|
||||||
(scenario) => scenario.name === 'typed obligations',
|
(scenario) => scenario.name === 'typed obligations',
|
||||||
);
|
);
|
||||||
@@ -49,6 +62,19 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
}
|
}
|
||||||
const measurements = await runLeavePlannerBenchmark(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(
|
const chained = measurements.find(
|
||||||
(measurement) => measurement.scenario === 'chained earned entitlements',
|
(measurement) => measurement.scenario === 'chained earned entitlements',
|
||||||
);
|
);
|
||||||
@@ -74,7 +100,7 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
expect(loaded[0].traceStatus).toBe('ready');
|
expect(loaded[0].traceStatus).toBe('ready');
|
||||||
expect(loaded[0].traceCandidates).toBeGreaterThan(0);
|
expect(loaded[0].traceCandidates).toBeGreaterThan(0);
|
||||||
const report = benchmarkReport(measurements);
|
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_ms\ttrace_delivery_ms');
|
||||||
expect(report).toContain('planning_work_ms\ttrace_capture_ms');
|
expect(report).toContain('planning_work_ms\ttrace_capture_ms');
|
||||||
expect(report).toContain('\tbaseline\t');
|
expect(report).toContain('\tbaseline\t');
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface BenchmarkMeasurement {
|
|||||||
planningMs: number;
|
planningMs: number;
|
||||||
traceDeliveryMs: number;
|
traceDeliveryMs: number;
|
||||||
maxUiDelayMs: number;
|
maxUiDelayMs: number;
|
||||||
|
/** Chromium's main-thread heap only; worker memory needs a separate browser profile. */
|
||||||
|
peakMainThreadHeapBytes?: number;
|
||||||
planningWorkMs?: number;
|
planningWorkMs?: number;
|
||||||
traceCaptureMs?: number;
|
traceCaptureMs?: number;
|
||||||
traceMaterializationMs?: 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();
|
const wallStart = performance.now();
|
||||||
let lastTick = wallStart;
|
let lastTick = wallStart;
|
||||||
let maxUiDelayMs = 0;
|
let maxUiDelayMs = 0;
|
||||||
|
let peakMainThreadHeapBytes: number | undefined;
|
||||||
const measureDelay = () => {
|
const measureDelay = () => {
|
||||||
const current = performance.now();
|
const current = performance.now();
|
||||||
maxUiDelayMs = Math.max(maxUiDelayMs, current - lastTick - 50);
|
maxUiDelayMs = Math.max(maxUiDelayMs, current - lastTick - 50);
|
||||||
lastTick = current;
|
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);
|
const heartbeat = setInterval(measureDelay, 50);
|
||||||
try {
|
try {
|
||||||
const preparationStart = performance.now();
|
const preparationStart = performance.now();
|
||||||
@@ -242,6 +269,7 @@ async function measureRepetition(
|
|||||||
planningMs,
|
planningMs,
|
||||||
traceDeliveryMs,
|
traceDeliveryMs,
|
||||||
maxUiDelayMs,
|
maxUiDelayMs,
|
||||||
|
peakMainThreadHeapBytes,
|
||||||
...timings,
|
...timings,
|
||||||
wallMs,
|
wallMs,
|
||||||
...summary,
|
...summary,
|
||||||
@@ -258,7 +286,7 @@ async function measureRepetition(
|
|||||||
|
|
||||||
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
||||||
const header =
|
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 [
|
return [
|
||||||
header,
|
header,
|
||||||
...measurements.map((item) =>
|
...measurements.map((item) =>
|
||||||
@@ -280,6 +308,7 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
|||||||
item.traceCaptureMs?.toFixed(1) ?? '',
|
item.traceCaptureMs?.toFixed(1) ?? '',
|
||||||
item.traceMaterializationMs?.toFixed(1) ?? '',
|
item.traceMaterializationMs?.toFixed(1) ?? '',
|
||||||
item.traceTransferMs?.toFixed(1) ?? '',
|
item.traceTransferMs?.toFixed(1) ?? '',
|
||||||
|
item.peakMainThreadHeapBytes ?? '',
|
||||||
].join('\t'),
|
].join('\t'),
|
||||||
),
|
),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { UserInputService } from '../../services/user-input-service';
|
|||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { RuleService, RuleType } from '../../services/rule-service';
|
import { RuleService, RuleType } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
import { DateRange } from '@angular/material/datepicker';
|
import { DateRange } from '@angular/material/datepicker';
|
||||||
|
|
||||||
const invalid: InvalidInputOutcome = {
|
const invalid: InvalidInputOutcome = {
|
||||||
@@ -404,6 +405,192 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
beforeEach(() => localStorage.clear());
|
beforeEach(() => localStorage.clear());
|
||||||
afterEach(() => 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<PlanningOutcome>();
|
||||||
|
const trace = deferred<PlanningTrace | undefined>();
|
||||||
|
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<void>((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<void>((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<PlanningOutcome>();
|
||||||
|
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<void>((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<PlanningOutcome>();
|
||||||
|
const latest = deferred<PlanningOutcome>();
|
||||||
|
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<void>((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<void>((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<PlanningOutcome>();
|
||||||
|
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<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
revisited.detectChanges();
|
||||||
|
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||||
|
} finally {
|
||||||
|
clock.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each([false, true])(
|
it.each([false, true])(
|
||||||
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||||||
async (withIncumbent) => {
|
async (withIncumbent) => {
|
||||||
@@ -1361,8 +1548,18 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('announces trace loading and completion while keeping the completed plan available', async () => {
|
it('announces trace loading and completion while keeping the completed plan available', async () => {
|
||||||
|
const outcomeDelivery = deferred<PlanningOutcome>();
|
||||||
const traceDelivery = deferred<PlanningTrace | undefined>();
|
const traceDelivery = deferred<PlanningTrace | undefined>();
|
||||||
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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -1378,6 +1575,17 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
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<void>((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<HTMLElement>('.calendar-year > p[aria-live]')!;
|
const liveMessage = root.querySelector<HTMLElement>('.calendar-year > p[aria-live]')!;
|
||||||
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
||||||
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
|
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
|
||||||
@@ -1392,6 +1600,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(liveMessage.textContent).toContain('decision trace has loaded');
|
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());
|
await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull());
|
||||||
root.querySelector<HTMLButtonElement>(
|
root.querySelector<HTMLButtonElement>(
|
||||||
'.trace-decisions nav button[aria-label="Next decision"]',
|
'.trace-decisions nav button[aria-label="Next decision"]',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
effect,
|
effect,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
|
Injector,
|
||||||
signal,
|
signal,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
untracked,
|
untracked,
|
||||||
@@ -18,7 +20,6 @@ import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatButton } from '@angular/material/button';
|
import { MatButton } from '@angular/material/button';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
||||||
@@ -30,10 +31,9 @@ import {
|
|||||||
SelectableDayType,
|
SelectableDayType,
|
||||||
VacDay,
|
VacDay,
|
||||||
} from '../../services/calendar-service';
|
} from '../../services/calendar-service';
|
||||||
import { RuleService } from '../../services/rule-service';
|
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
import {
|
import {
|
||||||
applyReplayDeltas,
|
applyReplayDeltas,
|
||||||
CounterfactualAnalysis,
|
CounterfactualAnalysis,
|
||||||
@@ -80,8 +80,8 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
|
|||||||
export class CalendarYear {
|
export class CalendarYear {
|
||||||
private readonly userInput = inject(UserInputService);
|
private readonly userInput = inject(UserInputService);
|
||||||
private readonly calendarService = inject(CalendarService);
|
private readonly calendarService = inject(CalendarService);
|
||||||
private readonly ruleService = inject(RuleService);
|
private readonly duration = inject(PlanningDurationService);
|
||||||
private readonly calendarSettings = inject(CalendarSettingsService);
|
private readonly injector = inject(Injector);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -453,32 +453,27 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startCalculation(): void {
|
private startCalculation(): void {
|
||||||
const horizon = this.calendarSettings.planningHorizon();
|
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
|
||||||
if (!horizon) return;
|
this.userInput.computationRequestedAtMs.set(null);
|
||||||
|
const request = this.duration.request();
|
||||||
|
if (!request) return;
|
||||||
|
const { horizon, rules: currentRules } = request;
|
||||||
this.planningHorizon.set(horizon);
|
this.planningHorizon.set(horizon);
|
||||||
this.resetCalculationState(horizon);
|
this.resetCalculationState(horizon);
|
||||||
const vacationData = this.userInput.vacationNumberSignal();
|
|
||||||
const currentRules = this.ruleService.rules;
|
|
||||||
const version = ++this.calculationVersion;
|
const version = ++this.calculationVersion;
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.isCancelling.set(false);
|
this.isCancelling.set(false);
|
||||||
this.loadingProgress.set(0);
|
this.loadingProgress.set(0);
|
||||||
this.liveMessage.set(this.tr().loading);
|
this.liveMessage.set(this.tr().loading);
|
||||||
let calculation: PlanningCalculationContext;
|
let calculation: PlanningCalculationContext;
|
||||||
|
let completed = false;
|
||||||
try {
|
try {
|
||||||
calculation = this.calendarService.createPlanningCalculation({
|
calculation = this.calendarService.createPlanningCalculation({
|
||||||
horizon,
|
...request,
|
||||||
balances: vacationData.map((balance) => ({
|
balances: request.balances.map((balance) => ({
|
||||||
id: balance.id,
|
...balance,
|
||||||
type: balance.type,
|
|
||||||
quantity: balance.numberOfDays,
|
|
||||||
label: this.translationService.getDayTypeLabel(balance.type),
|
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(
|
labels: Object.fromEntries(
|
||||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||||
),
|
),
|
||||||
@@ -495,7 +490,9 @@ export class CalendarYear {
|
|||||||
this.loadArtifacts(calculation, version);
|
this.loadArtifacts(calculation, version);
|
||||||
void calculation.outcome
|
void calculation.outcome
|
||||||
.then((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) => {
|
.catch((error: unknown) => {
|
||||||
if (version !== this.calculationVersion) return;
|
if (version !== this.calculationVersion) return;
|
||||||
@@ -519,6 +516,17 @@ export class CalendarYear {
|
|||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
||||||
this.isCancelling.set(false);
|
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 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
|
|
||||||
<app-rules-panel></app-rules-panel>
|
<app-rules-panel></app-rules-panel>
|
||||||
|
|
||||||
|
@if (duration.estimate()) {
|
||||||
|
<p id="planning-duration-estimate" class="duration-estimate">{{ durationLabel() }}</p>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
class="calculate-button"
|
class="calculate-button"
|
||||||
@@ -32,7 +35,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
[disabled]="!!calendarSettings.planningHorizonError()"
|
[disabled]="!!calendarSettings.planningHorizonError()"
|
||||||
[attr.aria-describedby]="
|
[attr.aria-describedby]="
|
||||||
calendarSettings.planningHorizonError() ? 'invalid-planning-horizon' : null
|
calendarSettings.planningHorizonError()
|
||||||
|
? 'invalid-planning-horizon'
|
||||||
|
: 'planning-duration-estimate'
|
||||||
"
|
"
|
||||||
(click)="triggerComputation()"
|
(click)="triggerComputation()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ form {
|
|||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.duration-estimate {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0 0 -0.5rem;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.add-day-button {
|
.add-day-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ describe('UserInput', () => {
|
|||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes Calculate with an indicative range that follows unsubmitted edits', () => {
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
|
||||||
|
const estimateText = () =>
|
||||||
|
root.querySelector('#planning-duration-estimate')?.textContent?.trim();
|
||||||
|
expect(estimateText()).toMatch(/Approximate wait: \d+–\d+ (s|min)/);
|
||||||
|
expect(calculate.getAttribute('aria-describedby')).toBe('planning-duration-estimate');
|
||||||
|
const initial = estimateText();
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(estimateText()).not.toBe(initial);
|
||||||
|
expect(userInputService.computeTriggerSignal()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
|
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
|
||||||
const settings = TestBed.inject(CalendarSettingsService);
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
settings.setPlanningDates('2024-03-01', '2024-02-29');
|
settings.setPlanningDates('2024-03-01', '2024-02-29');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { VacDay } from '../../services/calendar-service';
|
import { VacDay } from '../../services/calendar-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-input',
|
selector: 'app-user-input',
|
||||||
@@ -28,7 +29,18 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
|
|||||||
export class UserInput {
|
export class UserInput {
|
||||||
private userInputService: UserInputService = inject(UserInputService);
|
private userInputService: UserInputService = inject(UserInputService);
|
||||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
|
protected readonly duration = inject(PlanningDurationService);
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
|
protected readonly durationLabel = computed(() => {
|
||||||
|
const estimate = this.duration.estimate();
|
||||||
|
if (!estimate) return '';
|
||||||
|
return this.tr()
|
||||||
|
.userInput.durationEstimate.replace(
|
||||||
|
'{range}',
|
||||||
|
`${estimate.minimum}–${estimate.maximum}`,
|
||||||
|
)
|
||||||
|
.replace('{unit}', this.tr().userInput.durationUnits[estimate.unit]);
|
||||||
|
});
|
||||||
|
|
||||||
get tr() {
|
get tr() {
|
||||||
return this.translationService.tr;
|
return this.translationService.tr;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export interface PlanningRequest {
|
|||||||
identities?: unknown;
|
identities?: unknown;
|
||||||
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
|
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
|
||||||
labels?: unknown;
|
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;
|
deadlineMs?: number;
|
||||||
progress?: (stage: string) => void;
|
progress?: (stage: string) => void;
|
||||||
}
|
}
|
||||||
@@ -1235,8 +1235,6 @@ class UserCancelled extends Error {}
|
|||||||
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
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
|
// ponytail: cap the number of feasible obligation branches previewed for a
|
||||||
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
||||||
// that this ceiling misses a better lexicographic assignment.
|
// that this ceiling misses a better lexicographic assignment.
|
||||||
@@ -5735,14 +5733,8 @@ function* calculatePlanningSteps(
|
|||||||
calendar;
|
calendar;
|
||||||
const started =
|
const started =
|
||||||
typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
||||||
const configuredLimit =
|
const deadline =
|
||||||
typeof source.deadlineMs === 'number' ? source.deadlineMs : MAX_PLANNING_MS;
|
typeof source.deadlineMs === 'number' ? source.deadlineMs : Number.POSITIVE_INFINITY;
|
||||||
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 context = {
|
const context = {
|
||||||
horizon,
|
horizon,
|
||||||
holidays,
|
holidays,
|
||||||
@@ -5754,7 +5746,7 @@ function* calculatePlanningSteps(
|
|||||||
workingDays,
|
workingDays,
|
||||||
initialBalances: normalized.balances,
|
initialBalances: normalized.balances,
|
||||||
rules: normalized.rules,
|
rules: normalized.rules,
|
||||||
deadline: coreDeadline,
|
deadline,
|
||||||
started,
|
started,
|
||||||
request: source,
|
request: source,
|
||||||
nodes: 0,
|
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 () => {
|
it('returns an incomplete outcome instead of a false conflict at an expired deadline', async () => {
|
||||||
const result = await planLeave({
|
const result = await planLeave({
|
||||||
horizon,
|
horizon,
|
||||||
@@ -1134,6 +1191,53 @@ describe('planLeave', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('planLeave transport', () => {
|
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 () => {
|
it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => {
|
||||||
const incumbent = isPlan(
|
const incumbent = isPlan(
|
||||||
await runInNonBrowser({
|
await runInNonBrowser({
|
||||||
@@ -1400,45 +1504,52 @@ describe('planLeave transport', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a detached, callback-free request to the worker transport', async () => {
|
it.each([undefined, 25_000])(
|
||||||
const sourceDate = new Date('2024-01-01T00:00:00Z');
|
'sends a detached, callback-free request with budget %s',
|
||||||
const request = {
|
async (deadlineMs) => {
|
||||||
horizon: { start: sourceDate, end: '2025-01-01' },
|
const sourceDate = new Date('2024-01-01T00:00:00Z');
|
||||||
balances: [
|
const request = {
|
||||||
{
|
deadlineMs,
|
||||||
id: 'cp',
|
horizon: { start: sourceDate, end: '2025-01-01' },
|
||||||
type: 'cp' as const,
|
balances: [
|
||||||
quantity: 0,
|
{
|
||||||
availableFrom: sourceDate,
|
id: 'cp',
|
||||||
expiryDate: '2025-01-01',
|
type: 'cp' as const,
|
||||||
},
|
quantity: 0,
|
||||||
],
|
availableFrom: sourceDate,
|
||||||
rules: [],
|
expiryDate: '2025-01-01',
|
||||||
};
|
},
|
||||||
const expected = isPlan(await runInNonBrowser(request));
|
],
|
||||||
let posted: { type: string; request: PlanningRequest } | undefined;
|
rules: [],
|
||||||
class InspectingWorker {
|
};
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
const expected = isPlan(await runInNonBrowser(request));
|
||||||
onerror: (() => void) | null = null;
|
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 {
|
postMessage(message: { type: string; request: PlanningRequest }): void {
|
||||||
posted = structuredClone(message);
|
posted = structuredClone(message);
|
||||||
this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
|
this.onmessage?.({
|
||||||
|
data: { type: 'result', outcome: expected },
|
||||||
|
} as MessageEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(): void {}
|
||||||
}
|
}
|
||||||
|
vi.stubGlobal('Worker', InspectingWorker);
|
||||||
terminate(): void {}
|
try {
|
||||||
}
|
const result = await planLeave(request);
|
||||||
vi.stubGlobal('Worker', InspectingWorker);
|
expect(result.kind).toBe('Plan');
|
||||||
try {
|
expect(posted?.request).not.toBe(request);
|
||||||
const result = await planLeave(request);
|
expect(posted?.request.deadlineMs).toBe(deadlineMs);
|
||||||
expect(result.kind).toBe('Plan');
|
expect(posted?.request.progress).toBeUndefined();
|
||||||
expect(posted?.request).not.toBe(request);
|
expect(request.horizon.start).toBe(sourceDate);
|
||||||
expect(posted?.request.progress).toBeUndefined();
|
} finally {
|
||||||
expect(request.horizon.start).toBe(sourceDate);
|
vi.unstubAllGlobals();
|
||||||
} finally {
|
}
|
||||||
vi.unstubAllGlobals();
|
},
|
||||||
}
|
);
|
||||||
});
|
|
||||||
|
|
||||||
it('strips legacy aliases at the worker boundary', async () => {
|
it('strips legacy aliases at the worker boundary', async () => {
|
||||||
const request = {
|
const request = {
|
||||||
@@ -3186,6 +3297,7 @@ describe('planning calculation context', () => {
|
|||||||
const browserWindow = globalThis.window;
|
const browserWindow = globalThis.window;
|
||||||
vi.stubGlobal('window', {});
|
vi.stubGlobal('window', {});
|
||||||
vi.stubGlobal('Worker', SlowTraceWorker);
|
vi.stubGlobal('Worker', SlowTraceWorker);
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] });
|
||||||
try {
|
try {
|
||||||
const calculation = createPlanningCalculation(request);
|
const calculation = createPlanningCalculation(request);
|
||||||
let traceReady = false;
|
let traceReady = false;
|
||||||
@@ -3197,15 +3309,18 @@ describe('planning calculation context', () => {
|
|||||||
expect((await calculation.explanation)?.kind).toBe('PlanExplanation');
|
expect((await calculation.explanation)?.kind).toBe('PlanExplanation');
|
||||||
expect(traceReady).toBe(false);
|
expect(traceReady).toBe(false);
|
||||||
const traceStarted = performance.now();
|
const traceStarted = performance.now();
|
||||||
|
await vi.advanceTimersByTimeAsync(10_001);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
const loadedTrace = await tracePromise;
|
const loadedTrace = await tracePromise;
|
||||||
expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000);
|
expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000);
|
||||||
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount);
|
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount);
|
||||||
expect(loadedTrace?.status).toBe('ready');
|
expect(loadedTrace?.status).toBe('ready');
|
||||||
} finally {
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.stubGlobal('window', browserWindow);
|
vi.stubGlobal('window', browserWindow);
|
||||||
}
|
}
|
||||||
}, 15_000);
|
});
|
||||||
|
|
||||||
it('drains queued trace packets in batches of at most sixteen', async () => {
|
it('drains queued trace packets in batches of at most sixteen', async () => {
|
||||||
const request = {
|
const request = {
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ export type {
|
|||||||
TraceCandidateStatus,
|
TraceCandidateStatus,
|
||||||
} from './leave-planner.core';
|
} from './leave-planner.core';
|
||||||
|
|
||||||
const MAX_PLANNING_MS = 10_000;
|
// Responsive workers renew this inactivity timer regardless of calculation duration.
|
||||||
// Core owns the work deadline; this independent timer only recovers a stuck worker.
|
|
||||||
const WORKER_LIVENESS_TIMEOUT_MS = 30_000;
|
const WORKER_LIVENESS_TIMEOUT_MS = 30_000;
|
||||||
const MAX_TRACE_UPDATES_PER_DRAIN = 16;
|
const MAX_TRACE_UPDATES_PER_DRAIN = 16;
|
||||||
const TRACE_DRAIN_TIME_BUDGET_MS = 4;
|
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 {
|
function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined {
|
||||||
const deadline = request.deadlineMs;
|
const deadline = request.deadlineMs;
|
||||||
if (
|
if (
|
||||||
@@ -287,6 +281,7 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlannerWorkerResponse =
|
type PlannerWorkerResponse =
|
||||||
|
| { type: 'heartbeat' }
|
||||||
| { type: 'progress'; stage: string }
|
| { type: 'progress'; stage: string }
|
||||||
| { type: 'trace-overhead'; totalMs: number }
|
| { type: 'trace-overhead'; totalMs: number }
|
||||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
|
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
|
||||||
@@ -389,9 +384,8 @@ function fallbackTrace(
|
|||||||
|
|
||||||
function interruptedOutcome(
|
function interruptedOutcome(
|
||||||
checkpoint: PlanOutcome | undefined,
|
checkpoint: PlanOutcome | undefined,
|
||||||
deadline: number,
|
reasonCode: string,
|
||||||
reasonCode = 'deadline',
|
message: string,
|
||||||
message = `Planning stopped after ${deadline}ms before all quality stages completed.`,
|
|
||||||
): PlanningIncompleteOutcome {
|
): PlanningIncompleteOutcome {
|
||||||
return {
|
return {
|
||||||
kind: 'PlanningIncomplete',
|
kind: 'PlanningIncomplete',
|
||||||
@@ -413,7 +407,7 @@ function cancelLatestWorkers(): void {
|
|||||||
latestHistoricalEligibilityCancel = undefined;
|
latestHistoricalEligibilityCancel = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
|
function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
||||||
let resolveOutcome!: (outcome: PlanningOutcome) => void;
|
let resolveOutcome!: (outcome: PlanningOutcome) => void;
|
||||||
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
||||||
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
||||||
@@ -492,7 +486,6 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
) {
|
) {
|
||||||
cancelledRace = interruptedOutcome(
|
cancelledRace = interruptedOutcome(
|
||||||
result.kind === 'Plan' ? result : checkpoint,
|
result.kind === 'Plan' ? result : checkpoint,
|
||||||
deadline,
|
|
||||||
'user-cancelled',
|
'user-cancelled',
|
||||||
'The calculation was cancelled by the user.',
|
'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.',
|
message: 'Planning stopped because a newer calculation started.',
|
||||||
};
|
};
|
||||||
if (!hadOutcome)
|
if (!hadOutcome)
|
||||||
finishOutcome(
|
finishOutcome(interruptedOutcome(checkpoint, reason.code, reason.message));
|
||||||
interruptedOutcome(checkpoint, deadline, reason.code, reason.message),
|
|
||||||
);
|
|
||||||
finishExplanation(
|
finishExplanation(
|
||||||
receivedExplanation,
|
receivedExplanation,
|
||||||
hadOutcome ? receivedExplanationError : reason,
|
hadOutcome ? receivedExplanationError : reason,
|
||||||
@@ -707,8 +698,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
};
|
};
|
||||||
let scheduleTraceDrain: () => void = () => {};
|
let scheduleTraceDrain: () => void = () => {};
|
||||||
const fail = (code: string, message: string) => {
|
const fail = (code: string, message: string) => {
|
||||||
if (!outcomeSettled)
|
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
||||||
finishOutcome(interruptedOutcome(checkpoint, deadline, code, message));
|
|
||||||
pendingFailure ??= { code, message };
|
pendingFailure ??= { code, message };
|
||||||
if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
|
if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
|
||||||
else finalizeFailure(pendingFailure);
|
else finalizeFailure(pendingFailure);
|
||||||
@@ -809,6 +799,10 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
if (message.type === 'heartbeat') {
|
||||||
|
scheduleLivenessWatchdog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message.type === 'progress') {
|
if (message.type === 'progress') {
|
||||||
scheduleLivenessWatchdog();
|
scheduleLivenessWatchdog();
|
||||||
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
|
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
|
||||||
@@ -891,7 +885,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
try {
|
try {
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
type: 'plan',
|
type: 'plan',
|
||||||
request: { ...snapshot, deadlineMs: deadline, progress: undefined },
|
request: { ...snapshot, progress: undefined },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
|
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 {
|
} catch {
|
||||||
const result = interruptedOutcome(
|
const result = interruptedOutcome(
|
||||||
undefined,
|
undefined,
|
||||||
deadline,
|
|
||||||
'worker-unavailable',
|
'worker-unavailable',
|
||||||
'Planning could not start because isolated execution is unavailable.',
|
'Planning could not start because isolated execution is unavailable.',
|
||||||
);
|
);
|
||||||
@@ -1571,10 +1564,9 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
timings: Promise.resolve({}),
|
timings: Promise.resolve({}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const deadline = deadlineFor(transport);
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
if (typeof Worker !== 'undefined') {
|
if (typeof Worker !== 'undefined') {
|
||||||
run = planInWorker(transport, deadline);
|
run = planInWorker(transport);
|
||||||
} else {
|
} else {
|
||||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||||
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
||||||
@@ -1601,7 +1593,6 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
}
|
}
|
||||||
const result = interruptedOutcome(
|
const result = interruptedOutcome(
|
||||||
undefined,
|
undefined,
|
||||||
deadline,
|
|
||||||
'worker-unavailable',
|
'worker-unavailable',
|
||||||
'Planning could not start because isolated execution is unavailable.',
|
'Planning could not start because isolated execution is unavailable.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ describe('real planning worker cancellation', () => {
|
|||||||
let directory: string;
|
let directory: string;
|
||||||
let bundle: string;
|
let bundle: string;
|
||||||
let afterCheckpoint: (() => void) | undefined;
|
let afterCheckpoint: (() => void) | undefined;
|
||||||
|
let heartbeats = 0;
|
||||||
|
let simulateLongCalculation = false;
|
||||||
|
let simulatedElapsed = 0;
|
||||||
const threads = new Set<NodeWorker>();
|
const threads = new Set<NodeWorker>();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -19,8 +22,16 @@ describe('real planning worker cancellation', () => {
|
|||||||
const entry = join(directory, 'entry.ts');
|
const entry = join(directory, 'entry.ts');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
entry,
|
entry,
|
||||||
`import { parentPort } from 'node:worker_threads';
|
`import { parentPort, workerData } from 'node:worker_threads';
|
||||||
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
|
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'))});
|
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||||
`,
|
`,
|
||||||
@@ -30,13 +41,21 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
afterCheckpoint = undefined;
|
afterCheckpoint = undefined;
|
||||||
|
heartbeats = 0;
|
||||||
|
simulateLongCalculation = false;
|
||||||
|
simulatedElapsed = 0;
|
||||||
class RealWorker {
|
class RealWorker {
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
private thread = new NodeWorker(bundle);
|
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
|
||||||
constructor() {
|
constructor() {
|
||||||
threads.add(this.thread);
|
threads.add(this.thread);
|
||||||
this.thread.on('message', (data) => {
|
this.thread.on('message', (data) => {
|
||||||
|
if (data.type === 'test-clock') {
|
||||||
|
simulatedElapsed = data.elapsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'heartbeat') heartbeats++;
|
||||||
this.onmessage?.({ data } as MessageEvent);
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
if (data.type === 'checkpoint') afterCheckpoint?.();
|
if (data.type === 'checkpoint') afterCheckpoint?.();
|
||||||
});
|
});
|
||||||
@@ -63,7 +82,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
const stages: string[] = [];
|
const stages: string[] = [];
|
||||||
let preparationUpdates = 0;
|
let preparationUpdates = 0;
|
||||||
const calculation = createPlanningCalculation({
|
const calculation = createPlanningCalculation({
|
||||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||||
balances: [],
|
balances: [],
|
||||||
rules: [],
|
rules: [],
|
||||||
progress: (stage) => {
|
progress: (stage) => {
|
||||||
@@ -82,6 +101,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
expect(stages).not.toContain('obligations');
|
expect(stages).not.toContain('obligations');
|
||||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||||
expect(await calculation.explanationError).toBeUndefined();
|
expect(await calculation.explanationError).toBeUndefined();
|
||||||
|
expect(heartbeats).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
|
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);
|
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 = {
|
const submitted = {
|
||||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||||
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
balances: [
|
||||||
|
{
|
||||||
|
id: 'cp',
|
||||||
|
type: 'cp' as const,
|
||||||
|
quantity: 1,
|
||||||
|
availableFrom: '2026-12-24',
|
||||||
|
expiryDate: '2026-12-24',
|
||||||
|
},
|
||||||
|
],
|
||||||
rules: [],
|
rules: [],
|
||||||
};
|
};
|
||||||
const { calculatePlanningInternal } = await import('./leave-planner.core');
|
const { calculatePlanningInternal } = await import('./leave-planner.core');
|
||||||
const expected = calculatePlanningInternal(submitted);
|
const expected = calculatePlanningInternal(submitted);
|
||||||
|
expect(expected).toMatchObject({ kind: 'Plan', lastCompletedStage: 'dates' });
|
||||||
|
simulateLongCalculation = true;
|
||||||
const calculation = createPlanningCalculation(submitted);
|
const calculation = createPlanningCalculation(submitted);
|
||||||
expect(await calculation.outcome).toEqual(expected);
|
expect(await calculation.outcome).toEqual(expected);
|
||||||
|
expect(simulatedElapsed).toBe(20_000);
|
||||||
expect((await calculation.trace)?.status).toBe('ready');
|
expect((await calculation.trace)?.status).toBe('ready');
|
||||||
expect(await calculation.explanationError).toBeUndefined();
|
expect(await calculation.explanationError).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type WorkerRequest =
|
|||||||
};
|
};
|
||||||
|
|
||||||
type WorkerResponse =
|
type WorkerResponse =
|
||||||
|
| { type: 'heartbeat' }
|
||||||
| { type: 'progress'; stage: string }
|
| { type: 'progress'; stage: string }
|
||||||
| { type: 'trace-overhead'; totalMs: number }
|
| { type: 'trace-overhead'; totalMs: number }
|
||||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
|
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
|
||||||
@@ -301,7 +302,12 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|||||||
calculationComplete: false,
|
calculationComplete: false,
|
||||||
};
|
};
|
||||||
activeTraceDelivery = traceDelivery;
|
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 {
|
try {
|
||||||
|
heartbeat();
|
||||||
outcome = await calculatePlanningAsyncInternal(
|
outcome = await calculatePlanningAsyncInternal(
|
||||||
{
|
{
|
||||||
...event.data.request,
|
...event.data.request,
|
||||||
@@ -351,6 +357,7 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|||||||
} satisfies WorkerResponse);
|
} satisfies WorkerResponse);
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
if (activeCalculation === calculation) activeCalculation = undefined;
|
if (activeCalculation === calculation) activeCalculation = undefined;
|
||||||
}
|
}
|
||||||
if (!outcomePosted)
|
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;
|
description: string;
|
||||||
addVacationDay: string;
|
addVacationDay: string;
|
||||||
calculate: string;
|
calculate: string;
|
||||||
|
durationEstimate: string;
|
||||||
|
durationUnits: { seconds: string; minutes: string };
|
||||||
remainingDaysWarning: string;
|
remainingDaysWarning: string;
|
||||||
remainingDaysManualPlace: string;
|
remainingDaysManualPlace: string;
|
||||||
deleteEntry: string;
|
deleteEntry: string;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export class UserInputService {
|
|||||||
);
|
);
|
||||||
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
||||||
readonly computeTriggerSignal = signal(0);
|
readonly computeTriggerSignal = signal(0);
|
||||||
|
readonly computationRequestedAtMs = signal<number | null>(null);
|
||||||
|
|
||||||
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
|
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
|
||||||
this.vacationNumberSignal.update(updater);
|
this.vacationNumberSignal.update(updater);
|
||||||
@@ -129,6 +130,7 @@ export class UserInputService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public requestComputation(): void {
|
public requestComputation(): void {
|
||||||
|
this.computationRequestedAtMs.set(performance.now());
|
||||||
this.computeTriggerSignal.update((count) => count + 1);
|
this.computeTriggerSignal.update((count) => count + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"description": "Fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.",
|
"description": "Fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.",
|
||||||
"addVacationDay": "Urlaubskonto hinzufügen",
|
"addVacationDay": "Urlaubskonto hinzufügen",
|
||||||
"calculate": "Plan berechnen",
|
"calculate": "Plan berechnen",
|
||||||
|
"durationEstimate": "Ungefähre Wartezeit: {range} {unit}",
|
||||||
|
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||||
"remainingDaysWarning": "{count} nicht platziert:",
|
"remainingDaysWarning": "{count} nicht platziert:",
|
||||||
"remainingDaysManualPlace": "Bitte manuell platzieren.",
|
"remainingDaysManualPlace": "Bitte manuell platzieren.",
|
||||||
"deleteEntry": "Eintrag löschen",
|
"deleteEntry": "Eintrag löschen",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"description": "Add each leave balance and its validity period.",
|
"description": "Add each leave balance and its validity period.",
|
||||||
"addVacationDay": "Add leave balance",
|
"addVacationDay": "Add leave balance",
|
||||||
"calculate": "Calculate plan",
|
"calculate": "Calculate plan",
|
||||||
|
"durationEstimate": "Approximate wait: {range} {unit}",
|
||||||
|
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||||
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
|
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
|
||||||
"remainingDaysManualPlace": "Please place them manually.",
|
"remainingDaysManualPlace": "Please place them manually.",
|
||||||
"deleteEntry": "Delete entry",
|
"deleteEntry": "Delete entry",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"description": "Añade cada saldo de vacaciones y su periodo de validez.",
|
"description": "Añade cada saldo de vacaciones y su periodo de validez.",
|
||||||
"addVacationDay": "Añadir saldo de vacaciones",
|
"addVacationDay": "Añadir saldo de vacaciones",
|
||||||
"calculate": "Calcular plan",
|
"calculate": "Calcular plan",
|
||||||
|
"durationEstimate": "Espera aproximada: {range} {unit}",
|
||||||
|
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||||
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
|
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
|
||||||
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
|
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
|
||||||
"deleteEntry": "Eliminar entrada",
|
"deleteEntry": "Eliminar entrada",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"description": "Ajoutez chaque solde de congés et sa période de validité.",
|
"description": "Ajoutez chaque solde de congés et sa période de validité.",
|
||||||
"addVacationDay": "Ajouter un solde de congés",
|
"addVacationDay": "Ajouter un solde de congés",
|
||||||
"calculate": "Calculer le planning",
|
"calculate": "Calculer le planning",
|
||||||
|
"durationEstimate": "Attente indicative : {range} {unit}",
|
||||||
|
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||||
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
|
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
|
||||||
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
|
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
|
||||||
"deleteEntry": "Supprimer l’entrée",
|
"deleteEntry": "Supprimer l’entrée",
|
||||||
|
|||||||
Reference in new issue
Block a user