Compare commits

..
Author SHA1 Message Date
renovate Bot 53371a8a93 chore(deps): update https://gitea.com/actions/checkout action to v7
CI / validate (push) Successful in 3m36s
CI / build (push) Successful in 42s
CI / test (push) Failing after 2m52s
CI / security (push) Successful in 1m9s
CI / build (pull_request) Successful in 33s
CI / security (pull_request) Successful in 1m47s
CI / benchmark (push) Successful in 5m36s
CI / required (push) Failing after 1s
CI / images (pull_request) Skipped
CI / policy (push) Successful in 8s
CI / policy (pull_request) Successful in 10s
CI / validate (pull_request) Successful in 33s
CI / test (pull_request) Failing after 3m15s
CI / images (push) Skipped
CI / benchmark (pull_request) Successful in 4m36s
CI / required (pull_request) Failing after 1s
2026-10-18 00:07:52 +02:00
nessar 5ee95fe9ca chore(deps): update https://gitea.com/actions/upload-artifact action to v7
CI / validate (push) Successful in 34s
CI / security (push) Successful in 55s
CI / build (push) Successful in 3m22s
CI / test (push) Failing after 4m57s
CI / benchmark (push) Successful in 8m0s
CI / required (push) Failing after 2s
CI / policy (push) Successful in 16s
CI / images (push) Skipped
2026-10-18 00:07:18 +02:00
renovate Bot 389f6eb7e2 chore(deps): update https://gitea.com/actions/upload-artifact action to v7
CI / validate (push) Successful in 23s
CI / test (push) Successful in 4m39s
CI / security (push) Successful in 1m48s
CI / benchmark (push) Successful in 7m10s
CI / build (pull_request) Successful in 51s
CI / security (pull_request) Successful in 1m51s
CI / build (push) Successful in 10m40s
CI / required (push) Successful in 2s
CI / images (pull_request) Successful in 2m29s
CI / policy (push) Successful in 8s
CI / policy (pull_request) Successful in 11s
CI / validate (pull_request) Successful in 35s
CI / test (pull_request) Successful in 3m30s
CI / benchmark (pull_request) Successful in 5m4s
CI / images (push) Successful in 1m15s
CI / required (pull_request) Successful in 2s
2026-10-17 23:45:07 +02:00
nessar 880efe4e90 chore(deps): update angular-cli monorepo to v22.2.3
CI / validate (push) Successful in 36s
CI / security (push) Successful in 55s
CI / build (push) Successful in 6m46s
CI / benchmark (push) Successful in 7m56s
CI / test (push) Successful in 12m52s
CI / required (push) Successful in 3s
CI / policy (push) Successful in 17s
CI / images (push) Successful in 2m4s
2026-10-17 23:44:49 +02:00
nessarandClaude Opus 5.5 c59d75e526 chore(deps): update angular-cli monorepo to v22.2.3
CI / validate (push) Successful in 49s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m7s
CI / build (push) Successful in 4m45s
CI / build (pull_request) Successful in 1m6s
CI / security (pull_request) Successful in 1m4s
CI / required (push) Successful in 3s
CI / images (pull_request) Successful in 2m8s
CI / policy (push) Successful in 10s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 1m56s
CI / benchmark (push) Successful in 7m57s
CI / test (pull_request) Successful in 5m23s
CI / images (push) Successful in 1m17s
CI / benchmark (pull_request) Successful in 5m30s
CI / required (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:31:52 +02:00
nessar b086f1ffc8 fix(deps): update angular monorepo to v22.2.3
CI / build (push) Successful in 33s
CI / validate (push) Successful in 1m17s
CI / security (push) Successful in 1m48s
CI / test (push) Successful in 3m12s
CI / benchmark (push) Successful in 5m54s
CI / required (push) Successful in 2s
CI / policy (push) Successful in 13s
CI / images (push) Successful in 1m39s
2026-10-17 23:27:45 +02:00
renovate Bot 813d9b4d7f fix(deps): update angular monorepo to v22.2.3
CI / validate (push) Successful in 37s
CI / build (push) Successful in 35s
CI / security (push) Successful in 1m38s
CI / test (push) Successful in 3m53s
CI / benchmark (push) Successful in 5m15s
CI / security (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 5m47s
CI / required (push) Successful in 2s
CI / images (pull_request) Successful in 1m17s
CI / policy (push) Successful in 13s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 37s
CI / test (pull_request) Successful in 5m5s
CI / images (push) Successful in 1m59s
CI / benchmark (pull_request) Successful in 7m58s
CI / required (pull_request) Successful in 2s
2026-10-17 23:07:08 +02:00
nessar 9158966982 chore(deps): update docker docker tag to v29.9.1
CI / validate (push) Successful in 1m58s
CI / build (push) Successful in 2m31s
CI / security (push) Successful in 1m3s
CI / benchmark (push) Successful in 5m5s
CI / test (push) Successful in 8m8s
CI / required (push) Successful in 3s
CI / policy (push) Successful in 11s
CI / images (push) Successful in 1m0s
2026-10-17 23:05:22 +02:00
nessarandClaude Opus 5.5 d9c7f72461 docs(ci): note the Docker 29.9.1 image
CI / build (push) Successful in 40s
CI / security (push) Successful in 1m44s
CI / test (push) Successful in 3m6s
CI / build (pull_request) Successful in 1m33s
CI / security (pull_request) Successful in 1m1s
CI / validate (push) Successful in 11m34s
CI / images (pull_request) Successful in 2m42s
CI / required (push) Successful in 5s
CI / policy (push) Successful in 8s
CI / policy (pull_request) Successful in 10s
CI / validate (pull_request) Successful in 39s
CI / test (pull_request) Successful in 5m18s
CI / benchmark (push) Successful in 9m44s
CI / benchmark (pull_request) Successful in 7m56s
CI / required (pull_request) Successful in 1s
CI / images (push) Successful in 1m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:24:11 +02:00
renovate Bot 1636355920 chore(deps): update docker docker tag to v29.9.1 2026-10-17 21:23:53 +02:00
nessar 85fc4561fd build: run the project on Bun 1.4.3
CI / validate (push) Successful in 38s
CI / build (push) Successful in 24s
CI / security (push) Successful in 1m42s
CI / test (push) Successful in 2m48s
CI / benchmark (push) Successful in 4m48s
CI / required (push) Successful in 1s
CI / policy (push) Successful in 19s
CI / images (push) Successful in 58s
close #289, close #291, close #292

See pull request #299
2026-10-17 18:28:42 +02:00
nessarandClaude Opus 5.5 282d9c6787 fix: report line-only coverage honestly and tighten Bun test helpers
CI / validate (push) Successful in 41s
CI / build (push) Successful in 36s
CI / security (push) Successful in 1m3s
CI / test (push) Successful in 3m3s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 57s
CI / benchmark (push) Successful in 5m19s
CI / required (push) Successful in 3s
CI / images (pull_request) Successful in 54s
CI / policy (push) Successful in 7s
CI / policy (pull_request) Successful in 10s
CI / validate (pull_request) Successful in 32s
CI / test (pull_request) Successful in 3m25s
CI / benchmark (pull_request) Successful in 4m48s
CI / images (push) Successful in 1m20s
CI / required (pull_request) Successful in 1s
The generated HTML and Cobertura reports no longer show unmeasured
branch and function coverage as 100%. Poll and waitFor deadlines use
performance.now so frozen dates cannot make them loop forever, the
malformed-configuration test covers undefined again next to the empty
array, compose health checks honor PORT, and the migration spec lists
only what is still open.

Refs #291, #292, #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:10:29 +02:00
nessarandClaude Opus 5.5 dfe6d28d2b test(planning): make the checkpoint cancellation test deterministic
The cancelled stage takes about a millisecond, so under load the worker
could finish it before the cancel message arrived and report it as
completed. The real-worker harness now holds the worker after reporting
that stage until the cancel is delivered, keeping every assertion.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:57:33 +02:00
nessarandClaude Opus 5.5 ac69a41fc8 ci: run development, quality checks and CI on Bun 1.4.3
Pin Bun 1.4.3 with packageManager, and force every bun run script onto
the Bun runtime through bunfig so ng, ESLint and Prettier never fall
back to an installed Node. Type checking uses bun check for the
application and test projects, next to the Angular build.

CI jobs install the pinned release from a checksum-verified download
instead of npm and run project steps in a shell without Node on PATH,
which stays available only for the checkout and upload actions. The
image test's fake docker runs on Bun.

Refs #290, #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:25:46 +02:00
nessarandClaude Opus 5.5 f74d744f9b feat(server): serve the client-only build with Bun
Angular's server build needs Node loader hooks that Bun does not apply,
and every route is client-rendered, so Angular now builds a client-only
bundle and a small Bun.serve server delivers it: hashed assets with the
one-year cache, index.html revalidated, extension-less routes falling
back to index.html, missing assets 404, traversal and malformed paths
rejected, and other methods 405. Express, @angular/ssr and the unused
hydration provider are removed. Both Docker stages use the pinned
oven/bun:1.4.3-alpine image without Node, and the health checks use
Bun's fetch.

Refs #292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:50:05 +02:00
nessarandClaude Opus 5.5 94d57add2c test: run the suite and benchmark with Bun's native test runner
Replace Vitest with bun test across the whole suite. A preload compiles
components through Angular's JIT transform with inlined templates, sets
up jsdom, TestBed and zoneless change detection, and a small shim covers
the Vitest APIs bun:test lacks. Files run isolated, real-worker specs
spawn the running Bun, and line coverage from lcov is converted to the
existing HTML and Cobertura reports. The CI benchmark replaces its heap
cap with a container memory limit. Vitest, Jest, Nx and their Vite
configuration are removed.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:42 +02:00
nessarandClaude Opus 5.5 558c70aa24 docs(adr): record Bun 1.4.3 verification results and decisions
Stable Bun 1.4.3 runs installs, the development server, standalone type
checks, the native test runner and a Bun.serve static server. Native
lint/fmt are canary-only, and Angular's server build needs Node loader
hooks Bun does not apply, so the spec now keeps ESLint/Prettier until a
stable release, builds Angular client-only behind a Bun static server,
converts line coverage from lcov, and replaces the heap cap with a
container memory limit.

Refs #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:49:17 +02:00
nessarandClaude Opus 5.5 c949480e49 docs(adr): propose Bun runtime and native tooling
Record the proposed decision to run project-owned JavaScript under Bun
with its native test runner, linter, formatter, type checker and HTTP
server, keeping Angular's standard build pipeline, plus the development
spec for the migration tickets.

Refs #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:01:42 +02:00
nessar 752c8b7a99 refactor(planning): consolidate calculation, projection and worker ownership
CI / validate (push) Successful in 57s
CI / build (push) Successful in 57s
CI / security (push) Successful in 1m1s
CI / test (push) Successful in 3m12s
CI / benchmark (push) Successful in 7m13s
CI / required (push) Successful in 1s
CI / policy (push) Successful in 11s
CI / images (push) Successful in 1m10s
close #274, close #275, close #276, close #277

See pull request #298
2026-10-17 11:53:19 +02:00
nessarandClaude Opus 5.5 cee67ce3df refactor(calendar): drop the unused projection horizon
CI / validate (push) Successful in 1m2s
CI / build (push) Successful in 1m27s
CI / security (push) Successful in 1m12s
CI / test (push) Successful in 4m19s
CI / security (pull_request) Successful in 1m3s
CI / build (pull_request) Successful in 34s
CI / required (push) Successful in 3s
CI / images (pull_request) Successful in 1m56s
CI / policy (push) Successful in 7s
CI / policy (pull_request) Successful in 11s
CI / validate (pull_request) Successful in 1m21s
CI / benchmark (push) Successful in 4m10s
CI / test (pull_request) Successful in 3m23s
CI / images (push) Successful in 1m34s
CI / benchmark (pull_request) Successful in 7m13s
CI / required (pull_request) Successful in 2s
No caller read CalendarProjection.horizon, and with fixed months it no
longer described the listed months.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:36:19 +02:00
nessarandClaude Opus 5.5 a472347a5e fix(calendar): keep declaration-entry months fixed while editing
Declaring a date at the edge of the planning period widened the
projection and inserted a disabled month above the grid. Declaration
entry now lists only the planning period's months, while selections
keep the connected-period widening.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:29:31 +02:00
nessarandClaude Opus 5.5 feb651148e fix(planning): react only to calculations started by the mounted calendar
Re-mounting the calendar no longer records a duration sample or
announces the previous result, explanation or rejected explanation.
Starting a calculation during a cancellation announces loading and
keeps focus where it is. Only rejected explanation deliveries are
announced, as before. Replacement tests now replace a real plan.

Refs #275, #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:22:38 +02:00
nessarandClaude Opus 5.5 43677ff434 refactor(planning): drop the unreachable late worker message guard
release() already clears the worker handlers, so the released checks
inside them could never run. The lifetime test now states what it
checks: no handler is left for late messages.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:22:38 +02:00
nessarandClaude Opus 5.5 502d561f6f refactor(calendar): encapsulate calendar projection and selection ranges
CalendarService.project returns the covered months together with the
projected selections, so month enumeration and selected-day rendering
share one projection horizon. SelectedDates is built once from the
complete range list and groups connected periods internally, exposing
read-only selection, boundary and day-type queries.

In declaration entry, months reached by adjacent declared or
non-working days are now shown, matching the projected selections.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:18:33 +02:00
nessarandClaude Opus 5.5 5cb0b93339 refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.

Refs #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00
nessarandClaude Opus 5.5 b69b3c3690 refactor(planning): give planning workers one lifetime owner
Planning, counterfactual analysis and checkpoint explanation now share
one internal worker-job owner for creation, handler cleanup,
termination and supersession, and one request/response message
contract between the coordinator and the worker. Job-specific progress,
checkpoint recovery and completion rules stay explicit.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00
nessarandClaude Opus 5.5 7fc2b33587 refactor(planning): remove the unused remaining-balance mirror
The summary and details already read the displayed plan's remaining
balances, so the writable mirror, its legacy conversion and the VacDay
classification/reason fields it alone populated are removed.

Refs #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:05:51 +02:00
nessar 40a11c245a perf(planning): incremental exact scoring for the loaded year
CI / validate (push) Successful in 57s
CI / build (push) Successful in 1m1s
CI / security (push) Successful in 54s
CI / test (push) Successful in 3m9s
CI / benchmark (push) Successful in 7m25s
CI / required (push) Successful in 1s
CI / policy (push) Successful in 6s
CI / images (push) Successful in 1m10s
close #280

See pull request #287
2026-10-17 02:18:27 +02:00
nessarandClaude Opus 5.5 3bbe989eca test(benchmark): print a readable summary table
CI / validate (push) Successful in 1m5s
CI / build (push) Successful in 1m38s
CI / security (push) Successful in 1m19s
CI / test (push) Successful in 3m29s
CI / benchmark (push) Successful in 4m13s
CI / security (pull_request) Successful in 56s
CI / build (pull_request) Successful in 1m42s
CI / required (push) Successful in 1s
CI / images (pull_request) Successful in 1m11s
CI / policy (push) Successful in 7s
CI / policy (pull_request) Successful in 10s
CI / validate (pull_request) Successful in 1m42s
CI / test (pull_request) Successful in 3m8s
CI / images (push) Successful in 1m15s
CI / benchmark (pull_request) Successful in 3m37s
CI / required (pull_request) Successful in 1s
Print the calibration and a Markdown table with one aligned row per
scenario as soon as it finishes: both planning times, mean, budget, ratio,
multiplier, outcome, placements, determinism and budget status. Write
directly to stdout so Vitest adds no per-line headers. The complete TSV
report still follows the table.

Refs #281, #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:09:54 +02:00
nessarandClaude Opus 5.5 cfdb28c90a ci(benchmark): size the hang guard for the slowest runner
CI / validate (push) Successful in 53s
CI / build (push) Successful in 1m18s
CI / security (push) Successful in 1m12s
CI / test (push) Successful in 4m6s
CI / build (pull_request) Successful in 36s
CI / security (pull_request) Successful in 1m5s
CI / required (push) Successful in 1s
CI / images (pull_request) Successful in 1m50s
CI / policy (push) Successful in 6s
CI / policy (pull_request) Successful in 9s
CI / validate (pull_request) Successful in 1m18s
CI / benchmark (push) Successful in 3m56s
CI / test (pull_request) Successful in 3m2s
CI / images (push) Successful in 1m15s
CI / benchmark (pull_request) Successful in 7m18s
CI / required (pull_request) Successful in 1s
The first PR run landed on srvhome, about 1.9 times slower than
nessar-desktop. The fixed 300-second timeout killed its protocol during the
loaded year, although every completed scenario was within its relative
budget. Raise the CI timeout to 600 seconds and the test timeout to 570
seconds so they only guard against hangs, and record the first CI-runner
ratios.

Refs #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:40:41 +02:00
nessarandClaude Opus 5.5 63b50c4311 docs(benchmark): record the remaining gap to the speed target
CI / validate (push) Successful in 1m1s
CI / build (push) Successful in 1m27s
CI / security (push) Successful in 1m16s
CI / benchmark (push) Successful in 4m31s
CI / test (push) Successful in 4m36s
CI / build (pull_request) Successful in 39s
CI / security (pull_request) Successful in 1m11s
CI / required (push) Successful in 1s
CI / images (pull_request) Skipped
CI / policy (push) Successful in 6s
CI / policy (pull_request) Successful in 9s
CI / validate (pull_request) Successful in 1m34s
CI / test (pull_request) Successful in 3m24s
CI / images (push) Successful in 1m16s
CI / benchmark (pull_request) Failing after 6m0s
CI / required (pull_request) Failing after 2s
The exact optimizations bring the loaded year from 66.0 s to 44.1 s, but the
target of 10 s for the loaded year and 3 s for the other scenarios is not
reached for the loaded year, typed obligations and three years. Record the
gap and the remaining profile, keep the relative-gate multipliers and the
300-second hang guard unchanged, and note that ADR 0003's five-second target
should be revisited together with a decision on search changes.

Refs #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 acdb19352a ci(benchmark): gate scenario speed relative to the runner
Before the protocol, time the frozen engine on the two-year scenario, best
of three, and fail any scenario whose mean planning time exceeds its
multiplier times that calibration. Multipliers come from development-machine
ratios with about 1.5x headroom until #286 tightens them. The 300-second
timeout stays only as a hang guard. The benchmark keeps running on push and
pull_request events because the push run gates image publication.

This replaces the absolute 300-second acceptance protocol named in #78.

Refs #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 68a9e33de6 perf(planning): prune candidates that cannot beat the protected prefix
Finalizing a candidate never moves or retypes a placement, so preference
results and calendar optimizations already have their final values before
it, and leave-balance optimizations do too when nothing can be earned.
Reject a candidate before finalizing when the first differing such
objective of the protected prefix is worse than the baseline. Leave-placement
objectives and earning contexts stop the walk.

The frozen-engine check now also compares cancelled asynchronous runs,
including checkpoints and retained incumbents. The loaded year drops from
48.5 s to 44.3 s; outcomes, explanations and final states are unchanged.

Refs #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 7890a03a74 perf(planning): update plan observations incrementally
Cache an immutable occupancy per placements map and derive a candidate's
time-off period groups, holiday-week completeness and placement tie key
from its parent when one date is added or removed. The from-scratch
computation stays as the test oracle and an opt-in shadow mode. A
fast-check property test compares both after every operation.

The loaded year drops from 66.5 s to 48.5 s and the protocol from 262.0 s
to 193.7 s. Outcomes, explanations and final states stay identical.

Refs #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 87d72064be refactor(planning): consolidate plan observations in one module
Move time-off periods, work sequences, public-holiday weeks, balance
observations, the placement list and both tie keys into
plan-observations.ts. The code moves without behavior changes; all seven
benchmark scenarios produce byte-identical outcomes, explanations and final
states, and the frozen-engine check passes. Benchmark timings stay within
noise.

Refs #282

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:47:34 +02:00
nessarandClaude Opus 5.5 24c35542a8 test(benchmark): report each scenario as it finishes
Print the report header before the measured runs and each scenario's rows
as soon as its repetitions complete, so a timed-out job still shows the
finished scenarios. The final report, scenarios, warm-ups, repetitions,
assertions, heap limit and timeout are unchanged.

Refs #281

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:36:11 +02:00
nessarandClaude Opus 5.5 4d26e65672 test(planning): compare the engine against a frozen engine on random requests
Bundle the planning core at a871f8f (develop after PR #278) as a frozen
fixture and compare it with the current engine on 5,000 seeded random
requests. Outcomes, stable explanations, explanation errors and final-state
snapshots must be strictly deep-equal. Requests cover obligations,
preferences, IF_THEN and TIME_WINDOW rules, earned entitlements, composed
optimization rules, declared days, year boundaries and public holidays.

Refs #280

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:30:30 +02:00
nessar a871f8f28c add a skill
CI / validate (push) Successful in 1m3s
CI / build (push) Successful in 1m21s
CI / security (push) Successful in 57s
CI / test (push) Successful in 2m22s
CI / benchmark (push) Successful in 5m10s
CI / required (push) Failing after 1s
CI / policy (push) Failing after 9s
CI / images (push) Skipped
2026-10-16 22:47:08 +02:00
nessar a371e03462 perf(planning): restore the integrated benchmark gate
CI / validate (push) Successful in 1m17s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 2m30s
CI / benchmark (push) Failing after 5m55s
CI / required (push) Failing after 2s
CI / policy (push) Successful in 18s
CI / images (push) Skipped
See pull request #278
2026-10-16 22:36:31 +02:00
nessarandClaude Opus 5.5 1711bc570c docs(benchmark): record PR #278 benchmark headroom
CI / validate (push) Successful in 1m13s
CI / build (push) Successful in 1m0s
CI / security (push) Successful in 1m43s
CI / test (push) Successful in 3m21s
CI / build (pull_request) Successful in 1m29s
CI / security (pull_request) Successful in 1m42s
CI / benchmark (push) Failing after 5m50s
CI / required (push) Failing after 2s
CI / images (pull_request) Successful in 1m54s
CI / policy (push) Successful in 8s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 54s
CI / test (pull_request) Successful in 2m19s
CI / images (push) Skipped
CI / benchmark (pull_request) Successful in 4m58s
CI / required (pull_request) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:24:22 +02:00
nessarandClaude Opus 5.5 c6610cd633 perf(planning): reject impossible mandatory moves before copying
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:04:28 +02:00
nessar 433a8b0ade perf(planning): reuse immutable non-working date counts 2026-10-16 21:19:58 +02:00
nessar c7e7abcd24 perf(planning): build placement date keys directly 2026-10-16 21:03:36 +02:00
nessar cbde23f572 perf(planning): restore the integrated benchmark gate
CI / validate (push) Successful in 1m23s
CI / test (push) Successful in 3m17s
CI / build (push) Successful in 1m31s
CI / security (push) Successful in 1m39s
CI / build (pull_request) Successful in 1m30s
CI / security (pull_request) Successful in 1m40s
CI / images (pull_request) Skipped
CI / required (push) Failing after 2s
CI / policy (push) Successful in 7s
CI / benchmark (push) Failing after 5m52s
CI / images (push) Skipped
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m11s
CI / test (pull_request) Successful in 3m17s
CI / benchmark (pull_request) Failing after 5m57s
CI / required (pull_request) Failing after 1s
Reduce objective evaluation and calendar allocation work while preserving planner outcomes and deterministic explanations. Restore required benchmark gating and record the unchanged protocol, differential checks, and browser evidence for #78.
2026-10-16 14:01:32 +02:00
nessar 0852cd4444 test(planning): verify integrated planning after trace removal
CI / validate (push) Successful in 1m14s
CI / test (push) Successful in 3m21s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m21s
CI / security (push) Successful in 1m45s
CI / required (push) Successful in 4s
CI / policy (push) Successful in 7s
CI / images (push) Successful in 1m53s
close #77

See pull request #273
2026-10-16 13:57:53 +02:00
nessar 26a8b076ba ci: use reachable Trivy database mirror
CI / validate (push) Successful in 1m11s
CI / test (push) Successful in 3m20s
CI / benchmark (push) Failing after 5m57s
CI / build (push) Successful in 1m20s
CI / security (push) Successful in 3m29s
CI / build (pull_request) Successful in 1m26s
CI / security (pull_request) Successful in 5m57s
CI / required (push) Successful in 4s
CI / images (pull_request) Successful in 3m25s
CI / policy (push) Successful in 7s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m13s
CI / test (pull_request) Successful in 3m23s
CI / benchmark (pull_request) Failing after 6m10s
CI / images (push) Successful in 2m46s
CI / required (pull_request) Successful in 1s
2026-10-15 23:23:22 +02:00
nessar 56577d23b5 test(planning): verify integrated planning after trace removal
CI / validate (push) Successful in 44s
CI / build (push) Successful in 40s
CI / test (push) Successful in 1m52s
CI / benchmark (push) Failing after 5m29s
CI / security (pull_request) Failing after 12m15s
CI / build (pull_request) Failing after 14m33s
CI / security (push) Failing after 19m29s
CI / images (pull_request) Skipped
CI / required (push) Failing after 1s
CI / policy (push) Successful in 6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m19s
CI / test (pull_request) Successful in 3m22s
CI / benchmark (pull_request) Failing after 17m21s
CI / images (push) Skipped
CI / required (pull_request) Failing after 1s
2026-10-15 21:45:58 +02:00
nessar 2f4af14869 refactor(planning): remove the original decision trace
CI / validate (push) Successful in 48s
CI / build (push) Successful in 45s
CI / test (push) Successful in 2m10s
CI / benchmark (push) Failing after 5m49s
CI / security (push) Successful in 6m54s
CI / required (push) Successful in 1s
CI / policy (push) Successful in 21s
CI / images (push) Successful in 3m7s
close #76

See pull request #272
2026-10-15 21:26:36 +02:00
nessar d9d9befa12 refactor(planning): remove the original decision trace
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / build (pull_request) Successful in 43s
CI / required (push) Successful in 2s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / policy (push) Successful in 7s
CI / benchmark (push) Failing after 5m53s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / benchmark (pull_request) Failing after 5m28s
CI / required (pull_request) Successful in 1s
2026-10-15 20:42:33 +02:00
nessar ea4256107f Merge pull request 'ci: migrate to Gitea Actions' (#271) from feature/gitea-actions into develop
CI / validate (push) Successful in 1m18s
CI / test (push) Successful in 3m42s
CI / build (push) Successful in 1m16s
CI / security (push) Successful in 6m51s
CI / required (push) Failing after 1s
CI / policy (push) Failing after 8s
CI / benchmark (push) Failing after 5m59s
CI / images (push) Skipped
Reviewed-on: #271
2026-10-15 19:55:10 +02:00
104 changed files with 9624 additions and 16985 deletions

No files matched your search

@@ -0,0 +1,36 @@
---
name: tiered-verified-implement
description: Implement a spec or ticket in Claude Code by delegating the work to the cheapest Claude model that can handle each piece (Haiku for mechanical edits, Sonnet for normal work), then independently verify the final change on the orchestrator's model. Use when the user asks to implement, build, or fix work and wants it done cheaply, on a cheaper model, or with delegated implementation and a verification pass.
---
# Tiered Verified Implement
Invoke `/implement` and follow it as the baseline workflow. This skill is its cost-saving, delegated, verified variant: cheap models write the code, the orchestrator's model checks it.
## Model tiers
Pick the cheapest tier that can do each piece without guessing:
- `haiku`: mechanical work with no design decisions — renames, boilerplate, fixture or snapshot updates, wiring an existing pattern into one more place, translations, doc edits.
- `sonnet`: the default — new logic, bug fixes, tests for real behavior, anything touching several files in one seam.
- Orchestrator (yourself): only for work a cheap agent failed on twice, or that needs a consequential design call the spec leaves open. Ask the user about such calls before delegating rather than letting a cheap agent guess.
When unsure between two tiers, choose the lower one; escalation is cheaper than defaulting high.
## Workflow
1. Read the spec or ticket, repository guidance, affected flow, and existing worktree changes. Record the starting commit and task-owned scope; preserve unrelated changes.
2. Split only genuinely independent, bounded work. If the change is small or one seam owns it, use one implementation agent; do not manufacture parallelism. Assign each piece a tier.
3. Spawn each implementation agent with the Agent tool: `subagent_type: "general-purpose"` and `model` set to its tier. Never use `subagent_type: "fork"` for this — forks ignore the `model` override and run on the expensive orchestrator model. Fresh agents start cold, so the brief carries everything: exact owned files/seam, acceptance criteria, relevant file paths and existing patterns to copy, required checks (targeted tests, typecheck), and an instruction not to overwrite or revert other agents' work. A precise brief is the main cost lever: it stops the cheap agent from re-exploring the repo.
4. Wait for agents, inspect their diffs (not their whole exploration), resolve overlaps minimally, and run the checks required by `/implement`: targeted tests and typecheck during work, then the full relevant suite once.
5. If an agent's result fails checks or misses the spec, send it back once with the concrete failure (SendMessage keeps its context). If it fails again, escalate one tier: haiku → sonnet → orchestrator.
6. Spawn a fresh, read-only verification agent (`subagent_type: "general-purpose"`) with `model` set explicitly to the orchestrator's own tier (e.g. `opus` when you run on Opus). Set it explicitly rather than omitting it, since a configured default subagent model could silently downgrade the verifier. Give it the original request, starting commit, current diff, and test output, but not implementation conclusions. Ask it to check correctness, regressions, specification coverage, and test evidence, and to report findings without changing files.
7. Fix confirmed findings (delegate back to the cheap tier when the fix is mechanical), rerun affected checks, and repeat verification once if fixes were needed. Run `/code-review` on the current working result (including staged, unstaged, and new files), passing the original spec, starting commit, and task-owned scope. Follow `/implement`'s commit boundary: commit only when requested, otherwise hand off the verified diff.
In the final report, list which tier did each piece and any escalations, so the user can see where the cheap models held up.
## Delegation rules
- Keep all agents in the same workspace; assign non-overlapping ownership before they edit.
- Do not delegate secrets, production writes, destructive operations, or changes requiring new user authority.
- If subagents are unavailable, implement directly, run the same checks, and state that delegated implementation and verification were unavailable.
+7 -6
View File
@@ -3,16 +3,17 @@
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
1. **Test Coverage (>= 80%)**:
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
- The overall project line coverage must be maintained at **80% or higher**. Bun reports line data only; statement, branch and function coverage are no longer measured.
- Verify coverage by running:
```bash
bun scripts/generate-langs.js
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**'
bun scripts/coverage-reports.mjs
```
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
2. **Code Formatting (Prettier)**:
- All code files must be correctly formatted according to the project's Prettier config before finishing.
- All code files, the CI scripts and the workflow must be correctly formatted according to the project's Prettier config before finishing.
- Run the formatter with:
```bash
bun run format
@@ -29,8 +30,8 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
- You must execute the local validation commands before completing your task. The Gitea workflow is `.gitea/workflows/ci.yml`; owner activation is tracked in issue #268, and local checks do not satisfy the migration gate. All of the following commands must pass:
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
- **Formatting check**: `bun run format:check`
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json`
- **TypeScript Type check**: `bun run typecheck` (`bun check` for application and test code; it does not replace the production build, which alone checks Angular templates)
- **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
- **CI policy and publication**: `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs`
- **CI policy and publication**: `bun scripts/ci-policy.test.mjs` and `bun scripts/ci-image.test.mjs`
- **Production Build**: `bun run build -- --configuration production`
- The separate performance benchmark remains advisory while issue #78 is open. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
- The separate performance benchmark is a required CI job with a relative gate: each scenario's budget is a multiple of the frozen engine's two-year calibration measured in the same run. Run it outside coverage with `timeout 600s bun test src/app/benchmark --timeout=600000` when verifying performance; see `docs/benchmark/README.md`.
Symlink
+1
View File
@@ -0,0 +1 @@
.agents/
+66 -39
View File
@@ -9,6 +9,11 @@ on:
permissions:
contents: read
# Gitea's checkout and upload-artifact actions are JavaScript actions that the runner
# executes with the job container's own `node`, so those containers keep Node. Project
# commands never use it: the jobs that run them install the pinned Bun with
# scripts/install-bun.sh and run `run:` steps under a shell whose PATH has Bun but no Node.
concurrency:
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
@@ -18,54 +23,66 @@ jobs:
name: policy
runs-on: ubuntu-latest
container: node:24.21.0
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
outputs:
slug: ${{ steps.policy.outputs.slug }}
tag: ${{ steps.policy.outputs.tag }}
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install Bun
run: sh scripts/install-bun.sh
- name: Policy regression tests
run: node scripts/ci-policy.test.mjs && node scripts/ci-image.test.mjs
run: bun scripts/ci-policy.test.mjs && bun scripts/ci-image.test.mjs
- name: Validate event and commit
id: policy
run: node scripts/ci-policy.mjs
run: bun scripts/ci-policy.mjs
validate:
name: validate
runs-on: ubuntu-latest
container: node:24.21.0
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@v7
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- name: Install Bun
run: sh scripts/install-bun.sh
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run format:check
- run: bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
- run: bunx tsc --noEmit -p tsconfig.app.json
- run: bunx tsc --noEmit -p tsconfig.spec.json
- run: bun run typecheck
test:
name: test
runs-on: ubuntu-latest
container: node:24.21.0
env:
NODE_OPTIONS: --max-old-space-size=2048
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@v7
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- name: Install Bun
run: sh scripts/install-bun.sh
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Functional coverage
run: bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
run: bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**'
- name: Coverage report
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
run: bun scripts/coverage-reports.mjs
- name: Upload coverage
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v7
with:
name: coverage
path: coverage/
@@ -75,33 +92,39 @@ jobs:
benchmark:
name: benchmark
runs-on: ubuntu-latest
container: node:24.21.0
# shortcut: advisory while #78 is open; restore the gate after the protocol passes.
continue-on-error: true
env:
NODE_OPTIONS: --max-old-space-size=2048
container:
image: node:24.21.0
options: --memory=3g
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@v7
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- name: Install Bun
run: sh scripts/install-bun.sh
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Unchanged benchmark protocol
run: timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
- name: Benchmark protocol with relative gate
run: timeout 600s bun test src/app/benchmark --timeout=600000
build:
name: build
runs-on: ubuntu-latest
container: node:24.21.0
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@v7
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- name: Install Bun
run: sh scripts/install-bun.sh
- run: bun install --frozen-lockfile
- run: bun run build -- --configuration production
- uses: https://gitea.com/actions/upload-artifact@v3
- uses: https://gitea.com/actions/upload-artifact@v7
with:
name: dist
path: dist/
@@ -113,7 +136,7 @@ jobs:
runs-on: ubuntu-latest
container: node:24.21.0
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://gitea.com/actions/checkout@v7
with:
persist-credentials: false
- name: Install scanners
@@ -136,10 +159,10 @@ jobs:
run: /tmp/semgrep/bin/semgrep scan --config p/javascript --config p/typescript --metrics off --sarif --output semgrep-results.sarif src scripts
- name: Dependency and secret scan
if: ${{ !cancelled() }}
run: trivy fs --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
- name: Security reports
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
uses: https://gitea.com/actions/upload-artifact@v7
with:
name: security
path: '*-results.sarif'
@@ -148,20 +171,20 @@ jobs:
images:
name: images
needs: [policy, validate, test, build, security]
needs: [policy, validate, test, benchmark, build, security]
if: >-
success() &&
(github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository))
runs-on: ubuntu-latest
container: docker:29.8.2
container: docker:29.9.1
concurrency:
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
cancel-in-progress: false
services:
docker:
image: docker:29.8.2-dind
image: docker:29.9.1-dind
env:
DOCKER_TLS_CERTDIR: ''
options: >-
@@ -177,32 +200,36 @@ jobs:
run:
shell: sh
steps:
- name: Install checkout and policy runtimes
run: apk add --no-cache nodejs git
- uses: https://gitea.com/actions/checkout@v4
# Alpine's nodejs package is for the checkout action only (it shares /usr/bin
# with git); Bun needs libgcc and libstdc++ and the installer needs curl.
- name: Install checkout runtime and tools
run: apk add --no-cache nodejs git curl libgcc libstdc++
- uses: https://gitea.com/actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install Bun
run: sh scripts/install-bun.sh
- name: Build branch images or promote a release
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: node scripts/ci-image.mjs
run: /opt/bun/bin/bun scripts/ci-image.mjs
required:
name: required
if: ${{ always() }}
needs: [policy, validate, test, build, security, images]
needs: [policy, validate, test, benchmark, build, security, images]
runs-on: ubuntu-latest
container: node:24.21.0
env:
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.security.result }}
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.benchmark.result }} ${{ needs.build.result }} ${{ needs.security.result }}
IMAGE_RESULT: ${{ needs.images.result }}
IMAGE_EXPECTED: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
steps:
- name: Require successful validation and expected publication
run: |
test "$CHECK_RESULTS" = 'success success success success success'
test "$CHECK_RESULTS" = 'success success success success success success' || exit 1
if [ "$IMAGE_EXPECTED" = 'true' ]; then
test "$IMAGE_RESULT" = 'success'
else
+2
View File
@@ -6,6 +6,8 @@
/out-tsc
/bazel-out
.scratch/
# Node
/node_modules
npm-debug.log
+3
View File
@@ -0,0 +1,3 @@
{
"npm.packageManager": "bun"
}
+4 -23
View File
@@ -132,23 +132,8 @@ The numerical summary of a calculated valid plan's leave usage, time-off periods
A stable account of why an optimization result or incomplete calculation contains its displayed leave placements, expressed through placement provenance, planning objectives, time-off periods, and remaining balances. It reports every contribution of a placement while highlighting the first planning objective that made its position decisive, and describes the displayed plan rather than the calculation history.
_Avoid_: calculation explanation
**Calculation trace**:
The complete, untruncated, ordered history of the planning decisions and candidate evaluations from the same calculation that produced a planning outcome, grouped by calculation phase. It is not reconstructed by replaying the calculation afterward; for an incomplete calculation it ends at the actual interruption point.
_Avoid_: plan explanation
**Planning decision**:
An atomic choice within a calculation phase that accepts, moves, removes, or rejects one or more related leave placements. A joint obligation decision may therefore concern several dates.
_Avoid_: calendar day, step
**Candidate evaluation**:
The comparison the planner actually performs between a possible leave-placement change and the current plan. It belongs to the calculation trace whether the candidate is accepted or rejected.
**Candidate date status**:
The historical relationship between a date and a planning decision: chosen, evaluated and rejected, not examined, or ineligible with a reason. A later counterfactual date analysis never changes this historical status.
**Counterfactual date analysis**:
An on-demand evaluation that changes only the date of one selected leave placement while preserving its leave balance, leave type, and placement provenance. The planner did not necessarily examine that date; the analysis explains a hypothetical alternative but never influences the planning outcome and is not part of the calculation trace.
_Avoid_: candidate evaluation, calculation trace
An on-demand hypothetical comparison of another date for a displayed leave placement. It uses the immutable calculation configuration and final-state snapshot, preserving the same leave entitlement, leave type, and placement provenance while keeping unrelated placements fixed. It never mutates the displayed plan.
**Conflict explanation**:
A stable account of why no plan can jointly satisfy a set of obligation rules within the planning horizon and available leave balances.
@@ -166,12 +151,6 @@ The result of a calculation: invalid input, a planning conflict, an optimization
**Stable plan explanation**:
A deterministic explanation derived from the final displayed plan. It identifies each placement's entitlement and rule provenance, contributions, full objective vector, and the first objective that distinguishes the plan with that placement restored from the same plan with only that placement removed.
**Original decision trace**:
The untruncated phase-by-phase record captured by the calculation that produced the displayed outcome, including atomic decisions, candidate statuses, and state deltas. It is historical evidence and is never recomputed from the final plan.
**Counterfactual date analysis**:
A hypothetical comparison that keeps the calculation snapshot and all unrelated placements fixed while testing one selected placement across every date in the half-open planning horizon. It neither mutates the plan nor rewrites the original trace.
**Incomplete calculation**:
A calculation stopped by user cancellation or another interruption before it completed the planning objective order. On user cancellation it exposes its best available valid plan as partial, if one exists, and never implies that the obligations conflict.
@@ -192,4 +171,6 @@ A valid deterministic plan produced after completing the planning objective orde
Any quantity left unspent by the optimization result, reported as unavoidable expiry, an unplaced at-risk balance, or a deferred balance.
**Non-placement reason**:
A concrete explanation of why a quantity of a leave balance remains unspent in the displayed plan, distinguishing a blocking condition from a decision to preserve leave for later. It includes the relevant dates, is distinct from the balance's expiry-based classification, and can be supported by recorded candidate evaluations.
A concrete explanation of why a quantity of a leave balance remains unspent in the displayed plan, distinguishing a blocking condition from a decision to preserve leave for later. It includes the relevant dates, is distinct from the balance's expiry-based classification and is derived from the displayed plan.
The original decision trace, historical date analysis, historical calendar replay, candidate statuses, and remaining-balance attempt lists were removed by issue #76. Calculations retain only the snapshots needed for outcomes, cancellation recovery, stable explanations, and displayed-placement date analysis.
+10 -28
View File
@@ -1,48 +1,30 @@
# Stage 1: Build the Angular application
FROM oven/bun:alpine AS builder
# Stage 1: Build the Angular application and the Bun static server
FROM oven/bun:1.4.3-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS builder
# Install nodejs to support Angular prerendering worker threads
RUN apk add --no-cache nodejs
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json bun.lock* ./
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Install dependencies
RUN bun install
# Copy source code
COPY . .
# Build the application for production
# bunfig.toml makes the Node-shebang Angular CLI run on Bun; the image has no Node
RUN bun run build
# Stage 2: Production image
FROM oven/bun:alpine AS production
FROM oven/bun:1.4.3-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS production
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json bun.lock* ./
# Install only production dependencies
RUN bun install --production
# Copy built application from builder stage
# The server is a self-contained Bun bundle that serves the browser build, so
# the runtime has no dependencies to install.
COPY --from=builder /app/dist ./dist
# Set environment to production
ENV NODE_ENV=production
# Expose port (default Express port)
EXPOSE 4000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD bun -e "require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
CMD bun -e "fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
# Start the application
CMD ["bun", "run", "dist/vac-optimizer/server/server.mjs"]
CMD ["bun", "dist/vac-optimizer/server/server.js"]
+44 -33
View File
@@ -11,13 +11,13 @@ The planner answers these questions:
- What is the best plan found by the ordered heuristic?
- If calculation stops early, what valid incumbent is available?
- Why did each displayed placement survive the objective order?
- Which alternatives were considered, and what would another date change?
- What would another date change for a displayed placement?
## Architecture
```mermaid
flowchart LR
Host["Express production host<br/>static and client assets"] -. serves .-> UI
Host["Bun static server<br/>client assets"] -. serves .-> UI
subgraph Browser
UI["Angular standalone components"]
@@ -37,25 +37,24 @@ flowchart LR
Worker --> Core
Core --> Holidays
Worker -- "heartbeats, progress, checkpoints, outcome" --> Facade
Worker -- "after outcome: explanation + trace batches" --> Facade
Facade -- "trace acknowledgements" --> Worker
Worker -- "after outcome: stable explanation + final state" --> Facade
Facade --> UI
end
```
Leave calculation stays in the browser. The production Express process serves the generated Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. Trace delivery uses bounded batches with receiver acknowledgements; large calendars and traces can still cause UI pauses and substantial memory use, as recorded in the benchmark report.
Leave calculation stays in the browser. The production Bun process serves the generated client-only Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. The original decision trace and historical analysis were removed in issue #76; immutable checkpoints and final state remain available for cancellation recovery and displayed-placement alternatives.
### Main execution path
1. `PlanningDurationService` reads the pending horizon, balances, and ordered rules to estimate the wait without running the planner.
2. On startup or an explicit Calculate action, `CalendarYear` submits those inputs through `CalendarService`, which opens one immutable planning calculation context.
3. `leave-planner.service.ts` copies the request into a canonical transport shape and starts the worker.
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 final state.
5. `CalendarYear` ignores stale calculations, renders the selected dates, reports remaining balances or errors, and records successful request-to-rendered-calendar timing for duration refinement.
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).
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 stable explanation, 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.
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 observer callbacks and post-outcome explanation work. Expiry returns `PlanningIncomplete` with the latest valid checkpoint, if any, rather than a conflict. It is independent of worker liveness monitoring.
## Planning model
@@ -86,7 +85,7 @@ All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/
Settings save a fixed start date and an inclusive last planning date. The planner's exclusive end is the following calendar date, so matching settings dates cover one day. Without saved dates, the default is today in Europe/Paris through the same date twelve months later, exclusive (February 29 uses February 28 in the following year). Invalid edits prevent calculation and preserve the last valid saved dates. Changes to dates, balances, and rules apply to the displayed calendar on the next explicit Calculate action.
An indicative range beside Calculate follows the current dates, balances, and rules. Completed plans refine it locally for the current browser session using request-to-rendered-calendar time; later trace delivery is excluded. The range never stops a calculation. See [the duration model and measurement contract](docs/planning-duration.md).
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 explanation delivery is excluded. The range never stops a calculation. See [the duration model and measurement contract](docs/planning-duration.md).
## Optimization pipeline
@@ -134,7 +133,7 @@ stateDiagram-v2
Plan --> [*]
```
`PlanningIncomplete` is reachable from every active calculation state. Cancellation retains the best available jointly valid candidate, even before a stage completes; completed stages also publish checkpoints for other interruption paths. An explicit technical deadline applies only after calendar preparation, while worker failure can interrupt any active phase. `PlanningConflict` is produced only when the obligations are valid but jointly infeasible. Outcome delivery does not wait for explanation or trace completion.
`PlanningIncomplete` is reachable from every active calculation state. Cancellation retains the best available jointly valid candidate, even before a stage completes; completed stages also publish checkpoints for other interruption paths. An explicit technical deadline applies only after calendar preparation, while worker failure can interrupt any active phase. `PlanningConflict` is produced only when the obligations are valid but jointly infeasible. Outcome delivery does not wait for explanation completion.
1. validate and normalize the request;
2. find joint obligation assignments;
@@ -150,9 +149,9 @@ stateDiagram-v2
Every placement records its source balance and all rules it satisfies. Remaining balances are classified as unavoidable expiry, at risk, or deferred.
The calendar keeps plan details and the advanced trace collapsed initially. Plan details navigate final placements chronologically, showing provenance, contributions, the first decisive objective, and the full ordered comparison including obligation validity. **Compare dates** opens an on-demand hypothetical comparison across the submitted horizon, preserving the selected balance, type, and provenance without changing the plan.
The calendar keeps plan details collapsed initially. Plan details navigate final placements chronologically, showing provenance, contributions, the first decisive objective, and the full ordered comparison including obligation validity. **Compare dates** opens an on-demand hypothetical comparison across the submitted horizon, preserving the selected balance, type, and provenance without changing the plan.
The trace explorer navigates original phases and atomic decisions, expands candidate evaluations, and shows before/after calendar states. Historical date statuses remain separate from hypothetical results. Incomplete outcomes explain the retained plan and unfinished phases; conflicts identify the reported obligations and bottleneck. Controls support keyboard navigation, labelled dates, live announcements, and text or icons alongside color. Only the latest trace is retained locally in memory.
Incomplete outcomes explain the retained plan and unfinished phases; conflicts identify the reported obligations and bottleneck. Remaining balances retain their classification, validity, earned-entitlement source and concrete displayed-plan reasons, without historical attempt lists. Controls support keyboard navigation, labelled dates, live announcements, and text or icons alongside color.
## Technical stack
@@ -163,9 +162,9 @@ The trace explorer navigates original phases and atomic decisions, expands candi
- Web Workers for planner isolation
- `date-holidays` for the French public calendar
- Bun for dependency management and project scripts
- Vitest through the Angular test builder
- ESLint and Prettier
- Angular server build hosted by Express
- `bun test` with jsdom and Angular TestBed (JIT)
- ESLint and Prettier, run by Bun, and `bun check` for standalone type checking
- Client-only Angular build served by a small `Bun.serve` static server (`src/server.ts`)
- Gitea repository hosting and a multi-stage Docker image
The `/benchmark` route and planner core are lazy-loaded, so the normal application does not execute the benchmark or bundle the planner core into its main entry point.
@@ -199,9 +198,10 @@ CONTEXT.md # Canonical domain language and rules
## Local development
Prerequisite: [Bun](https://bun.sh/). Dependency versions are locked in `bun.lock`.
Prerequisite: [Bun](https://bun.sh/) 1.4.3, the exact version pinned by `packageManager` in `package.json` and used by CI and the Docker image. Node.js is not needed. Dependency versions are locked in `bun.lock`.
```bash
curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.3"
bun install --frozen-lockfile
bun start
```
@@ -210,22 +210,29 @@ Open <http://localhost:4200>. The development server reloads when source files c
### Useful commands
| Command | Purpose |
| ----------------------------------------- | ------------------------------------------------------------ |
| `bun start` | Generate the i18n registry and start the development server. |
| `bun run test --watch=false` | Run the unit and integration suite once. |
| `bun run lint` | Run ESLint. |
| `bun run format:check` | Check Prettier formatting. |
| `bunx tsc --noEmit -p tsconfig.app.json` | Type-check application code. |
| `bunx tsc --noEmit -p tsconfig.spec.json` | Type-check tests. |
| `bun run build` | Create the optimized browser and server bundles in `dist/`. |
| `bun run serve:ssr:vac-optimizer` | Serve a completed production build on port 4000. |
| Command | Purpose |
| ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `bun start` | Generate the i18n registry and start the development server. |
| `bun run watch` | Rebuild the development bundle when source files change. |
| `bun run test` | Run the unit, integration and benchmark suites once. |
| `bun test --isolate --path-ignore-patterns='**/benchmark/**'` | Run the functional suite only. |
| `bun test src/app/benchmark --timeout=600000` | Run the planning benchmark (no coverage). |
| `bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**' && bun scripts/coverage-reports.mjs` | Functional coverage: text, `lcov.info`, HTML and Cobertura in `coverage/`. Line coverage only; frozen engine excluded. |
| `bun run lint` | Run ESLint (`bun run lint:fix` applies its fixes). |
| `bun run format:check` | Check Prettier formatting of the application, the CI scripts and the workflow (`bun run format` writes it). |
| `bun run typecheck` | Type-check application and test code with `bun check`. It does not check Angular templates; only the build does. |
| `bun run build` | Create the optimized browser bundle and the static server bundle in `dist/vac-optimizer/`. |
| `bun run serve` | Serve a completed production build on `PORT` (default 4000). |
Project scripts run Node-shebang tools (`ng`, `eslint`, `prettier`) on Bun even when Node is installed: `bunfig.toml` sets `[run] bun = true`, which is the `--bun` flag for every `bun run`. Start them through the scripts above. A bare `bunx` or `bun x` uses Node when it is on `PATH`; for a one-off tool, use `bun --bun x <tool>`.
Angular compiles with TypeScript 6. `bun check` is Bun's TypeScript 7 checker for standalone type errors in application and test code. Neither replaces the other: only `bun run build` (Angular's compiler) checks templates, so a passing `typecheck` does not replace the production build. ESLint and Prettier stay until a stable Bun release ships its native linter and formatter ([bun-adoption](docs/bun-adoption.md#status)).
## 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, 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.
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, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or multi-year duration ceiling is established; timing measurements are release evidence, not CI assertions.
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI runs the complete seven-scenario protocol with `bun test` and requires each scenario to stay within its budget relative to a frozen-engine calibration, with a 600-second hang guard. Bun measurements do not establish browser or reference-phone speedups.
## Docker
@@ -234,15 +241,19 @@ docker build -t vac-optimizer .
docker run --rm -p 4000:4000 vac-optimizer
```
The image builds the Angular application with Bun and Node, then runs the generated Express server with Bun. Set `PORT` to override the default port `4000`.
The image builds the Angular application and the server bundle with Bun alone (no Node), then runs `dist/vac-optimizer/server/server.js` with Bun. The bundle has no runtime dependencies, so the production stage only copies `dist/` and installs nothing. Set `PORT` to override the default port `4000`; the health check requests `/`.
The server answers `GET` and `HEAD` only (other methods get 405). It serves files from `dist/vac-optimizer/browser` with `Cache-Control: public, max-age=31536000` (content-hashed assets), and returns `index.html` with `Cache-Control: no-cache` for `/index.html` and for every extension-less path, so direct navigation to client routes such as `/benchmark` works. A missing path with a file extension is a 404 rather than HTML, malformed or escaping paths are a 400, and unexpected failures are logged and answered with a plain 500.
## Continuous integration
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, application/test type checks, Vitest coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
Functional tests use one worker and a 2 GiB Node heap. The separate benchmark job runs without coverage and keeps its five-minute protocol timeout. At the user's request, it has `continue-on-error: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. #78 must restore the benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 600-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restores this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). Benchmark success is required for image publication and the aggregate `required` job. The narrow local timing margin still needs confirmation on the CI runner.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
@@ -279,7 +290,7 @@ The Gitea repository policy requires `develop` as the default branch and **Merge
Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
Run `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
Run `bun scripts/ci-policy.test.mjs` and `bun scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
## Domain and design references
@@ -287,6 +298,6 @@ Run `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs` local
- [ADR 0001](docs/adr/0001-ordered-heuristic-optimization.md) records the ordered heuristic decision.
- [ADR 0002](docs/adr/0002-personal-client-side-planner-for-now.md) records the client-side scope.
- [ADR 0003](docs/adr/0003-constraint-first-lexicographic-planning.md) records the constraint-first planner architecture.
- [ADR 0004](docs/adr/0004-stable-explanation-original-trace-counterfactual.md) separates the outcome, stable explanation, original trace, and hypothetical analysis.
- [ADR 0004](docs/adr/0004-stable-explanation-original-trace-counterfactual.md) separates the outcome, stable explanation and displayed-placement hypothetical analysis.
- [ADR 0005](docs/adr/0005-saved-planning-inputs-before-random-defaults.md) records saved-input restoration and random fallback behavior.
- [ADR 0006](docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md) records fixed saved horizons, cancellation, duration estimation, and the removal of the automatic deadline.
-9
View File
@@ -29,12 +29,6 @@
"styles": [
"src/styles.scss"
],
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
},
"prerender": false,
"allowedCommonJsDependencies": [
"deepmerge",
"moment-timezone",
@@ -85,9 +79,6 @@
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@analogjs/vitest-angular:test"
}
}
}
+80 -6545
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
[test]
preload = ["./src/test-setup.ts"]
root = "src"
coverage = true
coverageReporter = ["text", "lcov"]
coverageDir = "coverage"
coverageSkipTestFiles = true
coveragePathIgnorePatterns = ["**/frozen-engine/**", "**/testing/**", "**/test-setup.ts"]
+7
View File
@@ -0,0 +1,7 @@
[run]
# Node-shebang tools (ng, eslint, prettier) run on Bun even when Node is on PATH.
bun = true
[test]
preload = ["./src/test-setup.ts"]
root = "src"
+1 -1
View File
@@ -17,7 +17,7 @@ services:
'CMD',
'bun',
'-e',
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
"fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
]
interval: 30s
timeout: 10s
+1 -1
View File
@@ -17,7 +17,7 @@ services:
'CMD',
'bun',
'-e',
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
"fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
]
interval: 30s
timeout: 10s
@@ -1,5 +1,7 @@
# Separate Plan Explanations from Calculation Traces
# Stable Plan Explanations Without Decision History
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.
Issue #76 supersedes this ADR's original requirement to capture and deliver a complete calculation trace. The original decision trace, historical analysis, replay and candidate-history retention are removed end to end.
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.
The stable explanation describes the displayed plan, including valid incomplete incumbents: entitlement and rule provenance, contributions, the full ordered objective comparison and first decisive objective, time-off periods, remaining balances and concrete non-placement reasons. Invalid input and obligation conflicts retain their distinct explanations.
Outcomes are delivered before explanation work. Explanation failures cannot discard an available outcome. Immutable calculation, valid-incumbent and final-state snapshots remain available for cancellation recovery and hypothetical analysis; they are independent of tracing. ADR 0006's opt-in technical planning-work budget excludes observer callbacks and post-outcome explanation work. The user flow has no automatic elapsed-time cutoff.
@@ -1,11 +1,9 @@
# Stable Explanation, Original Trace, and Counterfactuals
# Stable Explanation and Displayed-Placement Counterfactuals
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.
Issue #76 supersedes the original decision-trace and historical-counterfactual requirements of this ADR. Calculations no longer capture, retain or deliver decision history, and the UI has no trace explorer, replay, historical candidate statuses or balance-attempt lists.
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 with an immutable submitted configuration. Its outcome is independent from the stable explanation, which describes the final displayed plan and remains useful for complete and valid incumbent plans.
- the stable plan explanation is derived from the final displayed plan and remains useful for complete or incumbent plans;
- the calculation trace is captured while the producing run executes, grouped by phase and atomic decision, and may become ready after the outcome;
- counterfactual date analysis is an explicitly hypothetical operation that removes only the selected final placement, or starts from a selected trace decision's exact pre-decision state, and never rewrites the plan or historical trace.
Counterfactual date analysis tests a displayed placement over every date of the submitted half-open horizon. It uses the captured configuration and final-state snapshot, releases only that placement, preserves its entitlement, leave type and rule provenance, and fixes unrelated placements. Earned-entitlement chronology and all obligations still apply. The result is explicitly hypothetical and never mutates the displayed plan. Historical decisions are not selectable analysis baselines.
The public planning outcome is delivered as soon as planning work finishes. Trace capture and delivery have separate readiness/failure state, remain local to the latest calculation, and cannot turn a valid outcome into an explanation error. Counterfactual work is cancellable or ignored when its calculation identity is stale. The existing deterministic constraint-first planner and its planning-work deadline remain unchanged.
The public outcome is delivered as soon as planning work finishes. Explanation delivery failures cannot replace it. Counterfactual work is cancelled or ignored when its calculation identity is stale. Search breadth, allocation previews, user priorities, deterministic selection and ADR 0006's cancellation, liveness and technical-budget contracts remain unchanged. Workers finish after required outcome and explanation delivery without acknowledgement queues.
@@ -1,17 +1,19 @@
# User-Selected Horizons and Cancellable Calculations
Accepted for [issue #12](https://gitea.nessar.fr/nessar/vac-optimizer/issues/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitea.nessar.fr/nessar/vac-optimizer/issues/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58); removal of the automatic calculation deadline and heartbeat-based worker liveness are implemented by [issue #59](https://gitea.nessar.fr/nessar/vac-optimizer/issues/59). The indicative duration estimate is implemented by [issue #60](https://gitea.nessar.fr/nessar/vac-optimizer/issues/60); its initial model, session-local refinement and calendar-rendering measurement are documented in [planning-duration.md](../planning-duration.md).
Accepted for [issue #12](https://gitea.nessar.fr/nessar/vac-optimizer/issues/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitea.nessar.fr/nessar/vac-optimizer/issues/57); user cancellation with a retained valid plan is implemented by [issue #58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58); removal of the automatic calculation deadline and heartbeat-based worker liveness are implemented by [issue #59](https://gitea.nessar.fr/nessar/vac-optimizer/issues/59). The indicative duration estimate is implemented by [issue #60](https://gitea.nessar.fr/nessar/vac-optimizer/issues/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.
`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 observer callbacks and post-outcome explanation 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 explanation 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 and stable explanation.
The planning-period control in Configure your leave uses an inclusive last planning date. The planner retains its half-open horizon by using the following calendar date as its exclusive end, matching the inclusive date-selection convention of TIME_WINDOW rules.
Editing the horizon does not trigger a calculation. The user can adjust dates, balances, and rules before using the existing Calculate action; until a new calculation is requested, the displayed calendar retains its preceding outcome and horizon.
A successful calculation displays the calendar once, as soon as the full planning objective order is complete, without displaying intermediate plans during optimization. Trace delivery continues in the background and never delays or changes that final calendar. The indicative duration estimate covers elapsed time until the final calendar is usable, including any trace capture that delays the outcome, rather than the additional time to deliver the complete trace.
A successful calculation displays the calendar once, as soon as the full planning objective order is complete, without displaying intermediate plans during optimization. Stable explanation delivery follows the outcome and never changes that final calendar. The indicative duration estimate covers elapsed time until the final calendar is usable, excluding later explanation work.
Before Calculate, the estimate is shown as an approximate range in seconds or minutes based on the horizon, balances, and rules. It starts with a rough estimate and is refined using measured calculation durations on the same device; it never becomes an automatic stopping deadline.
User cancellation during optimization preserves the best valid plan already found and labels it as a cancelled, incomplete calculation. Before any valid plan exists, cancellation reports that the calculation was cancelled without presenting a plan or claiming an obligation conflict.
Issue #76 supersedes all original-trace requirements in this ADR and both ADR 0004 documents. Decision capture, historical analysis and replay are removed. Immutable calculation, valid-incumbent and final-state snapshots remain for the retained explanation, cancellation recovery and displayed-placement counterfactuals. Cancellation still preserves the best available jointly valid plan and never implies an obligation conflict.
@@ -0,0 +1,26 @@
---
status: proposed
---
# Bun Runtime and Native Tooling
VacOptimizer will use Bun as the runtime for project-owned JavaScript in
development, CI, and production, adopting its native test runner, linter,
formatter, type checker, and HTTP server even without measured performance gains.
This deliberately accepts Bun-specific tooling and migration work while retaining
Angular's standard compiler and build pipeline, including TypeScript 6 for
Angular compilation alongside `bun check` for standalone TypeScript checking.
Verification on stable Bun 1.4.3 narrowed this: ESLint and Prettier stay,
run by Bun, until a stable release ships the native linter and formatter;
Angular builds a client-only bundle, because its server build relies on Node
loader hooks Bun does not apply, and a small `Bun.serve` server delivers it,
giving up `@angular/ssr`; coverage is line-level, converted from lcov to the
existing HTML and Cobertura reports; and the benchmark's heap cap becomes a
container memory limit.
Existing behavior, tests, lint rules, coverage reporting, and benchmark budgets
must be preserved; incompatibilities must be fixed or reported as blockers before
declaring completion, and a Node.js fallback does not meet the runtime requirement.
The [development spec](../bun-adoption.md) records the agreed boundaries,
acceptance criteria, and remaining verification.
+163 -45
View File
@@ -2,35 +2,160 @@
## Composable optimization rules: current verification status
The #70–#75 implementation uses explicit active optimization configurations in
the benchmark fixtures. The latest full functional coverage run passes 652 tests
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
This does not establish a passing performance protocol.
[Issue #78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) passes the
unchanged Node acceptance protocol on the documented development laptop:
**298.6 seconds**, including one warm-up and two measured repetitions of all
seven scenarios, without coverage, with one worker and a 2 GiB Node heap.
The full protocol keeps a 600-second timeout only as a hang guard (see
[Relative CI gate](#relative-ci-gate)). All expected outcomes,
placement counts, final stages and determinism checks pass. The fixture inputs
are unchanged; the test now prints its existing report.
Before the Gitea migration, GitLab pipeline 320 passed all 652 functional tests
in 109.43 seconds, then the seven-scenario protocol timed out at its unchanged
300-second limit without an out-of-memory failure. In an earlier diagnostic run, the loaded-year warm-up took
184.36 seconds and its first measured repetition began about 251.78 seconds into
the protocol. Those diagnostic timings precede the final period-projection cache
and the planned original-decision-trace removal, and are not a result for either.
## Relative CI gate
At the user's request, #75 finishes on saved-rule migration and functional
verification. #78 owns benchmark verification and implementation of further
optimizations when needed, after #76 removes tracing and #77 verifies the retained
behavior. All seven scenarios, one warm-up and two measured repetitions per
scenario, the 2 GB heap limit and the 300-second timeout remain required for #78.
At the user's request, the Gitea CI configuration defines the benchmark in a
separate job with `continue-on-error: true` while #78 is open. Functional coverage
tests and all other required checks remain blocking. #78 must restore the required benchmark gate
after the unchanged protocol passes. A green pipeline during this exception
does not establish a passing benchmark. No automatic elapsed-time planning
cutoff is introduced. Gitea Actions is enabled; live workflow validation is tracked in
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). The converted
workflow preserves the separate advisory benchmark job. See the
[CI migration status](../../README.md#continuous-integration).
The relative gate replaces the 300-second acceptance protocol named in
[#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) as the benchmark
policy. This is an explicit policy change from #279/#285: runners differ in
speed, so a fixed wall-clock limit was flaky.
The results below document earlier implementations and protocols. They are
historical measurements rather than the performance result for composable rules.
Before the protocol, in the same process, the spec times the frozen engine
(`src/app/services/frozen-engine/leave-planner.core.frozen.mjs`, the engine at
a871f8f) on the `two years` scenario, best of three, and prints the result.
Each scenario's budget is its multiplier times that calibration; the mean
`planningMs` of its two measured repetitions must not exceed it. A scenario
without a multiplier fails too. `#286` tightens the multipliers once the speed
target is met.
The job log shows a Markdown table with one row per scenario, printed as soon
as that scenario finishes. Each row gives both measured planning times, their
mean, the budget, the ratio to the calibration, the multiplier, the outcome,
placements, determinism and `ok`, `OVER` or `no budget`. The complete
tab-separated report follows the table.
| Scenario | Mean planning (s) | Ratio to calibration | Multiplier |
| --------------------------- | ----------------- | -------------------- | ---------- |
| representative year | 0.40 | 0.15 | 0.25 |
| typed obligations | 5.02 | 1.83 | 3 |
| chained earned entitlements | 0.025 | 0.01 | 0.05 |
| deliberate conflict | 0.013 | 0.005 | 0.05 |
| loaded year | 44.3 | 16.2 | 25 |
| two years | 1.84 | 0.67 | 1 |
| three years | 5.94 | 2.17 | 3.5 |
Ratios come from the development machine (calibration about 2.74 s); the
multipliers add roughly 1.5x headroom, rounded up, with a floor of 0.05 for the
two tiny scenarios. A failure lists, per scenario, the measured mean, the
budget, the multiplier and the calibration in milliseconds.
The first CI runs of PR #287 (commit `63b50c4`) give consistent ratios on two
runners:
| Scenario | nessar-desktop | srvhome |
| --------------------------- | -------------- | ------- |
| calibration | 3.03 s | 5.71 s |
| representative year | 0.17 | 0.14 |
| typed obligations | 1.83 | 1.75 |
| chained earned entitlements | 0.011 | 0.005 |
| deliberate conflict | 0.006 | 0.003 |
| loaded year | 16.6 | killed |
| two years | 0.65 | killed |
| three years | 1.99 | killed |
srvhome is about 1.9 times slower than nessar-desktop, so its whole protocol
needs roughly 400 seconds. The original 300-second `timeout` killed it during
the loaded year, while every completed scenario was within budget. The CI
`timeout` is therefore 600 seconds and the test timeout 570 seconds. They are
only hang guards and do not judge speed; #286 lowers them once the speed
target is met.
The protocol now runs on Bun's test runner, outside coverage:
`timeout 600s bun test src/app/benchmark --timeout=600000`. JavaScriptCore has
no equivalent of Node's `--max-old-space-size`, so the CI benchmark job enforces
memory with a 3 GiB container limit instead of the former 2 GiB heap cap.
Scenarios, calibration, warm-up, repetitions, determinism checks and
multipliers are unchanged. One local run (Bun 1.4.3, Node unavailable) took
268 seconds and passed every scenario; the frozen-engine calibration was
3.79 s and the ratios were 0.14 (representative year), 1.43 (typed
obligations), 0.01 (chained earned entitlements), 0.00 (deliberate conflict),
17.50 (loaded year), 0.64 (two years) and 2.18 (three years). These are Bun
measurements; they do not establish browser or reference-phone speedups.
The post-removal functional integration checks, browser QA and unchanged-input
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
The benchmark is required again in Gitea Actions, including the image and
aggregate gates. [The #78 report](issue-78.md) records the baseline timeout,
profiling, before/after diagnostics, full protocol output, correctness checks
and browser limitations. That pass had only 1.4 seconds of external-timeout
headroom. Three later exact optimizations for PR #278 cut the Vitest duration
to **228.1 seconds** (71.9 seconds of headroom) in the most recent local run:
[direct date keys](pr-278-date-keys.md),
[immutable date counts](pr-278-immutable-date-counts.md) and
[mandatory-move prechecks](pr-278-mandatory-moves.md). Outcomes, explanations
and final states are unchanged. CI-runner performance and live workflow
validation remain unverified.
The existing migration protection stays in place under
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268).
Before these optimizations, the trace-free integrated baseline at `2f4af14`
timed out after 300 seconds without a complete report. The #76 verification
previously passed 630 functional tests across 33 files with coverage under the
same heap setting, while its unchanged benchmark also timed out. Before the
Gitea migration, GitLab pipeline 320 passed 652 functional tests, but its
separate benchmark timed out as well. An earlier loaded-year warm-up took
184.36 seconds; that historical sample predates the final period-projection
cache and trace removal and is not a measurement of the current engine.
#75 owns functional saved-rule migration, #76 removes tracing, #77 verifies
retained behavior and #78 owns performance acceptance. No automatic elapsed-time
engine cutoff is introduced. No reference-phone run is claimed.
The measurements below describe earlier implementations and protocols. Their
trace counters and timings are historical; current evidence is in
[the #78 report](issue-78.md).
## Frozen-engine differential check
`src/app/services/frozen-engine.spec.ts` runs 5,000 seeded random planning
requests (seeds 280000 to 284999, one test per 200 seeds) through the current
engine and through a frozen copy of the engine at `a871f8f`, develop after
PR #278. For each request, the planning outcome, stable plan explanation,
explanation error and final-state snapshot must be strictly deep-equal,
including `PlanningConflict` and `InvalidInput` outcomes. Timings are not
compared.
Start dates span 2025 to 2027. About 15% of requests start in late December and
cross a year boundary, and about 15% start shortly before a weekday French
public holiday. Most horizons last 7 to 28 days; about 10% last 29 to 70 days
with optimization presets and at most one calendar rule. Requests cover
obligation and preference rules, `IF_THEN` rules with earned leave
entitlements, `TIME_WINDOW` rules, optimization presets in random order and
subsets, random composed optimization configurations, disabled rules, declared
non-working days and a few deliberately invalid requests. The last test fails
if an outcome kind, rule type, year-crossing horizon or public-holiday horizon
stops appearing. Requests stay small because candidate search grows quickly
with horizon length and balances; the slowest case takes about one second.
The fixture is `src/app/services/frozen-engine/leave-planner.core.frozen.mjs`,
a generated bundle with `date-holidays` left external, and its hand-written
`leave-planner.core.frozen.d.mts`. Rebuild it only for an intended behavior
change, in its own reviewed commit, from the commit that introduces the change:
```sh
bun build src/app/services/leave-planner.core.ts --target=node --external date-holidays --outfile src/app/services/frozen-engine/leave-planner.core.frozen.mjs
```
A failure prints the seed and the full request as JSON. To run only that seed:
```sh
FROZEN_ENGINE_SEED=<seed> bun test --isolate src/app/services/frozen-engine.spec.ts
```
The check runs in the functional suite. On the development laptop with Bun
1.4.3 it takes about 54 seconds with or without coverage
(`bun test --config=bunfig.coverage.toml --isolate src/app/services/frozen-engine.spec.ts`; coverage is line-level only).
A flipped objective direction in `compareObjectiveValues` made 26 of its tests
fail, starting at seed 280002; the change was reverted.
## Reference-phone protocol
@@ -38,13 +163,9 @@ Open the deployed application at `/benchmark` on the reference phone. Tap
**Run benchmark**, wait for the seven scenarios to finish, then tap **Copy
report** and save the tab-separated output with the date and build identifier.
The harness measures preparation, planning-to-outcome latency, later trace
delivery, and total wall time separately. It also reports planning work, trace
capture, trace materialization, and trace transfer independently, plus the
largest extra delay of a 50 ms UI heartbeat. It records the outcome, last
completed quality stage, placement count, determinism, trace status, and trace
candidate count. `PlanningIncomplete` is not a conflict: its last completed
stage and incumbent are retained.
The harness measures preparation, planning-to-outcome latency, planning work, and total wall time through stable explanation delivery. It also reports the largest extra delay of a 50 ms UI heartbeat, available main-thread heap, outcome, last completed quality stage, placement count and determinism. `PlanningIncomplete` retains its last completed stage and valid incumbent and is distinct from a conflict.
Issue #76 removes all trace timing, status and candidate-count columns, along with trace promises and delivery requirements. The seven scenarios, warm-ups, repetitions, 2 GB heap limit, 300-second timeout and performance gates are unchanged. #77 owns integrated functional verification; #78 owns performance acceptance and any necessary optimization.
## Protocol
@@ -67,23 +188,16 @@ stage and incumbent are retained.
The browser page uses the same `createPlanningCalculation(request)` context as
the calendar and adds no solver or backend dependency. It omits `deadlineMs`.
Trace delivery is reported separately and cannot replace a valid planning outcome.
Explanation delivery is independent of the outcome and cannot replace a valid plan.
`planning_ms` is the observed latency until the outcome is usable;
`planning_work_ms` excludes trace capture. `trace_capture_ms` accounts for
instrumentation during planning, `trace_materialization_ms` accounts for
building the stable explanation and finishing the trace after the outcome.
`trace_transfer_ms` measures worker transfer and receiver assembly work;
`trace_delivery_ms` measures elapsed time until the complete trace is ready. `max_ui_delay_ms` includes
planning, trace delivery, and report preparation. Trace readiness has no
elapsed-time ceiling and never permits truncating actual candidate evaluations.
`planning_ms` is the observed latency until the outcome is usable. `planning_work_ms` excludes observer callbacks and post-outcome explanation work. `wall_ms` includes preparation, outcome and explanation delivery. `max_ui_delay_ms` covers this entire interval; there is no decision recording, trace materialization or trace transfer.
`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,
explanation 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
@@ -91,11 +205,15 @@ 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
outcome, and delivery of its stable explanation. 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
## Historical measurements before trace removal
The following records remain as evidence for their dated builds. Trace-related behavior and metrics below are superseded by issue #76.
### Hardware status
No reference-phone run has been recorded. The project owner elected to close
the release tickets on 2026-09-25 using the emulated run below, accepting its
+200
View File
@@ -0,0 +1,200 @@
# Issue #279: incremental exact scoring
Step-by-step record for [issue #279](https://gitea.nessar.fr/nessar/vac-optimizer/issues/279).
Each step keeps planning outcomes, stable explanations and final states identical
to develop after PR #278 (`a871f8f`). The
[frozen-engine differential check](README.md#frozen-engine-differential-check)
guards every step.
All measurements use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the development
laptop, with no other test, build or profiling workload running alongside the
protocol. Only same-session before/after pairs are comparable.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
The per-scenario figures below are the means of the two measured calculations.
## Baseline profile
A CPU profile of the bundled core over all seven scenarios, with
`calculationDate: '2024-01-01'`, put 50.5 of 67.0 loaded-year seconds in
candidate evaluation (`evaluateRuleCandidate`). Of those, 41.3 seconds went to
active-objective evaluation: 15.6 seconds in time-off-period observations,
11.5 seconds in building observations and 7.6 seconds in sorting placement dates
for the placement tie key and the observation cache key. The asynchronous
adapter's 1 ms timer yields cost about 7% on typed obligations.
## #282: consolidate plan observations
Time-off periods, work sequences, public-holiday weeks, balance observations,
the placement list and both tie keys now live in
`src/app/services/plan-observations.ts`. The code moved without behavior
changes. All seven scenarios produce byte-identical outcomes, explanations and
final states compared with `a871f8f`.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 262.000 s | 262.030 s |
| Loaded year | 66.010 s | 66.497 s |
| Typed obligations | 4.887 s | 4.964 s |
| Two years | 2.914 s | 2.977 s |
| Three years | 10.203 s | 10.049 s |
| Representative year | 0.557 s | 0.569 s |
The differences are within run-to-run noise. The functional suite passes
666 tests across 34 files, including the frozen-engine check.
## #283: incremental plan observations
Each placements map now carries an immutable occupancy: its sorted placement
dates, time-off period groups, public-holiday-week completeness and lazily
computed calendar observations. A candidate derived from its parent by one added
or removed date, or by a copy with the same dates, updates only the affected
groups and holiday weeks. Unchanged groups keep their cached periods and
observation objects. Maps built from snapshots, maps observed against another
calendar and dates that are not in-horizon working days fall back to the
from-scratch computation.
The from-scratch computation remains the oracle. A fast-check property test
applies random add, remove and retype sequences across year boundaries, horizon
edges, declared days, weekends and public holidays, and compares every step with
it. An opt-in shadow mode (`VAC_OBSERVATION_SHADOW=1` or
`setObservationShadowMode(true)`) compares both on every observation and throws
on any mismatch. It passed across the frozen-engine check and all seven
benchmark scenarios, whose outcomes, explanations and final states stay
byte-identical.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 262.030 s | 193.680 s |
| Loaded year | 66.497 s | 48.489 s |
| Typed obligations | 4.964 s | 4.947 s |
| Two years | 2.977 s | 1.906 s |
| Three years | 10.049 s | 6.152 s |
| Representative year | 0.569 s | 0.441 s |
A fresh loaded-year CPU profile of the bundled core took 46.8 seconds. Of the
1.68 million observed placement maps, one was built from scratch; the rest were
derived. Observations now take about 4 seconds. Candidate evaluation still
dominates: 24.3 seconds in `evaluateRuleCandidate`, including 15.2 seconds of
active objectives and 6.9 seconds of candidate finalization, plus 6.2 seconds
assigning dates and about 5 seconds each of garbage collection and timer idle
time.
The functional suite passes 669 tests across 35 files.
## #284: prune candidates that cannot beat the protected prefix
Before #284, `evaluateRuleCandidate` finalized every candidate before comparing
its objectives. In the loaded year, 650,369 of 1,233,535 evaluations (53%) then
ended at a worse protected prefix. For typed obligations it was 74,336 of
273,470, and for three years 38,048 of 193,621.
Finalizing never moves or retypes a placement. It edits only rule
provenance, supporting status, earned activations and earned balances. Before
finalizing, the evaluation therefore walks the candidate's protected prefix in
objective order, using only objectives whose pre-finalize value is already their
exact final value, so the bound is tight:
- time-window and if-then preferences read only placement dates and types,
plus declared days;
- public-holiday-week, time-off-period and work-sequence optimizations read
only the placement dates;
- leave-balance optimizations are exact only when no enabled if-then rule earns
days and the candidate has no earned activation;
- leave-placement optimizations have no safe bound, because finalizing rewrites
supporting status.
The walk prunes at the first differing objective when it is worse than the
baseline. It stops without pruning at a better value or at an objective without
a safe bound. A pruned candidate would have failed finalization or returned at
the same comparison, and incumbents are retained only after the full objective
vector is built. Pruning therefore never changes which candidate wins or which
incumbent is retained.
The frozen-engine check now also runs every tenth seed through both engines'
asynchronous adapter, cancelled after a seed-derived number of steps. It
compares outcomes, artifacts, every checkpoint, the explanation and outcome
callbacks, and the step count. Of those 500 cases, 422 end incomplete and 217
of those keep an incumbent. Pruning on any differing objective breaks both
variants. Wrongly treating leave-placement or earning leave-balance objectives
as exact went undetected, because the generated requests rarely put those
objectives in a protected prefix, so their exclusion rests on the argument
above. The check now takes about 60 seconds with coverage.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 193.680 s | 180.250 s |
| Loaded year | 48.489 s | 44.287 s |
| Typed obligations | 4.947 s | 5.023 s |
| Two years | 1.906 s | 1.837 s |
| Three years | 6.152 s | 5.935 s |
| Representative year | 0.441 s | 0.400 s |
All seven scenarios keep byte-identical outcomes, explanations and final states.
The functional suite passes 684 tests across 35 files.
## #286: speed target not reached
The target is a loaded year under 10 seconds and every other scenario under 3
seconds, using the existing protocol in Node on the development laptop. The
exact optimizations of #282 to #284 do not reach it. Means from the latest
protocol run, with the #285 calibration of 2.854 seconds:
| Scenario | Target | Latest | Gap factor |
| --------------------------- | -----: | -------: | ---------: |
| Loaded year | < 10 s | 44.094 s | 4.4x |
| Typed obligations | < 3 s | 5.071 s | 1.7x |
| Three years | < 3 s | 5.887 s | 2.0x |
| Two years | < 3 s | 1.865 s | met |
| Representative year | < 3 s | 0.402 s | met |
| Chained earned entitlements | < 3 s | 0.025 s | met |
| Deliberate conflict | < 3 s | 0.013 s | met |
From develop after PR #278 to now, the loaded year went from 66.0 to 44.1
seconds (−33%), three years from 10.2 to 5.9 seconds and two years from 2.9 to
1.9 seconds. Typed obligations is unchanged at about 5 seconds. The protocol went
from 262.0 to 180.3 seconds.
Because the target is not met, the #285 multipliers stay at current speed and
the hang-guard timeout is not lowered. #286 remains open for a
separate decision on search changes.
### Remaining loaded-year profile
A fresh CPU profile of the bundled core at the #285 commit took 43.5 seconds,
with outcomes still byte-identical:
| Inclusive cost | Seconds |
| ----------------------------------------------------------------------- | ------: |
| Candidate evaluation (`evaluateRuleCandidate`) | 21.4 |
| — active objectives, including the pruning walk | 16.7 |
| — of which objective evaluation (`evaluateOptimization`) | 7.7 |
| — of which plan observations | 4.2 |
| — candidate finalization | 2.7 |
| Date assignment and release (`assignDateToBalance`, `releasePlacement`) | 8.1 |
| Garbage collection | 5.1 |
| Idle time between asynchronous slices | 4.6 |
The remaining cost is spread over about 1.2 million candidate evaluations
rather than concentrated in one computation. Further exact work could:
- yield with something cheaper than a 1 ms timer between 8 ms slices, saving up
to the 4.6 seconds of idle time;
- validate optimization configurations once instead of on every evaluation,
about 1 second;
- copy fewer balances and earned activations when assigning a date.
Together these are worth perhaps 8 to 10 seconds, still well above 10 seconds
for the loaded year. Reaching the target needs fewer candidate evaluations,
which is a search change outside #279's exactness constraint.
### ADR 0003
ADR 0003 targets five seconds for the dependency-free planner. Under composable
optimization rules, the loaded year takes about 44 seconds in Node on the
development laptop even after these exact optimizations. That target should be
revisited for the loaded year, together with the decision on search changes.
This record does not revise the ADR.
+15
View File
@@ -0,0 +1,15 @@
scenario repetition preparation_ms planning_ms wall_ms outcome last_stage placements deterministic max_ui_delay_ms planning_work_ms peak_main_thread_heap_bytes
representative year 1 0.1 564.4 564.6 Plan dates 30 baseline 8.0 552.6 ""
representative year 2 0.0 555.0 555.1 Plan dates 30 yes 8.1 544.4 ""
typed obligations 1 0.1 5040.7 5040.8 Plan dates 10 baseline 11.3 5030.5 ""
typed obligations 2 0.1 5141.2 5141.3 Plan dates 10 yes 12.9 5131.1 ""
chained earned entitlements 1 0.1 29.0 29.1 Plan dates 3 baseline 0.0 21.6 ""
chained earned entitlements 2 0.1 24.7 24.8 Plan dates 3 yes 0.0 17.4 ""
deliberate conflict 1 0.1 13.0 13.2 PlanningConflict 0 baseline 0.0 3.1 ""
deliberate conflict 2 0.0 12.6 12.7 PlanningConflict 0 yes 0.0 2.8 ""
loaded year 1 0.1 76341.7 76341.8 Plan dates 72 baseline 16.8 76326.9 ""
loaded year 2 0.1 79093.8 79094.0 Plan dates 72 yes 16.3 79074.9 ""
two years 1 0.1 3391.7 3391.9 Plan dates 60 baseline 12.9 3375.3 ""
two years 2 0.1 3282.6 3282.8 Plan dates 60 yes 10.9 3267.1 ""
three years 1 0.1 11333.3 11333.5 Plan dates 90 baseline 13.9 11313.4 ""
three years 2 0.1 11394.2 11394.3 Plan dates 90 yes 15.7 11368.4 ""
1 scenario repetition preparation_ms planning_ms wall_ms outcome last_stage placements deterministic max_ui_delay_ms planning_work_ms peak_main_thread_heap_bytes
2 representative year 1 0.1 564.4 564.6 Plan dates 30 baseline 8.0 552.6
3 representative year 2 0.0 555.0 555.1 Plan dates 30 yes 8.1 544.4
4 typed obligations 1 0.1 5040.7 5040.8 Plan dates 10 baseline 11.3 5030.5
5 typed obligations 2 0.1 5141.2 5141.3 Plan dates 10 yes 12.9 5131.1
6 chained earned entitlements 1 0.1 29.0 29.1 Plan dates 3 baseline 0.0 21.6
7 chained earned entitlements 2 0.1 24.7 24.8 Plan dates 3 yes 0.0 17.4
8 deliberate conflict 1 0.1 13.0 13.2 PlanningConflict 0 baseline 0.0 3.1
9 deliberate conflict 2 0.0 12.6 12.7 PlanningConflict 0 yes 0.0 2.8
10 loaded year 1 0.1 76341.7 76341.8 Plan dates 72 baseline 16.8 76326.9
11 loaded year 2 0.1 79093.8 79094.0 Plan dates 72 yes 16.3 79074.9
12 two years 1 0.1 3391.7 3391.9 Plan dates 60 baseline 12.9 3375.3
13 two years 2 0.1 3282.6 3282.8 Plan dates 60 yes 10.9 3267.1
14 three years 1 0.1 11333.3 11333.5 Plan dates 90 baseline 13.9 11313.4
15 three years 2 0.1 11394.2 11394.3 Plan dates 90 yes 15.7 11368.4
+345
View File
@@ -0,0 +1,345 @@
# Issue #78: integrated planner performance
Measurements on 2026-10-15/16 (Europe/Paris), starting from the trace-free
integrated planner at `2f4af14869c47836d2c1008157136f75c9d3e98b`.
[Issue #78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) supplies the
acceptance criteria. The original spec, baseline source, profiles, diagnostic
runners and complete local logs are retained in `/tmp/vac-issue-78`.
## Required protocol
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
| Run | Result |
| --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Unmodified baseline | External timeout, exit 124, 300.008 seconds; no complete report. |
| Optimized planner | Exit 0, 298.551 seconds externally; Vitest reports 294.16 seconds, with 292.958 seconds in the test. |
| Restart-interrupted optimized run | Stopped by the agent-server restart before a result; excluded from acceptance and timing comparisons. |
The passing run includes the existing typed-obligation precheck, all seven
warm-ups, and two measured repetitions of every scenario. Coverage is disabled,
the Node heap is limited to 2048 MiB and the full command retains its 300-second
timeout. No other build, test or profiling workload ran alongside it.
[The complete 14-row report](issue-78-protocol.tsv) preserves every measured row.
Every repetition has its expected outcome, placement count and final stage;
each second repetition matches its first complete outcome. The deliberate
conflict remains a conflict with zero placements. The other scenarios complete
through `dates`, with 30, 10, 3, 72, 60 and 90 placements respectively.
The fixture module is byte-for-byte unchanged, SHA-256
`16e057fa042c898d203f71e018f183c9fbecb5cd0edbf0ce0f3f25277cb00d3a`.
The test change only prints its existing report; all correctness assertions,
configurations, quotas, horizons, warm-ups and repetition counts remain intact.
There are no trace counters, transfers or acknowledgement waits.
The passing command has only **1.449 seconds of external-timeout headroom**.
This is a local pass, not a runner-speed guarantee. Gitea Actions now requires
benchmark success before image publication and the aggregate `required` check.
A successful live PR run, publication setup and migration-gate activation remain
with [#268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). No issue is
closed by this local verification.
## Environment and memory
AMD Ryzen 5 7640U, 12 logical CPUs, approximately 16 GiB RAM; Linux
`6.18.33.2-microsoft-standard-WSL2`. Node 24.18.0, Bun 1.3.14, Vitest 5.0.3,
TypeScript 6.0.3, Angular core 22.2.1 and date-holidays 3.30.2. The calculation
calendar date is fixed to `2024-01-01`, matching the benchmark test. Node runs
are unthrottled. These versions differ from the configured CI container's
Node 24.21.0 and Bun 1.4.2; live CI performance has not been measured here.
The passing protocol's sampled peak process-tree RSS is **1,528,056 KiB**,
sampled every 500 ms across the timeout/Bun/Vitest process tree. RSS includes
multiple processes and runtime overhead; it is not a JS-heap measurement.
Chromium's `performance.memory` is unavailable in the Node protocol, so its
`peak_main_thread_heap_bytes` cells remain blank.
## Profile and changes
A separate, explicitly interrupted 20-second loaded-year diagnostic used Node's
inspector CPU and allocation sampling. Candidate finalization, state copying,
calendar observation construction, period bounds and work-sequence scanning
were substantial costs. Allocation sampling included collected objects; its
cumulative allocation estimates are not peak live memory or protocol timings.
The diagnostic retained a valid 72-placement partial plan on cancellation.
A separate 20-second repeated-work probe observed 29 preference-pass executions
and no identical input-state revisit among cache misses. Complete obligation
previews remain in place; there is no new state-equivalence or search-budget
shortcut.
The change addresses those measured paths:
- Evaluate objectives in their actual priority order. Once a protected prefix
loses, or an admissible candidate loses to the current best candidate, lower
objective values cannot reverse that lexicographic comparison. Full vectors
remain available for retained improvements, displayed explanations and date
analysis; tied-prefix additions keep their original retention behavior.
- Build cached calendar observations only when an evaluated objective needs
them. The existing request-local, 4096-entry geometry cache retains its
boundary; balance quantities, provenance and supporting status remain outside
it. Work-sequence lengths use the prepared working-day prefixes and period
anchors instead of rebuilding every sequence's date array for scoring.
- Share existing placements for reading during a single-date assignment, while
continuing to copy balances and earned activations. Bundle and relocation
paths detach placements before changing supporting status. Snapshot,
provenance and other mutable-copy paths retain their deep copies.
- Reuse calendar-request validation during finalization instead of looking up
and scanning the same provenance again for each placement.
No candidate destinations, allocation choices, obligation previews or rule
priorities are removed. Search limits, earned-entitlement chronology, final
quality criteria and deterministic tie-breaks are unchanged. No elapsed-time
engine cutoff is introduced.
## Differential and diagnostic measurements
Complete outcomes, stable explanations and final-state snapshots are compared
with the saved baseline. The configuration matrix contains 182 cases: supported
conditions, aggregations and directions; personal objectives before and after
loss prevention; typed obligations and preferences; declarations; at-risk and
deferred balances; custom deficit thresholds; and empty/disabled rule lists.
Three permanent regression cases also pin reordered and custom objective
vectors, placement dates, provenance and first decisive objectives.
All seven completed diagnostic comparisons also match in full, including the
loaded year and both longer horizons. Table cells show **before → after**.
| Scenario | Preparation (ms) | Planning work (s) | Wall through explanation (s) | Maximum timer delay (ms) | Sampled Node heap (MiB) |
| --------------------------- | ---------------- | ----------------- | ---------------------------- | ------------------------ | ----------------------- |
| representative year | 39.7 → 37.5 | 1.261 → 0.787 | 1.302 → 0.826 | 7.7 → 8.5 | 76.5 → 90.8 |
| typed obligations | 10.9 → 9.4 | 8.563 → 5.642 | 8.574 → 5.652 | 7.8 → 11.2 | 127.7 → 115.1 |
| chained earned entitlements | 5.4 → 6.7 | 0.025 → 0.026 | 0.030 → 0.033 | 0.0 → 0.0 | — → — |
| deliberate conflict | 10.1 → 9.5 | 0.004 → 0.004 | 0.013 → 0.013 | 0.0 → 0.0 | — → — |
| loaded year | 8.8 → 9.1 | 143.648 → 72.029 | 143.664 → 72.043 | 14.6 → 16.2 | 206.1 → 177.3 |
| two years | 17.6 → 12.3 | 5.470 → 2.962 | 5.491 → 2.977 | 13.3 → 11.0 | 186.9 → 164.8 |
| three years | 16.4 → 14.4 | 19.674 → 10.287 | 19.696 → 10.310 | 15.8 → 16.1 | 289.2 → 253.5 |
The cold diagnostics run each unchanged scenario once, without warm-up or
coverage, through `calculatePlanningAsyncInternal`, with the same 2048 MiB heap
and calculation date. They are separate from the acceptance protocol. Occasional
focused/static checks ran during the baseline investigation; those diagnostic
timings are informational, not an isolated speedup estimate. The final diagnostic
run has no competing build/test workload.
Diagnostic preparation ends at the first non-preparation progress callback;
planning work comes from the core's timing artifact; wall time ends after
explanation delivery. In the protocol TSV, `preparation_ms` instead measures
request cloning, while calendar preparation is included in `planning_ms`.
Diagnostic timer delay and `process.memoryUsage().heapUsed` are sampled every
50 ms. Heap is the whole Node process, including earlier scenarios' live data
and caches. A dash means no interval sample was taken before a fast scenario
finished. These are neither allocation totals nor worker-isolated heap claims.
## Native browser checks
Production build, native Chrome 150.0.7871.186 Workers, 375×915 viewport,
device scale 1, reduced motion and 4× CPU throttling on the laptop above.
Each completion uses a new page and worker in one isolated Chrome process,
without a planner warm-up; browser/resource caches are shared between pages.
The cancellation checks use a separate isolated Chrome process. No other
build, test or profiler runs alongside these measurements.
| Scenario | Outcome usable (s) | Calendar ready (s) | Full artifact delivery (s) | Maximum page timer delay (ms) | Peak page heap (MiB) | Chrome RSS before / after outcome (MiB) |
| ------------------- | ------------------ | ------------------ | -------------------------- | ----------------------------- | -------------------- | --------------------------------------- |
| representative year | 2.070 | 2.574 | 2.158 | 886.5 | 22.8 | 1482.5 / 1488.4 |
| two years | 5.864 | 6.879 | 6.878 | 1287.9 | 22.8 | 1538.1 / 1540.3 |
| three years | 15.865 | 17.864 | 17.861 | 1987.2 | 21.1 | 1503.2 / 1506.8 |
Every completion returns `Plan` through `dates`, with 30, 60 or 90 placements,
its stable explanation and one planning request. Worker heartbeats continue
through the long calculations. There are no unexpected page exceptions or
page-level horizontal overflow at the measured viewport.
During the three-year worker calculation, keyboard editing changed the first
visible balance from 20 to 99. The captured request remained at 20, the plan
still contained 90 placements, and no second calculation was sent. Its displayed
estimate therefore followed the pending edited configuration (22–136 seconds).
The unchanged 90-day inputs in the cancellation runs retain their 11–68 second
range; cancelled outcomes add no completion calibration sample. The existing
session calibration and request-to-rendered-calendar contract are unchanged;
these samples do not justify changing the rough initial estimate.
Page heap is Chromium's main-thread `performance.memory` sample every 50 ms;
it excludes the worker heap. Chrome RSS sums the isolated browser process tree
at 500 ms intervals and outcome/artifact boundaries, including workers,
renderers and shared pages counted in each process's RSS. It is not a unique
physical-memory or worker-heap measurement. The post-outcome samples extend
through calendar readiness and artifact delivery. Calendar readiness is
observed by a 50 ms page timer after the loading overlay disappears, rather
than a physical paint measurement.
The observed page timer delays include calendar rendering and reach about
**2.0 seconds**. Inputs accept edits during worker planning; final calendar
rendering remains a visible main-thread pause.
Both cancellation flows activate Cancel with Enter and send one cancel message,
with no additional planning request:
| Cancellation point | Cancel to outcome (ms) | Cancel to artifacts (ms) | Retained placements |
| ---------------------------------------------------------- | ---------------------- | ------------------------ | ------------------- |
| Before a valid plan, controlled startup | 61.3 | 155.0 | 0 |
| Active optimization, after a valid 90-placement checkpoint | 20.6 | 1979.4 | 90 |
The preparation probe queues outgoing worker messages until the keyboard action,
then releases the plan and cancel in order. It verifies cancellation before any
valid plan, rather than ordinary startup-latency performance. It reports
`PlanningIncomplete` without an incumbent or a false conflict. The active
cancellation displays **Calculation cancelled — partial plan**, retains all 90
placements and delivers its partial-plan explanation. The first keyboard probe
omitted the Enter character event and issued no cancel; it was corrected and
excluded from cancellation evidence.
No physical reference-phone run is available. Desktop CPU throttling does not
establish phone performance or a duration ceiling. The browser reference
protocol still uses three measured repetitions; the required Node protocol uses
two. These counts are unchanged.
## Correctness, static checks and build
```sh
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
bunx tsc --noEmit -p tsconfig.app.json
bunx tsc --noEmit -p tsconfig.spec.json
bun run lint
bun run format:check
bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
node scripts/ci-policy.test.mjs
node scripts/ci-image.test.mjs
bun run build
git diff --check
```
All pass. Functional coverage: **633 tests across 33 files**, 93.61% statements,
85.99% branches, 95.70% functions and 95.44% lines, in 92.05 seconds with the
existing 2 GiB Node heap. The separate benchmark also passes, as recorded above.
Coverage settings and thresholds are unchanged. The source changes were also
checked with the 266-test focused planner suite during implementation.
A local probe runs the actual aggregate shell body for 12 combinations of
benchmark and image outcomes. Failed, cancelled and skipped benchmarks block
it; successful benchmarks still preserve the intentional fork/manual image
skip. Both `images` and `required` depend on `benchmark`. The aggregate's first
failed check exits explicitly, without depending on the runner's shell flags.
The production build retains the existing warning budgets: initial bundle
2.57 MB against 2.50 MB, and calendar-year styles 12.98 kB against 8 kB. Both
remain below their unchanged error limits. Sandbox restrictions prevented the
first build/policy attempt; reruns with local socket/child-process permissions
passed. Semgrep and Trivy are not installed locally, so their security jobs,
actual image publication and a live Gitea PR run remain unverified.
## Reproduction
Use the recorded Node/Bun versions and frozen lockfile. Run the exact acceptance
command above from the integrated baseline checkout for the before measurement,
and from this working result for the after measurement. The test prints the
entire TSV report, including failures of its unchanged assertions.
For the separate cold diagnostics, `measure-core.mjs` runs the bundled baseline
or optimized core over the unchanged `benchmarkScenarios()` JSON in fixture
order. Generate that JSON in a separate process so the measured Node process
does not include Angular fixture-export overhead:
```sh
mkdir -p tmp /tmp/vac-issue-78
cat > tmp/issue78-fixtures.ts <<'TS'
import '@angular/compiler';
import { benchmarkScenarios } from '../src/app/benchmark/leave-planner-benchmark';
console.log(JSON.stringify(benchmarkScenarios()));
TS
bun build tmp/issue78-fixtures.ts --target=node --outfile /tmp/vac-issue-78/fixtures.mjs
node /tmp/vac-issue-78/fixtures.mjs > /tmp/vac-issue-78/scenarios.json
```
The diagnostic runner supplies `calculationDate: '2024-01-01'`, captures
outcome/explanation/final-state callbacks, observes preparation progress and
samples timer delay and process heap every 50 ms:
```sh
bun build src/app/services/leave-planner.core.ts --target=node --outfile /tmp/vac-issue-78/optimized-core.mjs
node --max-old-space-size=2048 /tmp/vac-issue-78/measure-core.mjs optimized
node --max-old-space-size=2048 /tmp/vac-issue-78/compare-configurations.mjs
```
The baseline bundle and complete requests, results and profiling sources are
kept beside those runners. CPU/allocation profiling and the cancelled repeated-
work probe are diagnostics only; neither counts as a completed benchmark run.
Save the following as `/tmp/vac-issue-78/measure-core.mjs` to recreate the cold
measurements and complete comparison snapshots. Build `baseline-core.mjs` from
the recorded baseline with the same `bun build` command, then run with
`baseline`; use `optimized` for this implementation.
```js
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const engine = process.argv[2] || 'baseline';
const { calculatePlanningAsyncInternal } = await import(`./${engine}-core.mjs`);
const directory = new URL(`./${engine}-measurements/`, import.meta.url);
mkdirSync(directory, { recursive: true });
const scenarios = JSON.parse(readFileSync(new URL('./scenarios.json', import.meta.url)));
for (const scenario of scenarios.filter(
(item) => !process.argv[3] || item.name === process.argv[3],
)) {
let artifacts;
let outcomeAt;
let preparationMs;
let peakHeapUsedBytes = 0;
let maxTimerDelayMs = 0;
const start = performance.now();
let lastTick = start;
const sample = setInterval(() => {
const tick = performance.now();
maxTimerDelayMs = Math.max(maxTimerDelayMs, tick - lastTick - 50);
lastTick = tick;
peakHeapUsedBytes = Math.max(peakHeapUsedBytes, process.memoryUsage().heapUsed);
}, 50);
const outcome = await calculatePlanningAsyncInternal(
{
...scenario.request,
calculationDate: '2024-01-01',
progress: (stage) => {
if (stage !== 'preparation') preparationMs ??= performance.now() - start;
},
},
undefined,
(value) => {
artifacts = value;
},
() => {
outcomeAt = performance.now() - start;
},
);
clearInterval(sample);
const measurement = {
scenario: scenario.name,
diagnostic: true,
warmups: 0,
repetitions: 1,
preparationMs,
outcomeMs: outcomeAt,
planningWorkMs: artifacts.timings?.planningWorkMs,
wallMs: performance.now() - start,
maxTimerDelayMs,
peakHeapUsedBytes,
peakRssKiB: process.resourceUsage().maxRSS,
outcome: outcome.kind,
placements: outcome.placements?.length,
lastStage: outcome.lastCompletedStage,
};
writeFileSync(
new URL(scenario.name.replaceAll(' ', '-') + '.json', directory),
JSON.stringify({
measurement,
outcome,
explanation: artifacts.explanation,
finalState: artifacts.finalState,
}),
);
console.log(JSON.stringify(measurement));
}
```
+53
View File
@@ -0,0 +1,53 @@
# PR #278: build placement date keys directly
Measured on 2026-10-16, starting from PR #278 at `cbde23f`.
The unchanged benchmark finished in 299.372 seconds before this change, leaving
only 0.628 seconds before its 300-second timeout. A separate loaded-year CPU
profile attributed 8.27 of 66.57 seconds to `placementTieKey`, including its
callees. That function runs when looking up cached calendar observations and
when comparing final placement dates.
The one-line optimization sorts the placement map's date keys directly, using
native string sorting. It removes the intermediate placement-to-date array and
the comparison callback. Every placement map entry is keyed by its placement's
date, and native string sorting preserves the existing lexical date order, so
the resulting keys are identical. Search choices and scoring are unchanged.
## Before and after
Both runs used Node 24.18.0, Bun 1.3.14, Vitest 5.0.3, the same local machine,
and the exact command below. No other test, build or profiling workload ran
alongside either measurement.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
fixtures and assertions remain unchanged. Coverage is disabled for this command.
| Measurement | Before | After |
| ---------------------------------------------- | --------: | --------: |
| Entire command, external wall time | 299.372 s | 268.573 s |
| Benchmark test | 293.620 s | 263.265 s |
| Loaded year, mean of two measured calculations | 76.649 s | 67.747 s |
| Headroom before timeout | 0.628 s | 31.427 s |
The entire command was 10.3% faster in this before-and-after pair. Both runs
completed successfully, and every repeated scenario remained deterministic.
These are local measurements; Gitea runner performance still needs confirmation.
## Correctness checks
Complete outcomes, stable explanations and final-state snapshots match the
baseline for all seven scenarios. These separate comparisons ran alongside
functional checks; their diagnostic timings are not used in the table above.
The functional coverage suite passes all 635 tests across 33 files. Application
and spec type checks, lint, source formatting and `git diff --check` also pass.
No browser timing run or live CI rerun was performed for this change.
The profiling runner and full protocol logs are retained in
`/tmp/vac-opt278-measure`. The original diagnostic reproduction is documented in
[issue-78.md](issue-78.md#reproduction).
@@ -0,0 +1,61 @@
# PR #278: reuse counts for immutable calendar dates
Measured on 2026-10-16 against `c7e7abc`, which already includes the
[direct date-key optimization](pr-278-date-keys.md).
A loaded-year CPU profile attributed 4.06 of 69.37 seconds to repeatedly counting
distinct non-working dates for time-off-period objectives. The existing calendar
cache already shares frozen date arrays between candidate plans, but each score
rebuilt a `Set` to count the same dates again.
The objective evaluator now keeps those counts in a `WeakMap` keyed by the frozen
array. Mutable arrays are recounted on every evaluation. The weak keys allow
unused arrays to be collected, and configuration validation and duplicate-date
handling remain intact. This change touches only that scoring operation.
## Before and after
Both protocol runs used Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
machine. The baseline is the completed run recorded for `c7e7abc`; its saved core
bundle is byte-for-byte identical to a fresh bundle of that commit. No other
test, build or profiling workload ran alongside either protocol measurement.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
fixtures, assertions and resource limits remain unchanged.
| Measurement | Before | After |
| ---------------------------------------------- | --------: | --------: |
| Entire command, external wall time | 268.573 s | 256.034 s |
| Benchmark test | 263.265 s | 250.652 s |
| Loaded year, mean of two measured calculations | 67.747 s | 64.360 s |
| Headroom before timeout | 31.427 s | 43.966 s |
The full command was 4.7% faster in this pair. Both runs completed successfully,
and every repeated scenario remained deterministic. Live CI performance still
needs confirmation.
An isolated check using the loaded-year plan's frozen date arrays took
551–554 ms before and 46–57 ms after for 100,000 scoring calls, following 5,000
warm-ups per run in before/after/after/before order. That result isolates the repeated
counting cost; the protocol above measures its effect on complete calculations.
## Correctness checks
Complete outcomes, stable explanations and final-state snapshots match `c7e7abc`
for all seven scenarios. These separate comparisons ran alongside functional
checks; their diagnostic timings are not used in the protocol comparison.
The functional coverage suite passes all 638 tests across 33 files. Three new
regression cases cover duplicate dates and changes to mutable arrays alongside
frozen arrays for sum, count and maximum scores. Application and spec type checks,
lint, formatting and `git diff --check` also pass.
No browser timing run or live CI rerun was performed for this change.
Diagnostic sources, profiles, bundles and full logs are retained in
`/tmp/vac-opt278-step2`. The original core diagnostic runner is documented in
[issue-78.md](issue-78.md#reproduction).
+65
View File
@@ -0,0 +1,65 @@
# PR #278: reject impossible mandatory moves before copying
Measured on 2026-10-16 against `433a8b0`, which includes the
[immutable date-count optimization](pr-278-immutable-date-counts.md).
A loaded-year CPU profile attributed 5.09 seconds to candidate assignment and
6.53 seconds to final validation out of 58.24 seconds. Generic moves and swaps
copied the candidate state before discovering that a mandatory placement's new
date or leave type could never satisfy its rule.
The shared assignment function now calls the existing `mandatoryRelocationDate`
check before copying. It checks only necessary date and type conditions. Complete
multi-day patterns and earned-entitlement chronology are still validated after
the candidate is assembled. Preferences remain movable, and valid alternatives,
objective order and deterministic tie breaks remain unchanged.
## Before and after
Both protocol runs use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
machine. This comparison uses a fresh baseline from this session. No other test,
build or profiling workload runs alongside either protocol measurement.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
fixtures, assertions and resource limits remain unchanged.
| Measurement | Before | After |
| ---------------------------------------------------- | --------: | --------: |
| Vitest duration | 232.630 s | 228.130 s |
| Benchmark test | 231.483 s | 227.011 s |
| Loaded year, mean of two measured calculations | 59.255 s | 57.850 s |
| Typed obligations, mean of two measured calculations | 4.552 s | 4.368 s |
| Headroom before timeout (Vitest duration) | 67.370 s | 71.870 s |
The Vitest duration was 1.9% shorter in this pair. External wall time was not
recorded for these runs. Both runs completed successfully, and every repeated
scenario remained deterministic. The baseline is faster than the one recorded
for the preceding optimization, so only same-session pairs are comparable.
Live CI performance still needs confirmation.
## Correctness checks
Complete outcomes, stable explanations and final-state snapshots match the
baseline for all seven scenarios. Baseline snapshots come from the preceding
optimization's saved run, whose bundle matches a fresh build of `433a8b0` byte for
byte; the loaded-year snapshot was also regenerated in this session. Diagnostic
comparisons ran alongside functional checks, so their timings are not used for
the protocol comparison.
The functional coverage suite passes all 640 tests across 33 files. Two additional
regression cases cover obligations and lower-priority preferences when a calendar
objective competes for the same leave balance. Both cases pass before and after
the optimization. Existing tests also exercise typed reassignments, complete
multi-day patterns, earned entitlements and counterfactual explanations.
Application and spec type checks, lint, source formatting and `git diff --check`
pass.
No browser timing run or live CI rerun was performed for this change.
Diagnostic sources, profiles, bundles and full logs are retained in
`/tmp/vac-opt278-step3`. The original core diagnostic runner is documented in
[issue-78.md](issue-78.md#reproduction).
+304
View File
@@ -0,0 +1,304 @@
# Bun Runtime and Native Tooling — Development Spec
Status: the agreed decisions are amended by the verification results in #289,
recorded below. ADR 0009 remains proposed until the migration passes its final
gate.
## Status
Implementation status after #290 and the local parts of #293. "Verified locally"
means a command ran with Node unavailable (or a tripwire `node` that logs every
start); it is not a result from the live Gitea workflow.
**Done, verified locally:**
- **One pinned release.** Bun 1.4.3 is pinned by `packageManager`, the
checksum-verified `scripts/install-bun.sh` that every CI job running project
commands uses (no npm), the
`@types/bun` version and both Docker stages. The installer refuses a
`package.json` that names another version. Frozen installs leave `bun.lock`
unchanged.
- **Node-shebang tools on Bun.** `bunfig.toml` sets `[run] bun = true`, so
`start`, `build`, `watch`, `lint`, `lint:fix`, `format` and `format:check` run
`ng`, `eslint` and `prettier` on Bun even when Node is on `PATH`. CI also runs
project steps with a `PATH` that has Bun and no Node.
- **Standalone type checking.** `bun run typecheck` runs `bun check` on the
application and test projects. The production build remains the only check of
Angular templates.
- **Tests, coverage, benchmark, server, container** (#291, #292), and the policy
and image-publisher scripts with their regression tests, under Bun. The fake
`docker` fixture uses a `bun` shebang.
**Still open:**
- **Native lint and format (#290, user story 5).** Replace ESLint and Prettier in
one change when a stable Bun ships `bun lint` and `bun fmt`; today they exist
only in canary builds. Until then user story 5 and the matching #290 criteria
stay open.
- **Live Gitea validation (#293).** The custom job `shell` template, the Bun
installer, JavaScript actions beside Node-free project steps, the benchmark's
timing margin and the required checks on the final PR revision have not run on
the live runner. The `images` job still has Alpine's `nodejs` on `PATH` for the
checkout action, which the project command does not use. These and the Gitea
activation steps remain unverified until a live pull-request run.
- **ADR 0009** stays `proposed` until the final gate.
## Problem Statement
The maintainer wants VacOptimizer to use Bun as its actual JavaScript runtime and
adopt its native tooling wherever agreed, even without measurable performance
gains. Today Bun installs dependencies, generates the language registry, and runs
the deployed server, but development commands can launch Node-based CLIs, CI uses
Node containers, and the Docker build explicitly installs Node.
The toolchain is also inconsistent: the local Bun installation is 1.3.14, CI pins
1.4.2, and Docker uses a floating Bun image. Merely prefixing commands with Bun
does not establish which runtime executes their JavaScript.
## Solution
Use Bun for project-owned JavaScript in development, CI, and production. Adopt
Bun's native test runner, linter, formatter, standalone type checker, and HTTP
server. Retain Angular and its standard compiler and build pipeline, running the
JavaScript tooling under Bun.
Preserve the application's existing behavior and verification standards. The
planner remains client-side: planning horizons, planning rules, leave placements,
planning outcomes, plan statistics, and plan explanations retain their current
meaning. Browser code and Web Workers continue to run in the user's browser.
## User Stories
The following acceptance criteria define completion:
1. **Actual Bun execution.** Development serving and watch mode, production
builds, language generation, repository CI scripts, tests, and the production
server execute their project-owned JavaScript under Bun, including spawned
tools and test fixtures. A Node.js fallback does not satisfy this criterion.
2. **Reproducible setup.** Development instructions, CI, and both Docker stages
specify the same exact, verified Bun version. Frozen dependency installation
succeeds from a clean checkout without changing the lockfile, and the
production image installs only production dependencies.
3. **Native tests with preserved behavior.** All existing functional and Angular
component tests execute under `bun test`, preserving assertions, parameterized
cases, clock control, global mocks, cleanup, isolation, and real worker
integration. Tests cannot be dropped or weakened to make the migration pass.
4. **Preserved coverage.** Functional tests retain coverage reporting and CI
artifacts, including useful output when tests fail. Preserve the current
production-code coverage scope and exclusion of the frozen engine baseline;
report unsupported coverage capabilities or report formats as compatibility
gaps rather than silently removing them.
5. **Native linting and formatting.** Bun's native linter and formatter replace
ESLint and Prettier, preserving the current rule severities, type-aware checks,
unused-name exceptions, formatting options, and Angular-template formatting.
CI continues to reject violations. Checks cover the same application,
repository-script, and workflow content as before.
6. **Type checking alongside Angular.** `bun check` checks application and test
TypeScript. Angular's compiler retains its supported TypeScript 6 dependency
and continues to check templates during builds. Type errors still fail the
appropriate checks; successful standalone checking does not replace a
successful Angular build.
7. **Native HTTP serving.** `Bun.serve` replaces Express, preserving Angular's
request-handler integration, configured rendering modes, static assets and
cache behavior, configurable port, error handling, and container health check.
Development and production routes continue to work, including direct visits
to the benchmark page. The current catch-all route uses client rendering;
this migration does not enable server rendering for that route.
8. **Existing CI guarantees.** Policy and image-publication regression checks,
security scans, production builds, artifact retention, and required checks
continue to work. The separate required seven-scenario benchmark preserves
its calibration protocol, warm-up, measured repetitions, determinism checks,
timeouts, and performance budgets. A runtime change cannot justify weakening
these gates.
9. **Honest completion.** Fix incompatible required tooling before declaring the
migration complete. If a fix proves infeasible, report the failing behavior,
evidence, and remaining blocker. Local success must be distinguished from
successful live Gitea checks.
## Implementation Decisions
- **Agreed: runtime and native tools.** Bun adoption is a requirement independent
of speed gains. Use its native test runner, linter, formatter, standalone type
checker, and HTTP server. Compatible Node API imports may remain when executed
by Bun; rewriting every import is not required to satisfy the runtime goal.
- **Agreed: Angular boundary.** Keep Angular's standard compiler, bundler,
development server, and framework build support. Preserve the existing
rendering configuration and browser-based planner, consistent with ADR 0002.
- **Agreed: TypeScript coexistence.** Keep TypeScript 6 for Angular compilation
and use Bun's TypeScript 7-based checker for standalone checking. An Angular
upgrade is not required for this arrangement.
- **Agreed: tests and server integration.** Adapt the Angular test compilation
and setup, mocks, DOM environment, worker harnesses, and subprocess fixtures to
the chosen runtime and runner. Use Angular's web request API with Bun's HTTP
server, preserving its development/build handler contract and static-file
protections.
- **Migration reach.** Update package commands, test configuration and typings,
generated executable fixtures, server entry and exports, container build and
runtime stages, project-owned CI invocations, and development documentation.
Remove superseded dependencies and configuration after checking their callers.
- **Proposed implementation order.** Verify the Bun release, an Angular
production build, representative Angular component tests, and native
lint/format/type checks first. Extend that verified setup to the full suite,
server, containers, and CI before removing the replaced tools.
- **Proposed version.** Bun 1.4.3 is the initial candidate based on the researched
native-tool documentation. Select the exact version only after verifying the
required capabilities together.
## Verification Results and Decisions (#289)
Probes ran on 2026-10-17 against the stable Bun `1.4.3` release (`1.4.3+c6da4a4d3`)
from a clean checkout. "Node unavailable" means a `PATH` holding only a `bun`
executable plus `/usr/bin:/bin`, with `command -v node` failing.
**Verified:**
- **Install.** `bun install --frozen-lockfile` succeeds and leaves `bun.lock`
unchanged.
- **Runtime.** Package binaries with a `#!/usr/bin/env node` shebang (`ng`,
`eslint`, `prettier`, `tsc`) run on Bun only with `--bun`. Without it they use
Node when Node is on `PATH`, so every such command must pass `--bun`
explicitly. #290 sets it once for every `bun run` script with `[run] bun = true`
in `bunfig.toml`. Spawned build children are native executables (esbuild,
dart-sass); Angular's workers are threads of the Bun process.
- **Development server.** `bun --bun x ng serve` serves the application and
direct navigation to `/benchmark` with Node unavailable.
- **Type checking.** `bun check -p tsconfig.app.json` and
`-p tsconfig.spec.json` match `tsc` 6 on the clean tree and catch injected
application and test type errors. Only the Angular build catches template
errors, as expected.
- **Tests.** `bun test` runs the whole functional suite with a preload that
compiles components through Angular's JIT transform with inlined templates,
sets up jsdom, TestBed and zoneless change detection, plus a small shim for
the Vitest APIs Bun lacks (`stubGlobal`, `unstubAllGlobals`, `waitFor`,
`advanceTimersByTimeAsync`, `expect.poll`, `toHaveBeenCalledExactlyOnceWith`,
`setSystemTime` ordering and Date-only fake timers). Real-worker specs work
with Node unavailable. `--isolate` or `--parallel` is required for per-file
isolation. No spec uses snapshots.
- **Server.** `Bun.serve` passes route, asset, cache-header, missing-file and
path-traversal checks.
- **CI scripts.** The policy regression script passes under Bun. The image
regression script needs its fake `docker` shebang changed from `node` to
`bun`.
**Decisions taken after the probes:**
- **Linter and formatter.** Native `bun lint` and `bun fmt` exist only in canary
builds (`1.4.4-canary.1`), not in stable `1.4.3`. Pin stable `1.4.3`
everywhere and keep ESLint and Prettier, executed by Bun, until a stable
release ships them. Then replace them in one change. Until then, user story 5
stays open.
- **Client-only Angular build and a Bun static server.** Angular's production
build fails under Bun whenever a server or SSR entry is configured: route
extraction loads the server bundle through Node `module.register` loader
hooks, which Bun accepts but never applies. Every route is client-rendered
today, so the Angular build drops its server and SSR entries and a small
`Bun.serve` server delivers static assets with the same cache behavior and
falls back to `index.html`. This replaces the agreed Angular web request
handler integration and gives up `@angular/ssr`. Adding server rendering later
requires revisiting this decision.
- **Coverage.** `bun test` reports text and lcov with line data only. HTML and
Cobertura reports are generated from the lcov with the istanbul report
libraries. Branch and function coverage are no longer reported; this
reduction is accepted.
- **Benchmark memory guard.** JavaScriptCore has no `--max-old-space-size`
equivalent. The CI benchmark job enforces the limit with a container memory
limit instead of a heap-only cap.
**Platform constraints, not project commands:**
- **Gitea actions.** `actions/checkout@v4` (`node20`) and
`actions/upload-artifact@v3` (`node16`) are JavaScript actions, so the job
container must still provide `node` for the runner. Project commands must not
use it.
- **Allowed hosts.** Found while probing: the current Express server answers 400
unless `NG_ALLOWED_HOSTS` is set, and the compose files only set `PORT`. The
static Bun server removes this dependency.
## Testing Decisions
- Test observable behavior and failure outcomes, rather than configuration text
or internal adapter shapes. Reuse the existing behavior-level tests and change
harnesses only where the runtime or runner requires it.
- **Runtime and installation:** exercise clean frozen installs, development and
production commands, and project-owned subprocesses with Node unavailable to
those commands. Verify actual execution by Bun, rather than relying on a
command's name. Keep any CI-platform Node requirement separate from this check.
- **Angular and planning:** use the existing TestBed-based component and service
suites as prior art for compilation and DOM behavior. Retain the planning
integration, property-based, migration, date-sensitive, and cancellation tests
to check planning outcomes, saved inputs, and plan explanations.
- **Workers and subprocesses:** preserve the existing real-worker tests for
submitted-configuration isolation, outcome and explanation delivery, and worker
cleanup. Run policy and publisher regression scripts with Bun, keeping their
temporary Git repositories and fake Docker executable so verification remains
independent of real registry writes.
- **Lint, format, and types:** compare the current checks with the native tools.
Use small representative invalid inputs to establish that unused variables,
deprecated APIs, formatting violations, application/test type errors, and
Angular template errors still fail the intended command. Verify formatting
options and representative Angular templates without an unrelated reformat.
- **HTTP and containers:** add focused HTTP behavior checks around the native
server, since the explored test suite provides no equivalent server coverage.
Exercise configured routes, direct navigation, static JavaScript/CSS assets,
cache and content-type headers, missing assets, error responses, and traversal
protection. Smoke-test the production container and its health check.
- **Coverage and benchmark:** verify functional coverage artifacts, including
failure-path uploads, and run the existing benchmark outside coverage. Keep the
frozen-engine calibration and current budget assertions. Record Bun results as
new measurements rather than claiming browser or phone speedups from them.
- **CI:** use the existing policy/publisher regression scenarios as prior art for
push, pull-request, fork, manual, and release-event behavior. Obtain successful
required checks on the final PR revision; live publication and activation
validation remain subject to the repository's existing Gitea process.
## Out of Scope
- Replacing Angular's build pipeline with Bun's bundler, rewriting the framework,
or bypassing Angular's TypeScript support checks.
- Moving leave planning to the server, changing planning rules or objectives,
altering saved data, or adding accounts, synchronization, or backend services.
- Changing configured rendering modes or treating Bun as the browser runtime.
- Dropping tests, weakening lint rules or benchmark budgets, or silently using
Node to complete otherwise incompatible project commands.
- Replacing security scanners, changing registry publication policy, provisioning
CI credentials, modifying branch protections or activation settings, creating
releases, publishing this spec, or deploying the application.
- Rewriting compatible Node API imports solely because their names mention Node.
## Further Notes
This spec records the decisions made in the interview. The architecture record
remains proposed; the Status section lists what is implemented.
The glossary receives no new terms because the choices concern general tooling,
rather than new leave-planning concepts.
Remaining verification (everything else is recorded as verified under Status):
- Native lint and format wait for a stable Bun release that ships `bun lint` and
`bun fmt` (#290).
- Live Gitea validation (#293): the custom job `shell` template, the Bun download
in each job, the `images` job with publisher credentials, and the benchmark's
timing margin on the runners.
- The `images` job keeps Alpine's `nodejs` on `PATH` because the checkout action
needs it (a runner requirement); project commands do not use it and no Node
fallback for them is agreed.
- `src/test-setup.ts` imports Analog's internal `jitTransform` by path; replace it
when Angular or Analog exports a JIT transform.
Research checked on 2026-10-17:
- [Bun runtime selection](https://bun.sh/docs/runtime#--bun) explains why a Bun
command can still launch a Node executable.
- [Bun 1.4.3](https://bun.sh/blog/bun-v1.4.3),
[native linting](https://bun.sh/docs/lint),
[formatting](https://bun.sh/docs/format), and
[type checking](https://bun.sh/docs/runtime/check) describe the candidate tools.
- [Angular build guidance](https://angular.dev/ecosystem/custom-build-pipeline)
recommends retaining its standard CLI pipeline.
- [Angular's web request engine](https://angular.dev/api/ssr/AngularAppEngine) and
[Bun's HTTP server](https://bun.sh/docs/runtime/http/server) describe the original
server integration plan, replaced by the `Bun.serve` static server above.
- [Angular version compatibility](https://angular.dev/reference/versions) and
[compiler roadmap](https://angular.dev/roadmap) establish the current Angular
compiler boundary. [Microsoft's coexistence guidance](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#typescript-and-embedded-languages)
describes standalone TypeScript 7 checks alongside Angular-specific tooling.
+59 -13
View File
@@ -16,16 +16,24 @@ synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
| Job | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs lint, formatting, and application/test type checks. |
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
| `benchmark` | Runs outside coverage with the same worker/heap limits and 300-second protocol timeout. Advisory while #78 is open. |
| `build` | Builds browser/server production bundles and retains `dist` for seven days. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. |
| `images` | Publishes branch/SHA images after required validation, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. Excludes the advisory benchmark. |
| Job | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 600-second timeout is only a hang guard. |
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
The benchmark runs on both push and pull_request events: the push run gates image
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
The relative gate absorbs a slower runner and load that lasts through the whole
job, because calibration runs in the same job. It does not absorb load that
starts or stops during the protocol, such as an overlapping push and
pull_request run of the same commit; the 600-second hang guard still bounds
those runs.
Push and PR runs are separate so PR title and rebase checks run on the PR event.
New runs cancel older runs for the same event/ref. Image jobs serialize per
@@ -33,11 +41,13 @@ branch slug; stable promotions share one group and are not cancelled in progress
## Runners and permissions
Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
Every job that runs project commands uses Bun 1.4.3 with the frozen lockfile (see [Bun toolchain and Node](#bun-toolchain-and-node)). Job containers stay on Node 24.21.0. The image
job uses Docker 29.9.1 and its matching Docker-in-Docker service. Scanner
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
Trivy downloads its vulnerability database from Aqua's public ECR mirror because
the default registries returned a missing database blob and a download timeout.
Jobs select the `ubuntu-latest` label and require Linux container-job support.
Allow at least the preserved 2 GiB Node heap plus runner and container overhead.
Allow at least the benchmark job's 3 GiB container memory limit plus runner overhead.
The instance currently has an online inherited runner named `srvhome`, labelled
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
@@ -62,6 +72,42 @@ Keep secret values in Gitea's Actions secret store. The configured tea account's
administrator token is not a CI publishing credential. No SSH or deployment
secret is required by this workflow.
## Bun toolchain and Node
Project commands run on Bun 1.4.3 only. `package.json` (`packageManager`),
`@types/bun`, both Docker stages and `scripts/install-bun.sh` carry the same
version. To upgrade, change all of them together and replace the installer's
four SHA-256 values with those of the new release's `SHASUMS256.txt`; the
installer refuses a `package.json` that names a different version, so a bump of
only one place fails CI instead of drifting.
- **Install.** `policy`, `validate`, `test`, `benchmark`, `build` and `images`
run `sh scripts/install-bun.sh`. It downloads the release zip for the
runner's architecture (x64 or aarch64) and libc (glibc, or musl on Alpine),
verifies its pinned SHA-256, and installs `/opt/bun/bin/bun`. It uses no npm.
- **Node-free project steps.** The five Debian jobs set
`defaults.run.shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}`. Every
`run:` step then sees Bun but not `/usr/local/bin`, where the `node` image keeps
`node`, `npm` and `yarn`. `bun run` additionally runs Node-shebang tools on Bun
through `bunfig.toml`. The security scanners are not project JavaScript, so
`security` has no Bun and no custom shell.
- **Why the containers keep Node.** `actions/checkout@v4` (`node20`) and
`actions/upload-artifact@v3` (`node16`) are JavaScript actions. The runner
starts them with the container's `node`; the custom shell does not affect
`uses:` steps. This is a platform requirement, not permission to run project
commands on Node.
- **`images` exception.** Alpine's `nodejs` package installs `node` into
`/usr/bin`, beside `git`, and checkout's post step needs it, so `PATH` cannot
hide it there. The project command is `/opt/bun/bin/bun scripts/ci-image.mjs`;
it starts only `process.execPath`, `docker` and `git`. Hiding Node there needs
an infrastructure decision (for example a runner image that ships Node outside
`PATH`), which this conversion does not make.
- **Needs live validation.** The custom shell template is a GitHub Actions
feature that Gitea's runner implements. A local run of the real engine
(`gitea-runner exec` v5.1.0, with the job containers above) executed it, but
only a live Gitea run confirms the instance's runner version, the checkout
action and the privileged Docker-in-Docker `images` job.
## Images and releases
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
+18 -19
View File
@@ -1,6 +1,6 @@
# Cancelling a planning calculation
Issue [#58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/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.
Issue [#58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58) implements the cancellation portion of ADR 0006. Issue #59 removes the automatic deadline. Issue #76 removes decision tracing while preserving cancellation and valid-plan recovery. 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.
@@ -10,42 +10,41 @@ The core exposes resumable generator steps. `calculatePlanningAsyncInternal` dri
The synchronous `calculatePlanningInternal` adapter drains the same steps without pauses for existing direct core callers. The public non-browser calculation context uses the asynchronous driver so its cancellation method also works.
User cancellation is distinct from replacement by another calculation (`stale-calculation`) and isolated worker failure (`worker-error` or `worker-unavailable`). Replacement retains the existing termination and stale-response protection. User cancellation leaves the producing worker alive until its explanation and trace have been delivered.
User cancellation is distinct from replacement by another calculation (`stale-calculation`) and isolated worker failure (`worker-error` or `worker-unavailable`). Replacement retains the existing termination and stale-response protection. User cancellation leaves the producing worker alive until its outcome and explanation data have been delivered.
## Retaining a valid plan
The asynchronous calculation retains a copied incumbent as soon as the obligation search finds a jointly valid solution, before that entire stage has completed. It also considers accepted placements and every feasible evaluated quality candidate, including candidates rejected by a stage's narrower comparator. Each candidate is reconciled for earned entitlement availability and checked against all obligation rules before retention. The existing full lexicographic comparator chooses the best available incumbent; its comparisons are recorded in the original trace.
The asynchronous calculation retains a copied incumbent as soon as the obligation search finds a jointly valid solution, before that entire stage has completed. It also considers accepted placements and every feasible evaluated quality candidate, including candidates rejected by a stage's narrower comparator. Each candidate is reconciled for earned entitlement availability and checked against all obligation rules before retention. The existing full lexicographic comparator chooses the best available incumbent.
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone, with the actual short-circuited comparison recorded in the trace. This avoids rescoring dominated states during large obligation previews without skipping any produced history or a candidate that could improve the retained plan.
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone. This avoids rescoring dominated states during large obligation previews without skipping a candidate that could improve the retained plan.
Preference occurrence dates and patterns are calculated once for the run's immutable calendar and rules. Each comparison still checks the candidate's actual placements against those patterns; candidate scores and trace records are not cached.
Preference occurrence dates and patterns are calculated once for the run's immutable calendar and rules. Each comparison still checks the candidate's actual placements against those patterns; candidate scores are not cached.
On cancellation, the core materializes the retained state with consistent placements, balances, periods, rule provenance, and preference satisfaction. It returns `PlanningIncomplete` with reason `user-cancelled`. With an incumbent the French status is **Calcul annulé — plan partiel**; without one it is **Calcul annulé**, without a plan or an obligation conflict. Temporary search states never become the displayed incumbent.
## Preserving the original trace
## Delivering the outcome and explanation
Cancellation follows the core's normal finish path. Quality evaluations are recorded before their incumbent comparisons, and each greedy placement attempt starts its own atomic decision. The recorder closes interrupted and unexecuted phases and drains every remaining operation from that run. The worker retains staged updates, including ones not yet posted, and continues its bounded, acknowledged trace delivery after optimization has stopped. It sends `artifacts-complete` only when all queued updates have been sent and acknowledged. The facade drains its own received update queue before resolving the trace. Neither layer replays planning to reconstruct this history.
Cancellation follows the normal finish path. The stable explanation is derived from the retained state. Outcome and explanation have separate promises: delayed or failed explanation delivery cannot replace or remove an already settled valid partial plan. Repeated and late outcome messages cannot change that outcome, and calculation identities protect the UI against obsolete results and explanations.
The stable explanation is derived from the retained state. The outcome, explanation, and trace have separate promises: delayed or failed artifact delivery cannot replace or remove an already settled valid partial plan. Repeated and late outcome messages cannot change that outcome, and calculation identities protect the UI against obsolete results and artifacts.
Immutable `PlanningStateSnapshot` values preserve exact entitlement metadata, earned activations, declarations and placements for checkpoints and the final state. A worker failure retains the latest delivered valid checkpoint and can recover its stable explanation in a separate worker without rerunning planning. Displayed-placement alternatives use the captured configuration and state; no original decisions or historical baselines are retained.
If Cancel is accepted just before a queued natural result reaches the facade, the pending outcome still becomes a user cancellation. An already computed valid plan can be retained, while a queued conflict cannot become the cancellation result. Its existing explanation receives interruption metadata, and the original phase history is preserved even when the worker had already completed those phases.
If Cancel is accepted just before a queued natural result reaches the facade, the pending outcome still becomes a user cancellation. An already computed valid plan can be retained, while a queued conflict cannot become the cancellation result. Its existing explanation receives interruption metadata. The planning worker terminates after delivering the required artifacts; it never waits for trace acknowledgements.
## Verification
Run the focused checks with the project's installed Bun and Node:
Run the focused checks with the pinned Bun (Node is not needed):
```sh
bunx vitest run src/app/services/leave-planner-cancellation.spec.ts
bunx vitest run src/app/services/leave-planner.worker.spec.ts
bunx vitest run src/app/components/calendar-year/calendar-year-planning.spec.ts
bunx tsc --noEmit -p tsconfig.app.json
bunx tsc --noEmit -p tsconfig.spec.json
bun test --isolate src/app/services/leave-planner-cancellation.spec.ts
bun test --isolate src/app/services/leave-planner.worker.spec.ts
bun test --isolate src/app/components/calendar-year/calendar-year-planning.spec.ts
bun run typecheck
```
The public cancellation tests cover immediate cancellation, queued trace updates on both sides of the request, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. A core regression compares the retained plan's full objective vector with every valid candidate actually evaluated before cancellation. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete trace delivery.
The public cancellation tests cover immediate cancellation, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. Core regressions verify retained validity, provenance, earned chronology and the best available partial plan through the existing checkpoint seam. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete explanation delivery.
The real-worker suite bundles the production worker source with Bun and runs it on a separate Node worker thread. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and acknowledged trace protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
The real-worker suite bundles the production worker source with Bun and runs it on a separate `node:worker_threads` thread of the Bun process. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and outcome and explanation delivery protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and complete trace delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and stable explanation delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
The production build was checked in native Chromium for all three interruption points, including keyboard cancellation during a 375px dark-theme obligation search and desktop light-theme checks. An editable input could still receive focus during CPU work, and focus moved to the result heading after keyboard cancellation. Each run sent one planning request and one cancellation request, displayed the appropriate French status, and completed acknowledged artifact delivery. The Cancel control was 44px high and the page had no horizontal overflow. In these sample runs, cancellation through artifact completion took under 200ms; this is measured evidence, not a latency guarantee.
Earlier browser checks before #76: the production build was checked in native Chromium for all three interruption points, including keyboard cancellation during a 375px dark-theme obligation search and desktop light-theme checks. An editable input could still receive focus during CPU work, and focus moved to the result heading after keyboard cancellation. Each run sent one planning request and one cancellation request, displayed the appropriate French status, and completed acknowledged artifact delivery. The Cancel control was 44px high and the page had no horizontal overflow. In these sample runs, cancellation through artifact completion took under 200ms; this is measured evidence, not a latency guarantee.
+9 -11
View File
@@ -73,19 +73,17 @@ 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`.
Every request, manual or initial, captures `performance.now()` inside
`PlanningCalculationService.start`.
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
calendar preparation, worker startup, search, 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.
measurement. The callback does not wait for the explanation promise;
later delivery, navigation and explanation 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
@@ -98,12 +96,12 @@ timer, date adjustment or new calculation. **Cancel** remains explicit.
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
scheduling, a twenty-second calculation exceeding its initial range, an explanation
that arrives 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.
tests cover saved horizons, long calculations and interrupted-plan explanations.
Rendered annual, two-year and three-year references, with the actual displayed
range and separate later trace wait, are recorded in
range and historical artifact-delivery wait, are recorded in
[the benchmark report](benchmark/README.md).
+150
View File
@@ -0,0 +1,150 @@
# Integrated planning verification after trace removal
Functional verification for [#77](https://gitea.nessar.fr/nessar/vac-optimizer/issues/77),
run on 2026-10-15 against `2f4af14869c47836d2c1008157136f75c9d3e98b`
(`develop`, including the merged #76 implementation). The original checkout was
clean at `d9d9befa1256cd2200aa4778d28b2bc23ba141e6`; both commits have the same
tree. This verification adds two real-worker lifecycle tests and this report.
It does not change production behavior, saved data, placement expectations,
benchmark inputs, CI settings or performance limits.
## Contract audit
| Path | Verified retained contract |
| --- | --- |
| Synchronous and cooperative core | Outcomes, valid checkpoints, final-state snapshots, stable explanations and explicit cancellation; no recorder, candidate history or historical replay API. |
| Server calculation context | Immutable submitted configuration, independently delivered outcome and explanation, and displayed-placement date analysis. |
| Browser without Worker | Validation and explicit `worker-unavailable` handling; no synchronous planning or trace compatibility result. |
| Native worker and coordinator | Heartbeat, progress, checkpoint, result, explanation and final artifacts. The coordinator terminates the worker after required delivery with no acknowledgement message or queue. |
| Calendar and remaining balances | Final and valid partial calendars, placement provenance, objective comparisons, earned sources, validity dates and concrete non-placement reasons. |
| Alternative dates | Captured horizon, entitlement, type and rule provenance; unrelated placements stay fixed. Hypothetical comparisons never update the displayed plan. |
| Benchmark callers | Supported calculation outcome, explanation and timings; no trace promises, waits, counts or replacement trace metrics. |
Production-source searches found no decision-trace, historical analysis,
acknowledgement or replay implementation. `PlanningArtifacts` carries actual explanation,
final-state and planning-work timing data, rather than a compatibility shell.
## Existing deterministic fixtures
The full functional suite passes without changing any existing expected placement,
balance, metric, objective, chronology or completed-stage assertion.
| Requirement | Existing regression coverage |
| --- | --- |
| Typed joint obligations and shared provenance | `leave-planner.service.spec.ts`, `leave-planner-explanation.spec.ts` and real-worker tests. |
| Earned-entitlement chains and availability | Integration, explanation, supporting-placement and real-worker tests, including declarations and cancellation. |
| Custom formulas and reordered priorities | Optimization, calendar-objective, work-sequence and supporting-placement tests, including real-worker comparisons. |
| Two- and three-year horizons | Service, calendar and worker tests retain the submitted dates and deterministic completed plans. |
| No active optimization rules | Optimization and migration tests retain declarations and remaining balances without adding placements. |
| Stable explanations and remaining reasons | Entitlements, every contributing rule, ordered objective values, first decisive objective, periods and dated non-placement reasons. |
| Cancellation and failures | Before and after a jointly valid incumbent, stale calculations and date analyses, worker liveness, technical budgets, and local explanation errors. |
| Saved settings | Existing migration and persistence tests preserve user rule choices, balances, declarations and explicitly selected horizons. |
An additional direct comparison uses the pre-removal core from `ea42561`, with
only its relative imports redirected to the unchanged calendar/date/objective
modules. Both versions receive the same cloned requests and calculation date
`2024-01-01`. Full outcomes and stable explanations are compared with deep
equality; no placement expectations or retained explanation fields are filtered.
All ten completed comparisons match for both the full outcome and stable
explanation:
| Fixture | Outcome | Placements | Last completed stage |
| --- | --- | --- | --- |
| Representative year | `Plan` | 30 | `dates` |
| Typed joint obligations | `Plan` | 10 | `dates` |
| Chained earned entitlements | `Plan` | 3 | `dates` |
| Deliberate conflict | `PlanningConflict` | 0 | — |
| Two years | `Plan` | 60 | `dates` |
| Three years | `Plan` | 90 | `dates` |
| Holiday value before non-working-day gain | `Plan` | 3 | `dates` |
| Non-working-day gain before holiday value | `Plan` | 3 | `dates` |
| Custom maximum holiday-week aggregation | `Plan` | 3 | `dates` |
| No active optimization, with a declared day | `Plan` | 0 | `dates` |
The reordered priorities retain their different placement dates: 6, 7 and 10 May
for holiday value first; 29 April, 10 May and 17 May for gain first. The zero-rule
case retains its declaration. These are correctness comparisons, not timed
benchmark repetitions. An exploratory pre-removal loaded-year replay was stopped
and is not used as passing evidence; its full protocol remains with #78.
## Correctness and static checks
Environment: Node `24.18.0`, Bun `1.3.14`, Linux under WSL2.
```sh
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
bunx tsc -p tsconfig.app.json --noEmit
bunx tsc -p tsconfig.spec.json --noEmit
bun run lint
bun run format:check
bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
node scripts/ci-policy.test.mjs
node scripts/ci-image.test.mjs
bun run build
git diff --check
```
All checks pass. Functional coverage: **632 tests across 33 files**, 93.56%
statements, 85.90% branches, 95.69% functions and 95.41% lines. The full coverage
run took 95.67 seconds with the existing 2 GB Node heap setting. The focused
real-worker file passes all 15 tests, including the two added lifecycle checks.
Coverage configuration and thresholds are unchanged.
The production build retains its existing warning budgets: initial bundle
2.57 MB against the 2.50 MB warning and calendar-year styles 12.98 kB against
the 8 kB warning. Both remain below their unchanged error limits.
## Browser QA
The production build was served locally with
`NG_ALLOWED_HOSTS=localhost,127.0.0.1 PORT=4177` and exercised in headless
Chromium `149.0.7827.55`, using native module Workers, reduced motion and
375×915, 768×915 and 1440-pixel-wide viewports. The calculation date was fixed
to `2024-01-01`. This is desktop functional evidence, not a phone performance run.
| Flow | Result |
| --- | --- |
| Completed calendar and explanation | Three CP placements on 6, 7 and 10 May 2024, six gained non-working dates; keyboard disclosure, placement navigation and calendar selection work. |
| Remaining balances | Future RTT availability and unavoidable expiry show their actual dates, reasons and planning horizon without attempt/history controls. Earned-source rendering is also covered by component tests. |
| Date comparison | An alternative on 13 May is hypothetical; placements and selected calendar dates remain unchanged. Escape closes the dialog and restores focus to Compare dates. |
| Immutable settings | Persisting a current CP balance edit from 3 to 99 leaves the displayed three placements unchanged. Select another placement and verify that a new comparison request is sent after the edit; that request still contains the captured quantity 3 and original horizon. |
| Cancellation before a valid plan | At 375 px, hold the native worker script download, activate Cancel with Enter, then release startup. Result is `PlanningIncomplete` / `user-cancelled`, without an incumbent or a false conflict. This controlled startup is not latency evidence. |
| Cancellation after a valid plan | At 375 px, use the unchanged loaded-year request and cancel with Enter after a real checkpoint. All three typed obligation windows remain satisfied, placements plus remaining balances total 72, and the partial explanation is available. Inputs remain editable during planning; Cancel is at least 44 px high. |
| Delayed and stale delivery | At 375 px, hold explanation and artifact messages after a real outcome. The three-placement calendar is usable immediately. Recalculate, then release the old messages; the current result and explanation remain intact. |
| Explanation failure | At 375 px, inject explanation transport and checkpoint-explanation recovery failures. An inline error appears while the completed calendar and all three placements remain available. |
| Responsive and keyboard interaction | No page-level horizontal overflow at 375, 768 or 1440 px. Comparison dialogs fit, retain keyboard focus and restore it on dismissal. No unexpected browser page errors. |
| Removed UI | No advanced trace explorer, historical overlays, replay controls, candidate-status UI or remaining-balance attempt controls. |
Controlled-clock transport tests additionally verify that heartbeats renew the
30-second inactivity watchdog across repeated windows and that monitoring ends
at outcome delivery. Native-worker tests verify actual heartbeat delivery,
incumbent validity and retained explanations. No automatic engine cutoff is added.
## Handoff to #78
The #70 → #71 → #72 → #73 → #74 → #75 dependency order is preserved. The tracker
already reports those six tickets and #76 closed. This work verifies their
functional integration and does not close or modify any other ticket.
The benchmark source and its original correctness assertions remain unchanged
from the integrated baseline:
| File | SHA-256 |
| --- | --- |
| `src/app/benchmark/leave-planner-benchmark.ts` | `16e057fa042c898d203f71e018f183c9fbecb5cd0edbf0ce0f3f25277cb00d3a` |
| `src/app/benchmark/leave-planner-benchmark.spec.ts` | `c7b4c37b6c169b42c4d18dfc3e650b25a2ba47415c97268dafefafbfb0ee3023` |
All seven scenarios, typed and earned-chain fixtures, two-/three-year horizons,
60-/90-placement expectations, determinism comparisons and final `dates` stage
assertions remain in place. Warm-ups and measured repetitions remain unchanged.
The full 2 GB / 300-second benchmark protocol and any required optimization stay
open in [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78); they are not
a completion gate for #75 or #77. This run makes no performance acceptance or
trace-removal speedup claim. The separate advisory benchmark status must be
resolved by #78 under the existing limits.
Local run logs, the original spec and checkout record, comparison scripts and
browser screenshots are in `/tmp/vac-issue-77`. The source fixtures and commands
above identify the reproducible correctness checks; temporary QA controls are
not part of the application.
+25 -33
View File
@@ -1,17 +1,20 @@
{
"name": "vac-optimizer",
"version": "1.5.0",
"packageManager": "bun@1.4.3",
"scripts": {
"ng": "ng",
"start": "bun scripts/generate-langs.js && ng serve",
"build": "bun scripts/generate-langs.js && ng build",
"postbuild": "bun build src/server.ts --target=bun --outfile dist/vac-optimizer/server/server.js",
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
"test": "bun scripts/generate-langs.js && ng test",
"serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
"test": "bun scripts/generate-langs.js && bun test --isolate",
"serve": "bun dist/vac-optimizer/server/server.js",
"typecheck": "bun scripts/generate-langs.js && bun check -p tsconfig.app.json && bun check -p tsconfig.spec.json",
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\""
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\"",
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\""
},
"prettier": {
"printWidth": 100,
@@ -27,51 +30,40 @@
},
"private": true,
"dependencies": {
"@angular/animations": "22.2.1",
"@angular/animations": "22.2.3",
"@angular/cdk": "^22.0.0",
"@angular/common": "22.2.1",
"@angular/compiler": "22.2.1",
"@angular/core": "22.2.1",
"@angular/forms": "22.2.1",
"@angular/common": "22.2.3",
"@angular/compiler": "22.2.3",
"@angular/core": "22.2.3",
"@angular/forms": "22.2.3",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "22.2.1",
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.2",
"@angular/platform-browser": "22.2.3",
"@angular/platform-browser-dynamic": "22.2.3",
"@angular/router": "22.2.3",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1",
"@angular/build": "22.2.3",
"@angular/cli": "22.2.3",
"@angular/compiler-cli": "22.2.3",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
"@nx/vite": "^23.0.0",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/bun": "1.4.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^26.0.0",
"@vitest/coverage-istanbul": "^5.0.0",
"@vitest/coverage-v8": "^5.0.0",
"@vitest/ui": "^5.0.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"fast-check": "4.10.2",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"jsdom": "^30.0.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.60.0",
"vite": "^8.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^5.0.0"
"typescript-eslint": "^8.60.0"
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ try {
const docker = join(bin, 'docker');
writeFileSync(
docker,
`#!/usr/bin/env node
`#!/usr/bin/env bun
import { appendFileSync, readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
+55
View File
@@ -0,0 +1,55 @@
// Converts coverage/lcov.info (Bun) to HTML and Cobertura. Run: bun scripts/coverage-reports.mjs
// shortcut: line coverage only, Bun's lcov has no usable branch/function data; revisit if Bun adds it.
// Totals here sum lines (8351/8578); Bun's text table averages per-file percentages, so it reads lower.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import libCoverage from 'istanbul-lib-coverage';
import libReport from 'istanbul-lib-report';
import reports from 'istanbul-reports';
const map = libCoverage.createCoverageMap({});
const loc = (line) => ({ start: { line, column: 0 }, end: { line, column: 1 } });
for (const record of readFileSync('coverage/lcov.info', 'utf8').split('end_of_record')) {
const file = /^SF:(.+)$/m.exec(record)?.[1];
if (!file) continue;
const statementMap = {};
const s = {};
for (const [, line, hits] of record.matchAll(/^DA:(\d+),(\d+)/gm)) {
statementMap[line] = loc(+line);
s[line] = +hits;
}
map.addFileCoverage({
path: resolve(file),
statementMap,
s,
fnMap: {},
f: {},
branchMap: {},
b: {},
});
}
const context = libReport.createContext({ dir: 'coverage', coverageMap: map });
for (const name of ['html', 'cobertura']) reports.create(name).execute(context);
// Istanbul reports 0 branches/functions as 100%; relabel them instead of claiming coverage.
const rewrite = (file, edit) => writeFileSync(file, edit(readFileSync(file, 'utf8')));
for (const page of readdirSync('coverage', { recursive: true }).filter((f) => f.endsWith('.html')))
rewrite(join('coverage', page), (html) =>
html
.replace(
/<span class="strong">100% <\/span>(\s*<span class="quiet">(?:Branches|Functions)<\/span>\s*<span class='fraction'>)0\/0</g,
'<span class="strong">n/a </span>$1not collected<',
)
.replace(
/<td data-value="100" class="pct \w+">100%<\/td>(\s*)<td data-value="0" class="abs \w+">0\/0<\/td>/g,
'<td data-value="0" class="pct">n/a</td>$1<td data-value="0" class="abs">not collected</td>',
),
);
rewrite('coverage/cobertura-coverage.xml', (xml) =>
xml
.replaceAll('branch-rate="1"', 'branch-rate="0"')
.replace(
'<coverage ',
'<!-- Line coverage only: bun test does not collect branch or function coverage. -->\n<coverage ',
),
);
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Installs the pinned Bun release to /opt/bun/bin without npm, after verifying its SHA-256.
# Needs curl, unzip and sha256sum. Bun upgrades change the version, package.json and every checksum together.
set -eu
version=1.4.3
dest=${BUN_INSTALL_DIR:-/opt/bun}
grep -q "\"packageManager\": \"bun@${version}\"" package.json || {
echo "package.json packageManager must be bun@${version}." >&2
exit 1
}
case "$(uname -m)" in
x86_64) arch=x64 ;;
aarch64 | arm64) arch=aarch64 ;;
*)
echo "Unsupported Bun architecture: $(uname -m)" >&2
exit 1
;;
esac
libc=
if [ -e /etc/alpine-release ]; then libc=-musl; fi
asset="bun-linux-${arch}${libc}"
case "$asset" in
bun-linux-x64) sha256=3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18 ;;
bun-linux-x64-musl) sha256=6e9ea98554ea0d05efda5840ae292618aaa3adefd66b0853520c5c86a37eac9c ;;
bun-linux-aarch64) sha256=efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21 ;;
bun-linux-aarch64-musl) sha256=8de8a0003cc2707bb555887e3d450e24e1edf3f605f491393e89ed61a8853420 ;;
esac
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
curl --fail --location --silent --show-error \
"https://github.com/oven-sh/bun/releases/download/bun-v${version}/${asset}.zip" \
--output "$work/bun.zip"
echo "${sha256} ${work}/bun.zip" | sha256sum -c -
unzip -q "$work/bun.zip" -d "$work"
mkdir -p "${dest}/bin"
install -m 0755 "${work}/${asset}/bun" "${dest}/bin/bun"
"${dest}/bin/bun" --version
+1 -1
View File
@@ -7,7 +7,7 @@ import { MatOptionHarness } from '@angular/material/core/testing';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { type MockInstance, vi } from 'vitest';
import { type MockInstance, vi } from '../testing/vitest-compat';
import { App } from './app';
import { CalendarService } from './services/calendar-service';
import { calculatePlanningInternal } from './services/leave-planner.core';
-10
View File
@@ -1,10 +0,0 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
-2
View File
@@ -6,13 +6,11 @@ import {
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideClientHydration(withEventReplay()),
],
};
-8
View File
@@ -1,8 +0,0 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Client,
},
];
@@ -0,0 +1,63 @@
import {
BenchmarkMeasurement,
benchmarkSummaryHeader,
benchmarkSummaryRow,
budgetViolations,
} from './leave-planner-benchmark';
const measure = (scenario: string, planningMs: number) =>
({ scenario, planningMs }) as BenchmarkMeasurement;
describe('benchmark budget gate', () => {
const multipliers = { a: 2 };
it('accepts a mean within multiplier x calibration', () => {
expect(budgetViolations([measure('a', 150), measure('a', 250)], 100, multipliers)).toEqual(
[],
);
});
it('reports measured time, budget, multiplier and calibration when over budget', () => {
const [message] = budgetViolations(
[measure('a', 300), measure('a', 320)],
100,
multipliers,
);
expect(message).toContain('a:');
expect(message).toContain('measured 310.0 ms');
expect(message).toContain('budget 200.0 ms');
expect(message).toContain('multiplier 2');
expect(message).toContain('calibration 100.0 ms');
});
it('fails a scenario without a multiplier', () => {
expect(budgetViolations([measure('new', 1)], 100, multipliers)).toEqual([
'new: no budget multiplier defined',
]);
});
});
describe('benchmark summary table', () => {
const runs = (planningMs: number[]) =>
planningMs.map(
(ms, index) =>
({
scenario: 'a',
planningMs: ms,
outcome: 'Plan',
lastCompletedStage: 'dates',
placements: 3,
deterministic: index ? 'yes' : 'baseline',
}) as BenchmarkMeasurement,
);
it('aligns rows with the header', () => {
const [header, separator] = benchmarkSummaryHeader().split('\n');
const row = benchmarkSummaryRow(runs([150, 250]), 100, { a: 2 });
expect(row).toHaveLength(header.length);
expect(separator).toHaveLength(header.length);
expect(row).toContain('0.15 / 0.25 s');
expect(row).toContain('Plan (dates)');
expect(row).toMatch(/\| ok +\|$/);
});
it('marks scenarios over budget or without a budget', () => {
expect(benchmarkSummaryRow(runs([300, 320]), 100, { a: 2 })).toMatch(/\| OVER +\|$/);
expect(benchmarkSummaryRow(runs([1]), 100, {})).toMatch(/\| no budget \|$/);
});
});
@@ -1,15 +1,20 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
import { createPlanningCalculation } from '../services/leave-planner.service';
import {
BENCHMARK_BUDGET_MULTIPLIERS,
benchmarkReport,
benchmarkScenarios,
benchmarkSummaryHeader,
benchmarkSummaryRow,
budgetViolations,
runLeavePlannerBenchmark,
} from './leave-planner-benchmark';
describe('leave planner benchmark protocol', () => {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
// Warm-up and two repetitions include complete trace delivery after planning.
// Warm-up and two repetitions include explanation delivery after planning.
it('keeps seven deterministic scenarios including two and three years and a later earned consumer', async () => {
const scenarios = benchmarkScenarios();
expect(scenarios).toHaveLength(7);
@@ -64,7 +69,25 @@ describe('leave planner benchmark protocol', () => {
),
).toHaveLength(2);
}
const measurements = await runLeavePlannerBenchmark(2);
// Frozen engine (a871f8f) on 'two years', best of three: the runner-speed yardstick.
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
let calibrationMs = Infinity;
for (let run = 0; run < 3; run++) {
const request = structuredClone(calibrationRequest);
const start = performance.now();
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
calibrationMs = Math.min(calibrationMs, performance.now() - start);
}
// Direct stdout writes show each row as soon as its scenario finishes.
const print = (text: string) => process.stdout.write(`${text}\n`);
print(
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
'\nBudget = limit x calibration; ratio = mean / calibration.\n',
);
print(benchmarkSummaryHeader());
const measurements = await runLeavePlannerBenchmark(2, (rows) =>
print(benchmarkSummaryRow(rows, calibrationMs)),
);
for (const [name, placements] of [
['two years', 60],
['three years', 90],
@@ -75,8 +98,6 @@ describe('leave planner benchmark protocol', () => {
expect(longer.map((item) => item.lastCompletedStage)).toEqual(['dates', 'dates']);
expect(longer.map((item) => item.placements)).toEqual([placements, placements]);
expect(longer.map((item) => item.deterministic)).toEqual(['baseline', 'yes']);
expect(longer.map((item) => item.traceStatus)).toEqual(['ready', 'ready']);
expect(longer[0].traceCandidates).toBeGreaterThan(0);
}
const chained = measurements.find(
(measurement) => measurement.scenario === 'chained earned entitlements',
@@ -91,24 +112,27 @@ describe('leave planner benchmark protocol', () => {
expect(loaded[0].deterministic).toBe('baseline');
expect(loaded[1].deterministic).toBe('yes');
expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].traceDeliveryMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].planningWorkMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].planningWorkMs).toBeLessThanOrEqual(loaded[0].planningMs);
expect(loaded[0].traceCaptureMs).toBeGreaterThan(0);
expect(loaded[0].traceMaterializationMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].maxUiDelayMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].wallMs).toBeGreaterThanOrEqual(
loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs,
loaded[0].preparationMs + loaded[0].planningMs,
);
expect(loaded[0].traceStatus).toBe('ready');
expect(loaded[0].traceCandidates).toBeGreaterThan(0);
const report = benchmarkReport(measurements);
print(`\nRaw report (TSV):\n${report}\n`);
expect(report.split('\n')).toHaveLength(15);
expect(report).toContain('planning_ms\ttrace_delivery_ms');
expect(report).toContain('planning_work_ms\ttrace_capture_ms');
expect(report).toContain('planning_ms\twall_ms');
expect(report).toContain('planning_work_ms\tpeak_main_thread_heap_bytes');
expect(report).toContain('\tbaseline\t');
const violations = budgetViolations(
measurements,
calibrationMs,
BENCHMARK_BUDGET_MULTIPLIERS,
);
if (violations.length)
throw new Error(`Benchmark over budget:\n${violations.join('\n')}`);
} finally {
vi.stubGlobal('window', browserWindow);
}
}, 300_000);
}, 570_000);
});
+110 -33
View File
@@ -2,7 +2,6 @@ import {
createPlanningCalculation,
PlanningOutcome,
PlanningRequest,
PlanningTrace,
} from '../services/leave-planner.service';
import { createOptimizationPresetRules } from '../services/rule-service';
@@ -16,21 +15,15 @@ export interface BenchmarkMeasurement {
repetition: number;
preparationMs: number;
planningMs: number;
traceDeliveryMs: number;
maxUiDelayMs: number;
/** Chromium's main-thread heap only; worker memory needs a separate browser profile. */
peakMainThreadHeapBytes?: number;
planningWorkMs?: number;
traceCaptureMs?: number;
traceMaterializationMs?: number;
traceTransferMs?: number;
wallMs: number;
outcome: PlanningOutcome['kind'];
lastCompletedStage?: string;
placements: number;
deterministic: 'baseline' | 'yes' | 'no';
traceStatus: PlanningTrace['status'] | 'not-produced';
traceCandidates: number;
}
const balance = (
@@ -200,18 +193,24 @@ function resultSummary(outcome: PlanningOutcome): {
return { outcome: outcome.kind, placements: 0 };
}
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
export async function runLeavePlannerBenchmark(
repetitions = 1,
onScenario?: (measurements: BenchmarkMeasurement[]) => void,
): Promise<BenchmarkMeasurement[]> {
const scenarios = benchmarkScenarios();
for (const scenario of scenarios) await warmUpScenario(scenario);
const measurements: BenchmarkMeasurement[] = [];
for (const scenario of scenarios) {
const scenarioMeasurements: BenchmarkMeasurement[] = [];
let baseline: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) {
const result = await measureRepetition(scenario, repetition, baseline);
measurements.push(result.measurement);
scenarioMeasurements.push(result.measurement);
baseline ??= result.stable;
}
measurements.push(...scenarioMeasurements);
onScenario?.(scenarioMeasurements);
}
return measurements;
}
@@ -219,7 +218,8 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
async function warmUpScenario(scenario: BenchmarkScenario): Promise<void> {
const calculation = createPlanningCalculation(structuredClone(scenario.request));
await calculation.outcome;
await calculation.trace;
await calculation.explanation;
await calculation.timings;
}
async function measureRepetition(
@@ -250,24 +250,11 @@ async function measureRepetition(
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
const planningMs = performance.now() - calculationStart;
const traceStart = performance.now();
const trace = await calculation.trace;
const traceDeliveryMs = performance.now() - traceStart;
const timings = await calculation.timings;
const summary = resultSummary(outcome);
const stable = JSON.stringify(outcome);
const deterministic =
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
const traceCandidates =
trace?.phases.reduce(
(total, phase) =>
total +
phase.decisions.reduce(
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
0,
),
0,
) ?? 0;
const wallMs = performance.now() - wallStart;
measureDelay();
return {
@@ -276,15 +263,12 @@ async function measureRepetition(
repetition,
preparationMs,
planningMs,
traceDeliveryMs,
maxUiDelayMs,
peakMainThreadHeapBytes,
...timings,
wallMs,
...summary,
deterministic,
traceStatus: trace?.status ?? 'not-produced',
traceCandidates,
},
stable,
};
@@ -295,7 +279,7 @@ async function measureRepetition(
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
const header =
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms\tpeak_main_thread_heap_bytes';
'scenario\trepetition\tpreparation_ms\tplanning_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\tmax_ui_delay_ms\tplanning_work_ms\tpeak_main_thread_heap_bytes';
return [
header,
...measurements.map((item) =>
@@ -304,21 +288,114 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
item.repetition,
item.preparationMs.toFixed(1),
item.planningMs.toFixed(1),
item.traceDeliveryMs.toFixed(1),
item.wallMs.toFixed(1),
item.outcome,
item.lastCompletedStage ?? '',
item.placements,
item.deterministic,
item.traceStatus,
item.traceCandidates,
item.maxUiDelayMs.toFixed(1),
item.planningWorkMs?.toFixed(1) ?? '',
item.traceCaptureMs?.toFixed(1) ?? '',
item.traceMaterializationMs?.toFixed(1) ?? '',
item.traceTransferMs?.toFixed(1) ?? '',
item.peakMainThreadHeapBytes ?? '',
].join('\t'),
),
].join('\n');
}
// Measured on the development machine (ratio to the frozen two-years run), tightened by #286.
export const BENCHMARK_BUDGET_MULTIPLIERS: Record<string, number> = {
'representative year': 0.25,
'typed obligations': 3,
'chained earned entitlements': 0.05,
'deliberate conflict': 0.05,
'loaded year': 25,
'two years': 1,
'three years': 3.5,
};
/** One message per scenario whose mean planning time exceeds multiplier x calibration, or that has no multiplier. */
export function budgetViolations(
measurements: BenchmarkMeasurement[],
calibrationMs: number,
multipliers: Record<string, number> = BENCHMARK_BUDGET_MULTIPLIERS,
): string[] {
const byScenario = new Map<string, number[]>();
for (const { scenario, planningMs } of measurements)
byScenario.set(scenario, [...(byScenario.get(scenario) ?? []), planningMs]);
const violations: string[] = [];
for (const [scenario, times] of byScenario) {
const mean = average(times);
const multiplier = multipliers[scenario];
if (multiplier === undefined) {
violations.push(`${scenario}: no budget multiplier defined`);
continue;
}
const budget = multiplier * calibrationMs;
if (mean > budget)
violations.push(
`${scenario}: measured ${mean.toFixed(1)} ms > budget ${budget.toFixed(1)} ms (multiplier ${multiplier} x calibration ${calibrationMs.toFixed(1)} ms)`,
);
}
return violations;
}
function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
// Fixed widths keep rows printed as each scenario finishes aligned with the header.
const SUMMARY_COLUMNS: [title: string, width: number, alignRight: boolean][] = [
['Scenario', 27, false],
['Planning (runs)', 17, true],
['Mean', 9, true],
['Budget', 9, true],
['Ratio', 6, true],
['Limit', 6, true],
['Outcome', 16, false],
['Placements', 10, true],
['Deterministic', 13, false],
['Status', 9, false],
];
function summaryLine(cells: string[]): string {
return `| ${cells
.map((cell, index) => {
const [, width, alignRight] = SUMMARY_COLUMNS[index];
return alignRight ? cell.padStart(width) : cell.padEnd(width);
})
.join(' | ')} |`;
}
/** Markdown table header for {@link benchmarkSummaryRow}. */
export function benchmarkSummaryHeader(): string {
return [
summaryLine(SUMMARY_COLUMNS.map(([title]) => title)),
`|${SUMMARY_COLUMNS.map(([, width, alignRight]) => (alignRight ? `${'-'.repeat(width + 1)}:` : `:${'-'.repeat(width + 1)}`)).join('|')}|`,
].join('\n');
}
/** One aligned table row for one scenario's measured repetitions, judged like {@link budgetViolations}. */
export function benchmarkSummaryRow(
scenarioMeasurements: BenchmarkMeasurement[],
calibrationMs: number,
multipliers: Record<string, number> = BENCHMARK_BUDGET_MULTIPLIERS,
): string {
const [first] = scenarioMeasurements;
const seconds = (ms: number) => (ms / 1000).toFixed(2);
const mean = average(scenarioMeasurements.map(({ planningMs }) => planningMs));
const multiplier = multipliers[first.scenario];
const budget = multiplier === undefined ? undefined : multiplier * calibrationMs;
return summaryLine([
first.scenario,
`${scenarioMeasurements.map(({ planningMs }) => seconds(planningMs)).join(' / ')} s`,
`${seconds(mean)} s`,
budget === undefined ? '-' : `${seconds(budget)} s`,
(mean / calibrationMs).toFixed(2),
multiplier === undefined ? '-' : String(multiplier),
first.lastCompletedStage ? `${first.outcome} (${first.lastCompletedStage})` : first.outcome,
String(first.placements),
scenarioMeasurements.slice(1).every(({ deterministic }) => deterministic === 'yes')
? 'yes'
: 'no',
budget === undefined ? 'no budget' : mean > budget ? 'OVER' : 'ok',
]);
}
@@ -29,7 +29,6 @@
} @else {
@let dayTooltip = getDayTooltip(day);
@let dateKey = getDateKey(day);
@let status = getStatus(day);
@let declaredType = getDeclaredType(dateKey);
@let previewed = declarationPreview?.dates?.has(dateKey);
<button
@@ -45,18 +44,6 @@
[class.declared]="!!declaredType"
[class.declaration-preview]="previewed"
[class.declaration-erase]="previewed && !declaredType"
[class.eligible]="status?.kind === 'eligible'"
[class.ineligible]="status?.kind === 'ineligible'"
[class.chosen]="status?.kind === 'chosen'"
[class.evaluated-rejected]="status?.kind === 'evaluated-rejected'"
[class.pruned]="status?.kind === 'pruned'"
[class.not-examined]="status?.kind === 'not-examined'"
[class.change-added]="status?.change === 'added'"
[class.change-moved-from]="status?.change === 'moved-from'"
[class.change-moved-to]="status?.change === 'moved-to'"
[class.change-removed]="status?.change === 'removed'"
[class.change-shared]="status?.change === 'shared'"
[class.change-unchanged]="status?.change === 'unchanged'"
[class]="getDayClasses(day)"
class="day number"
[disabled]="isDateDisabled(day)"
@@ -70,16 +57,6 @@
(click)="dateSelected.emit(dateKey)"
>
<span>{{ day.getDate() }}</span>
@if (status) {
<small class="status-marker" aria-hidden="true">{{
getStatusMarker(day)
}}</small>
@if (status.hypotheticalLabel) {
<small class="hypothetical-marker" aria-hidden="true">{{
getHypotheticalMarker(status)
}}</small>
}
}
</button>
}
}
@@ -108,31 +108,6 @@
}
}
.status-marker {
position: absolute;
inset-block-start: -0.35rem;
inset-inline-end: -0.2rem;
min-width: 0.85rem;
font-size: 0.65rem;
font-weight: 800;
line-height: 1;
background: var(--ui-card-bg, #fff);
border-radius: 999px;
}
.hypothetical-marker {
position: absolute;
inset-block-end: -0.35rem;
inset-inline-start: -0.2rem;
min-width: 1.25rem;
font-size: 0.55rem;
font-weight: 800;
line-height: 1;
background: var(--ui-card-bg, #fff);
border: 1px dashed currentColor;
border-radius: 999px;
}
.current {
outline: 2px solid var(--today-border-color);
outline-offset: -2px;
@@ -142,43 +117,6 @@
box-shadow: inset 0 -3px var(--today-border-color);
}
.eligible,
.chosen {
border-block-end: 3px solid var(--ui-success-text, #174d26);
}
.ineligible,
.evaluated-rejected {
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.pruned {
border: 2px dotted currentColor;
}
.not-examined {
border: 2px dashed currentColor;
}
.change-added,
.change-moved-to {
box-shadow: inset 0 -3px var(--ui-success-text, #174d26);
}
.change-moved-from,
.change-removed {
box-shadow: inset 0 3px var(--ui-error-text, #8b1a1a);
}
.change-shared {
box-shadow: inset 3px 0 var(--today-border-color, #1976d2);
}
.change-unchanged {
box-shadow: inset 0 0 0 1px currentColor;
}
.selected {
cursor: pointer;
}
@@ -15,16 +15,6 @@ import { TranslationService } from '../../services/translation-service';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import type { LeaveType } from '../../services/leave-planner.service';
export interface CalendarDayStatus {
kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined';
label: string;
reason?: string;
hypotheticalLabel?: string;
hypotheticalReason?: string;
change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged';
changeLabel?: string;
}
export interface DeclarationPreview {
dates: ReadonlySet<string>;
type: LeaveType | null;
@@ -112,9 +102,6 @@ export class CalendarMonth implements OnInit {
@Input()
explainedPeriod?: { start: string; end: string };
@Input()
dateStatuses: Readonly<Record<string, CalendarDayStatus>> = {};
@Output()
readonly dateSelected = new EventEmitter<string>();
@@ -134,10 +121,6 @@ export class CalendarMonth implements OnInit {
);
}
getStatus(day: Date): CalendarDayStatus | undefined {
return this.dateStatuses[this.getDateKey(day)];
}
isInExplainedPeriod(day: Date): boolean {
const date = this.getDateKey(day);
return (
@@ -147,44 +130,6 @@ export class CalendarMonth implements OnInit {
);
}
getStatusMarker(day: Date): string {
switch (this.getStatus(day)?.change) {
case 'added':
return '+';
case 'moved-from':
return '←';
case 'moved-to':
return '→';
case 'removed':
return '−';
case 'shared':
return '≈';
case 'unchanged':
return '=';
}
switch (this.getStatus(day)?.kind) {
case 'chosen':
case 'eligible':
return '✓';
case 'ineligible':
return '×';
case 'evaluated-rejected':
return '−';
case 'pruned':
return '⌁';
case 'not-examined':
return '?';
default:
return '';
}
}
getHypotheticalMarker(status: CalendarDayStatus): string {
return status.hypotheticalLabel === this.translationService.tr().planning.eligible
? '~✓'
: '~×';
}
getDayAriaLabel(day: Date, tooltip: string | null = this.getDayTooltip(day)): string {
const locale = this.translationService.locale();
if (!this.dayFormatter || locale !== this.dayFormatterLocale) {
@@ -239,33 +184,15 @@ export class CalendarMonth implements OnInit {
getDayTooltip(day: Date): string | null {
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
const tooltips: string[] = this.selectedDates.datesSelected
.filter(
(dateSelected) =>
dateSelected.isSelected(day) &&
dateSelected.type &&
(!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)),
)
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
const tooltips: string[] = this.selectedDates
.dayTypes(day)
.filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type))
.map((type) => this.translationService.getDayTypeLabel(type));
const declaredType = this.getDeclaredType(this.getDateKey(day));
if (declaredType)
tooltips.push(
`${this.tr().bookedDays.booked} (${this.declaredTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
);
const status = this.getStatus(day);
if (status) {
tooltips.push(status.label);
if (status.changeLabel) tooltips.push(status.changeLabel);
if (status.reason) tooltips.push(status.reason);
if (status.hypotheticalLabel)
tooltips.push(
`${this.translationService.tr().planning.counterfactual}: ${status.hypotheticalLabel}`,
);
if (status.hypotheticalReason)
tooltips.push(
`${this.translationService.tr().planning.reason}: ${status.hypotheticalReason}`,
);
}
return tooltips.length > 0 ? tooltips.join(' + ') : null;
}
File diff suppressed because it is too large. Load diff
@@ -533,476 +533,8 @@
{{ tr().planning.explanationError }} {{ explanationError()!.message }}
</p>
}
@if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') {
<details class="trace-explorer" [open]="traceOpen()" (toggle)="toggleTrace($event)">
<summary>
<mat-icon aria-hidden="true">account_tree</mat-icon>
<span
>{{ tr().planning.traceTitle
}}<small>{{ tr().planning.traceHelp }}</small></span
>
<mat-icon class="disclosure-chevron" aria-hidden="true">expand_more</mat-icon>
</summary>
@if (traceLoading()) {
<p role="status">{{ tr().planning.traceLoading }}</p>
} @else if (planningTrace(); as trace) {
@if (trace.error) {
<p class="inline-error" role="alert">{{ trace.error.message }}</p>
}
<div class="trace-layout">
<nav class="trace-phases" [attr.aria-label]="tr().planning.phases">
<div class="trace-phase-stepper">
<button
type="button"
data-phase-step="first"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.firstPhase"
(click)="stepTracePhase('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
data-phase-step="previous"
[attr.aria-disabled]="tracePhaseIndex() === 0"
[attr.aria-label]="tr().planning.previousPhase"
(click)="stepTracePhase('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="tracePhaseAnnouncement()"
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
{{ trace.phases.length }}</output
>
<button
type="button"
data-phase-step="next"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.nextPhase"
(click)="stepTracePhase('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
data-phase-step="last"
[attr.aria-disabled]="
tracePhaseIndex() === trace.phases.length - 1
"
[attr.aria-label]="tr().planning.lastPhase"
(click)="stepTracePhase('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</button>
</div>
@for (phase of trace.phases; track phase.id; let index = $index) {
<button
type="button"
[class.active]="index === tracePhaseIndex()"
[attr.aria-pressed]="index === tracePhaseIndex()"
(click)="selectTracePhase(index)"
>
<span>{{ phaseLabel(phase.name) }}</span>
<small>{{ tracePhaseStatusLabel(phase.status) }}</small>
</button>
}
</nav>
@if (selectedTracePhase(); as phase) {
<section class="trace-decisions" aria-labelledby="trace-phase-title">
<h3 id="trace-phase-title">{{ phaseLabel(phase.name) }}</h3>
@if (phase.decisions.length) {
<nav [attr.aria-label]="tr().planning.decisions">
<button
type="button"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.firstDecision"
(click)="stepTraceDecision('first')"
>
<mat-icon aria-hidden="true">first_page</mat-icon>
</button>
<button
type="button"
[attr.aria-disabled]="traceDecisionIndex() === 0"
[attr.aria-label]="tr().planning.previousDecision"
(click)="stepTraceDecision('previous')"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<output
aria-current="step"
[attr.aria-label]="traceDecisionAnnouncement()"
>{{ tr().planning.decision }}
{{ traceDecisionIndex() + 1 }} {{ tr().planning.of }}
{{ phase.decisions.length }}</output
>
<button
type="button"
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.nextDecision"
(click)="stepTraceDecision('next')"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button
type="button"
[attr.aria-disabled]="
traceDecisionIndex() === phase.decisions.length - 1
"
[attr.aria-label]="tr().planning.lastDecision"
(click)="stepTraceDecision('last')"
>
<mat-icon aria-hidden="true">last_page</mat-icon>
</button>
</nav>
@if (selectedTraceDecision(); as decision) {
<div
class="trace-toolbar"
role="group"
[attr.aria-label]="tr().planning.replay"
>
<button
type="button"
[attr.aria-pressed]="
traceReplay() && traceMoment() === 'before'
"
(click)="showTraceMoment('before')"
>
{{ tr().planning.before }}
</button>
<button
type="button"
[attr.aria-pressed]="
traceReplay() && traceMoment() === 'after'
"
(click)="showTraceMoment('after')"
>
{{ tr().planning.after }}
</button>
<button
type="button"
[attr.aria-pressed]="!traceReplay()"
(click)="showFinalPlan()"
>
{{ tr().planning.finalPlan }}
</button>
</div>
@if (historicalEligibilityLoading()) {
<p role="status">
{{ tr().planning.historicalEligibilityLoading }}
</p>
}
@if (historicalEligibilityError(); as error) {
<p role="alert">
{{ tr().planning.historicalEligibilityError }}
{{ error }}
<button
type="button"
(click)="retryHistoricalEligibility()"
>
{{ tr().planning.retryHistoricalEligibility }}
</button>
</p>
}
<p>
<strong>{{ tr().planning.dates }}:</strong>
{{
decision.dates.length
? decision.dates.join(', ')
: tr().planning.none
}}
</p>
@if (decision.reason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ decision.reason.message }}
</p>
}
<h4>{{ tr().planning.candidates }}</h4>
@if (decision.candidates.length > 50) {
<nav
class="candidate-pagination"
[attr.aria-label]="tr().planning.candidates"
>
<button
type="button"
[attr.aria-disabled]="
traceCandidatePage() === 0
"
[attr.aria-label]="
tr().planning.previousCandidates
"
(click)="stepTraceCandidatePage(-1)"
>
<mat-icon aria-hidden="true"
>chevron_left</mat-icon
>
</button>
<output
>{{ tr().planning.page }}
{{ traceCandidatePage() + 1 }}
{{ tr().planning.of }}
{{ traceCandidatePages() }}</output
>
<button
type="button"
[attr.aria-disabled]="
traceCandidatePage() ===
traceCandidatePages() - 1
"
[attr.aria-label]="tr().planning.nextCandidates"
(click)="stepTraceCandidatePage(1)"
>
<mat-icon aria-hidden="true"
>chevron_right</mat-icon
>
</button>
</nav>
}
<div class="candidate-list">
@for (
candidate of visibleTraceCandidates();
track $index
) {
<details>
<summary>
<span
class="status-symbol"
aria-hidden="true"
>{{
candidate.status === 'chosen'
? '✓'
: candidate.status ===
'ineligible'
? '×'
: '•'
}}</span
>
{{ traceStatusLabel(candidate.status) }} ·
@if (
candidate.sourceDate &&
candidate.sourceDate !== candidate.date
) {
{{ candidate.sourceDate }} →
}
{{
candidate.dates?.join(', ') ||
candidate.date ||
tr().planning.none
}}
</summary>
@if (candidate.reason) {
<p>{{ candidate.reason.message }}</p>
}
@if (candidate.technicalComparison?.length) {
<section class="technical-comparison">
<h5>
{{
tr().planning.details
.traceStageTitle
}}
</h5>
<p class="calculation-intro">
{{
tr().planning.details
.traceStageHelp
}}
</p>
<table class="impact-table">
<thead>
<tr>
<th scope="col">
{{
tr().planning
.explanation
.criterion
}}
</th>
<th scope="col">
{{
tr().planning.before
}}
</th>
<th scope="col">
{{
tr().planning.after
}}
</th>
</tr>
</thead>
<tbody>
@for (
comparator of candidate.technicalComparison;
track $index
) {
<tr
[class.decisive-row]="
comparator.name ===
candidate.firstDecisiveComparator
"
>
<th scope="row">
<code>{{
comparator.name
}}</code>
<small>{{
comparator.direction ===
'maximize'
? tr()
.planning
.details
.higher
: tr()
.planning
.details
.lower
}}</small>
@if (
comparator.name ===
candidate.firstDecisiveComparator
) {
<span
class="decisive-label"
>{{
tr()
.planning
.details
.firstDifference
}}</span
>
}
</th>
<td>
{{
comparator.before
}}
</td>
<td>
{{
comparator.after
}}
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (candidate.comparison; as comparison) {
<div class="calculation-content">
<ng-container
[ngTemplateOutlet]="
calculationTable
"
[ngTemplateOutletContext]="{
comparison,
beforeLabel:
tr().planning.before,
afterLabel: tr().planning.after,
audit: comparison,
traceEffect:
!!candidate
.technicalComparison
?.length,
}"
/>
</div>
}
@if (candidate.placement) {
<button
type="button"
(click)="
analyzeTraceCandidate(candidate)
"
>
{{ tr().planning.compareAlternatives }}
</button>
}
</details>
}
</div>
<h4>{{ tr().planning.deltas }}</h4>
<ul>
@for (delta of decision.deltas; track $index) {
<li>
<strong>{{
formatIdentifier(delta.kind)
}}</strong>
@if (delta.fromDate || delta.date) {
· {{ delta.fromDate || delta.date }}
}
@if (delta.toDate) {
→ {{ delta.toDate }}
}
@if (delta.balanceId) {
· {{ delta.balanceId }}
}
@if (delta.remainingAfter !== undefined) {
· {{ delta.remainingBefore }} →
{{ delta.remainingAfter }}
}
</li>
}
</ul>
@if (traceState(); as state) {
<p class="trace-state-summary" role="status">
{{
traceMoment() === 'before'
? tr().planning.before
: tr().planning.after
}}: {{ state.placements.length }}
{{ tr().planning.placements }},
{{ state.earnedActivations.length }}
{{ tr().planning.activations }}
@for (balance of state.balances; track balance.id) {
<span class="trace-state-balance"
>{{ balance.id }}: {{ balance.remaining }}
{{ tr().planning.balanceRemaining }}
<small
>{{ tr().planning.details.available }}
{{ displayDate(balance.availableFrom) }}
·
{{ tr().planning.details.expires }}
{{
displayDate(balance.expiryDate)
}}</small
></span
>
}
</p>
}
}
} @else {
<p>{{ tr().planning.noDecisions }}</p>
}
</section>
}
</div>
} @else if (traceResolved()) {
<p role="status">{{ tr().planning.traceUnavailable }}</p>
}
</details>
}
</div>
@if (traceReplay()) {
<div class="replay-notice" role="status">
<span>{{ tr().planning.replayNotice }}</span>
<button type="button" class="quiet-button" (click)="showFinalPlan()">
{{ tr().planning.finalPlan }}
</button>
</div>
}
<p class="calendar-help">
<mat-icon aria-hidden="true">touch_app</mat-icon
>{{ declarationEntry() ? tr().bookedDays.help : tr().planning.calendarHelp }}
@@ -1037,7 +569,7 @@
<span class="legend-swatch today" aria-hidden="true"></span
>{{ tr().dayTypes.today }}
</li>
@if (planDetailsOpen() || traceReplay()) {
@if (planDetailsOpen()) {
<li>
<span class="legend-swatch current" aria-hidden="true"></span
>{{ tr().planning.currentPlacement }}
@@ -1049,62 +581,6 @@
>{{ tr().planning.explainedPeriod }}
</li>
}
@if (traceReplay() && counterfactual()) {
<li>
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~✓</span
>{{ tr().planning.eligible }}
</li>
<li>
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~×</span
>{{ tr().planning.ineligible }}
</li>
}
@if (traceReplay()) {
<li>
<span class="legend-symbol" aria-hidden="true">✓</span
>{{ traceStatusLabel('chosen') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceStatusLabel('evaluated-rejected') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">⌁</span
>{{ traceStatusLabel('pruned') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">?</span
>{{ traceStatusLabel('not-examined') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">×</span
>{{ traceStatusLabel('ineligible') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">+</span
>{{ traceChangeLabel('added') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">←</span
>{{ traceChangeLabel('moved-from') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">→</span
>{{ traceChangeLabel('moved-to') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceChangeLabel('removed') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">≈</span
>{{ traceChangeLabel('shared') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">=</span
>{{ traceChangeLabel('unchanged') }}
</li>
}
</ul>
</div>
@@ -1129,7 +605,6 @@
[declarationPreview]="declarationPreview()"
[currentDate]="declarationEntry() ? null : currentPlacementDate()"
[explainedPeriod]="declarationEntry() ? undefined : explainedPeriod()"
[dateStatuses]="!declarationEntry() && traceReplay() ? dateStatuses() : {}"
(dateSelected)="!declarationEntry() && selectCalendarDate($event)"
></app-calendar-month>
</mat-card-content>
@@ -1144,22 +619,13 @@
let-beforeLabel="beforeLabel"
let-afterLabel="afterLabel"
let-audit="audit"
let-traceEffect="traceEffect"
>
<section class="criteria-detail">
<h4>
{{
traceEffect
? tr().planning.details.traceImpactTitle
: tr().planning.details.criteria
}}
{{ tr().planning.details.criteria }}
</h4>
<p class="calculation-intro">
{{
traceEffect
? tr().planning.details.traceImpactHelp
: tr().planning.details.criteriaHelp
}}
{{ tr().planning.details.criteriaHelp }}
</p>
<table class="impact-table criteria-table">
<thead>
@@ -1178,7 +644,7 @@
@if (objective.numeric) {
<tr
[class.decisive-row]="
!traceEffect && objective.id === comparison.firstDecisiveObjective
objective.id === comparison.firstDecisiveObjective
"
>
<th scope="row">
@@ -1191,10 +657,7 @@
? tr().planning.details.higher
: tr().planning.details.lower
}}</small>
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
@if (objective.id === comparison.firstDecisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
@@ -1218,10 +681,7 @@
@if (!objective.numeric) {
<li>
{{ objective.label }}
@if (
!traceEffect &&
objective.id === comparison.firstDecisiveObjective
) {
@if (objective.id === comparison.firstDecisiveObjective) {
<span class="decisive-label">{{
tr().planning.details.firstDifference
}}</span>
@@ -1347,11 +807,7 @@
{{ tr().planning.comparison.title }}
</h2>
<p id="comparison-intro">
{{
traceReplay()
? tr().planning.explanation.traceHelp
: tr().planning.explanation.moveHelp
}}
{{ tr().planning.explanation.moveHelp }}
</p>
</div>
<button
@@ -1376,11 +832,7 @@
} @else if (counterfactual(); as analysis) {
<div class="comparison-date-pair">
<div class="original-date">
<span>{{
traceReplay()
? tr().planning.comparison.candidate
: tr().planning.explanation.plannedDate
}}</span>
<span>{{ tr().planning.explanation.plannedDate }}</span>
<strong>{{ displayDate(analysis.placement.date) }}</strong>
</div>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
@@ -1469,7 +921,7 @@
}
</div>
</div>
} @else if (!traceReplay() && result.date === analysis.placement.date) {
} @else if (result.date === analysis.placement.date) {
<p class="comparison-empty">{{ tr().planning.explanation.sameDate }}</p>
} @else {
<div class="comparison-result-heading">
@@ -1488,20 +940,10 @@
{{ tr().planning.explanation.criterion }}
</th>
<th scope="col">
{{
traceReplay()
? tr().planning.explanation
.beforeDecision
: tr().planning.explanation.currentPlan
}}
{{ tr().planning.explanation.currentPlan }}
</th>
<th scope="col">
{{
traceReplay()
? tr().planning.explanation
.withCandidate
: tr().planning.explanation.alternative
}}
{{ tr().planning.explanation.alternative }}
</th>
</tr>
</thead>
@@ -1529,34 +971,14 @@
<details class="technical-details date-calculation-details">
<summary>{{ tr().planning.technicalDetails }}</summary>
<div class="calculation-content">
<p>
<strong>{{ tr().planning.historical }}:</strong>
{{
dateStatuses()[result.date]?.label ??
traceStatusLabel(
result.historicalStatus ?? 'not-examined'
)
}}
</p>
@if (dateStatuses()[result.date]?.reason; as historicalReason) {
<p>
<strong>{{ tr().planning.reason }}:</strong>
{{ historicalReason }}
</p>
}
@if (dateComparison(); as comparison) {
<ng-container
[ngTemplateOutlet]="calculationTable"
[ngTemplateOutletContext]="{
comparison,
beforeLabel: traceReplay()
? tr().planning.explanation.beforeDecision
: tr().planning.explanation.currentPlan,
afterLabel: traceReplay()
? tr().planning.explanation.withCandidate
: tr().planning.explanation.alternative,
beforeLabel: tr().planning.explanation.currentPlan,
afterLabel: tr().planning.explanation.alternative,
audit: result,
traceEffect: traceReplay(),
}"
/>
} @else {
@@ -1583,9 +1005,7 @@
{{
counterfactualError()
? tr().planning.retryCounterfactual
: traceReplay()
? tr().planning.comparison.backToTrace
: tr().planning.comparison.backToPlan
: tr().planning.comparison.backToPlan
}}
</button>
</footer>
@@ -85,8 +85,7 @@ summary:focus-visible {
}
.results-header,
.explanation-heading,
.replay-notice {
.explanation-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -249,14 +248,12 @@ summary:focus-visible {
}
.placement-explanation,
.outcome-explanation,
.trace-decisions {
.outcome-explanation {
display: grid;
gap: 1rem;
min-width: 0;
}
.placement-explanation,
.trace-layout {
.placement-explanation {
padding: 1.25rem;
}
.placement-explanation ul {
@@ -264,8 +261,7 @@ summary:focus-visible {
padding-inline-start: 1.25rem;
}
.break-story,
.outcome-explanation,
.replay-notice {
.outcome-explanation {
padding: 1rem;
border-radius: var(--ui-radius-sm);
background: var(--ui-panel-bg);
@@ -339,17 +335,13 @@ summary:focus-visible {
.compare-dates-button {
justify-self: start;
}
.placement-stepper,
.trace-toolbar,
.trace-decisions nav,
.trace-phase-stepper {
.placement-stepper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.placement-stepper button,
.trace-phase-stepper button {
.placement-stepper button {
padding: 0.375rem;
}
output {
@@ -499,52 +491,6 @@ output {
box-shadow: none;
}
.trace-layout {
display: grid;
grid-template-columns: minmax(0, 0.35fr) minmax(0, 1fr);
gap: 1rem;
}
.trace-phases {
display: grid;
align-content: start;
gap: 0.5rem;
> button {
justify-content: space-between;
text-align: start;
overflow-wrap: anywhere;
}
.active {
border-inline-start: 4px solid var(--mat-sys-primary);
}
small {
color: var(--ui-text-secondary);
flex-shrink: 0;
white-space: nowrap;
}
}
.trace-phase-stepper {
justify-content: center;
}
.trace-decisions {
align-content: start;
}
.candidate-list {
display: grid;
gap: 0.5rem;
details {
padding: 0.5rem;
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius-sm);
overflow-wrap: anywhere;
}
}
.trace-state-summary,
.replay-notice {
padding: 0.75rem;
border: 1px solid var(--mat-sys-primary);
border-radius: var(--ui-radius-sm);
}
.loading-overlay {
position: absolute;
inset: 0;
@@ -576,18 +522,6 @@ output {
font-weight: 600;
}
@container calendar-year (max-width: 48rem) {
.trace-layout {
grid-template-columns: minmax(0, 1fr);
}
.trace-phases {
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.trace-phase-stepper {
grid-column: 1 / -1;
}
}
@container calendar-year (max-width: 35rem) {
.plan-insights {
grid-template-columns: minmax(0, 1fr);
@@ -604,8 +538,7 @@ output {
font-size: 0.75rem;
}
}
.placement-explanation,
.trace-layout {
.placement-explanation {
padding: 0.875rem;
}
.placement-stepper {
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,7 @@ import { TranslationService } from '../../services/translation-service';
import { By } from '@angular/platform-browser';
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { vi } from 'vitest';
import { vi } from '../../../testing/vitest-compat';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { MatExpansionPanel } from '@angular/material/expansion';
@@ -2,7 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatExpansionPanel } from '@angular/material/expansion';
import { vi } from 'vitest';
import { vi } from '../../../testing/vitest-compat';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { calculatePlanningInternal } from '../../services/leave-planner.core';
+1 -107
View File
@@ -173,113 +173,7 @@
@if (day.suggestion) {
<p>{{ day.suggestion }}</p>
}
<details
name="remaining-balances"
[open]="historyBalanceId() === day.id"
(toggle)="toggleRemainingHistory(day.id, $event)"
>
<summary
[attr.aria-label]="
tr().userInput.remainingWhy +
' : ' +
day.title +
'. ' +
day.validity
"
>
{{ tr().userInput.remainingWhy }}
</summary>
<p>{{ day.horizon }}</p>
@if (historyBalanceId() === day.id) {
@if (!traceResolved()) {
<p role="status">{{ tr().loading }}</p>
} @else {
@if (!trace() || trace()?.status === 'failed') {
<p>{{ tr().planning.traceUnavailable }}</p>
}
@if (remainingHistory().length) {
<p>{{ tr().userInput.remainingHistoryHelp }}</p>
<ol
class="remaining-history"
[attr.start]="historyPage() * 20 + 1"
>
@for (
attempt of visibleHistory();
track $index
) {
<li>
<strong>{{ attempt.date }}</strong> ·
{{ attempt.status }}
@if (attempt.phase) {
<p>{{ attempt.phase }}</p>
}
@if (attempt.reason) {
<p>{{ attempt.reason }}</p>
}
@for (
criterion of attempt.criteria;
track $index
) {
<p
[class.decisive]="
criterion.decisive
"
>
@if (criterion.decisive) {
{{
tr().planning.details
.firstDifference
}}
:
}
{{ criterion.text }}
</p>
}
</li>
}
</ol>
@if (historyPageCount() > 1) {
<nav
class="history-pagination"
[attr.aria-label]="tr().planning.candidates"
>
<button
mat-button
type="button"
[disabled]="historyPage() === 0"
(click)="
historyPage.set(historyPage() - 1)
"
>
{{ tr().planning.previousCandidates }}
</button>
<span
>{{ tr().planning.page }}
{{ historyPage() + 1 }}
{{ tr().planning.of }}
{{ historyPageCount() }}</span
>
<button
mat-button
type="button"
[disabled]="
historyPage() + 1 >=
historyPageCount()
"
(click)="
historyPage.set(historyPage() + 1)
"
>
{{ tr().planning.nextCandidates }}
</button>
</nav>
}
} @else if (trace() && trace()?.status !== 'failed') {
<p>{{ tr().userInput.remainingNoEvaluations }}</p>
}
}
}
</details>
<p>{{ day.horizon }}</p>
</li>
}
</ul>
+1 -21
View File
@@ -239,33 +239,13 @@ form {
}
}
.remaining-balances,
.remaining-history {
.remaining-balances {
display: grid;
gap: 1rem;
margin: 0.5rem 0;
padding-inline-start: 1.25rem;
}
.remaining-day-detail summary {
min-height: 2.75rem;
align-content: center;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.25em;
}
.decisive {
font-weight: 700;
}
.history-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
:host {
display: block;
container: user-input / inline-size;
@@ -7,12 +7,19 @@ import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { vi } from 'vitest';
import { type MockInstance, vi } from '../../../testing/vitest-compat';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { CalendarService } from '../../services/calendar-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
import {
createPlanningCalculation,
type PlanningCalculationContext,
type PlanningOutcome,
PlanningRequest,
} from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
@@ -21,6 +28,8 @@ describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
let userInputService: UserInputService;
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
let calculations: PlanningCalculationService;
beforeEach(async () => {
localStorage.clear();
@@ -45,6 +54,11 @@ describe('UserInput', () => {
fixture = TestBed.createComponent(UserInput);
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
calculations = TestBed.inject(PlanningCalculationService);
// Unless a test supplies a result, a started calculation stays pending.
createCalculation = vi
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
.mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
fixture.detectChanges();
await fixture.whenStable();
}
@@ -91,18 +105,25 @@ describe('UserInput', () => {
async function calculate(request: PlanningRequest): Promise<void> {
const browserWindow = globalThis.window;
let calculation: ReturnType<typeof createPlanningCalculation>;
let calculation: PlanningCalculationContext;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
const [outcome, trace] = await Promise.all([calculation.outcome, calculation.trace]);
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
userInputService.planningTraceSignal.set(trace ?? null);
userInputService.traceResolvedSignal.set(true);
await show(calculation);
}
async function show(
calculation: PlanningCalculationContext,
outcome?: PlanningOutcome,
): Promise<void> {
const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
createCalculation.mockReturnValueOnce(shown);
calculations.start();
await shown.outcome;
await Promise.resolve();
fixture.detectChanges();
}
@@ -134,7 +155,34 @@ describe('UserInput', () => {
expect(card.textContent).toBe(explanation);
});
it('shows genuine recorded attempts on demand for usable leave kept for later', async () => {
it('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => {
await calculate({
horizon: { start: '2027-05-01', end: '2027-05-10' },
balances: [
{
id: 'other',
type: 'other',
quantity: 1,
availableFrom: '2027-05-10',
expiryDate: '2028-05-10',
},
],
rules: [],
});
const root = fixture.nativeElement as HTMLElement;
expect(calculations.displayedPlan()).toBeDefined();
expect(root.querySelector('.warning-card')).not.toBeNull();
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningConflict',
reasons: [],
conflictingRuleIds: ['rule'],
});
expect(calculations.displayedPlan()).toBeUndefined();
expect(root.querySelector('.warning-card')).toBeNull();
});
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
horizon: { start: '2024-06-04', end: '2024-06-13' },
@@ -157,37 +205,22 @@ describe('UserInput', () => {
expect(card.textContent).toContain('Aucune action nécessaire');
expect(card.textContent).not.toContain('Disponible seulement');
expect(root.querySelector('.remaining-history')).toBeNull();
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
const trace = JSON.stringify(userInputService.planningTraceSignal());
const details = root.querySelector<HTMLDetailsElement>('.remaining-day-detail details')!;
details.open = true;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(root.querySelectorAll('.remaining-history > li').length).toBeGreaterThan(0);
expect(details.textContent).toContain('Ces dates ont réellement été examinées');
expect(details.textContent).toContain(presets[3].name);
expect(details.textContent).toContain('Évalué et rejeté');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
expect(JSON.stringify(userInputService.planningTraceSignal())).toBe(trace);
const outcome = JSON.stringify(calculations.outcome());
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
const plan = calculations.displayedPlan()!;
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
userInputService.planningTraceSignal.set(null);
fixture.detectChanges();
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
expect(card.textContent).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
details.open = true;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(details.textContent).toContain(
TestBed.inject(TranslationService).tr().planning.traceUnavailable,
);
expect(card.querySelector('details')).toBeNull();
});
it('describes Calculate with an indicative range that follows unsubmitted edits', () => {
@@ -201,7 +234,7 @@ describe('UserInput', () => {
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
fixture.detectChanges();
expect(estimateText()).not.toBe(initial);
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
});
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
@@ -234,7 +267,7 @@ describe('UserInput', () => {
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
@@ -249,11 +282,11 @@ describe('UserInput', () => {
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(createCalculation).toHaveBeenCalledTimes(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
@@ -298,7 +331,7 @@ describe('UserInput', () => {
expect(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
});
it('should initialize with vacation days from service', () => {
@@ -534,7 +567,7 @@ describe('UserInput', () => {
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(createCalculation).toHaveBeenCalledTimes(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
+10 -95
View File
@@ -1,11 +1,4 @@
import {
Component,
computed,
effect,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { Component, computed, effect, inject, ChangeDetectionStrategy } from '@angular/core';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatDatepickerModule } from '@angular/material/datepicker';
@@ -22,6 +15,7 @@ import { VacDay } from '../../services/calendar-service';
import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -52,6 +46,7 @@ export class UserInput {
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
protected readonly planningDates = computed(() => {
@@ -78,27 +73,22 @@ export class UserInput {
return this.translationService.tr;
}
protected readonly trace = this.userInputService.planningTraceSignal;
protected readonly traceResolved = this.userInputService.traceResolvedSignal;
protected readonly historyBalanceId = signal<string | null>(null);
protected readonly historyPage = signal(0);
protected readonly remainingDays = computed(() =>
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
(this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
(total, balance) => total + balance.quantity,
0,
),
);
protected readonly hasAtRiskDays = computed(() =>
this.userInputService
.displayedPlanSignal()
this.calculations
.displayedPlan()
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
);
protected remainingDaysDetails = computed(() => {
const plan = this.userInputService.displayedPlanSignal();
const plan = this.calculations.displayedPlan();
if (!plan) return [];
const incomplete =
this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
@@ -145,57 +135,8 @@ export class UserInput {
});
});
protected readonly remainingHistory = computed(() => {
const id = this.historyBalanceId();
if (!id) return [];
return (
this.trace()?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) =>
decision.candidates
.filter((candidate) => candidate.placement?.balanceId === id)
.map((candidate) => ({ candidate, phase: phase.name })),
),
) ?? []
);
});
protected readonly historyPageCount = computed(() =>
Math.ceil(this.remainingHistory().length / 20),
);
protected readonly visibleHistory = computed(() =>
this.remainingHistory()
.slice(this.historyPage() * 20, (this.historyPage() + 1) * 20)
.map(({ candidate, phase }) => ({
date: (candidate.dates ?? (candidate.date ? [candidate.date] : []))
.map((date) => this.translationService.formatPlanningDate(date))
.join(', '),
phase: this.tr().planning.explanation.stages[phase] ?? this.criterionLabel(phase),
status: this.tr().planning.traceStatuses[candidate.status],
reason: candidate.reason
? (this.tr().userInput.remainingCandidateReasons[candidate.reason.code] ??
this.tr().planning.explanation.unavailable[candidate.reason.code] ??
this.tr().userInput.remainingCandidateReasons['unknown'])
: '',
criteria: (candidate.technicalComparison ?? []).flatMap((value) => {
const label = this.criterionLabel(value.name);
return label
? [
{
text: `${label} : ${value.before} → ${value.after}`,
decisive: value.name === candidate.firstDecisiveComparator,
},
]
: [];
}),
})),
);
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
effect(() => {
this.userInputService.planningOutcomeSignal();
this.historyBalanceId.set(null);
this.historyPage.set(0);
});
}
protected setPlanningDate(field: 'start' | 'lastPlanningDate', value: Date | null): void {
@@ -207,33 +148,7 @@ export class UserInput {
}
private ruleName(id: string): string {
return (
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
?.name ?? id
);
}
private criterionLabel(name: string): string | undefined {
const id = name.replace(/^protected:/, '');
if (id.startsWith('optimization:')) return this.ruleName(id.slice('optimization:'.length));
const key =
id === 'obligations'
? 'obligation-validity'
: id.startsWith('preference:')
? 'preference'
: id;
return this.tr().planning.explanation.objectives[key]?.label.replace(
'{rule}',
this.ruleName(id.slice('preference:'.length)),
);
}
protected toggleRemainingHistory(id: string, event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open && id !== this.historyBalanceId()) {
this.historyPage.set(0);
this.historyBalanceId.set(id);
} else if (!open && id === this.historyBalanceId()) this.historyBalanceId.set(null);
return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
@@ -261,6 +176,6 @@ export class UserInput {
public triggerComputation() {
if (this.calendarSettings.planningHorizonError()) return;
this.userInputService.requestComputation();
this.calculations.start();
}
}
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { Worker as NodeWorker } from 'node:worker_threads';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import { createPlanningCalculation } from './leave-planner.service';
import type { OptimizationPlanningRule } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
@@ -13,6 +13,8 @@ describe('calendar objectives in the real worker', () => {
let directory: string;
let bundle: string;
const threads = new Set<NodeWorker>();
const messages: string[] = [];
let stopped = 0;
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-calendar-worker-'));
@@ -26,10 +28,18 @@ await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
);
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
await promisify(execFile)(process.execPath, [
'build',
entry,
'--target=node',
'--outfile',
bundle,
]);
});
beforeEach(() => {
messages.length = 0;
stopped = 0;
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
class BrowserWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
@@ -37,13 +47,17 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
private readonly thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => this.onmessage?.({ data } as MessageEvent));
this.thread.on('message', (data) => {
messages.push(data.type);
this.onmessage?.({ data } as MessageEvent);
});
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
this.thread.postMessage(message);
}
terminate() {
stopped++;
void this.thread.terminate();
}
}
@@ -109,14 +123,19 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
if (explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a worker explanation');
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
expect((await calculation.trace)?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
first,
`optimization:${rules[1].id}`,
'expiry',
'dates',
]);
await calculation.timings;
expect(new Set(messages)).toEqual(
new Set([
'heartbeat',
'progress',
'checkpoint',
'result',
'explanation',
'artifacts',
]),
);
expect(messages.indexOf('result')).toBeLessThan(messages.indexOf('explanation'));
expect(stopped).toBe(1);
const comparison = await calculation.analyzeCounterfactual(plan.placements[0]);
if (comparison.kind !== 'Counterfactual')
throw new Error('Expected a worker comparison');
@@ -169,29 +188,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
name: 'Captured supporting economy',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
const trace = await calculation.trace;
expect(trace?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
`optimization:${rules[0].id}`,
`optimization:${rules[1].id}`,
'expiry',
'dates',
]);
const candidates = trace?.phases.flatMap(({ decisions }) =>
decisions.flatMap(({ candidates }) => candidates),
);
expect(candidates).toContainEqual(
expect.objectContaining({
status: economyFirst ? 'evaluated-rejected' : 'chosen',
dates: ['2026-11-12', '2026-11-13'],
comparison: expect.objectContaining({
firstDecisiveObjective: economyFirst
? 'optimization:economy'
: 'optimization:holiday',
}),
}),
);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
if (economyFirst) {
+134 -111
View File
@@ -46,21 +46,18 @@ describe('CalendarService', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Europe/Paris';
try {
const selected = service.selectedDatesForPlan({
const { selections: selected } = service.project({
horizon: { start: date, end },
placements: [
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
});
const period = selected.datesSelected.find(
(item) =>
item.type === null &&
item.range.start?.getTime() ===
new Date(`${periodStart}T00:00:00`).getTime() &&
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
);
expect(period).toBeDefined();
expect(period?.getLengthInDays()).toBe(3);
const day = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.isStart(day(periodStart))).toBe(true);
expect(selected.isEnd(day(periodStart))).toBe(false);
expect(selected.isStart(day(date))).toBe(false);
expect(selected.isEnd(day(date))).toBe(true);
expect(selected.isSelected(day(end))).toBe(false);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
@@ -70,7 +67,7 @@ describe('CalendarService', () => {
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
const horizon = { start: '2024-04-02', end: '2024-04-03' };
const selected = service.selectedDatesForPlan({
const { selections: selected } = service.project({
horizon,
placements: [
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
@@ -107,21 +104,15 @@ describe('CalendarService', () => {
],
});
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
const selected = service.selectedDatesForPlan(outcome);
const selected = service.project(outcome).selections;
const date = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
expect(
selected.datesSelected
.find(
(selection) =>
selection.type === null &&
selection.range.start?.getTime() === date('2024-05-31').getTime(),
)
?.getLengthInDays(),
).toBe(4);
expect(selected.isStart(date('2024-05-31'))).toBe(true);
expect(selected.isEnd(date('2024-06-03'))).toBe(true);
expect(selected.isEnd(date('2024-06-02'))).toBe(false);
expect(outcome.placements).toEqual([]);
expect(outcome.horizon).toEqual(horizon);
});
@@ -134,7 +125,7 @@ describe('CalendarService', () => {
];
const before = { horizon, declaredDays, placements: [] };
expect(calendarProjectionHorizon(before)).toEqual(horizon);
expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false);
expect(service.project(before).selections.isSelected(new Date('2024-08-01'))).toBe(false);
const after = {
...before,
placements: [
@@ -147,7 +138,7 @@ describe('CalendarService', () => {
},
],
};
const selected = service.selectedDatesForPlan(after);
const selected = service.project(after).selections;
expect(calendarProjectionHorizon(after)).toEqual({
start: horizon.start,
end: '2024-08-02',
@@ -201,52 +192,60 @@ describe('CalendarService', () => {
});
});
describe('getSelectedDatesForYear', () => {
it('should return selected dates for the year', () => {
const selectedDates = service.getSelectedDatesForYear();
expect(selectedDates).toBeDefined();
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
describe('project', () => {
const project = (horizon: { start: string; end: string }) =>
service.project({ placements: [], horizon });
const day = (key: string) => new Date(`${key}T00:00:00`);
it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
const horizon = service.getDefaultPlanningHorizon();
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
DayType.TODAY,
);
});
it('should include weekends', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasWeekend = selectedDates._datesSelected.some(
(date) => date.type === DayType.WEEKEND,
);
expect(hasWeekend).toBe(true);
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
{ monthIndex: 11, year: 2024 },
{ monthIndex: 0, year: 2025 },
{ monthIndex: 1, year: 2025 },
]);
});
it('should include holidays', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasHoliday = selectedDates._datesSelected.some(
(date) => date.type === DayType.CLOSED_DAY,
);
expect(hasHoliday).toBe(true);
});
it('should include today', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasToday = selectedDates._datesSelected.some(
(date) => date.type === DayType.TODAY,
);
expect(hasToday).toBe(true);
it('keeps fixed months when an edge declaration widens the projection', () => {
const plan = {
placements: [],
horizon: { start: '2024-07-01', end: '2025-01-04' },
declaredDays: [{ date: '2024-07-01', type: 'cp' as const }],
};
const months = (projection: ReturnType<typeof service.project>) =>
projection.months.map(({ monthIndex, year }) => `${year}-${monthIndex}`);
expect(months(service.project(plan))[0]).toBe('2024-5');
const fixed = service.project(plan, { fixedMonths: true });
expect(months(fixed)).toEqual([
'2024-6',
'2024-7',
'2024-8',
'2024-9',
'2024-10',
'2024-11',
'2025-0',
]);
expect(fixed.selections.dayTypes(day('2024-06-30'))).toContain(DayType.WEEKEND);
expect(fixed.selections.isStart(day('2024-07-01'))).toBe(false);
});
it('includes the final partial month of a half-open one-year horizon', () => {
const selectedDates = service.getSelectedDatesForHorizon({
start: '2024-01-15',
end: '2025-01-15',
});
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
DayType.WEEKEND,
);
const { selections } = project({ start: '2024-01-15', end: '2025-01-15' });
expect(selections.dayTypes(day('2025-01-04'))).toContain(DayType.WEEKEND);
});
it('projects holidays in every covered year and in both partial boundary months', () => {
const selected = service.getSelectedDatesForHorizon({
start: '2023-12-26',
end: '2027-01-03',
});
const { selections } = project({ start: '2023-12-26', end: '2027-01-03' });
for (const date of [
'2023-12-25',
'2024-04-01',
@@ -254,20 +253,34 @@ describe('CalendarService', () => {
'2026-04-06',
'2027-01-01',
]) {
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
DayType.CLOSED_DAY,
);
expect(selections.dayTypes(day(date))).toContain(DayType.CLOSED_DAY);
}
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2027-01-02'))).toContain(DayType.WEEKEND);
});
});
describe('setWeekendSelection', () => {
it('should add weekend for Saturday', () => {
const selectedDates = new SelectedDates();
const saturday = new Date(2024, 0, 6); // A Saturday
service.setWeekendSelection(saturday, selectedDates);
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
it('does not mutate the submitted plan or its horizon', () => {
const plan = {
horizon: { start: '2024-04-02', end: '2024-04-03' },
placements: [
{
date: '2024-04-02',
balanceId: 'cp',
type: 'cp' as const,
ruleIds: [],
supporting: false,
},
],
declaredDays: [{ date: '2024-04-03', type: 'rtt' as const }],
};
const before = structuredClone(plan);
const frozen = JSON.parse(JSON.stringify(plan), (_, value) =>
typeof value === 'object' && value ? Object.freeze(value) : value,
);
const projection = service.project(frozen);
expect(projection.selections.dayTypes(new Date('2024-04-03T00:00:00'))).toContain(
DayType.RTT,
);
expect(frozen).toEqual(before);
});
});
});
@@ -298,13 +311,6 @@ describe('SelectedDate', () => {
expect(classes).toContain(DayType.CP);
});
it('should return tooltip for selected date', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
const tooltip = selectedDate.getTooltip(date);
expect(tooltip).toBeTruthy();
});
it('should calculate length in days', () => {
const startDate = new Date(2024, 0, 1);
const endDate = new Date(2024, 0, 3);
@@ -320,47 +326,64 @@ describe('SelectedDate', () => {
});
describe('SelectedDates', () => {
it('should push selected dates', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates._datesSelected.length).toBe(1);
});
const range = (type: DayType, start: Date, end = start) =>
new SelectedDate(type, new DateRange<Date>(start, end));
it('should check if date is selected', () => {
const selectedDates = new SelectedDates();
it('selects covered dates and reports their day types', () => {
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const selectedDates = new SelectedDates([range(DayType.CP, date)]);
expect(selectedDates.isSelected(date)).toBe(true);
expect(selectedDates.dayTypes(date)).toEqual([DayType.CP]);
expect(selectedDates.getClasses(date)).toContain(DayType.CP);
});
it('should group adjacent dates', () => {
const selectedDates = new SelectedDates();
const date1 = new Date(2024, 0, 1);
const date2 = new Date(2024, 0, 2);
selectedDates._datesSelected.push(
new SelectedDate(DayType.CP, new DateRange<Date>(date1, date1)),
);
selectedDates._datesSelected.push(
new SelectedDate(DayType.RTT, new DateRange<Date>(date2, date2)),
);
selectedDates.update();
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
it('treats adjacent ranges as one period with shared boundaries', () => {
const first = new Date(2024, 0, 1);
const second = new Date(2024, 0, 2);
const third = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.RTT, second),
range(DayType.OTHER, third),
]);
expect(selectedDates.isStart(first)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(false);
expect(selectedDates.isStart(second)).toBe(false);
expect(selectedDates.isEnd(second)).toBe(false);
expect(selectedDates.isEnd(third)).toBe(true);
expect(selectedDates.dayTypes(second)).toEqual([DayType.RTT]);
});
it('should return correct classes', () => {
const selectedDates = new SelectedDates();
it('keeps separated ranges as separate periods', () => {
const first = new Date(2024, 0, 1);
const later = new Date(2024, 0, 5);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.CP, later),
]);
expect(selectedDates.isStart(later)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(true);
});
it('merges overlapping ranges and keeps every overlapping day type', () => {
const start = new Date(2024, 0, 1);
const overlap = new Date(2024, 0, 2);
const end = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.WEEKEND, start, overlap),
range(DayType.CLOSED_DAY, overlap, end),
]);
expect(selectedDates.dayTypes(overlap)).toEqual([DayType.WEEKEND, DayType.CLOSED_DAY]);
expect(selectedDates.isStart(start)).toBe(true);
expect(selectedDates.isEnd(end)).toBe(true);
expect(selectedDates.isStart(overlap)).toBe(false);
});
it('does not select today', () => {
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const classes = selectedDates.getClasses(date);
expect(classes).toContain(DayType.CP);
});
it('should return tooltip', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const tooltip = selectedDates.getTooltip(date);
expect(tooltip).toBeTruthy();
const selectedDates = new SelectedDates([range(DayType.TODAY, date)]);
expect(selectedDates.isSelected(date)).toBe(false);
expect(selectedDates.dayTypes(date)).toEqual([]);
expect(selectedDates.getClasses(date)).toContain(DayType.TODAY);
});
});
+108 -234
View File
@@ -10,17 +10,15 @@ import {
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
PlanningTraceState,
RemainingClassification,
PlanningStateSnapshot,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
type CalendarPlan = Pick<PlanningTraceState, 'placements' | 'declaredDays'> & {
horizon?: PlanningTraceState['horizon'];
type CalendarPlan = Pick<PlanningStateSnapshot, 'placements' | 'declaredDays'> & {
horizon?: PlanningStateSnapshot['horizon'];
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
};
@@ -54,6 +52,11 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
return horizon;
}
export interface CalendarProjection {
months: { monthIndex: number; year: number }[];
selections: SelectedDates;
}
@Injectable({
providedIn: 'root',
})
@@ -68,112 +71,82 @@ export class CalendarService {
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
const projectionHorizon = calendarProjectionHorizon(plan);
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) =>
!projectionHorizon ||
(day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
),
]) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(
new Date(`${placement.date}T00:00:00`),
new Date(`${placement.date}T00:00:00`),
),
),
);
}
return selected;
}
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
const _holidays = this.getHolidays();
const selectedDates: SelectedDates = new SelectedDates();
// Logic to populate selectedDates from now until the next year
return selectedDates;
}
getSelectedDatesForYear(): SelectedDates {
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
}
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
const start = new Date(`${horizon.start}T00:00:00`);
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
project(
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
{ fixedMonths = false } = {},
): CalendarProjection {
const horizon = calendarProjectionHorizon(plan)!;
const firstMonth = plan.horizon.start.slice(0, 7);
const lastMonth = addPlanningDays(plan.horizon.end, -1).slice(0, 7);
const first = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start);
cursor.setDate(1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
);
cursor.setMonth(cursor.getMonth() + 1);
}
const selectedDates: SelectedDates = new SelectedDates();
// Use DateCacheService to get today's date components
const holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
);
const {
date: todayDate,
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInHorizon) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
this.setWeekendSelection(day, selectedDates);
if (holidayTimestamps.has(day.getTime())) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
}
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)),
);
}
const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = [];
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
const month = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
if (!fixedMonths || (month >= firstMonth && month <= lastMonth))
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
for (const week of CalendarService.weeksInMonth(
cursor.getMonth(),
cursor.getFullYear(),
)) {
for (const day of week) {
if (!day) continue;
if (day.getDay() === 6) {
const end = new Date(day);
end.setDate(end.getDate() + 1);
ranges.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(day, end)),
);
}
if (day.getDate() === 1 && day.getDay() === 0) {
const start = new Date(day);
start.setDate(start.getDate() - 1);
ranges.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, day)),
);
}
if (holidays.has(day.getTime()))
ranges.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
}
}
cursor.setMonth(cursor.getMonth() + 1);
}
selectedDates.update();
return selectedDates;
}
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
if (date.getDay() === 6) {
const end = new Date(date);
end.setDate(end.getDate() + 1);
selectedDates._datesSelected.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
);
}
if (date.getDate() === 1 && date.getDay() === 0) {
const start = new Date(date);
start.setDate(start.getDate() - 1);
selectedDates._datesSelected.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) => day.date >= horizon.start && day.date < horizon.end,
),
]) {
const day = new Date(`${placement.date}T00:00:00`);
ranges.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(day, day),
),
);
}
return { months, selections: new SelectedDates(ranges) };
}
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
@@ -255,8 +228,6 @@ export type VacDay = {
/** Legacy balances omit this value; the planner then uses the horizon start. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
@@ -285,16 +256,6 @@ const dayTypeForLeaveType: Record<LeaveType, DayType> = {
other: DayType.OTHER,
};
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
[DayType.RTT]: 'RTT',
[DayType.CP]: 'Congé Payé',
[DayType.OTHER]: 'Autre',
[DayType.TODAY]: "Aujourd'hui",
[SelectableDayType.ANY]: "N'importe",
};
export const monthNames: string[] = [
'Janvier',
'Février',
@@ -354,15 +315,6 @@ export class SelectedDate implements SelectedDateInterface {
return returnType;
}
getTooltip(date: Date): string | null | undefined {
if (this.isSelected(date)) {
if (this.type) {
return tooltipTypeMapping[this.type];
}
}
return null;
}
getLengthInDays(): number {
if (this.range.start && this.range.end) {
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
@@ -372,113 +324,60 @@ export class SelectedDate implements SelectedDateInterface {
}
export class SelectedDates implements SelectedDateInterface {
_datesSelected: SelectedDate[] = [];
datesSelected: SelectedDate[] = [];
private readonly ranges: readonly SelectedDate[];
// Ranges plus synthetic null-type groups for connected ranges, so connected periods share boundaries.
private readonly grouped: readonly SelectedDate[];
private minDate: Date | null = null;
private maxDate: Date | null = null;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
constructor(ranges: readonly SelectedDate[] = []) {
this.ranges = ranges.slice();
this.grouped = SelectedDates.group(this.ranges);
}
push(selectedDate: SelectedDate) {
this._datesSelected.push(selectedDate);
this.datesSelected = this._datesSelected.slice();
this.updateMinMaxDates();
this.grouping();
}
private updateMinMaxDates(): void {
if (this._datesSelected.length === 0) {
this.minDate = null;
this.maxDate = null;
return;
}
this.minDate = this._datesSelected.reduce((min, date) => {
if (!date.range.start) return min;
if (!min) return date.range.start;
return date.range.start < min ? date.range.start : min;
}, this._datesSelected[0].range.start);
this.maxDate = this._datesSelected.reduce((max, date) => {
if (!date.range.end) return max;
if (!max) return date.range.end;
return date.range.end > max ? date.range.end : max;
}, this._datesSelected[0].range.end);
}
// make a "mother group" with type = null when two or more groups are adjacent or overlapping; keep the children groups
grouping(): void {
if (this._datesSelected.length <= 1) {
return;
}
// Sort dates by start date
const sorted = [...this._datesSelected].sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
const bStart = b.range.start?.getTime() ?? 0;
return aStart - bStart;
});
// Group adjacent or overlapping dates
private static group(ranges: readonly SelectedDate[]): SelectedDate[] {
const result = ranges.slice();
if (ranges.length <= 1) return result;
const sorted = [...ranges].sort(
(a, b) => (a.range.start?.getTime() ?? 0) - (b.range.start?.getTime() ?? 0),
);
const groups: SelectedDate[][] = [];
let currentGroup: SelectedDate[] = [sorted[0]];
let currentGroupMaxEnd = sorted[0].range.end?.getTime() ?? 0;
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const currStart = current.range.start?.getTime() ?? 0;
const currEnd = current.range.end?.getTime() ?? 0;
// Check if current date is adjacent or overlapping with the maximum end of the current group
const nextDay = new Date(currentGroupMaxEnd);
let current: SelectedDate[] = [sorted[0]];
let maxEnd = sorted[0].range.end?.getTime() ?? 0;
for (const next of sorted.slice(1)) {
const nextDay = new Date(maxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
currentGroup = [current];
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
if ((next.range.start?.getTime() ?? 0) > nextDay.getTime()) {
if (current.length > 1) groups.push(current);
current = [];
}
current.push(next);
maxEnd = Math.max(maxEnd, next.range.end?.getTime() ?? 0);
}
// Add the last group if it has more than one element
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
// Create mother groups and add to result
const result: SelectedDate[] = [...this._datesSelected];
if (current.length > 1) groups.push(current);
for (const group of groups) {
const groupStart = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const groupEnd = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
const start = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const end = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
result.push(
new SelectedDate(
null,
new DateRange<Date>(new Date(groupStart), new Date(groupEnd)),
),
new SelectedDate(null, new DateRange<Date>(new Date(start), new Date(end))),
);
}
return result;
}
this.datesSelected = result;
/** Day types covering the date, in insertion order (today is never a selection). */
dayTypes(date: Date): readonly DayType[] {
return this.ranges
.filter((selected) => selected.type && selected.isSelected(date))
.map((selected) => selected.type!);
}
isSelected(date: Date): boolean {
return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date));
return this.grouped.some((selectedDate) => selectedDate.isSelected(date));
}
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
let starts = 0;
let ends = 0;
for (const selected of this.datesSelected) {
for (const selected of this.grouped) {
if (selected.range.start && date.getTime() === selected.range.start.getTime()) {
starts++;
}
@@ -501,27 +400,9 @@ export class SelectedDates implements SelectedDateInterface {
return ends >= starts;
}
/**
* Check whether a given date is selected and lies strictly inside a selected range
* (i.e., it is part of at least one selected range but is not the start or end of that range).
*
* This method returns true only if:
* - `this.isSelected(dateTest)` is true (the date is considered selected), and
* - there exists at least one element in `this.datesSelected` for which
* `dateSelected.isStart(dateTest)` and `dateSelected.isEnd(dateTest)` are both false.
*
* Notes:
* - The exact equality/comparison semantics are delegated to `isSelected`, `isStart`
* and `isEnd` implementations on the respective objects.
* - `dateTest` is expected to be a valid Date. Behavior for null/undefined/invalid Date
* values is not defined here.
*
* @private
* @param dateTest - The date to test.
* @returns True if the date is an interior day of at least one selected range; otherwise false.
*/
// True when the date lies strictly inside at least one selected range.
private existInARangeButNotABoundary(dateTest: Date): boolean {
return this.datesSelected.some(
return this.grouped.some(
(dateSelected) =>
dateSelected.isSelected(dateTest) &&
!dateSelected.isStart(dateTest) &&
@@ -530,13 +411,6 @@ export class SelectedDates implements SelectedDateInterface {
}
getClasses(date: Date): string {
return this.datesSelected.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
getTooltip(date: Date): string | null | undefined {
return this.datesSelected
.map((dateSelected) => dateSelected.getTooltip(date))
.filter((tooltip) => tooltip)
.join(' + ');
return this.grouped.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
}
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CalendarSettingsService } from './calendar-settings-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
describe('CalendarSettingsService', () => {
let service: CalendarSettingsService;
+328
View File
@@ -0,0 +1,328 @@
import { afterAll, describe, expect, it } from 'bun:test';
import { createOptimizationPresetRules } from './rule-service';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import { OPTIMIZATION_CATALOG } from './planning-objectives';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
type PlanningArtifacts,
type PlanningOutcome,
} from './leave-planner.core';
import {
calculatePlanningAsyncInternal as frozenCalculateAsync,
calculatePlanningInternal as frozenCalculate,
} from './frozen-engine/leave-planner.core.frozen.mjs';
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
const BASE_SEED = 280_000;
const CASES = 5000;
const CHUNK = 200;
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const DAY = 86_400_000;
const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
function generateRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const chance = (p: number) => rnd() < p;
const shuffle = <T>(items: T[]): T[] => {
for (let i = items.length - 1; i > 0; i--) {
const j = int(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return items;
};
const long = chance(0.1);
const mode = rnd();
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
else if (mode < 0.3) {
const year = pick([2025, 2026, 2027]);
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
weekday(Date.parse(d)),
);
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
}
const days = long ? int(29, 70) : int(7, 28);
const start = iso(startMs);
const date = (offset: number) => iso(startMs + offset * DAY);
const types = ['cp', 'rtt', 'other'] as const;
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
id: `b${i}`,
type: pick(types),
quantity: int(0, long ? 4 : 3),
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
expiryDate: date(int(-3, days + 40)),
})).map((b) =>
b.availableFrom && b.availableFrom > b.expiryDate
? { ...b, expiryDate: b.availableFrom }
: b,
);
const declaredDays: { date: string; type: string }[] = [];
for (let n = int(0, 4); n > 0; n--) {
const ms = startMs + int(0, days) * DAY;
const d = iso(ms);
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
declaredDays.push({ date: d, type: pick(types) });
}
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
const rules: Record<string, unknown>[] = [];
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
const from = int(0, days - 1);
rules.push({
id: `tw${rules.length}`,
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
force: pick(['obligation', 'preference']),
startDate: date(from),
endDate: date(from + int(0, 10)),
placeDays: int(1, 4),
placeType: pick(selectors),
});
}
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
rules.push({
id: `it${rules.length}`,
type: chance(0.5) ? 'if_then' : 'IF_THEN',
force: pick(['obligation', 'preference']),
dayOfWeek: int(1, 5),
placeDays: int(1, 3),
placeType: pick(selectors),
placement: pick(['before', 'after', 'around', 'auto']),
rewardDays: int(0, 2),
rewardType: pick(types),
});
}
const presets: Record<string, unknown>[] = shuffle(
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
).slice(0, long ? int(1, 4) : int(0, 3));
rules.push(...presets);
if (!long && chance(0.3)) {
const row = pick(OPTIMIZATION_CATALOG);
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
rules.push({
id: 'composed',
type: 'optimization',
force: 'preference',
configuration: {
elements: row.elements,
conditions,
measure: row.measure,
aggregation: pick(row.aggregations),
direction: pick(['minimize', 'maximize']),
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
},
});
}
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
const request: Req = {
calculationDate: start,
horizon: { start, end: date(days) },
balances,
declaredDays,
rules,
};
if (chance(0.04)) {
const breakIt = int(0, 3);
if (breakIt === 0) balances[0].quantity = -1;
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
else declaredDays.push({ date: start, type: 'nope' });
}
return request;
}
interface Run {
outcome: PlanningOutcome;
artifacts?: Pick<PlanningArtifacts, 'explanation' | 'finalState' | 'explanationError'>;
}
function run(
calculate: (
request: unknown,
c?: undefined,
a?: (artifacts: PlanningArtifacts) => void,
) => PlanningOutcome,
request: Req,
): Run {
let artifacts: Run['artifacts'];
const outcome = calculate(structuredClone(request), undefined, (a) => {
artifacts = {
explanation: a.explanation,
finalState: a.finalState,
explanationError: a.explanationError,
};
});
return { outcome, artifacts };
}
const kinds = new Map<string, number>();
const ruleTypes = new Set<string>();
const count = (key: string) => kinds.set(key, (kinds.get(key) ?? 0) + 1);
let slowest = { seed: 0, ms: 0 };
function crossesYearAndHoliday(request: Req): void {
const { start, end } = request['horizon'] as { start: string; end: string };
if (start.slice(0, 4) !== end.slice(0, 4)) count('crosses-year');
for (let ms = Date.parse(start); ms <= Date.parse(end); ms += DAY)
if (
weekday(ms) &&
frenchPublicHolidaysForYear(new Date(ms).getUTCFullYear()).has(iso(ms))
) {
count('holiday-week');
return;
}
}
function assertSameAsFrozen(seed: number): void {
const request = generateRequest(seed);
const t0 = performance.now();
const current = run(calculatePlanningInternal, request);
const frozen = run(frozenCalculate, request);
const ms = performance.now() - t0;
if (ms > slowest.ms) slowest = { seed, ms: Math.round(ms) };
crossesYearAndHoliday(request);
count(current.outcome.kind);
for (const rule of request.rules) ruleTypes.add(String(rule['type']).toLowerCase());
if ((request['declaredDays'] as unknown[]).length) count('declared');
if (request.rules.some((r) => r['rewardDays'])) count('reward');
try {
expect(current).toStrictEqual(frozen);
} catch (error) {
throw new Error(
`Engine differs from frozen engine. seed=${seed}\nrequest=${JSON.stringify(request)}\n${
(error as Error).message
}`,
{ cause: error },
);
}
}
type AsyncEngine = typeof calculatePlanningAsyncInternal;
/** Cancels after a seed-derived step count: during preparation, mid-search, or never. */
function cancelAfter(seed: number, total: number): number {
const rnd = mulberry32(seed ^ 0x5bd1e995);
const roll = rnd();
if (roll < 0.1) return Math.floor(rnd() * total);
if (roll < 0.25) return Number.POSITIVE_INFINITY;
// Preparation is most of the steps; spend the rest of the budget on the search.
return Math.floor(total * (0.9 + rnd() * 0.1));
}
async function runCancelled(engine: AsyncEngine, request: Req, limit: number) {
const calls: unknown[] = [];
const artifacts: unknown[] = [];
const explanations: unknown[] = [];
const outcomes: unknown[] = [];
let polls = 0;
const outcome = await engine(
structuredClone(request),
(plan, state) => calls.push({ plan, state }),
({ timings: _timings, ...rest }) => artifacts.push(rest),
(o) => outcomes.push(o),
(...args) => explanations.push(args),
() => ++polls > limit,
);
return { outcome, calls, artifacts, explanations, outcomes, polls };
}
const cancelledKinds = new Map<string, number>();
let incumbents = 0;
async function assertCancelledSameAsFrozen(seed: number): Promise<void> {
const request = generateRequest(seed);
const total = (await runCancelled(calculatePlanningAsyncInternal, request, Infinity)).polls;
const limit = cancelAfter(seed, total);
const current = await runCancelled(calculatePlanningAsyncInternal, request, limit);
const frozen = await runCancelled(frozenCalculateAsync, request, limit);
const kind = current.outcome.kind;
cancelledKinds.set(kind, (cancelledKinds.get(kind) ?? 0) + 1);
if (kind === 'PlanningIncomplete' && current.calls.length) incumbents++;
try {
expect(current).toStrictEqual(frozen);
} catch (error) {
throw new Error(
`Cancelled run differs from frozen engine. seed=${seed} limit=${limit}\nrequest=${JSON.stringify(request)}\n${
(error as Error).message
}`,
{ cause: error },
);
}
}
const ONLY_SEED = process.env['FROZEN_ENGINE_SEED'];
describe('frozen engine differential check', () => {
if (ONLY_SEED) {
it(`matches the frozen engine for seed ${ONLY_SEED}`, async () => {
assertSameAsFrozen(Number(ONLY_SEED));
await assertCancelledSameAsFrozen(Number(ONLY_SEED));
}, 120_000);
return;
}
for (let from = 0; from < CASES; from += CHUNK) {
it(`matches the frozen engine for seeds ${BASE_SEED + from}..${BASE_SEED + from + CHUNK - 1}`, () => {
for (let i = from; i < from + CHUNK; i++) assertSameAsFrozen(BASE_SEED + i);
}, 120_000);
}
for (let from = 0; from < CASES; from += CHUNK * 2) {
it(`matches the frozen engine when cancelled, seeds ${BASE_SEED + from}.. every 10th`, async () => {
for (let i = from; i < from + CHUNK * 2; i += 10)
await assertCancelledSameAsFrozen(BASE_SEED + i);
}, 300_000);
}
it('retains incumbents of cancelled calculations', () => {
console.info('cancelled coverage', Object.fromEntries(cancelledKinds), { incumbents });
expect(cancelledKinds.get('PlanningIncomplete') ?? 0).toBeGreaterThan(20);
expect(incumbents).toBeGreaterThan(10);
});
afterAll(() => {
console.info(
'frozen-engine coverage of generated cases',
Object.fromEntries(kinds),
[...ruleTypes],
'slowest case',
slowest,
);
});
it('generates every outcome kind and rule type', () => {
for (const kind of [
'Plan',
'PlanningConflict',
'InvalidInput',
'declared',
'reward',
'crosses-year',
'holiday-week',
])
expect(kinds.get(kind) ?? 0, kind).toBeGreaterThan(20);
expect(['if_then', 'optimization', 'time_window'].filter((t) => !ruleTypes.has(t))).toEqual(
[],
);
});
});
@@ -0,0 +1,22 @@
import type {
PlanningArtifactsCallback,
PlanningCheckpoint,
PlanningExplanationCallback,
PlanningOutcome,
PlanningOutcomeCallback,
} from '../leave-planner.core';
export function calculatePlanningInternal(
request: unknown,
checkpointCallback?: undefined,
artifactsCallback?: PlanningArtifactsCallback,
): PlanningOutcome;
export function calculatePlanningAsyncInternal(
request: unknown,
checkpointCallback?: PlanningCheckpoint,
artifactsCallback?: PlanningArtifactsCallback,
outcomeCallback?: PlanningOutcomeCallback,
explanationCallback?: PlanningExplanationCallback,
isCancelled?: () => boolean,
): Promise<PlanningOutcome>;
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
@@ -334,6 +334,43 @@ describe('composed calendar objectives', () => {
},
);
it.each(['obligation', 'preference'] as const)(
'keeps a lower %s only when required while completing a holiday week',
(force) => {
const { plan } = calculate({
...concurrent,
rules: [
holiday,
{
id: 'monday',
type: 'time_window',
force,
startDate: '2024-04-29',
endDate: '2024-04-29',
placeDays: 1,
placeType: 'cp',
},
],
});
if (force === 'obligation') {
expect(plan.placements).toMatchObject([
{ date: '2024-04-29', type: 'cp', ruleIds: ['monday'] },
]);
expect(plan.metrics.holidayWeekValue).toBe(0);
} else {
expect(plan.placements.map(({ date }) => date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(plan.metrics.holidayWeekValue).toBe(5);
expect(plan.placements.every(({ ruleIds }) => !ruleIds.includes('monday'))).toBe(
true,
);
}
},
);
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
const request: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-04' },
@@ -417,7 +454,7 @@ describe('composed calendar objectives', () => {
expect(ordinary.metrics.holidayWeekValue).toBe(0);
});
it('uses the same first calendar objective in the plan explanation, trace and date comparison', () => {
it('uses the same first calendar objective in the explanation and date comparison', () => {
for (const rules of [
[holiday, gain],
[gain, holiday],
@@ -439,15 +476,6 @@ describe('composed calendar objectives', () => {
'balance-expiry',
'placement-date',
]);
const comparisons = artifacts.trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates)
.filter((candidate) => candidate.status === 'chosen' && candidate.comparison);
expect(
comparisons?.some(
(candidate) => candidate.comparison?.firstDecisiveObjective === first,
),
).toBe(true);
const placement = rules[0].id === 'holiday' ? plan.placements[1] : plan.placements[0];
const alternative = rules[0].id === 'holiday' ? '2024-04-30' : '2024-05-02';
const comparison = analyzeCounterfactualInternal(
@@ -455,11 +483,16 @@ describe('composed calendar objectives', () => {
plan,
placement,
artifacts.finalState,
{ removeDate: placement.date, compareOriginalState: true },
);
const original = comparison.results.find((result) => result.date === placement.date)
?.comparison?.after;
const alternativeVector = comparison.results.find(
(result) => result.date === alternative,
)?.comparison?.after;
expect(
comparison.results.find((result) => result.date === alternative)?.comparison
?.firstDecisiveObjective,
alternativeVector?.objectives.find(
(objective, index) => objective.value !== original?.objectives[index].value,
)?.id,
).toBe(first);
}
});
@@ -1,10 +1,6 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
createPlanningCalculation,
type PlanningIncompleteOutcome,
type PlanningTraceUpdate,
} from './leave-planner.service';
import { vi } from '../../testing/vitest-compat';
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
const request = {
horizon: { start: '2024-01-01', end: '2025-01-01' },
@@ -46,7 +42,6 @@ describe('user cancellation', () => {
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
expect((await calculation.trace)?.status).toBe('incomplete');
});
it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => {
@@ -96,7 +91,6 @@ describe('user cancellation', () => {
placements: [],
incomplete: { reason: { code: 'user-cancelled' } },
});
expect(artifacts.trace?.status).toBe('incomplete');
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
});
@@ -193,39 +187,47 @@ describe('user cancellation', () => {
kind: 'IncompleteExplanation',
reason: { code: 'stale-calculation' },
});
expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
await current.trace;
});
it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
it('retains a plan at least as good as every valid checkpoint before cancellation', async () => {
const core = await import('./leave-planner.core');
let cancelled = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
horizon: request.horizon,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
rules: [
presets[0],
...[
{
id: 'january',
type: 'time_window',
force: 'preference',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 2,
placeType: 'cp',
},
],
...presets.slice(1),
const checkpoints: (readonly import('./leave-planner.core').PlanningObjective[])[] = [];
const submitted = {
horizon: request.horizon,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
rules: [
presets[0],
...[
{
id: 'january',
type: 'time_window',
force: 'preference',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 2,
placeType: 'cp',
},
],
progress: (stage: string) => {
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
},
...presets.slice(1),
],
progress: (stage: string) => {
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
},
};
const outcome = await core.calculatePlanningAsyncInternal(
submitted,
(plan, state) => {
if (!state) return;
const explained = core.explainIncompleteCheckpoint(submitted, plan, state, {
code: 'user-cancelled',
message: 'Cancelled',
}).explanation;
if (explained?.kind === 'PlanExplanation' && explained.placements.length)
checkpoints.push(explained.placements[0].objectiveVector.objectives);
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
@@ -235,36 +237,25 @@ describe('user cancellation', () => {
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
throw new Error('Expected a retained plan explanation and its original trace.');
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a retained plan explanation.');
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
const evaluated = artifacts.trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) =>
decision.candidates.flatMap((candidate) =>
candidate.comparison?.after.objectives[0].value === 0
? [candidate.comparison.after.objectives]
: [],
),
),
);
expect(evaluated.length).toBeGreaterThan(0);
for (const candidate of evaluated) {
expect(checkpoints.length).toBeGreaterThan(0);
for (const candidate of checkpoints) {
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
const firstDifference = retained.findIndex(
const first = retained.findIndex(
(objective, index) => objective.value !== candidate[index].value,
);
if (firstDifference < 0) continue;
const chosen = retained[firstDifference];
const alternative = candidate[firstDifference];
if (first < 0) continue;
expect(
chosen.direction === 'minimize'
? chosen.value < alternative.value
: chosen.value > alternative.value,
retained[first].direction === 'minimize'
? retained[first].value < candidate[first].value
: retained[first].value > candidate[first].value,
).toBe(true);
}
});
it('requests interruption before a valid plan without terminating trace delivery', async () => {
it('requests interruption before a valid plan and terminates after required delivery', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
calculation.cancel();
@@ -285,13 +276,11 @@ describe('user cancellation', () => {
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(currentWorker().terminate).not.toHaveBeenCalled();
currentWorker().emit({ type: 'artifacts', artifacts: {} });
await calculation.trace;
expect(currentWorker().terminate).toHaveBeenCalledOnce();
});
it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
it('keeps a cancelled partial plan after explanation delivery fails', async () => {
const core = await import('./leave-planner.core');
const updates: PlanningTraceUpdate[] = [];
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const submitted = {
@@ -302,8 +291,6 @@ describe('user cancellation', () => {
const cancelled = await core.calculatePlanningAsyncInternal(
{
...submitted,
traceProgress: (_: number, update: PlanningTraceUpdate) =>
updates.push(structuredClone(update)),
progress: (stage: string) => {
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
@@ -314,20 +301,9 @@ describe('user cancellation', () => {
undefined,
() => interrupted,
);
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent)
throw new Error('Expected a cancelled fixture with a valid plan.');
const calculation = createPlanningCalculation(submitted);
const operations = updates.flatMap(({ operations }) => operations);
const split = Math.floor(operations.length / 2);
currentWorker().emit({
type: 'trace-update',
totalMs: 0,
acknowledge: true,
update: {
...updates[0],
operations: operations.slice(0, split),
},
});
calculation.cancel();
currentWorker().emit({ type: 'result', outcome: cancelled });
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
@@ -336,29 +312,14 @@ describe('user cancellation', () => {
explanationError: { code: 'explanation-error', message: 'Unavailable' },
});
currentWorker().emit({
type: 'trace-update',
totalMs: 0,
acknowledge: true,
update: {
operations: operations.slice(split),
},
});
const trace = artifacts.trace;
currentWorker().emit({
type: 'artifacts-start',
type: 'artifacts',
artifacts: {},
trace: {
status: trace.status,
initialState: trace.initialState,
completedStages: trace.completedStages,
nonWorkingDates: trace.nonWorkingDates,
error: trace.error,
},
});
currentWorker().emit({ type: 'artifacts-complete' });
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
expect(await calculation.trace).toEqual(trace);
expect(currentWorker().terminate).toHaveBeenCalledOnce();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
expect(artifacts.finalState?.placements).toEqual(cancelled.incumbent.placements);
});
it.each([false, true])(
@@ -399,7 +360,6 @@ describe('user cancellation', () => {
kind: 'PlanningIncomplete',
reasons: [{ code: 'worker-error' }],
});
expect((await calculation.trace)?.status).toBe('failed');
});
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
@@ -424,7 +384,6 @@ describe('user cancellation', () => {
kind: 'PlanExplanation',
incomplete: { reason: { code: 'user-cancelled' } },
});
expect((await calculation.trace)?.status).toBe('incomplete');
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
});
@@ -453,7 +412,6 @@ describe('user cancellation', () => {
currentWorker().emit({ type: 'result', outcome });
currentWorker().emit({ type: 'artifacts', artifacts: {} });
expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' });
await newer.trace;
const sent = currentWorker().postMessage.mock.calls.length;
newer.cancel();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
+161 -595
View File
@@ -1,6 +1,7 @@
import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { vi } from '../../testing/vitest-compat';
import { createPlanningCalculation } from './leave-planner.service';
const balance = (
id: string,
@@ -18,8 +19,92 @@ afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('planner explanation and trace seam', () => {
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
describe('planner explanation and alternative dates', () => {
it('delivers only the displayed plan, explanation and state needed for alternative dates', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-09')],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
expect(Object.keys(calculation).sort()).toEqual([
'analyzeCounterfactual',
'cancel',
'explanation',
'explanationError',
'id',
'outcome',
'snapshot',
'timings',
]);
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
expect(
core.calculatePlanningInternal(
calculation.snapshot,
undefined,
(value) => (artifacts = value),
),
).toEqual(outcome);
expect(Object.keys(artifacts).sort()).toEqual([
'explanation',
'explanationError',
'finalState',
'timings',
]);
expect(artifacts.finalState?.placements).toEqual(outcome.placements);
expect(Object.keys(artifacts.timings ?? {})).toEqual(['planningWorkMs']);
const original = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(analysis.results).toHaveLength(8);
for (const result of analysis.results) {
expect(result).not.toHaveProperty('historicalStatus');
expect(result).not.toHaveProperty('historicalReason');
}
expect(outcome).toEqual(original);
});
it('excludes observers from the technical budget and keeps the outcome after explanation delivery fails', async () => {
const core = await import('./leave-planner.core');
let elapsed = 0;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
let delivered = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
try {
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-01-02', end: '2024-01-10' },
balances: [],
rules: [],
deadlineMs: 10,
progress: () => (elapsed += 100),
},
() => (elapsed += 500),
(value) => (artifacts = value),
() => (delivered = true),
() => {
expect(delivered).toBe(true);
elapsed += 1000;
throw new Error('Explanation delivery failed.');
},
);
expect(outcome.kind).toBe('Plan');
expect(artifacts.explanationError).toMatchObject({
code: 'explanation-error',
message: 'Explanation delivery failed.',
});
expect(artifacts.finalState?.horizon).toEqual({
start: '2024-01-02',
end: '2024-01-10',
});
expect(artifacts.timings?.planningWorkMs).toBe(0);
} finally {
clock.mockRestore();
}
});
it('keeps earned availability after a reported occurrence is extended', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -67,42 +152,25 @@ describe('planner explanation and trace seam', () => {
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
throw new Error('Expected the extended generator to finance a traced plan.');
const replay = {
balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })),
placements: new Map(
artifacts.trace.initialState.placements.map((item) => [item.date, item]),
),
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
};
if (outcome.kind !== 'Plan' || !artifacts.finalState)
throw new Error('Expected the extended generator to finance a plan.');
const earnedId = 'earned:producer:2024-05-01';
let recognized = false;
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) {
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
if (
decision.deltas.some(
({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId,
)
) {
recognized = true;
expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId);
expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId);
}
}
expect(recognized).toBe(true);
expect(replay.balances).toEqual(artifacts.finalState.balances);
expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements);
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({
remaining: 0,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
});
expect(artifacts.finalState.balances).toContainEqual(
expect.objectContaining({
id: earnedId,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
remaining: 0,
}),
);
expect(outcome.placements.find(({ balanceId }) => balanceId === earnedId)?.date).toBe(
'2024-05-07',
);
expect(artifacts.finalState.placements).toEqual(outcome.placements);
});
it.each([5, 8])(
'keeps %s unrelated obligation branches linear when a declared entitlement is already reported',
'keeps %s unrelated obligations valid without duplicating a reported entitlement',
async (count) => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
@@ -116,74 +184,58 @@ describe('planner explanation and trace seam', () => {
'2024-04-25',
'2024-04-26',
].slice(0, count);
const branchCounts: number[] = [];
for (const rewardDays of [0, 1]) {
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-04-17', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
],
balances: [
{
id: 'other',
type: 'other',
quantity: dates.length,
availableFrom: '2024-04-17',
expiryDate: '2025-12-31',
},
],
rules: [
...dates.map((date) => ({
id: date,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: date,
endDate: date,
placeDays: 1,
placeType: 'other' as const,
})),
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays,
rewardType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a traced completed plan.');
const outcome = core.calculatePlanningInternal({
horizon: { start: '2024-04-17', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
],
balances: [
{
id: 'other',
type: 'other',
quantity: dates.length,
availableFrom: '2024-04-17',
expiryDate: '2025-12-31',
},
],
rules: [
...dates.map((date) => ({
id: date,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: date,
endDate: date,
placeDays: 1,
placeType: 'other' as const,
})),
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays,
rewardType: 'other',
},
],
});
if (outcome.kind !== 'Plan') throw new Error('Expected a completed plan.');
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
expect(outcome.remainingBalances).toEqual([]);
branchCounts.push(
artifacts.trace.phases
.find(({ name }) => name === 'obligations')
?.decisions.filter(({ deltas }) =>
deltas.some(({ placement }) => placement !== undefined),
).length ?? 0,
);
if (rewardDays)
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-02',
added: false,
});
}
expect(branchCounts[0]).toBeGreaterThan(0);
expect(branchCounts[1]).toBe(branchCounts[0]);
},
);
it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
it('keeps reported entitlements out of the final state under explicit priorities', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -224,69 +276,17 @@ describe('planner explanation and trace seam', () => {
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
throw new Error('Expected a traced completed plan.');
if (outcome.kind !== 'Plan' || !artifacts.finalState)
throw new Error('Expected a completed plan.');
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
expect(outcome.remainingBalances).toEqual([]);
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-06',
added: false,
});
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
const deltas = decisions.flatMap(({ deltas }) => deltas);
expect(deltas).toContainEqual(
expect.objectContaining({ kind: 'balance-changed', balanceId: 'cp' }),
);
expect(deltas).not.toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
balanceId: 'earned:producer:2024-05-01',
}),
);
const initial = artifacts.trace.initialState;
const replay = {
balances: initial.balances.map((balance) => ({ ...balance })),
placements: new Map(
initial.placements.map((placement) => [
placement.date,
{ ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: initial.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
};
for (const decision of decisions) applyReplayDeltas(replay, decision.deltas);
expect(replay.balances).toEqual(artifacts.finalState.balances);
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
expect([...replay.placements.values()]).toEqual(outcome.placements);
});
it('records only actual evaluations and publishes non-working dates as metadata', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-07' },
balances: [],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
expect(trace?.nonWorkingDates?.map(({ date }) => date)).toEqual([
'2024-01-01',
'2024-01-06',
]);
expect(
trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates),
).toEqual([]);
expect(trace?.phases.every((phase) => phase.status === 'completed')).toBe(true);
});
it('keeps joint obligation branches atomic and associates the selected shared placement', async () => {
it('associates every joint obligation with its shared placement', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
@@ -312,151 +312,13 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const obligations = trace.phases.find((phase) => phase.name === 'obligations');
const branch = obligations?.decisions
.flatMap((decision) => decision.candidates)
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 2);
expect(branch?.dates).toHaveLength(2);
expect(
branch?.dates?.every((date) => outcome.placements.some((item) => item.date === date)),
).toBe(true);
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const sharedPlacement = outcome.placements.find(
(placement) =>
placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'),
);
expect(sharedPlacement).toBeDefined();
const acceptedMutation = obligations?.decisions.find((decision) =>
decision.deltas.some(
(delta) =>
(delta.kind === 'placement-added' || delta.kind === 'placement-updated') &&
delta.placement !== undefined &&
delta.placement.date === sharedPlacement?.date &&
delta.placement.ruleIds.includes('joint') &&
delta.placement.ruleIds.includes('shared'),
),
);
expect(acceptedMutation).toBeDefined();
expect(acceptedMutation?.branch?.deltas).toContainEqual(
expect.objectContaining({
kind: 'placement-updated',
placement: expect.objectContaining({
date: sharedPlacement?.date,
ruleIds: ['joint', 'shared'],
}),
}),
);
const sameDatesOnRejectedParent = obligations?.decisions.filter(
(decision) =>
decision.id !== acceptedMutation?.id &&
decision.candidates.some(
(candidate) =>
candidate.status === 'evaluated-rejected' &&
candidate.dates?.includes(sharedPlacement?.date ?? ''),
),
);
expect(
sameDatesOnRejectedParent?.some((decision) => decision.branch?.parentDecisionId),
).toBe(true);
});
it('records at-risk assignments as sequential decisions with exact prior-state deltas', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const atRisk = trace.phases.find(
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
);
const jan2 = atRisk?.decisions.find((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
),
);
const jan3 = atRisk?.decisions.find((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
),
);
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan2?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
technicalComparison: [
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
{
name: `optimization:${presets[0].id}`,
direction: 'minimize',
before: 2,
after: 1,
},
],
firstDecisiveComparator: `optimization:${presets[0].id}`,
});
expect(jan2?.deltas).toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
remainingBefore: 2,
remainingAfter: 1,
}),
);
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan3?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
technicalComparison: [
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
{
name: `optimization:${presets[0].id}`,
direction: 'minimize',
before: 1,
after: 0,
},
],
firstDecisiveComparator: `optimization:${presets[0].id}`,
});
expect(jan3?.deltas).toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
remainingBefore: 1,
remainingAfter: 0,
}),
);
for (const [date, decision] of [
['2024-01-02', jan2],
['2024-01-03', jan3],
] as const) {
expect(
decision?.candidates.filter(
(candidate) => candidate.status === 'chosen' && candidate.date === date,
),
).toHaveLength(1);
expect(
decision?.deltas.filter(
(delta) => delta.kind === 'placement-added' && delta.date === date,
),
).toHaveLength(1);
}
const selected = jan3?.deltas.find(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
)?.placement;
expect(selected).toBeDefined();
if (!jan3 || !selected) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: jan3.id,
placement: selected,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind === 'Counterfactual')
expect(analysis.results.find((item) => item.date === '2024-01-02')).toMatchObject({
status: 'ineligible',
reason: { code: 'occupied' },
});
});
it('puts actual obligation validity first in stable plan explanations', async () => {
@@ -478,6 +340,7 @@ describe('planner explanation and trace seam', () => {
const outcome = await calculation.outcome;
const explanation = await calculation.explanation;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
@@ -498,7 +361,7 @@ describe('planner explanation and trace seam', () => {
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
});
it('records the balance order used for a shared ANY preference placement', async () => {
it('uses the earlier-expiring balance for shared ANY preferences', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [
@@ -528,9 +391,8 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const sharedPlacement = outcome.placements.find((placement) =>
placement.ruleIds.includes('any-preference'),
@@ -540,116 +402,9 @@ describe('planner explanation and trace seam', () => {
balanceId: 'rtt-first',
ruleIds: ['any-preference', 'rtt-preference'],
});
const candidate = trace.phases
.find((phase) => phase.name === 'preference:any-preference')
?.decisions.flatMap((decision) => decision.candidates)
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
expect(candidate).toMatchObject({
firstDecisiveComparator: 'preference:rtt-preference',
placement: expect.objectContaining({ balanceId: 'rtt-first' }),
});
const compared = trace.phases
.find((phase) => phase.name === 'preference:any-preference')
?.decisions.flatMap((decision) => decision.candidates);
expect(compared).toContainEqual(
expect.objectContaining({
status: 'evaluated-rejected',
placement: expect.objectContaining({ balanceId: 'rtt-second' }),
technicalComparison: expect.arrayContaining([
{
name: 'preference:any-preference',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'preference:rtt-preference',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'balance-expiry',
direction: 'minimize',
before: '2025-01-05',
after: '2025-01-06',
},
]),
firstDecisiveComparator: 'balance-expiry',
}),
);
});
it('records actual IfThen leaf comparisons and selected branch identity', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
rules: [
{
id: 'if-then-preference',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 0,
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const candidates = trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) =>
decision.candidates
.filter((candidate) =>
candidate.technicalComparison?.some(
(item) => item.name === 'preference:if-then-preference',
),
)
.map((candidate) => ({ candidate, decision })),
);
const chosen = candidates.find(({ candidate }) => candidate.status === 'chosen');
const preferenceLeaves =
trace.phases
.find((phase) => phase.name === 'preference:if-then-preference')
?.decisions.flatMap((decision) => decision.candidates)
.filter((candidate) =>
candidate.technicalComparison?.some(
(item) => item.name === 'preference:if-then-preference',
),
) ?? [];
expect(preferenceLeaves.length).toBeGreaterThan(1);
expect(preferenceLeaves.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(
preferenceLeaves.some((candidate) => candidate.status === 'evaluated-rejected'),
).toBe(true);
expect(candidates.some(({ candidate }) => candidate.status === 'evaluated-rejected')).toBe(
true,
);
expect(chosen?.candidate.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'preference:if-then-preference',
direction: 'maximize',
}),
]),
);
expect(
(chosen?.decision.branch?.deltas ?? chosen?.decision.deltas ?? []).length,
).toBeGreaterThan(0);
expect(
chosen?.candidate.dates?.every((date) =>
outcome.placements.some((p) => p.date === date),
),
).toBe(true);
});
it('records earned obligation branches with the activated entitlement and its consumer', async () => {
it('keeps an earned obligation entitlement and its consumer chronological', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
@@ -680,29 +435,13 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const earned = outcome.placements.find((placement) =>
placement.balanceId.startsWith('earned:'),
);
expect(earned?.ruleIds).toContain('consumer');
const decisions = trace.phases.flatMap((phase) => phase.decisions);
const activation = decisions
.flatMap((decision) => decision.deltas)
.find(
(delta) =>
delta.kind === 'earned-activated' && delta.balanceId === earned?.balanceId,
);
expect(activation?.activation).toMatchObject({ ruleId: 'producer', dates: ['2024-05-02'] });
const branch = decisions
.flatMap((decision) => decision.candidates)
.find(
(candidate) =>
candidate.status === 'chosen' && candidate.dates?.includes(earned?.date ?? ''),
);
expect(branch).toBeDefined();
});
it('rejects a producer move that delays its earned entitlement past the consumer', async () => {
@@ -737,7 +476,7 @@ describe('planner explanation and trace seam', () => {
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const producer = outcome.placements.find((placement) =>
placement.ruleIds.includes('producer'),
);
@@ -802,7 +541,7 @@ describe('planner explanation and trace seam', () => {
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const atRisk = outcome.placements.find(
(placement) =>
placement.balanceId === 'cp2' &&
@@ -823,7 +562,7 @@ describe('planner explanation and trace seam', () => {
).toBeUndefined();
});
it('records earned balance availability metadata changes when a shared period extends', async () => {
it('keeps earned balance metadata consistent when a shared period extends', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [
@@ -863,30 +602,13 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const earnedBalanceId = outcome.placements.find((placement) =>
placement.ruleIds.includes('rtt-preference'),
)?.balanceId;
expect(earnedBalanceId).toBeDefined();
const changes = trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.deltas)
.filter(
(delta) => delta.kind === 'balance-changed' && delta.balanceId === earnedBalanceId,
);
expect(changes).toContainEqual(
expect.objectContaining({
remainingBefore: 1,
remainingAfter: 1,
balance: expect.objectContaining({
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
}),
}),
);
});
it('compares every horizon date while preserving the selected placement provenance', async () => {
@@ -907,7 +629,7 @@ describe('planner explanation and trace seam', () => {
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const selected = outcome.placements.find((placement) =>
placement.ruleIds.includes('exact-date'),
);
@@ -1022,27 +744,6 @@ describe('planner explanation and trace seam', () => {
if (outcome.kind !== 'PlanningConflict') continue;
expect(outcome.reasons[0].code).toBe(conflictCodes[index]);
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
const trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true);
expect(
trace?.phases.some(
(phase) => phase.name === 'optimization:default-holiday-week-value',
),
).toBe(false);
if (index === 1)
expect(
trace?.phases
.find((phase) => phase.name === 'obligations')
?.decisions.flatMap((decision) => decision.candidates).length,
).toBeGreaterThan(0);
if (index === 2)
expect(
trace?.phases
.find((phase) => phase.name === 'obligations')
?.decisions.some((decision) =>
decision.candidates.some((candidate) => candidate.dates?.length),
),
).toBe(true);
}
const invalid = createPlanningCalculation({
@@ -1052,7 +753,6 @@ describe('planner explanation and trace seam', () => {
});
expect((await invalid.outcome).kind).toBe('InvalidInput');
expect(await invalid.explanation).toBeUndefined();
expect(await invalid.trace).toBeUndefined();
});
it('does not classify a joint-date conflict as earned when only a preference generates leave', async () => {
@@ -1094,7 +794,7 @@ describe('planner explanation and trace seam', () => {
it('recovers an incumbent explanation from the exact delivered checkpoint state', async () => {
const core = await import('./leave-planner.core');
let checkpointPlan: import('./leave-planner.core').PlanOutcome | undefined;
let checkpointState: import('./leave-planner.core').PlanningTraceState | undefined;
let checkpointState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
@@ -1124,7 +824,7 @@ describe('planner explanation and trace seam', () => {
expect(recovered.explanation.incomplete?.reason.code).toBe('worker-error');
expect(recovered.explanation.placements).toHaveLength(checkpointPlan.placements.length);
let completedState: import('./leave-planner.core').PlanningTraceState | undefined;
let completedState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
const completed = core.calculatePlanningInternal(
{ ...request, progress: undefined },
(plan, state) => {
@@ -1144,116 +844,7 @@ describe('planner explanation and trace seam', () => {
expect(completedRecovery.explanation.incomplete).toBeUndefined();
});
it('does not charge a deliberately slow incremental trace consumer to planning work', async () => {
const core = await import('./leave-planner.core');
let clock = 0;
vi.stubGlobal('performance', { now: () => ++clock });
const request = {
horizon: { start: '2024-01-01', end: '2024-01-15' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: presets,
deadlineMs: 100_000,
};
const plain = core.calculatePlanningInternal(request);
clock = 0;
let callbackCount = 0;
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
const traced = core.calculatePlanningInternal(
{
...request,
traceProgress: (
_totalMs: number,
update: import('./leave-planner.core').PlanningTraceUpdate,
) => {
callbackCount++;
clock += 10_000;
operations.push(...update.operations);
},
},
undefined,
(value) => (artifacts = value),
);
expect(traced).toEqual(plain);
expect(callbackCount).toBeGreaterThan(0);
expect(artifacts?.trace?.status).toBe('ready');
expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThan(request.deadlineMs);
const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate');
expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true);
const streamedCandidates = new Map<string, unknown>();
for (const operation of candidateUpdates)
if (operation.kind === 'candidate')
streamedCandidates.set(
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
operation.candidate,
);
expect(streamedCandidates.size).toBe(
decisions.flatMap((decision) => decision.candidates).length,
);
expect(operations.filter((operation) => operation.kind === 'delta')).toHaveLength(
decisions.flatMap((decision) => decision.deltas).length,
);
});
it('coalesces pending candidate snapshots without losing later flushed replacements', async () => {
const core = await import('./leave-planner.core');
vi.stubGlobal('performance', { now: () => 0 });
const request = {
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [presets[0]],
};
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
plainArtifacts = value;
});
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
let streamedArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const streamed = core.calculatePlanningInternal(
{
...request,
traceProgress: (
_totalMs: number,
update: import('./leave-planner.core').PlanningTraceUpdate,
) => operations.push(...update.operations),
},
undefined,
(value) => (streamedArtifacts = value),
);
expect(streamed).toEqual(plain);
expect(streamedArtifacts?.trace).toEqual(plainArtifacts?.trace);
const decisions =
streamedArtifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidates = decisions.flatMap((decision) =>
decision.candidates.map((candidate, index) => ({
key: `${decision.id}/${index}`,
candidate,
})),
);
expect(candidates.length).toBeGreaterThan(0);
const candidateOperations = operations.filter(
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
operation.kind === 'candidate',
);
expect(candidateOperations).toHaveLength(candidates.length);
const finalStreamedCandidates = new Map(
candidateOperations.map((operation) => [
`${operation.decisionId}/${operation.index}`,
operation,
]),
);
for (const { key, candidate } of candidates) {
expect(finalStreamedCandidates.get(key)?.candidate).toEqual(candidate);
if (candidate.status === 'chosen')
expect(finalStreamedCandidates.get(key)?.mode).toBe('append');
}
});
it('reports matching completed, interrupted, and not-run phases at both interruption boundaries', async () => {
it('explains incomplete calculations before and after a valid plan', async () => {
const early = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
@@ -1261,22 +852,9 @@ describe('planner explanation and trace seam', () => {
deadlineMs: 0,
});
const earlyOutcome = await early.outcome;
const earlyTrace = await early.trace;
expect(earlyOutcome.kind).toBe('PlanningIncomplete');
if (earlyOutcome.kind === 'PlanningIncomplete')
expect(earlyOutcome.completedStages).toEqual(['validation']);
expect(earlyTrace?.completedStages).toEqual(['validation']);
expect(earlyTrace?.phases.find((phase) => phase.name === 'validation')?.status).toBe(
'completed',
);
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
'interrupted',
);
expect(
earlyTrace?.phases.find(
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
)?.status,
).toBe('not-run');
const quality = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
@@ -1289,7 +867,6 @@ describe('planner explanation and trace seam', () => {
},
});
const qualityOutcome = await quality.outcome;
const qualityTrace = await quality.trace;
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
@@ -1298,16 +875,5 @@ describe('planner explanation and trace seam', () => {
'obligations',
'optimization:default-unplaced-at-risk',
]);
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
expect(
qualityTrace?.phases.find(
(phase) => phase.name === 'optimization:default-holiday-week-value',
)?.status,
).toBe('interrupted');
expect(
qualityTrace?.phases.find(
(phase) => phase.name === 'optimization:default-non-working-day-gain',
)?.status,
).toBe('not-run');
});
});
@@ -1,5 +1,5 @@
import { createOptimizationPresetRules } from './rule-service';
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
import {
evaluateOptimization,
@@ -1,169 +0,0 @@
import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
import {
calculatePlanningInternal,
type PlanningArtifacts,
type PlanningTraceCandidate,
type PlanningTraceUpdate,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals());
const presets = createOptimizationPresetRules();
describe('quality calculation trace', () => {
it('keeps emitted comparison metadata unchanged when candidate snapshots are replaced', () => {
const updates: PlanningTraceUpdate[] = [];
const captured: string[] = [];
let artifacts: PlanningArtifacts = {};
let clock = 0;
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => (clock += 30));
try {
const outcome = calculatePlanningInternal(
{
calculationDate: '2026-11-01',
horizon: { start: '2026-11-03', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
declaredDays: [
{ date: '2026-11-09', type: 'other' },
{ date: '2026-11-10', type: 'other' },
],
rules: [
{
id: 'holiday',
name: 'Holiday weeks',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
},
...[3, 5].map((thresholdDays) => ({
id: `deficit-${thresholdDays}`,
name: `Deficit ${thresholdDays} days`,
type: 'optimization',
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
conditions: ['non-empty'],
thresholdDays,
},
})),
],
traceProgress: (_elapsed: number, update: PlanningTraceUpdate) => {
updates.push(update);
captured.push(JSON.stringify(update));
},
},
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('Plan');
expect(artifacts.trace?.status).toBe('ready');
expect(updates.map((update) => JSON.stringify(update))).toEqual(captured);
const emitted = new Map<string, PlanningTraceCandidate>();
let replacements = 0;
for (const update of updates)
for (const operation of update.operations) {
if (operation.kind !== 'candidate') continue;
if (operation.mode === 'replace') replacements++;
emitted.set(
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
operation.candidate,
);
}
expect(replacements).toBeGreaterThan(0);
expect(emitted.size).toBeGreaterThan(0);
for (const phase of artifacts.trace!.phases)
for (const decision of phase.decisions)
decision.candidates.forEach((candidate, index) =>
expect(emitted.get(`${phase.id}/${decision.id}/${index}`)).toEqual(
candidate,
),
);
const comparison = [...emitted.values()].find(
(candidate) => candidate.comparison,
)?.comparison;
expect(comparison?.after.objectives).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'optimization:deficit-3',
label: 'Deficit 3 days',
direction: 'minimize',
}),
expect.objectContaining({
id: 'optimization:deficit-5',
label: 'Deficit 5 days',
direction: 'minimize',
}),
]),
);
expect(
[...emitted.values()].some(({ comparison }) => {
const values = comparison?.after.objectives;
return (
values?.find(({ id }) => id === 'optimization:deficit-3')?.value === 0 &&
values?.find(({ id }) => id === 'optimization:deficit-5')?.value === 1
);
}),
).toBe(true);
} finally {
clockSpy.mockRestore();
}
});
it('separates source effects from the actual incumbent comparison', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
availableFrom: '2024-01-01',
expiryDate: '2024-01-31',
},
],
rules: presets,
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
expect(trace?.status).toBe('ready');
const phase = trace?.phases.find(({ name }) => name === `optimization:${presets[0].id}`);
const decision = phase?.decisions.find(({ candidates }) =>
candidates.some(({ status, date }) => status === 'chosen' && date === '2024-01-02'),
);
const rejected = decision?.candidates.find(
({ date, comparison }) => date === '2024-01-05' && comparison,
);
expect(rejected?.status).toBe('evaluated-rejected');
const sourceValue = (state: 'before' | 'after', id: string) =>
rejected?.comparison?.[state].objectives.find((objective) => objective.id === id)
?.value;
expect(sourceValue('before', `optimization:${presets[0].id}`)).toBe(2);
expect(sourceValue('after', `optimization:${presets[0].id}`)).toBe(1);
expect(sourceValue('before', `optimization:${presets[2].id}`)).toBe(0);
expect(sourceValue('after', `optimization:${presets[2].id}`)).toBe(2);
expect(rejected?.comparison?.firstDecisiveObjective).toBe(`optimization:${presets[0].id}`);
// The January 2 candidate already gains three natural non-working days.
// January 5 improves its source state, but loses to that current best candidate.
expect(rejected?.technicalComparison).toContainEqual({
name: `optimization:${presets[2].id}`,
direction: 'maximize',
before: 3,
after: 2,
});
expect(
rejected?.technicalComparison?.some(
({ name }) => name === `optimization:${presets[3].id}`,
),
).toBe(false);
expect(rejected?.firstDecisiveComparator).toBe(`optimization:${presets[2].id}`);
expect(rejected?.reason?.message).toContain('did not improve the current best result');
expect(decision?.deltas).toContainEqual(
expect.objectContaining({
kind: 'placement-added',
date: '2024-01-02',
}),
);
});
});
@@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
@@ -6,10 +6,9 @@ import {
type PlanOutcome,
type PlanningArtifacts,
type PlanningRequest,
type PlanningTraceUpdate,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { applyReplayDeltas, createPlanningCalculation } from './leave-planner.service';
import { createPlanningCalculation } from './leave-planner.service';
const economy: OptimizationPlanningRule = {
id: 'economy',
@@ -415,25 +414,20 @@ describe('supporting placement economy and provenance', () => {
});
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
let artifacts: PlanningArtifacts | undefined;
const outcome = calculatePlanningInternal(
{
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
{
id: 'at-risk',
type: 'rtt',
quantity: 1,
availableFrom: '2024-05-07',
expiryDate: '2024-05-07',
},
],
rules: [bundles[0].rule, economy],
},
undefined,
(value) => (artifacts = value),
);
const outcome = calculatePlanningInternal({
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
{
id: 'at-risk',
type: 'rtt',
quantity: 1,
availableFrom: '2024-05-07',
expiryDate: '2024-05-07',
},
],
rules: [bundles[0].rule, economy],
});
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(
outcome.placements.map(({ date, balanceId, supporting }) => ({
@@ -448,20 +442,6 @@ describe('supporting placement economy and provenance', () => {
]);
expect(outcome.metrics.supportingPlacements).toBe(2);
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
expect(
artifacts?.trace?.phases.flatMap(({ decisions }) =>
decisions.flatMap(({ deltas }) => deltas),
),
).toContainEqual(
expect.objectContaining({
kind: 'placement-updated',
previousPlacement: expect.objectContaining({
balanceId: 'deferred',
supporting: true,
}),
placement: expect.objectContaining({ balanceId: 'at-risk', supporting: false }),
}),
);
});
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
@@ -491,13 +471,10 @@ describe('supporting placement economy and provenance', () => {
};
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
let artifacts: PlanningArtifacts | undefined;
const updates: { update: PlanningTraceUpdate; snapshot: string }[] = [];
const windowFirst = calculatePlanningInternal(
{
...request,
rules: [holiday, early, economy],
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
updates.push({ update, snapshot: JSON.stringify(update) }),
},
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
(value) => (artifacts = value),
@@ -547,12 +524,8 @@ describe('supporting placement economy and provenance', () => {
expect(windowFirst.metrics.supportingPlacements).toBe(2);
expect(windowFirst.preferences[0].satisfied).toBe(2);
expect(windowFirst.metrics.holidayWeekValue).toBe(3);
if (
artifacts?.explanation?.kind !== 'PlanExplanation' ||
!artifacts.finalState ||
!artifacts.trace
)
throw new Error('Expected matching explanation and trace artifacts');
if (artifacts?.explanation?.kind !== 'PlanExplanation' || !artifacts.finalState)
throw new Error('Expected matching explanation and state snapshots');
expect(artifacts.finalState.placements).toEqual(windowFirst.placements);
expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
windowFirst.placements,
@@ -568,33 +541,6 @@ describe('supporting placement economy and provenance', () => {
expect(placement.contributions).not.toContainEqual(contribution);
}
}
const replay = {
balances: artifacts.trace.initialState.balances.map((balance) => ({ ...balance })),
placements: new Map(
artifacts.trace.initialState.placements.map((placement) => [
placement.date,
placement,
]),
),
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
};
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions))
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
expect(
[...replay.placements.values()].sort((a, b) => a.date.localeCompare(b.date)),
).toEqual(windowFirst.placements);
expect(
artifacts.trace.phases
.filter(({ name }) => name === 'preference:early')
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates)),
).toContainEqual(
expect.objectContaining({
status: 'chosen',
placement: expect.objectContaining({ date: '2026-11-03', ruleIds: ['early'] }),
}),
);
expect(updates.length).toBeGreaterThan(0);
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
let cancelled = false;
let cancelledArtifacts: PlanningArtifacts | undefined;
let published: PlanOutcome | undefined;
@@ -605,8 +551,6 @@ describe('supporting placement economy and provenance', () => {
progress: (stage: string) => {
if (stage === 'optimization:economy') cancelled = true;
},
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
updates.push({ update, snapshot: JSON.stringify(update) }),
},
(checkpoint, state) => {
expect(state?.placements).toEqual(checkpoint.placements);
@@ -637,7 +581,6 @@ describe('supporting placement economy and provenance', () => {
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
);
}
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
});
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
@@ -708,28 +651,6 @@ describe('supporting placement economy and provenance', () => {
});
});
it('records the calendar credit and classification change in the original calendar-rule phase', () => {
let artifacts: PlanningArtifacts | undefined;
calculatePlanningInternal(
{ ...weekend, rules: [gain, economy, requested] },
undefined,
(value) => (artifacts = value),
);
const changes = artifacts?.trace?.phases
.find(({ name }) => name === 'preference:requested')
?.decisions.flatMap(({ deltas }) => deltas);
expect(changes).toContainEqual(
expect.objectContaining({
kind: 'placement-updated',
previousPlacement: expect.objectContaining({ supporting: true }),
placement: expect.objectContaining({
ruleIds: ['gain', 'requested'],
supporting: false,
}),
}),
);
});
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
vi.stubGlobal('window', undefined);
try {
@@ -792,15 +713,6 @@ describe('supporting placement economy and provenance', () => {
expect(supporting.contributions).toContainEqual(
expect.objectContaining({ kind: 'supporting' }),
);
const trace = await calculation.trace;
expect(trace?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
'optimization:gain',
'optimization:economy',
'expiry',
'dates',
]);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({
@@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
calculatePlanningInternal,
type OptimizationPlanningRule,
@@ -407,7 +407,7 @@ describe('composed work-sequence preferences', () => {
},
);
it('captures distinct threshold values in explanations, atomic trace and counterfactual comparisons', async () => {
it('captures distinct threshold values in explanations and date comparisons', async () => {
vi.stubGlobal('window', undefined);
try {
const five = structuredClone(deficit);
@@ -435,18 +435,6 @@ describe('composed work-sequence preferences', () => {
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ value }) => value),
).toEqual([2, 4]);
const trace = await calculation.trace;
const chosen = trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.find(
(candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3,
);
expect(
chosen?.comparison?.before.objectives
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ value }) => value),
).toEqual([0, 2]);
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual')
throw new Error('Expected a counterfactual');
@@ -0,0 +1,172 @@
import { vi } from '../../testing/vitest-compat';
import {
createPlanningCalculation,
type LeavePlacement,
type PlanningCalculationContext,
} from './leave-planner.service';
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
const plan = {
kind: 'Plan',
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
completedStages: [],
};
const state = { marker: 'state' };
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
class FakeWorker {
static all: FakeWorker[] = [];
static constructThrows = false;
static postThrows = false;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
terminate = vi.fn();
postMessage = vi.fn(() => {
if (this.throwOnPost) throw new Error('post failed');
});
private throwOnPost = FakeWorker.postThrows;
constructor() {
if (FakeWorker.constructThrows) throw new Error('no worker');
FakeWorker.all.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
interface Started {
worker?: FakeWorker;
succeeded: Promise<boolean>;
deliver: () => void;
late: () => void;
}
type Flags = { construct?: boolean; post?: boolean };
const arm = (flags: Flags) => {
FakeWorker.constructThrows = !!flags.construct;
FakeWorker.postThrows = !!flags.post;
};
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
plan: async (flags) => {
arm(flags);
const progress = vi.fn();
const calculation = createPlanningCalculation({ ...request, progress });
const worker = FakeWorker.all[0];
return {
worker,
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
deliver: () => {
worker?.emit({ type: 'result', outcome: plan });
worker?.emit({ type: 'artifacts', artifacts: {} });
},
late: () => {
worker?.emit({ type: 'progress', stage: 'late' });
expect(progress).not.toHaveBeenCalled();
},
};
},
counterfactual: async (flags) => {
const calculation = createPlanningCalculation(request);
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
await calculation.timings;
arm(flags);
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
plan.placements[0],
);
const succeeded = analysis.then(
(result) => result.kind !== 'CounterfactualStale',
() => false,
);
await flush();
const worker = FakeWorker.all[1];
return {
worker,
succeeded,
deliver: () =>
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
'explain-checkpoint': async (flags) => {
const calculation = createPlanningCalculation(request);
const first = FakeWorker.all[0];
first.emit({ type: 'checkpoint', plan, state });
arm(flags);
first.emit({ type: 'error', message: 'planner crashed' });
const worker = FakeWorker.all[1];
return {
worker,
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
deliver: () =>
worker?.emit({
type: 'explanation',
explanation: { kind: 'IncompleteExplanation' },
}),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
};
describe('worker job lifetime', () => {
beforeEach(() => {
FakeWorker.all = [];
arm({});
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', FakeWorker);
});
afterEach(() => {
arm({});
vi.unstubAllGlobals();
});
describe.each(Object.keys(jobs))('%s job', (name) => {
const start = jobs[name];
const released = (worker: FakeWorker) => {
expect(worker.terminate).toHaveBeenCalledOnce();
expect(worker.onmessage).toBeNull();
expect(worker.onerror).toBeNull();
};
it('releases the worker once on success and leaves no handler for late messages', async () => {
const job = await start({});
job.deliver();
expect(await job.succeeded).toBe(true);
released(job.worker!);
job.late();
job.deliver();
released(job.worker!);
});
it('fails without a worker when construction throws', async () => {
const job = await start({ construct: true });
expect(job.worker).toBeUndefined();
expect(await job.succeeded).toBe(false);
});
it('releases the worker once when postMessage throws', async () => {
const job = await start({ post: true });
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once on a runtime error', async () => {
const job = await start({});
job.worker!.onerror!();
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once when a newer calculation supersedes it', async () => {
const job = await start({});
createPlanningCalculation(request);
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
released(job.worker!);
});
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,60 @@
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningArtifacts } from './leave-planner.core';
// Message contract shared by the planning coordinator and its worker; types only.
export type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
export type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
export type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
+96 -83
View File
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Worker as NodeWorker } from 'node:worker_threads';
import { promisify } from 'node:util';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
createPlanningCalculation,
type PlanningCalculationContext,
@@ -31,8 +31,12 @@ describe('real planning worker cancellation', () => {
let holdArtifacts = false;
const heldArtifactMessages: (() => void)[] = [];
let simulateLongCalculation = false;
let holdForCancelAtStage: string | undefined;
let simulatedElapsed = 0;
const threads = new Set<NodeWorker>();
const sentTypes: string[] = [];
const receivedTypes: string[] = [];
let terminationCount = 0;
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
@@ -40,8 +44,9 @@ describe('real planning worker cancellation', () => {
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort, workerData } from 'node:worker_threads';
`import { parentPort, receiveMessageOnPort, workerData } from 'node:worker_threads';
let elapsed = 0;
const pause = new Int32Array(new SharedArrayBuffer(4));
const originalNow = performance.now.bind(performance);
if (workerData.simulateLongCalculation)
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
@@ -49,12 +54,28 @@ globalThis.self = { postMessage: (message) => {
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk')
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
parentPort.postMessage(message);
// The worker reads its queue only between event-loop turns, so a stage that is shorter than the
// host round trip finishes first. Hold the stage until the host's cancel arrives, then deliver it.
if (message.type === 'progress' && message.stage === workerData.holdForCancelAtStage) {
let next;
for (let waits = 0; !(next = receiveMessageOnPort(parentPort)); waits++) {
if (waits > 5000) throw new Error('The host never sent the cancel request.');
Atomics.wait(pause, 0, 0, 1);
}
globalThis.self.onmessage({ data: next.message });
}
} };
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
);
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
await promisify(execFile)(process.execPath, [
'build',
entry,
'--target=node',
'--outfile',
bundle,
]);
});
beforeEach(() => {
@@ -64,11 +85,17 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
localStorage.clear();
heartbeats = 0;
simulateLongCalculation = false;
holdForCancelAtStage = undefined;
simulatedElapsed = 0;
sentTypes.length = 0;
receivedTypes.length = 0;
terminationCount = 0;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
private thread = new NodeWorker(bundle, {
workerData: { simulateLongCalculation, holdForCancelAtStage },
});
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => {
@@ -76,15 +103,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
simulatedElapsed = data.elapsed;
return;
}
if (
holdArtifacts &&
[
'explanation',
'artifacts-start',
'trace-status-index',
'artifacts-complete',
].includes(data.type)
) {
receivedTypes.push(data.type);
if (holdArtifacts && ['explanation', 'artifacts'].includes(data.type)) {
heldArtifactMessages.push(() => this.onmessage?.({ data } as MessageEvent));
return;
}
@@ -94,10 +114,12 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
postMessage(message: { type: string }) {
sentTypes.push(message.type);
this.thread.postMessage(message);
}
terminate() {
terminationCount++;
void this.thread.terminate();
}
}
@@ -112,6 +134,59 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('delivers the outcome and explanation and releases the worker without acknowledgements', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-02', balanceId: 'cp', type: 'cp' }],
lastCompletedStage: 'dates',
});
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
placements: [{ placement: { date: '2024-01-02', balanceId: 'cp' } }],
});
expect(await calculation.explanationError).toBeUndefined();
expect(await calculation.timings).toEqual({ planningWorkMs: expect.any(Number) });
expect(sentTypes).toEqual(['plan']);
expect(receivedTypes.slice(-3)).toEqual(['result', 'explanation', 'artifacts']);
expect(new Set(receivedTypes)).toEqual(
new Set(['heartbeat', 'progress', 'checkpoint', 'result', 'explanation', 'artifacts']),
);
expect(terminationCount).toBe(1);
});
it('ignores a replaced real worker’s late explanation without losing either outcome', async () => {
const request = {
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
rules: [presets[0]],
};
holdArtifacts = true;
const first = createPlanningCalculation(request);
const original = await first.outcome;
expect(original.kind).toBe('Plan');
await vi.waitFor(() => expect(heldArtifactMessages).toHaveLength(2));
holdArtifacts = false;
const latest = createPlanningCalculation({ ...request, rules: [] });
const outcome = await latest.outcome;
expect(outcome).toMatchObject({ kind: 'Plan', placements: [] });
const explanation = await latest.explanation;
expect(explanation).toMatchObject({ kind: 'PlanExplanation', placements: [] });
await latest.timings;
for (const deliver of heldArtifactMessages) deliver();
expect(await first.outcome).toEqual(original);
expect(await first.explanation).toBeUndefined();
expect(await latest.outcome).toEqual(outcome);
expect(await latest.explanation).toEqual(explanation);
expect(await latest.explanationError).toBeUndefined();
expect(terminationCount).toBe(2);
});
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,
@@ -152,14 +227,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
'balance-expiry',
'placement-date',
]);
const trace = await calculation.trace;
expect(trace?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
'optimization:personal-loss',
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([
@@ -170,7 +237,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
]);
});
it('keeps the submitted work-sequence threshold in the real worker, trace and counterfactuals', async () => {
it('keeps the submitted work-sequence threshold in the real worker and date comparisons', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.shortSequenceDeficit,
);
@@ -206,15 +273,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
({ id }) => id === 'optimization:deficit',
)?.value,
).toBe(4);
const trace = await calculation.trace;
const chosen = trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3);
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
expect(
chosen?.comparison?.before.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(2);
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
@@ -390,7 +448,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
for (const deliver of heldArtifactMessages.splice(0)) deliver();
}
const explanation = await calculation.explanation;
const trace = await calculation.trace;
await fixture.whenStable();
fixture.detectChanges();
expect(connectedHolidays()).toBe('1');
@@ -416,11 +473,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
},
],
});
expect(trace?.initialState).toMatchObject({
calculationDate: '2024-05-01',
declaredDays: originalDeclarations,
});
expect(trace?.status).toBe(cancelled ? 'incomplete' : 'ready');
const core = await import('./leave-planner.core');
const direct = core.calculatePlanningInternal(calculation.snapshot);
if (direct.kind !== 'Plan')
@@ -450,24 +502,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(comparison?.results.find(({ date }) => date === '2024-05-03')?.status).toBe(
'eligible',
);
fixture.componentInstance['showFinalPlan']();
if (!trace) throw new Error('Expected the original trace.');
const phaseIndex = trace.phases.findIndex(({ decisions }) =>
decisions.some(({ deltas }) =>
deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')),
),
);
expect(phaseIndex).toBeGreaterThanOrEqual(0);
fixture.componentInstance['selectTracePhase'](phaseIndex);
const decisionIndex = trace.phases[phaseIndex].decisions.findIndex(({ deltas }) =>
deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')),
);
fixture.componentInstance['selectTraceDecision'](decisionIndex);
fixture.componentInstance['showTraceMoment']('after');
expect(fixture.componentInstance['selectedTraceTransition']()?.after).toMatchObject({
calculationDate: '2024-05-01',
declaredDays: originalDeclarations,
});
root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
await fixture.whenStable();
expect(connectedHolidays()).toBe('1');
@@ -541,9 +575,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
kind: 'PlanExplanation',
declaredDays: submitted.declaredDays,
});
expect((await calculation.trace)?.initialState.declaredDays).toEqual(
submitted.declaredDays,
);
const original = structuredClone(outcome);
const comparison = await calculation.analyzeCounterfactual(plan!.placements[0]);
for (const { date } of submitted.declaredDays)
@@ -577,10 +608,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(outcome.incumbent).toBeUndefined();
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
expect(stages).not.toContain('obligations');
expect(await calculation.trace).toMatchObject({
status: 'incomplete',
initialState: { declaredDays: [{ date: '2024-01-02', type: 'other' }] },
});
expect(await calculation.explanationError).toBeUndefined();
expect(heartbeats).toBeGreaterThan(0);
});
@@ -611,10 +638,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeUndefined();
expect((await calculation.trace)?.status).toBe('incomplete');
});
it('stops an active obligation search and retains a jointly valid incumbent and its history', async () => {
it('stops an active obligation search and retains a jointly valid incumbent', async () => {
let cancelledAt = 0;
afterCheckpoint = () => {
afterCheckpoint = undefined;
@@ -663,15 +689,11 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
);
expect(explanation.incomplete?.reason.code).toBe('user-cancelled');
}
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(
trace?.phases.find((phase) => phase.name === 'obligations')?.decisions.length,
).toBeGreaterThan(0);
expect(trace?.phases.find((phase) => phase.name === 'dates')?.status).toBe('not-run');
});
it('preserves balances and provenance after a valid checkpoint and drains every produced trace packet', async () => {
it('preserves balances and provenance after a valid checkpoint', async () => {
const cancelStage = 'optimization:default-non-working-day-gain';
holdForCancelAtStage = cancelStage;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
@@ -691,7 +713,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
...presets.slice(1),
],
progress: (stage) => {
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
if (stage === cancelStage) calculation.cancel();
},
});
const outcome = await calculation.outcome;
@@ -699,7 +721,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
expect(outcome.completedStages).not.toContain(cancelStage);
const incumbent = outcome.incumbent!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
@@ -716,14 +738,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
incumbent.placements,
);
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(trace?.error?.code).toBe('user-cancelled');
expect(
trace?.phases.find(
(phase) => phase.name === 'optimization:default-non-working-day-gain',
)?.status,
).toBe('interrupted');
const frozenOutcome = structuredClone(outcome);
calculation.cancel();
expect(await calculation.outcome).toEqual(frozenOutcome);
@@ -750,7 +764,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const calculation = createPlanningCalculation(submitted);
expect(await calculation.outcome).toEqual(expected);
expect(simulatedElapsed).toBe(20_000);
expect((await calculation.trace)?.status).toBe('ready');
expect(await calculation.explanationError).toBeUndefined();
});
});
+4 -325
View File
@@ -1,309 +1,29 @@
/// <reference lib="webworker" />
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceUpdate,
PlanningSnapshot,
PlanningTraceState,
TraceCandidateStatus,
} from './leave-planner.service';
import type { PlanningOutcome } from './leave-planner.service';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
import {
analyzeCounterfactualInternal,
calculatePlanningAsyncInternal,
explainIncompleteCheckpoint,
historicalCounterfactualReasons,
type CounterfactualBaselineOptions,
type PlanningArtifacts,
} from './leave-planner.core';
type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'trace-ack'; count: number }
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningTraceState;
options?: CounterfactualBaselineOptions;
}
| {
type: 'historical-eligibility';
request: PlanningSnapshot;
placement: LeavePlacement;
state: PlanningTraceState;
options?: CounterfactualBaselineOptions;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningTraceState;
interruption: PlanningReason;
};
type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningTraceState;
}
| {
type: 'artifacts-start';
artifacts: Omit<
PlanningArtifacts,
'trace' | 'explanation' | 'explanationError' | 'finalState'
>;
trace?: Omit<PlanningTrace, 'phases'>;
}
| {
type: 'trace-update';
totalMs: number;
update: PlanningTraceUpdate;
acknowledge?: true;
}
| {
type: 'trace-status-index';
statuses: ReadonlyMap<string, TraceCandidateStatus>;
nonWorkingDates: readonly string[];
}
| { type: 'artifacts-complete'; traceTransferMs: number }
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| {
type: 'historical-eligibility-result';
entries: ReturnType<typeof historicalCounterfactualReasons>;
}
| { type: 'error'; code?: string; message: string };
const worker = self as DedicatedWorkerGlobalScope;
const TRACE_OPERATIONS_PER_MESSAGE = 64;
const MAX_TRACE_PACKETS_IN_FLIGHT = 16;
interface StagedTraceUpdate {
totalMs: number;
update?: PlanningTraceUpdate;
nextOperation: number;
}
interface TraceDelivery {
updates: StagedTraceUpdate[];
nextUpdate: number;
inFlight: number;
traceTransferMs: number;
artifacts?: PlanningArtifacts;
calculationComplete: boolean;
}
let activeTraceDelivery: TraceDelivery | undefined;
let activeCalculation: { cancelled: boolean } | undefined;
function traceStatusIndex(trace: PlanningTrace): {
statuses: Map<string, TraceCandidateStatus>;
nonWorkingDates: string[];
} {
const priority: Record<TraceCandidateStatus, number> = {
ineligible: 0,
'not-examined': 1,
pruned: 2,
'evaluated-rejected': 3,
chosen: 4,
};
const statuses = new Map<string, TraceCandidateStatus>();
const nonWorkingDates = new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []);
for (const phase of trace.phases) {
for (const decision of phase.decisions) {
for (const candidate of decision.candidates) {
const dates = candidate.dates ?? (candidate.date ? [candidate.date] : []);
for (const date of dates) {
const previous = statuses.get(date);
if (!previous || priority[candidate.status] > priority[previous])
statuses.set(date, candidate.status);
}
}
}
}
return { statuses, nonWorkingDates: [...nonWorkingDates] };
}
function sendArtifacts(artifacts: PlanningArtifacts, deferredTraceTransferMs = 0): void {
const trace = artifacts.trace;
let traceMaterializationMs = artifacts.timings?.traceMaterializationMs ?? 0;
let traceTransferMs = (artifacts.timings?.traceTransferMs ?? 0) + deferredTraceTransferMs;
if (trace) {
const materializationStarted = performance.now();
const statusIndex = traceStatusIndex(trace);
traceMaterializationMs += Math.max(0, performance.now() - materializationStarted);
const transferStarted = performance.now();
worker.postMessage({
type: 'trace-status-index',
statuses: statusIndex.statuses,
nonWorkingDates: statusIndex.nonWorkingDates,
} satisfies WorkerResponse);
traceTransferMs += Math.max(0, performance.now() - transferStarted);
}
const rest: Omit<
PlanningArtifacts,
'trace' | 'explanation' | 'explanationError' | 'finalState'
> = artifacts.timings
? {
timings: {
...artifacts.timings,
traceMaterializationMs,
traceTransferMs,
},
}
: {};
const transferStarted = performance.now();
worker.postMessage({
type: 'artifacts-start',
artifacts: rest,
trace: trace
? {
status: trace.status,
initialState: trace.initialState,
completedStages: trace.completedStages,
nonWorkingDates: trace.nonWorkingDates,
error: trace.error,
}
: undefined,
} satisfies WorkerResponse);
traceTransferMs += Math.max(0, performance.now() - transferStarted);
// Capture updates already carried every candidate in bounded operations.
// The final packet closes the trace without cloning it a second time.
worker.postMessage({ type: 'artifacts-complete', traceTransferMs } satisfies WorkerResponse);
}
function finishTraceDelivery(delivery: TraceDelivery): void {
if (
!delivery.calculationComplete ||
delivery.nextUpdate < delivery.updates.length ||
delivery.inFlight > 0 ||
!delivery.artifacts
)
return;
if (activeTraceDelivery === delivery) activeTraceDelivery = undefined;
try {
sendArtifacts(delivery.artifacts, delivery.traceTransferMs);
} catch (error) {
worker.postMessage({
type: 'error',
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
}
delivery.updates = [];
}
function pumpTraceUpdates(delivery: TraceDelivery): void {
while (
delivery.inFlight < MAX_TRACE_PACKETS_IN_FLIGHT &&
delivery.nextUpdate < delivery.updates.length
) {
const staged = delivery.updates[delivery.nextUpdate];
const update = staged.update;
if (!update) {
delivery.nextUpdate++;
continue;
}
const start = staged.nextOperation;
const end = Math.min(start + TRACE_OPERATIONS_PER_MESSAGE, update.operations.length);
const firstPacket = start === 0;
const packet: WorkerResponse = {
type: 'trace-update',
totalMs: staged.totalMs,
acknowledge: true,
update: {
initialState: firstPacket ? update.initialState : undefined,
nonWorkingDates: firstPacket ? update.nonWorkingDates : undefined,
operations: update.operations.slice(start, end),
},
};
const transferStarted = performance.now();
try {
worker.postMessage(packet satisfies WorkerResponse);
} catch (error) {
if (activeTraceDelivery === delivery) activeTraceDelivery = undefined;
delivery.updates = [];
worker.postMessage({
type: 'error',
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
return;
}
delivery.traceTransferMs += Math.max(0, performance.now() - transferStarted);
delivery.inFlight++;
staged.nextOperation = end;
if (update.operations.length === 0 || end >= update.operations.length) {
// Drop the full drain array as soon as its last bounded packet is posted.
staged.update = undefined;
delivery.nextUpdate++;
}
}
if (delivery.nextUpdate === delivery.updates.length) {
// All queued operation arrays are released; only in-flight structured clones remain.
delivery.updates = [];
delivery.nextUpdate = 0;
}
finishTraceDelivery(delivery);
}
worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
try {
if (event.data?.type === 'cancel') {
if (activeCalculation) activeCalculation.cancelled = true;
return;
}
if (event.data?.type === 'trace-ack') {
const delivery = activeTraceDelivery;
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
delivery.inFlight = Math.max(0, delivery.inFlight - event.data.count);
pumpTraceUpdates(delivery);
return;
}
if (event.data?.type === 'plan') {
const calculation = { cancelled: false };
activeCalculation = calculation;
let artifacts: PlanningArtifacts = {};
let outcomePosted = false;
let outcome: PlanningOutcome;
const traceDelivery: TraceDelivery = {
updates: [],
nextUpdate: 0,
inFlight: 0,
traceTransferMs: 0,
calculationComplete: false,
};
activeTraceDelivery = traceDelivery;
// The cooperative driver yields during preparation and search, even within one phase.
const heartbeat = () =>
worker.postMessage({ type: 'heartbeat' } satisfies WorkerResponse);
@@ -318,13 +38,6 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
type: 'progress',
stage,
} satisfies WorkerResponse),
traceProgress: (totalMs: number, update: PlanningTraceUpdate) => {
worker.postMessage({
type: 'trace-overhead',
totalMs,
} satisfies WorkerResponse);
traceDelivery.updates.push({ totalMs, update, nextOperation: 0 });
},
},
(plan, state) =>
worker.postMessage({
@@ -350,8 +63,6 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
() => calculation.cancelled,
);
} catch (error) {
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
traceDelivery.updates = [];
worker.postMessage({
type: 'error',
code: 'planner-error',
@@ -364,9 +75,7 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
}
if (!outcomePosted)
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
traceDelivery.artifacts = artifacts;
traceDelivery.calculationComplete = true;
pumpTraceUpdates(traceDelivery);
worker.postMessage({ type: 'artifacts', artifacts } satisfies WorkerResponse);
return;
}
if (event.data?.type === 'explain-checkpoint') {
@@ -379,44 +88,14 @@ worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
worker.postMessage({ type: 'explanation', ...result } satisfies WorkerResponse);
return;
}
if (event.data?.type === 'historical-eligibility') {
worker.postMessage({
type: 'historical-eligibility-result',
entries: historicalCounterfactualReasons(
event.data.request,
event.data.state,
event.data.placement,
event.data.options,
),
} satisfies WorkerResponse);
return;
}
if (event.data?.type !== 'counterfactual') return;
try {
let analysis = analyzeCounterfactualInternal(
const analysis = analyzeCounterfactualInternal(
event.data.request,
event.data.plan,
event.data.placement,
event.data.state,
event.data.options,
);
if (event.data.options && event.data.state) {
const historicalReasons = new Map(
historicalCounterfactualReasons(
event.data.request,
event.data.state,
event.data.placement,
event.data.options,
).map(({ date, reason }) => [date, reason]),
);
analysis = {
...analysis,
results: analysis.results.map((result) => {
const historicalReason = historicalReasons.get(result.date);
return historicalReason ? { ...result, historicalReason } : result;
}),
};
}
worker.postMessage({
type: 'counterfactual-result',
analysis,
+140 -228
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import {
calculatePlanningInternal,
type PlanningArtifacts,
@@ -42,7 +42,7 @@ describe('integrated optimization and earned leave', () => {
},
);
it('runs the complete configuration once after the sole obligation state and records exact reused evaluations', () => {
it('uses the complete configuration after the sole obligation state', () => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
composedRequest,
@@ -54,27 +54,6 @@ describe('integrated optimization and earned leave', () => {
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
const trace = artifacts.trace;
expect(trace?.status).toBe('ready');
expect(
trace?.phases
.find(({ name }) => name === 'obligations')
?.decisions.flatMap(({ candidates }) => candidates)
.some(({ comparison }) => comparison),
).toBe(false);
expect(
trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(({ reason }) => reason?.code === 'unchanged-rule-state'),
).toBe(true);
expect(
trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(
({ status, reason }) =>
status === 'not-examined' && reason?.code === 'unchanged-generic-state',
),
).toBe(true);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation.');
expect(
@@ -92,34 +71,87 @@ describe('integrated optimization and earned leave', () => {
]);
});
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
it.each([
{
name: 'holiday value first',
rules: [presets[1], presets[0], ...presets.slice(2)],
dates: ['2024-05-06', '2024-05-07', '2024-05-10'],
values: [0, 5, 0, 6, 5, 0, 0],
decisive: 'optimization:default-holiday-week-value',
},
{
name: 'non-working-day gain first',
rules: [presets[2], presets[0], presets[1], ...presets.slice(3)],
dates: ['2024-05-03', '2024-05-10', '2024-05-17'],
values: [0, 9, 0, 0, 4, 4, 0],
decisive: 'optimization:default-non-working-day-gain',
},
{
name: 'maximum holiday value after loss prevention',
rules: [
presets[0],
{
...presets[1],
configuration: {
...presets[1].configuration,
aggregation: 'maximum' as const,
},
},
...presets.slice(2),
],
dates: ['2024-05-06', '2024-05-07', '2024-05-10'],
values: [0, 0, 5, 6, 5, 0, 0],
decisive: 'optimization:default-unplaced-at-risk',
},
])('preserves complete vectors and placement explanations with $name', (fixture) => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{
...composedRequest,
balances: ['first-cp', 'second-cp', 'rtt'].map((id) => ({
id,
type: id === 'rtt' ? 'rtt' : 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2024-05-17',
})),
rules: [
presets[0],
{
id: 'typed-window',
type: 'time_window',
startDate: '2024-05-07',
endDate: '2024-05-07',
placeDays: 1,
placeType: 'rtt',
},
...presets.slice(1),
],
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-05-17' }],
rules: fixture.rules,
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a complete plan and explanation.');
expect(outcome.placements.map(({ date }) => date)).toEqual(fixture.dates);
for (const placement of artifacts.explanation.placements) {
expect(placement.objectiveVector.objectives.map(({ value }) => value)).toEqual([
...fixture.values,
'2024-05-17|2024-05-17|2024-05-17',
fixture.dates.join('|'),
]);
expect(placement.firstDecisiveObjective).toBe(fixture.decisive);
expect(placement.ruleIds).toEqual([fixture.decisive.slice('optimization:'.length)]);
}
});
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
const outcome = calculatePlanningInternal({
...composedRequest,
balances: ['first-cp', 'second-cp', 'rtt'].map((id) => ({
id,
type: id === 'rtt' ? 'rtt' : 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2024-05-17',
})),
rules: [
presets[0],
{
id: 'typed-window',
type: 'time_window',
startDate: '2024-05-07',
endDate: '2024-05-07',
placeDays: 1,
placeType: 'rtt',
},
...presets.slice(1),
],
});
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [
@@ -130,100 +162,56 @@ describe('integrated optimization and earned leave', () => {
preferences: [{ ruleId: 'typed-window', satisfied: 1 }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
const candidates = artifacts.trace?.phases
.find(({ name }) => name === `optimization:${presets[0].id}`)
?.decisions.flatMap(({ candidates }) => candidates);
const skipped = candidates?.filter(
({ reason }) => reason?.code === 'equivalent-date-funding',
);
expect(skipped?.length).toBeGreaterThan(0);
expect(
skipped?.every(({ status, comparison }) => status === 'not-examined' && !comparison),
).toBe(true);
expect(
skipped?.some(
({ date, placement }) => date === '2024-05-07' && placement?.balanceId === 'rtt',
),
).toBe(false);
expect(
candidates?.some(
({ date, placement, comparison }) =>
date === '2024-05-07' && placement?.balanceId === 'rtt' && comparison,
),
).toBe(true);
});
it.each(['maximum', 'unsafe-total'] as const)(
'keeps distinct funding evaluations when equivalence cannot be proved (%s)',
(control) => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{
calculationDate: '2024-01-01',
horizon: { start: '2024-01-02', end: '2024-01-03' },
balances: [
{
id: 'first',
type: 'cp',
quantity: control === 'maximum' ? 1 : Number.MAX_SAFE_INTEGER,
expiryDate: '2024-01-02',
},
{
id: 'second',
type: 'rtt',
quantity: control === 'maximum' ? 2 : 1,
expiryDate: '2024-01-02',
},
],
rules: [
presets[0],
...(control === 'maximum'
? [
{
id: 'largest-balance',
type: 'optimization',
configuration: {
...OPTIMIZATION_PRESETS.unplacedAtRisk,
aggregation: 'maximum',
},
const outcome = calculatePlanningInternal({
calculationDate: '2024-01-01',
horizon: { start: '2024-01-02', end: '2024-01-03' },
balances: [
{
id: 'first',
type: 'cp',
quantity: control === 'maximum' ? 1 : Number.MAX_SAFE_INTEGER,
expiryDate: '2024-01-02',
},
{
id: 'second',
type: 'rtt',
quantity: control === 'maximum' ? 2 : 1,
expiryDate: '2024-01-02',
},
],
rules: [
presets[0],
...(control === 'maximum'
? [
{
id: 'largest-balance',
type: 'optimization',
configuration: {
...OPTIMIZATION_PRESETS.unplacedAtRisk,
aggregation: 'maximum',
},
]
: []),
],
},
undefined,
(value) => (artifacts = value),
);
},
]
: []),
],
});
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ balanceId: control === 'maximum' ? 'second' : 'first' }],
});
const candidates = artifacts.trace?.phases
.find(({ name }) => name === `optimization:${presets[0].id}`)
?.decisions.flatMap(({ candidates }) => candidates);
expect(
candidates?.some(({ reason }) => reason?.code === 'equivalent-date-funding'),
).toBe(false);
for (const balanceId of ['first', 'second'])
expect(
candidates?.some(
({ placement, comparison }) =>
placement?.balanceId === balanceId && comparison,
),
).toBe(true);
},
);
it('finishes a renamed first quantity rule at zero and still evaluates later priorities and final ties', () => {
const loss = { ...presets[0], id: 'personal-quantity', name: 'Personal quantity' };
const run = (rules: PlanningRequest['rules']) => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{ ...composedRequest, rules },
undefined,
(value) => (artifacts = value),
);
return { outcome, trace: artifacts.trace };
const outcome = calculatePlanningInternal({ ...composedRequest, rules });
return { outcome };
};
const window = {
id: 'already-satisfied',
@@ -239,40 +227,8 @@ describe('integrated optimization and earned leave', () => {
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
const stopped = first.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.filter(({ reason }) => reason?.code === 'quantity-prefix-lower-bound');
expect(stopped?.length).toBeGreaterThan(0);
expect(
stopped?.every(
({ status, reason }) =>
status === 'not-examined' && reason?.ruleIds?.includes(loss.id),
),
).toBe(true);
expect(
first.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(
({ status, reason }) =>
status === 'not-examined' &&
reason?.code === 'satisfied-window-prefix-bound' &&
reason.ruleIds?.includes(window.id),
),
).toBe(true);
const expiry = first.trace?.phases.find(({ name }) => name === 'expiry');
expect(expiry?.completed).toBe(true);
expect(
expiry?.decisions.some(({ candidates }) =>
candidates.some(({ comparison }) => comparison),
),
).toBe(true);
const reordered = run([presets[1], loss, ...presets.slice(2)]);
expect(reordered.outcome.kind).toBe('Plan');
expect(
reordered.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(({ reason }) => reason?.code === 'quantity-prefix-lower-bound'),
).toBe(false);
});
it.each(['sum', 'count', 'maximum'] as const)(
@@ -283,12 +239,10 @@ describe('integrated optimization and earned leave', () => {
id: 'personal-holiday',
configuration: { ...OPTIMIZATION_PRESETS.holidayWeekValue, aggregation },
};
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{ ...composedRequest, rules: [presets[0], holiday, ...presets.slice(2)] },
undefined,
(value) => (artifacts = value),
);
const outcome = calculatePlanningInternal({
...composedRequest,
rules: [presets[0], holiday, ...presets.slice(2)],
});
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [
@@ -298,85 +252,43 @@ describe('integrated optimization and earned leave', () => {
],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
expect(
artifacts.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(
({ status, reason }) =>
status === 'not-examined' &&
reason?.code === 'satisfied-holiday-prefix-bound' &&
reason.ruleIds?.includes(holiday.id),
),
).toBe(true);
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
expect(final?.completed).toBe(true);
expect(
final?.decisions.some(({ candidates }) =>
candidates.some(({ comparison }) => comparison),
),
).toBe(true);
},
);
it('commits a strict holiday bundle before deferring old generic alternatives and preserves higher rules', () => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{
calculationDate: '2026-11-01',
horizon: { start: '2026-11-02', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2026-11-30' }],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'mandatory',
type: 'time_window',
force: 'obligation',
startDate: '2026-11-02',
endDate: '2026-11-02',
placeDays: 1,
placeType: 'cp',
},
presets[0],
{
id: 'earlier-window',
type: 'time_window',
startDate: '2026-11-02',
endDate: '2026-11-02',
placeDays: 1,
placeType: 'cp',
},
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
const outcome = calculatePlanningInternal({
calculationDate: '2026-11-01',
horizon: { start: '2026-11-02', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2026-11-30' }],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'mandatory',
type: 'time_window',
force: 'obligation',
startDate: '2026-11-02',
endDate: '2026-11-02',
placeDays: 1,
placeType: 'cp',
},
presets[0],
{
id: 'earlier-window',
type: 'time_window',
startDate: '2026-11-02',
endDate: '2026-11-02',
placeDays: 1,
placeType: 'cp',
},
...presets.slice(1),
],
});
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2026-11-02' }, { date: '2026-11-12' }, { date: '2026-11-13' }],
preferences: [{ ruleId: 'earlier-window', satisfied: 1 }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 3 },
});
const holiday = artifacts.trace?.phases.find(
({ name }) => name === `optimization:${presets[1].id}`,
);
const decisions = holiday?.decisions.flatMap(({ candidates }) => candidates);
expect(
decisions?.some(
({ status, reason }) =>
status === 'not-examined' && reason?.code === 'strict-prefix-bundle-progress',
),
).toBe(true);
expect(decisions?.some(({ status, comparison }) => status === 'chosen' && comparison)).toBe(
true,
);
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
expect(final?.completed).toBe(true);
expect(
final?.decisions.some(({ candidates }) =>
candidates.some(({ comparison }) => comparison),
),
).toBe(true);
});
it.each([false, true])(
+1 -32
View File
@@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import { calculatePlanningInternal } from './leave-planner.core';
import { createPlanningCalculation } from './leave-planner.service';
import type { PlanningRequest } from './leave-planner.service';
@@ -254,17 +254,6 @@ describe('explicit active planning objectives', () => {
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
const trace = await calculation.trace;
expect(
trace?.phases
.flatMap(({ decisions }) => decisions)
.flatMap(({ candidates }) => candidates)
.some(
({ sourceDate, dates }) =>
sourceDate === '2024-01-02' &&
dates?.join(',') === '2024-01-02,2024-01-03,2024-01-04',
),
).toBe(true);
});
it('satisfies a lower IF_THEN pattern by reassigning its complete typed bundle', async () => {
@@ -376,17 +365,6 @@ describe('explicit active planning objectives', () => {
expect(
outcome.placements.filter(({ ruleIds }) => ruleIds.includes(rule.id)),
).toHaveLength(2);
const trace = await calculation.trace;
expect(
trace?.phases
.flatMap(({ decisions }) => decisions)
.flatMap(({ candidates }) => candidates)
.some(
({ status, reason }) =>
status === 'not-examined' &&
reason?.code === 'window-obligation-preview-sampling',
),
).toBe(true);
const reversed = await createPlanningCalculation({
...request,
rules: [...obligations, personal, loss],
@@ -492,15 +470,6 @@ describe('explicit active planning objectives', () => {
expect(explanation.placements[0].objectiveVector.objectives[1].label).toBe(
'Prevent losses',
);
const trace = await calculation.trace;
expect(trace?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
'optimization:loss',
'preference:personal',
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual(ids);
+198
View File
@@ -0,0 +1,198 @@
import fc from 'fast-check';
import { describe, expect, it } from 'bun:test';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
import {
addDays,
compareDate,
derivePlacements,
notePlacementAdded,
notePlacementRemoved,
observationsFromScratch,
optimizationObservations,
periodData,
placementTieKey,
setObservationShadowMode,
utcDate,
type ObservationCalendar,
type ObservedPlacement,
type ObservedPlan,
} from './plan-observations';
function calendar(start: string, length: number, declared: readonly string[]): ObservationCalendar {
const end = addDays(start, length);
const first = addDays(start, -400);
const last = addDays(end, 400);
const holidays = new Set<string>();
for (let year = Number(first.slice(0, 4)); year <= Number(last.slice(0, 4)); year++)
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
const isWorking = (date: string) =>
![0, 6].includes(utcDate(date).getUTCDay()) && !holidays.has(date);
const calendarDays: string[] = [];
const calendarIndex = new Map<string, number>();
const workingCalendar = new Set<string>();
const workingDays: string[] = [];
const workingPrefix = [0];
for (let date = first; compareDate(date, last) < 0; date = addDays(date, 1)) {
calendarIndex.set(date, calendarDays.length);
calendarDays.push(date);
const working = isWorking(date);
if (working) {
workingCalendar.add(date);
if (date >= start && date < end) workingDays.push(date);
}
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
}
const weeks = new Map<string, { dates: string[]; value: number }>();
for (const holiday of holidays) {
const weekday = utcDate(holiday).getUTCDay();
if (holiday < start || holiday >= end || weekday < 1 || weekday > 5) continue;
const monday = addDays(holiday, 1 - weekday);
const dates = [0, 1, 2, 3, 4].map((i) => addDays(monday, i)).filter(isWorking);
weeks.set(monday, { dates, value: 1 + (weekday % 3) });
}
return {
horizon: { start, end },
declaredDates: new Set(declared),
holidayWeeks: [...weeks.values()],
calendarDays,
calendarIndex,
workingCalendar,
workingPrefix,
workingDays,
check: () => {},
};
}
type Op = { kind: 'add' | 'remove' | 'retype'; pick: number };
const scenario = fc.record({
// Horizons around year boundaries, Easter Monday, 1 May and Ascension 2024.
start: fc.constantFrom('2023-12-18', '2024-03-28', '2024-04-24', '2024-05-06', '2024-12-23'),
length: fc.integer({ min: 1, max: 45 }),
declared: fc.array(fc.integer({ min: -3, max: 48 }), { maxLength: 8 }),
ops: fc.array(
fc.record({
kind: fc.constantFrom<Op['kind']>('add', 'add', 'remove', 'retype'),
pick: fc.nat(1000),
}),
{ maxLength: 40 },
),
});
function observe(plan: ObservedPlan, context: ObservationCalendar) {
const observations = optimizationObservations(plan, context);
return {
tieKey: placementTieKey(plan, context),
periods: periodData(plan, context),
publicHolidayWeeks: observations.publicHolidayWeeks,
timeOffPeriods: observations.timeOffPeriods,
workSequences: observations.workSequences,
};
}
function expectScratch(plan: ObservedPlan, context: ObservationCalendar) {
const scratch = observationsFromScratch(plan, context);
expect(observe(plan, context)).toEqual({
tieKey: scratch.tieKey,
periods: scratch.periods,
publicHolidayWeeks: scratch.publicHolidayWeeks,
timeOffPeriods: scratch.timeOffPeriods,
workSequences: scratch.workSequences,
});
}
const placement = (date: string, balanceId: string, supporting = false): ObservedPlacement => ({
date,
balanceId,
supporting,
});
describe('incremental plan observations', () => {
it('match the from-scratch computation after every command', () => {
fc.assert(
fc.property(scenario, ({ start, length, declared, ops }) => {
const context = calendar(
start,
length,
declared.map((offset) => addDays(start, offset)),
);
let plan: ObservedPlan = { balances: [], placements: new Map() };
expectScratch(plan, context);
ops.forEach(({ kind, pick }, step) => {
const parent = plan.placements as Map<string, ObservedPlacement>;
// Every fourth add may hit a weekend, holiday, declared date or a date up to three
// days outside the horizon, as snapshots can.
const free = (
pick % 4
? context.workingDays.filter((date) => !context.declaredDates.has(date))
: context.calendarDays.filter(
(date) =>
date >= addDays(context.horizon.start, -3) &&
date < addDays(context.horizon.end, 3),
)
).filter((date) => !parent.has(date));
const used = [...parent.keys()];
const child = new Map(parent);
if (kind === 'add' && free.length) {
const date = free[pick % free.length];
notePlacementAdded(parent, child, date);
child.set(date, placement(date, `b${step}`));
} else if (kind === 'remove' && used.length) {
const date = used[pick % used.length];
notePlacementRemoved(parent, child, date);
child.delete(date);
} else if (kind === 'retype' && used.length) {
const date = used[pick % used.length];
derivePlacements(parent, child);
child.set(date, placement(date, `r${step}`, true));
} else return;
plan = { balances: [], placements: child };
expectScratch(plan, context);
});
}),
{ numRuns: 300 },
);
});
it('resolves a chain of unobserved derivations', () => {
const context = calendar('2024-03-28', 20, ['2024-04-04']);
const root = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
expectScratch({ balances: [], placements: root }, context);
let parent = root;
for (const date of ['2024-04-03', '2024-04-05', '2024-04-08']) {
const child = new Map(parent);
notePlacementAdded(parent, child, date);
child.set(date, placement(date, 'b'));
parent = child;
}
const child = new Map(parent);
notePlacementRemoved(parent, child, '2024-04-03');
child.delete('2024-04-03');
expectScratch({ balances: [], placements: child }, context);
});
it('derives a child from an observed parent', () => {
const context = calendar('2024-03-28', 20, []);
const parent = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
expectScratch({ balances: [], placements: parent }, context);
const child = new Map(parent);
notePlacementAdded(parent, child, '2024-04-03');
child.set('2024-04-03', placement('2024-04-03', 'b'));
expectScratch({ balances: [], placements: child }, context);
});
});
describe('observation shadow mode', () => {
it('throws when a placements Map is mutated in place after being observed', () => {
const context = calendar('2024-03-28', 20, []);
const placements = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
const plan: ObservedPlan = { balances: [], placements };
setObservationShadowMode(true);
try {
optimizationObservations(plan, context);
placements.set('2024-04-10', placement('2024-04-10', 'b'));
expect(() => optimizationObservations(plan, context)).toThrow(/shadow mismatch/);
} finally {
setObservationShadowMode(false);
}
});
});
+693
View File
@@ -0,0 +1,693 @@
import { isWorkingPlanningDate } from './declared-non-working-days';
import type { TimeOffPeriod } from './leave-planner.core';
import {
evaluateOptimization,
type OptimizationConfiguration,
type OptimizationObservations,
} from './planning-objectives';
// Everything planning objectives observe of a plan: periods, work sequences, holiday weeks, balances, placements and tie keys.
export interface ObservedBalance {
id: string;
remaining: number;
expiryDate: string;
}
export interface ObservedPlacement {
date: string;
balanceId: string;
supporting: boolean;
}
export interface ObservedPlan<P extends ObservedPlacement = ObservedPlacement> {
balances: readonly ObservedBalance[];
placements: ReadonlyMap<string, P>;
}
export interface ObservationCalendar {
horizon: { start: string; end: string };
declaredDates: ReadonlySet<string>;
holidayWeeks: readonly { dates: readonly string[]; value: number }[];
calendarDays: readonly string[];
calendarIndex: ReadonlyMap<string, number>;
workingCalendar: ReadonlySet<string>;
workingPrefix: readonly number[];
workingDays: readonly string[];
check: () => void;
}
const DAY_MS = 86_400_000;
export function utcDate(key: string): Date {
return new Date(`${key}T00:00:00Z`);
}
export function addDays(key: string, days: number): string {
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
export function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export function dateRange(start: string, endExclusive: string): string[] {
const result: string[] = [];
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
result.push(date);
return result;
}
type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
interface CalendarPeriod {
start: string;
end: string;
duration: number;
nonWorkingDates: readonly string[];
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<number, CalendarPeriod>>();
interface Group {
anchors: readonly string[];
startIndex: number;
endIndex: number;
period?: CalendarPeriod;
timeOff?: { readonly nonWorkingDates: readonly string[] };
}
// Immutable view of one placements Map's key set, valid for one calendar.
interface Occupancy {
calendar: ObservationCalendar;
dates: readonly string[];
groups: readonly Group[];
tieKey?: string;
observed: CalendarOptimizationObservations;
}
interface Derivation {
parent: ReadonlyMap<string, unknown>;
op: 'same' | 'add' | 'remove';
date?: string;
}
const occupancies = new WeakMap<ReadonlyMap<string, unknown>, Occupancy>();
const derivations = new WeakMap<ReadonlyMap<string, unknown>, Derivation>();
let shadowMode =
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
'VAC_OBSERVATION_SHADOW'
] === '1';
export function setObservationShadowMode(enabled: boolean): void {
shadowMode = enabled;
}
function note(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
op: Derivation['op'],
date?: string,
): void {
// A stale occupancy of the child would describe its key set before this change.
occupancies.delete(child);
if (parent === child) return;
derivations.set(child, { parent, op, date });
}
/** `child` is a copy of `parent` with the same key set (placement objects may differ). */
export function derivePlacements(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
): void {
note(parent, child, 'same');
}
/** Call before `child.set(date, ...)` where `child` is a fresh copy of `parent` lacking `date`. */
export function notePlacementAdded(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
date: string,
): void {
note(parent, child, 'add', date);
}
/** Call before `child.delete(date)` where `child` is a fresh copy of `parent` holding `date`. */
export function notePlacementRemoved(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
date: string,
): void {
note(parent, child, 'remove', date);
}
function insertionPoint(sorted: readonly string[], date: string): number {
let low = 0;
let high = sorted.length;
while (low < high) {
const mid = (low + high) >> 1;
if (sorted[mid] < date) low = mid + 1;
else high = mid;
}
return low;
}
function occupancyFromScratch(
placements: ReadonlyMap<string, unknown>,
context: ObservationCalendar,
): Occupancy {
const dates = [...placements.keys()].sort();
return { calendar: context, dates, groups: groupsFromScratch(dates, context), observed: {} };
}
function deriveOccupancy(
parent: Occupancy,
{ op, date }: Derivation,
context: ObservationCalendar,
): Occupancy | undefined {
if (op === 'same') return parent;
const at = insertionPoint(parent.dates, date!);
const present = parent.dates[at] === date;
if (present === (op === 'add')) return undefined;
const dates = parent.dates.slice();
if (op === 'add') dates.splice(at, 0, date!);
else dates.splice(at, 1);
const touchesGroups =
date! >= context.horizon.start &&
date! < context.horizon.end &&
!context.declaredDates.has(date!);
// Group merging and splitting assume a working day; snapshots may hold any date.
if (touchesGroups && !context.workingCalendar.has(date!)) return undefined;
const groups = !touchesGroups
? parent.groups
: op === 'add'
? groupsWithDate(parent.groups, date!, context)
: groupsWithoutDate(parent.groups, date!, context);
if (!groups) return undefined;
const observed: CalendarOptimizationObservations = {};
if (parent.observed.publicHolidayWeeks)
observed.publicHolidayWeeks = derivedHolidayWeeks(
parent.observed.publicHolidayWeeks,
dates,
date!,
context,
);
return { calendar: context, dates, groups, observed };
}
const weeksByDate = new WeakMap<ObservationCalendar, Map<string, number[]>>();
function derivedHolidayWeeks(
weeks: NonNullable<CalendarOptimizationObservations['publicHolidayWeeks']>,
dates: readonly string[],
date: string,
context: ObservationCalendar,
) {
let index = weeksByDate.get(context);
if (!index) {
index = new Map();
context.holidayWeeks.forEach((week, i) =>
week.dates.forEach((d) => {
const list = index!.get(d);
if (list) list.push(i);
else index!.set(d, [i]);
}),
);
weeksByDate.set(context, index);
}
const affected = index.get(date);
if (!affected) return weeks;
const next = weeks.slice();
for (const i of affected) {
const week = context.holidayWeeks[i];
const complete = week.dates.every((d) => {
const at = insertionPoint(dates, d);
return dates[at] === d || context.declaredDates.has(d);
});
if (complete !== weeks[i].complete)
next[i] = Object.freeze({ value: week.value, complete });
}
return Object.freeze(next);
}
function groupsWithDate(
groups: readonly Group[],
date: string,
context: ObservationCalendar,
): readonly Group[] {
let low = 0;
let high = groups.length;
while (low < high) {
const mid = (low + high) >> 1;
if (groups[mid].anchors[0] < date) low = mid + 1;
else high = mid;
}
const previous = low > 0 ? groups[low - 1] : undefined;
const next = low < groups.length ? groups[low] : undefined;
const index = context.calendarIndex.get(date)!;
const joinsPrevious =
!!previous &&
context.workingPrefix[index] -
context.workingPrefix[
context.calendarIndex.get(previous.anchors[previous.anchors.length - 1])! + 1
] ===
0;
const joinsNext =
!!next &&
context.workingPrefix[context.calendarIndex.get(next.anchors[0])!] -
context.workingPrefix[index + 1] ===
0;
const merged: Group = {
anchors: [
...(joinsPrevious ? previous!.anchors : []),
date,
...(joinsNext ? next!.anchors : []),
],
startIndex: joinsPrevious ? previous!.startIndex : extendStart(index, context),
endIndex: joinsNext ? next!.endIndex : extendEnd(index, context),
};
const from = joinsPrevious ? low - 1 : low;
return [...groups.slice(0, from), merged, ...groups.slice(joinsNext ? low + 1 : low)];
}
function groupsWithoutDate(
groups: readonly Group[],
date: string,
context: ObservationCalendar,
): readonly Group[] | undefined {
let low = 0;
let high = groups.length;
while (low < high) {
const mid = (low + high) >> 1;
if (groups[mid].anchors[0] <= date) low = mid + 1;
else high = mid;
}
const group = groups[low - 1];
if (!group) return undefined;
const cut = insertionPoint(group.anchors, date);
if (group.anchors[cut] !== date) return undefined;
const leftAnchors = group.anchors.slice(0, cut);
const rightAnchors = group.anchors.slice(cut + 1);
const split: Group[] = [];
if (leftAnchors.length)
split.push({
anchors: leftAnchors,
startIndex: group.startIndex,
endIndex: extendEnd(
context.calendarIndex.get(leftAnchors[leftAnchors.length - 1])!,
context,
),
});
if (rightAnchors.length)
split.push({
anchors: rightAnchors,
startIndex: extendStart(context.calendarIndex.get(rightAnchors[0])!, context),
endIndex: group.endIndex,
});
return [...groups.slice(0, low - 1), ...split, ...groups.slice(low)];
}
function occupancyOf(
placements: ReadonlyMap<string, unknown>,
context: ObservationCalendar,
): Occupancy {
// Walk the record chain up to the nearest materialized ancestor, then derive back down.
const chain: [ReadonlyMap<string, unknown>, Derivation][] = [];
let current = placements;
let occupancy: Occupancy | undefined;
for (;;) {
const known = occupancies.get(current);
if (known?.calendar === context) {
occupancy = known;
break;
}
const derivation = derivations.get(current);
if (!derivation) break;
chain.push([current, derivation]);
current = derivation.parent;
}
if (!occupancy) {
occupancy = occupancyFromScratch(current, context);
occupancies.set(current, occupancy);
}
for (let i = chain.length - 1; i >= 0; i--) {
const [map, derivation] = chain[i];
derivations.delete(map);
occupancy =
deriveOccupancy(occupancy, derivation, context) ?? occupancyFromScratch(map, context);
occupancies.set(map, occupancy);
}
return occupancy;
}
function tieKeyOf(occupancy: Occupancy): string {
return (occupancy.tieKey ??= occupancy.dates.join('|'));
}
export function placementTieKey(state: ObservedPlan, context: ObservationCalendar): string {
const occupancy = occupancyOf(state.placements, context);
if (shadowMode) verifyOccupancy(state, context, occupancy);
return tieKeyOf(occupancy);
}
export function expiryTieKey(state: ObservedPlan): string {
return [...state.placements.values()]
.map(
(placement) =>
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
)
.sort(compareDate)
.join('|');
}
export function isAtRisk(balance: ObservedBalance, context: ObservationCalendar): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
compareDate(balance.expiryDate, context.horizon.end) <= 0
);
}
function isNaturalNonWorkingDate(date: string, context: ObservationCalendar): boolean {
return context.calendarIndex.has(date)
? !context.workingCalendar.has(date)
: !isWorkingPlanningDate(date);
}
function calendarDateAt(index: number, context: ObservationCalendar): string {
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
}
function extendStart(index: number, context: ObservationCalendar): number {
while (
context.declaredDates.has(calendarDateAt(index - 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(index - 1, context), context)
) {
context.check();
index--;
}
return index;
}
function extendEnd(index: number, context: ObservationCalendar): number {
while (
context.declaredDates.has(calendarDateAt(index + 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(index + 1, context), context)
) {
context.check();
index++;
}
return index;
}
function groupsFromScratch(
placementDates: Iterable<string>,
context: ObservationCalendar,
): Group[] {
const dates = [...new Set([...placementDates, ...context.declaredDates])]
.filter((date) => date >= context.horizon.start && date < context.horizon.end)
.sort(compareDate);
if (!dates.length) return [];
const groups: string[][] = [];
let group = [dates[0]];
for (let i = 1; i < dates.length; i++) {
context.check();
const previousIndex = context.calendarIndex.get(dates[i - 1])!;
const currentIndex = context.calendarIndex.get(dates[i])!;
const workingInGap =
context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
if (workingInGap === 0) group.push(dates[i]);
else {
groups.push(group);
group = [dates[i]];
}
}
groups.push(group);
return groups.map((anchors) => {
context.check();
return {
anchors,
startIndex: extendStart(context.calendarIndex.get(anchors[0])!, context),
endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1])!, context),
};
});
}
function calendarPeriod(
startIndex: number,
endIndex: number,
context: ObservationCalendar,
): CalendarPeriod {
let periods = calendarPeriodCache.get(context);
if (!periods) {
periods = new Map();
calendarPeriodCache.set(context, periods);
}
const key = startIndex * 1_048_576 + endIndex;
let period = periods.get(key);
if (period) periods.delete(key);
else {
const start = calendarDateAt(startIndex, context);
const end = calendarDateAt(endIndex, context);
const dates =
startIndex >= 0 && endIndex < context.calendarDays.length
? context.calendarDays.slice(startIndex, endIndex + 1)
: dateRange(start, addDays(end, 1));
period = Object.freeze({
start,
end,
duration: dates.length,
nonWorkingDates: Object.freeze(
dates.filter((date) => isNaturalNonWorkingDate(date, context)),
),
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))),
});
}
periods.set(key, period);
if (periods.size > 4096) periods.delete(periods.keys().next().value!);
return period;
}
function periodsOf(
state: ObservedPlan,
groups: readonly Group[],
context: ObservationCalendar,
): TimeOffPeriod[] {
return groups.map((group) => {
const period = (group.period ??= calendarPeriod(group.startIndex, group.endIndex, context));
return {
start: period.start,
end: period.end,
duration: period.duration,
placementDates: group.anchors.filter((date) => state.placements.has(date)),
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
nonWorkingDates: [...period.nonWorkingDates],
nonWorkingDayGain: period.nonWorkingDates.length,
};
});
}
export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
const occupancy = occupancyOf(state.placements, context);
if (shadowMode) verifyOccupancy(state, context, occupancy);
return periodsOf(state, occupancy.groups, context);
}
/** Reference computation, ignoring every cache: the test oracle and the shadow-mode baseline. */
export function observationsFromScratch(state: ObservedPlan, context: ObservationCalendar) {
const dates = [...state.placements.keys()].sort();
const groups = groupsFromScratch(dates, context);
return {
tieKey: dates.join('|'),
groups: groups.map(({ anchors, startIndex, endIndex }) => ({
anchors,
startIndex,
endIndex,
})),
periods: periodsOf(state, groups, context),
...calendarObservations(state, groups, context),
};
}
function holidayWeeksFor(state: ObservedPlan, context: ObservationCalendar) {
return Object.freeze(
context.holidayWeeks.map((week) =>
Object.freeze({
value: week.value,
complete: week.dates.every(
(date) => state.placements.has(date) || context.declaredDates.has(date),
),
}),
),
);
}
function timeOffPeriodsFor(
groups: readonly Group[],
context: ObservationCalendar,
periods?: TimeOffPeriod[],
) {
return Object.freeze(
periods
? periods.map((period) =>
Object.freeze({ nonWorkingDates: Object.freeze([...period.nonWorkingDates]) }),
)
: groups.map(
(group) =>
(group.timeOff ??= Object.freeze({
nonWorkingDates: (group.period ??= calendarPeriod(
group.startIndex,
group.endIndex,
context,
)).nonWorkingDates,
})),
),
);
}
function workSequencesFor(groups: readonly Group[], context: ObservationCalendar) {
return Object.freeze(
workSequenceLengths(groups, context).map((sequence) => Object.freeze(sequence)),
);
}
function calendarObservations(
state: ObservedPlan,
groups: readonly Group[],
context: ObservationCalendar,
) {
return {
publicHolidayWeeks: holidayWeeksFor(state, context),
timeOffPeriods: timeOffPeriodsFor(groups, context),
workSequences: workSequencesFor(groups, context),
};
}
function verifyOccupancy(
state: ObservedPlan,
context: ObservationCalendar,
occupancy: Occupancy,
): void {
const expected = observationsFromScratch(state, context);
const fail = (field: string, got: unknown, want: unknown): never => {
throw new Error(
`Observation shadow mismatch on ${field} for placements [${[...state.placements.keys()].sort().join(', ')}]: got ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
);
};
if (tieKeyOf(occupancy) !== expected.tieKey)
fail('tieKey', tieKeyOf(occupancy), expected.tieKey);
const groups = occupancy.groups.map(({ anchors, startIndex, endIndex }) => ({
anchors,
startIndex,
endIndex,
}));
if (JSON.stringify(groups) !== JSON.stringify(expected.groups))
fail('groups', groups, expected.groups);
for (const field of ['publicHolidayWeeks', 'timeOffPeriods', 'workSequences'] as const) {
const cached = occupancy.observed[field];
// Caller-supplied periods are never compared: they are not derived from the key set.
if (cached && JSON.stringify(cached) !== JSON.stringify(expected[field]))
fail(field, cached, expected[field]);
}
}
export function optimizationBalanceObservations(state: ObservedPlan, context: ObservationCalendar) {
return state.balances.map((balance) => ({
unplacedQuantity: balance.remaining,
atRisk: isAtRisk(balance, context),
}));
}
export function optimizationObservations(
state: ObservedPlan,
context: ObservationCalendar,
periods?: TimeOffPeriod[],
): OptimizationObservations {
// Calendar facts depend only on occupied dates within this immutable request.
// Balance quantities, provenance and supporting status stay outside this cache.
const occupancy = occupancyOf(state.placements, context);
const calendar = occupancy.observed;
if (shadowMode) {
verifyOccupancy(state, context, occupancy);
calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context);
calendar.workSequences ??= workSequencesFor(occupancy.groups, context);
if (!periods) calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context);
verifyOccupancy(state, context, occupancy);
}
return {
leaveBalances: optimizationBalanceObservations(state, context),
get publicHolidayWeeks() {
return (calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context));
},
get timeOffPeriods() {
return (calendar.timeOffPeriods ??= timeOffPeriodsFor(
occupancy.groups,
context,
periods,
));
},
get workSequences() {
return (calendar.workSequences ??= workSequencesFor(occupancy.groups, context));
},
leavePlacements: [...state.placements.values()],
};
}
function workSequenceLengths(
bounds: readonly Group[],
context: ObservationCalendar,
): OptimizationObservations['workSequences'] {
if (!context.workingDays.length) return [];
let nextIndex = context.calendarIndex.get(context.horizon.start)!;
const sequences = bounds.map(({ anchors }, index) => {
const previousIndex = nextIndex;
nextIndex = context.calendarIndex.get(anchors[anchors.length - 1])! + 1;
return {
length:
context.workingPrefix[context.calendarIndex.get(anchors[0])!] -
context.workingPrefix[previousIndex],
internal: index > 0,
};
});
sequences.push({
length:
context.workingPrefix[context.calendarIndex.get(context.horizon.end)!] -
context.workingPrefix[nextIndex],
internal: false,
});
return sequences;
}
export function workSequenceDates(state: ObservedPlan, context: ObservationCalendar) {
const sequences: string[][] = [];
let current: string[] = [];
let inTimeOffPeriod = false;
for (const date of context.workingDays) {
context.check();
if (state.placements.has(date) || context.declaredDates.has(date)) {
if (!inTimeOffPeriod) sequences.push(current);
current = [];
inTimeOffPeriod = true;
} else {
current.push(date);
inTimeOffPeriod = false;
}
}
if (context.workingDays.length) sequences.push(current);
return sequences.map((dates, index) => ({
dates,
internal: index > 0 && index < sequences.length - 1,
}));
}
export function evaluateStateOptimization(
configuration: OptimizationConfiguration,
state: ObservedPlan,
context: ObservationCalendar,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
}
@@ -0,0 +1,301 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from '../../testing/vitest-compat';
import { CalendarService } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import type {
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
} from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { UserInputService } from './user-input-service';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function planFor(start: string, end: string): PlanOutcome {
return {
kind: 'Plan',
horizon: { start, end },
declaredDays: [],
placements: [],
timeOffPeriods: [],
preferences: [],
remainingBalances: [],
completedStages: [],
} as unknown as PlanOutcome;
}
const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
function fakeCalculation(id: string) {
const outcome = deferred<PlanningOutcome>();
const explanation = deferred<PlanningExplanation | undefined>();
const explanationError = deferred<PlanningReason | undefined>();
const cancel = vi.fn();
const context = {
id,
cancel,
snapshot: { rules: [], labels: {}, declaredDays: [] },
outcome: outcome.promise,
explanation: explanation.promise,
explanationError: explanationError.promise,
analyzeCounterfactual: vi.fn(),
} as unknown as PlanningCalculationContext;
return { context, outcome, explanation, explanationError, cancel };
}
type Fake = ReturnType<typeof fakeCalculation>;
const flush = () => new Promise<void>((resolve) => setTimeout(resolve));
describe('PlanningCalculationService', () => {
let create: ReturnType<typeof vi.fn>;
let service: PlanningCalculationService;
function queue(...fakes: Fake[]): void {
for (const fake of fakes) create.mockReturnValueOnce(fake.context);
}
beforeEach(() => {
localStorage.clear();
create = vi.fn();
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: { createPlanningCalculation: create } },
],
});
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
service = TestBed.inject(PlanningCalculationService);
});
afterEach(() => {
TestBed.resetTestingModule();
localStorage.clear();
});
it('does not calculate until started and ignores input edits afterwards', async () => {
const first = fakeCalculation('first');
queue(first);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
expect(create).not.toHaveBeenCalled();
expect(service.accepted()).toBeNull();
service.start();
expect(create).toHaveBeenCalledTimes(1);
expect(service.isCalculating()).toBe(true);
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
const accepted = service.accepted()!;
expect(accepted.successful).toBe(true);
expect(service.isCalculating()).toBe(false);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
await flush();
expect(create).toHaveBeenCalledTimes(1);
expect(service.accepted()).toBe(accepted);
expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
});
it('hands the submission translated labels and leaves declaration entry', () => {
queue(fakeCalculation('first'));
const input = TestBed.inject(UserInputService);
input.declarationEntrySignal.set(true);
service.start();
expect(input.declarationEntrySignal()).toBe(false);
const submission = create.mock.calls[0][0];
expect(submission.horizon).toEqual(service.request()!.horizon);
expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
expect(submission.labels).toBeDefined();
});
it('reports progress of the latest calculation only', () => {
queue(fakeCalculation('old'), fakeCalculation('latest'));
service.start();
const oldProgress = create.mock.calls[0][0].progress;
service.start();
const latestProgress = create.mock.calls[1][0].progress;
oldProgress('obligations');
expect(service.progress()).toBe(0);
latestProgress('at-risk');
expect(service.progress()).toBe(35);
latestProgress('unknown');
expect(service.progress()).toBe(35);
});
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
const first = fakeCalculation('first');
const stale = fakeCalculation('stale');
const latest = fakeCalculation('latest');
queue(first, stale, latest);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
const previous = service.accepted()!;
service.start();
service.start();
expect(service.accepted()).toBe(previous);
expect(service.displayedPlan()).toBe(previous.outcome);
stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
await flush();
expect(service.accepted()).toBe(previous);
expect(service.isCalculating()).toBe(true);
latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
await flush();
expect(service.accepted()!.calculation).toBe(latest.context);
expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
expect(service.isCalculating()).toBe(false);
});
it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
const first = fakeCalculation('first');
const second = fakeCalculation('second');
queue(first, second);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanation()).toBeNull();
service.start();
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
first.explanation.resolve(explained);
await flush();
expect(service.explanation()).toBeNull();
second.explanation.resolve(undefined);
second.explanationError.resolve(undefined);
await flush();
expect(service.outcome()?.kind).toBe('Plan');
});
it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
const first = fakeCalculation('first');
const second = fakeCalculation('second');
queue(first, second);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
service.start();
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
first.explanationError.resolve({ code: 'old', message: 'old' });
await flush();
expect(service.explanationError()).toBeNull();
});
it('keeps the outcome when the explanation fails', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
first.explanation.reject(new Error('no trace'));
first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanationError()?.message).toBeTruthy();
expect(service.explanation()).toBeNull();
});
it('keeps the outcome when the explanation error promise rejects', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
first.explanationError.reject(new Error('lost'));
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
});
it('turns an outcome rejection into an accepted planner error', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.reject(new Error('worker died'));
await flush();
const accepted = service.accepted()!;
expect(accepted.successful).toBe(false);
expect(accepted.outcome).toEqual({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'planner-error', message: 'worker died' }],
});
expect(service.isCalculating()).toBe(false);
});
it('turns a construction failure into an accepted planner error with the requested horizon', () => {
create.mockImplementation(() => {
throw new Error('cannot start');
});
service.start();
const reason = { code: 'planner-error', message: 'cannot start' };
expect(service.outcome()).toEqual({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [reason],
});
expect(service.explanationError()).toEqual(reason);
expect(service.accepted()!.calculation).toBeNull();
expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
expect(service.isCalculating()).toBe(false);
});
it('cancels cooperatively once, and not when nothing is running', async () => {
const first = fakeCalculation('first');
queue(first);
service.cancel();
expect(first.cancel).not.toHaveBeenCalled();
service.start();
service.cancel();
service.cancel();
expect(first.cancel).toHaveBeenCalledTimes(1);
expect(service.isCancelling()).toBe(true);
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
expect(service.isCancelling()).toBe(false);
expect(service.accepted()!.successful).toBe(false);
});
it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
service.cancel();
first.outcome.resolve({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
await flush();
expect(service.outcome()?.kind).toBe('PlanningIncomplete');
expect(service.displayedPlan()).toBeUndefined();
expect(service.accepted()!.successful).toBe(false);
});
it('shows the incumbent of a cancelled calculation', async () => {
const first = fakeCalculation('first');
queue(first);
const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
service.start();
service.cancel();
first.outcome.resolve({
kind: 'PlanningIncomplete',
incumbent,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
await flush();
expect(service.displayedPlan()).toBe(incumbent);
expect(service.accepted()!.successful).toBe(false);
});
});
@@ -0,0 +1,233 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarService } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import type {
DeclaredNonWorkingDay,
PlanningCalculationContext,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
} from './leave-planner.service';
import { RuleService } from './rule-service';
import { TranslationService } from './translation-service';
import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string };
interface PendingCalculation {
id: number;
horizon: Horizon;
progress: number;
cancelling: boolean;
}
export interface AcceptedCalculation {
/** Null when the planner could not even be created. */
calculation: PlanningCalculationContext | null;
outcome: PlanningOutcome;
snapshot: PlanningSnapshot | null;
horizon: Horizon;
declaredDays: readonly DeclaredNonWorkingDay[];
requestedAt: number;
successful: boolean;
}
function stageProgress(stage: string, current: number): number {
if (stage === 'complete') return 100;
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
if (stage === 'short-sequence-deficit') return 79;
if (stage === 'supporting-placements') return 86;
if (stage === 'expiry') return 93;
if (stage === 'dates') return 98;
return current;
}
function plannerError(error: unknown): PlanningReason {
return {
code: 'planner-error',
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
};
}
function explanationFailure(error: unknown): PlanningReason {
return {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
}
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
@Injectable({ providedIn: 'root' })
export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calendar = inject(CalendarService);
private readonly translation = inject(TranslationService);
private version = 0;
private running: PlanningCalculationContext | null = null;
private readonly pendingState = signal<PendingCalculation | null>(null);
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
private readonly explanationState = signal<PlanningExplanation | null>(null);
private readonly explanationErrorState = signal<PlanningReason | null>(null);
private readonly explanationRejectedState = signal(false);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
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 accepted = this.acceptedState.asReadonly();
readonly explanation = this.explanationState.asReadonly();
readonly explanationError = this.explanationErrorState.asReadonly();
readonly explanationRejected = this.explanationRejectedState.asReadonly();
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
readonly isCalculating = computed(() => this.pendingState() !== null);
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => {
const outcome = this.outcome();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
readonly displayedHorizon = computed(
() => this.acceptedState()?.horizon ?? this.pendingState()?.horizon ?? null,
);
start(): void {
const request = this.request();
if (!request) return;
const requestedAt = performance.now();
const version = ++this.version;
this.userInput.declarationEntrySignal.set(false);
this.pendingState.set({
id: version,
horizon: request.horizon,
progress: 0,
cancelling: false,
});
let calculation: PlanningCalculationContext;
try {
calculation = this.calendar.createPlanningCalculation({
...request,
balances: request.balances.map((balance) => ({
...balance,
label: this.translation.getDayTypeLabel(balance.type),
})),
labels: Object.fromEntries(
request.rules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.version)
this.pendingState.update(
(pending) =>
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
);
},
});
} catch (error) {
const reason = plannerError(error);
this.running = null;
this.accept(null, this.incomplete(reason), request.horizon, [], requestedAt);
this.explanationErrorState.set(reason);
return;
}
this.running = calculation;
const accept = (outcome: PlanningOutcome) => {
if (version !== this.version) return false;
this.running = null;
this.accept(calculation, outcome, request.horizon, undefined, requestedAt);
return true;
};
void calculation.outcome
.then((outcome) => accept(outcome))
.catch((error: unknown) => accept(this.incomplete(plannerError(error))))
.then((accepted) => {
if (accepted) this.loadArtifacts(calculation);
});
}
cancel(): void {
const pending = this.pendingState();
if (!pending || pending.cancelling || !this.running) return;
this.pendingState.set({ ...pending, cancelling: true });
this.running.cancel();
}
private incomplete(reason: PlanningReason): PlanningOutcome {
return { kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] };
}
private accept(
calculation: PlanningCalculationContext | null,
outcome: PlanningOutcome,
requestHorizon: Horizon,
declaredDays: readonly DeclaredNonWorkingDay[] | undefined,
requestedAt: number,
): void {
const plan = outcome.kind === 'Plan' ? outcome : undefined;
const shown =
plan ?? (outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined);
const cancelling = this.pendingState()?.cancelling ?? false;
this.explanationState.set(null);
this.explanationErrorState.set(null);
this.explanationRejectedState.set(false);
this.pendingState.set(null);
this.acceptedState.set({
calculation,
outcome,
snapshot: calculation?.snapshot ?? null,
horizon: shown?.horizon ?? requestHorizon,
declaredDays:
shown?.declaredDays ?? declaredDays ?? calculation?.snapshot.declaredDays ?? [],
requestedAt,
successful: plan !== undefined && !cancelling,
});
}
private loadArtifacts(calculation: PlanningCalculationContext): void {
const current = () => this.acceptedState()?.calculation === calculation;
const fail = (error: unknown) => {
if (!current()) return;
this.explanationRejectedState.set(true);
this.explanationErrorState.set(explanationFailure(error));
};
calculation.explanation
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
.catch(fail);
calculation.explanationError
.then((reason) => current() && this.explanationErrorState.set(reason ?? null))
.catch(fail);
}
}
@@ -5,6 +5,7 @@ import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('Planning duration estimate', () => {
@@ -31,7 +32,8 @@ describe('Planning duration estimate', () => {
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(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
expect(TestBed.inject(RuleService).rules).toEqual([]);
});
@@ -47,7 +49,7 @@ describe('Planning duration estimate', () => {
});
const duration = TestBed.inject(PlanningDurationService);
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
duration.recordCompletion(duration.request()!, 20_000);
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
@@ -81,19 +83,20 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(moreLeave);
settings.setPlanningDates('2026-12-31', '2024-01-01');
expect(duration.estimate()).toBeNull();
expect(input.computeTriggerSignal()).toBe(0);
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
});
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()!;
const submitted = TestBed.inject(PlanningCalculationService).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()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
duration.recordCompletion(submitted, 20_000);
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
@@ -114,14 +117,16 @@ describe('Planning duration estimate', () => {
placeType: SelectableDayType.CP,
});
const duration = TestBed.inject(PlanningDurationService);
const calculation = createPlanningCalculation(duration.request()!);
const calculation = createPlanningCalculation(
TestBed.inject(PlanningCalculationService).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()!;
const submitted = TestBed.inject(PlanningCalculationService).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' });
@@ -136,7 +141,7 @@ describe('Planning duration estimate', () => {
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);
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
expect(duration.estimate()).toEqual(initial);
});
});
+3 -26
View File
@@ -1,9 +1,7 @@
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';
import { PlanningCalculationService } from './planning-calculation.service';
export interface PlanningDurationRange {
minimum: number;
@@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number {
@Injectable({ providedIn: 'root' })
export class PlanningDurationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calculations = inject(PlanningCalculationService);
private readonly calibration = signal<readonly number[]>([]);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
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();
const request = this.calculations.request();
if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
+26 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import {
compareObjectiveLists,
compareObjectiveValues,
@@ -75,6 +75,29 @@ describe('composable planning objectives', () => {
).toEqual([3, 7, 4, 6, 5, 2]);
});
it.each(['sum', 'count', 'maximum'] as const)(
'keeps %s distinct-date scores current with frozen and mutable period dates',
(aggregation) => {
const mutableDates = ['2024-05-08'];
const observations: OptimizationObservations = {
...empty,
timeOffPeriods: Object.freeze([
Object.freeze({
nonWorkingDates: Object.freeze(['2024-01-06', '2024-01-07', '2024-01-07']),
}),
Object.freeze({ nonWorkingDates: mutableDates }),
]),
};
const configuration = { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, aggregation };
const initial = { sum: 3, count: 2, maximum: 2 };
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
mutableDates.push('2024-05-09', '2024-05-09', '2024-05-11');
const updated = { sum: 5, count: 2, maximum: 3 };
expect(evaluateOptimization(configuration, observations)).toBe(updated[aggregation]);
},
);
it.each(['sum', 'count', 'maximum'] as const)(
'defines empty %s as zero for every measure',
(aggregation) => {
@@ -151,7 +174,8 @@ describe('composable planning objectives', () => {
it.each([
null,
[],
[undefined],
[[]],
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
+11 -3
View File
@@ -203,6 +203,9 @@ export interface OptimizationObservations {
leavePlacements: readonly { supporting: boolean }[];
}
// Calendar geometry reuses frozen date arrays across candidate plans.
const nonWorkingDateCounts = new WeakMap<readonly string[], number>();
export function evaluateOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
@@ -224,9 +227,14 @@ export function evaluateOptimization(
.map((week) => week.value);
break;
case 'time-off-periods':
values = observations.timeOffPeriods.map(
(period) => new Set(period.nonWorkingDates).size,
);
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
const cached = nonWorkingDateCounts.get(nonWorkingDates);
if (cached !== undefined) return cached;
const count = new Set(nonWorkingDates).size;
if (Object.isFrozen(nonWorkingDates))
nonWorkingDateCounts.set(nonWorkingDates, count);
return count;
});
break;
case 'work-sequences':
values = observations.workSequences
+3 -16
View File
@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import { CalendarSettingsService } from './calendar-settings-service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { UserInputService } from './user-input-service';
import { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
@@ -241,12 +241,6 @@ describe('saved rule migration', () => {
},
],
});
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
'expiry',
'dates',
]);
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
placements: [],
@@ -266,7 +260,7 @@ describe('saved rule migration', () => {
expiryDate: new Date('2024-01-05'),
},
]);
const request = TestBed.inject(PlanningDurationService).request()!;
const request = TestBed.inject(PlanningCalculationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
@@ -277,12 +271,5 @@ describe('saved rule migration', () => {
metrics: { unplacedAtRisk: 0 },
placements: [{ balanceId: 'cp' }],
});
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
...request.rules.map(({ id }) => `optimization:${id}`),
'expiry',
'dates',
]);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from './rule-service';
import { SelectableDayType } from './calendar-service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
describe('RuleService', () => {
let service: RuleService;
-69
View File
@@ -1,4 +1,3 @@
/// <reference types="vite/client" />
import { Injectable, inject, Injector, computed } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
@@ -39,14 +38,10 @@ export interface AppTranslations {
durationEstimate: string;
durationUnits: { seconds: string; minutes: string };
remainingDaysWarning: string;
remainingWhy: string;
remainingValidity: string;
remainingHorizon: string;
remainingEarnedFrom: string;
remainingHistoryHelp: string;
remainingNoEvaluations: string;
remainingSuggestions: Record<string, string>;
remainingCandidateReasons: Record<string, string>;
deleteEntry: string;
chooseDate: string;
availableDate: string;
@@ -177,7 +172,6 @@ export interface AppTranslations {
includedEntitlement: string;
comparison: {
title: string;
candidate: string;
preview: string;
nearby: string;
noAlternatives: string;
@@ -188,7 +182,6 @@ export interface AppTranslations {
available: string;
unavailable: string;
backToPlan: string;
backToTrace: string;
};
details: {
intro: string;
@@ -197,10 +190,6 @@ export interface AppTranslations {
expires: string;
criteria: string;
criteriaHelp: string;
traceImpactTitle: string;
traceImpactHelp: string;
traceStageTitle: string;
traceStageHelp: string;
firstDifference: string;
higher: string;
lower: string;
@@ -233,9 +222,6 @@ export interface AppTranslations {
alternative: string;
sameDate: string;
moveHelp: string;
traceHelp: string;
beforeDecision: string;
withCandidate: string;
comparisonUnavailable: string;
objectives: Record<string, { label: string; help: string }>;
preferenceHelp: Record<string, string | undefined>;
@@ -245,11 +231,9 @@ export interface AppTranslations {
title: string;
calendarHelp: string;
explanationHelp: string;
traceHelp: string;
technicalDetails: string;
compareDates: string;
stopComparing: string;
replayNotice: string;
daysPlaced: string;
daysGained: string;
longestWorkSequence: string;
@@ -267,7 +251,6 @@ export interface AppTranslations {
placementNavigation: string;
placement: string;
placements: string;
decision: string;
of: string;
first: string;
previous: string;
@@ -280,13 +263,8 @@ export interface AppTranslations {
counterfactualReady: string;
counterfactualError: string;
retryCounterfactual: string;
historicalEligibilityLoading: string;
historicalEligibilityReady: string;
historicalEligibilityError: string;
retryHistoricalEligibility: string;
eligible: string;
ineligible: string;
historical: string;
reason: string;
explanationError: string;
conflictDetails: string;
@@ -300,53 +278,6 @@ export interface AppTranslations {
currentPlacement: string;
explainedPeriod: string;
outsideHorizon: string;
traceTitle: string;
traceLoading: string;
traceReady: string;
traceUnavailable: string;
phases: string;
firstPhase: string;
previousPhase: string;
nextPhase: string;
lastPhase: string;
decisions: string;
firstDecision: string;
previousDecision: string;
nextDecision: string;
lastDecision: string;
replay: string;
before: string;
after: string;
finalPlan: string;
dates: string;
candidates: string;
previousCandidates: string;
nextCandidates: string;
page: string;
compareAlternatives: string;
deltas: string;
activations: string;
noDecisions: string;
traceStatuses: {
chosen: string;
'evaluated-rejected': string;
pruned: string;
'not-examined': string;
ineligible: string;
};
tracePhaseStatuses: {
completed: string;
interrupted: string;
'not-run': string;
};
traceChanges: {
added: string;
'moved-from': string;
'moved-to': string;
removed: string;
shared: string;
unchanged: string;
};
};
benchmark: {
title: string;
+1 -3
View File
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { UserInputService } from './user-input-service';
import { SelectableDayType, VacDay } from './calendar-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
describe('UserInputService', () => {
const savedBalance = {
@@ -46,7 +46,6 @@ describe('UserInputService', () => {
{ date: '2024-01-02', type: 'other' },
]);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
@@ -119,7 +118,6 @@ describe('UserInputService', () => {
]);
expect(save).toHaveBeenCalledTimes(3);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
});
it('restores an empty list when saved declarations are unreadable', () => {
+2 -28
View File
@@ -1,4 +1,4 @@
import { computed, Injectable, signal } from '@angular/core';
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
@@ -6,12 +6,7 @@ import {
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
import type {
LeaveType,
PlanningOutcome,
PlanningSnapshot,
PlanningTrace,
} from './leave-planner.service';
import type { LeaveType } from './leave-planner.service';
@Injectable({
providedIn: 'root',
@@ -166,22 +161,6 @@ export class UserInputService {
readonly vacationNumberSignal = signal<VacDay[]>(
this.getSavedValues() ?? this.getInitialValues(),
);
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly planningOutcomeSignal = signal<PlanningOutcome | null>(null);
readonly planningSnapshotSignal = signal<PlanningSnapshot | null>(null);
readonly planningTraceSignal = signal<PlanningTrace | null>(null);
readonly traceResolvedSignal = signal(false);
readonly displayedPlanSignal = computed(() => {
const outcome = this.planningOutcomeSignal();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
readonly computeTriggerSignal = signal(0);
readonly computationRequestedAtMs = signal<number | null>(null);
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
try {
@@ -201,9 +180,4 @@ export class UserInputService {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
public requestComputation(): void {
this.computationRequestedAtMs.set(performance.now());
this.computeTriggerSignal.update((count) => count + 1);
}
}
+1 -75
View File
@@ -34,25 +34,15 @@
"daySuffix": "Tag",
"daysSuffix": "Tage",
"expires": "abl.",
"remainingWhy": "Warum?",
"remainingValidity": "Nutzbar vom {available} bis zum {expiry}.",
"remainingHorizon": "Berechneter Plan vom {start} bis zum {end}.",
"remainingEarnedFrom": "Durch die Regel „{rule}“ erworbener Urlaub.",
"remainingHistoryHelp": "Diese Daten wurden während der Berechnung tatsächlich geprüft. Die Werte vergleichen jeden Versuch mit seinem Ausgangszustand; der endgültige Plan kann abweichen.",
"remainingNoEvaluations": "Für dieses Urlaubskonto wurde kein zuordenbarer Versuch aufgezeichnet. Die Ablaufkategorie allein erklärt nicht, warum es ungenutzt bleibt.",
"remainingSuggestions": {
"available-after-horizon": "Sie können den Planungszeitraum über das Verfügbarkeitsdatum hinaus verlängern und neu berechnen.",
"no-working-date": "Prüfen Sie den Planungszeitraum und die Gültigkeitsdaten dieses Urlaubskontos.",
"all-dates-occupied": "Sie können den Zeitraum bei entsprechender Gültigkeit verlängern oder Ihre bereits gebuchten Tage und Regeln anpassen und neu berechnen.",
"preserved": "Keine Aktion nötig. Um die Nutzung anzufordern, fügen Sie eine Regel innerhalb der Gültigkeit hinzu und berechnen Sie neu.",
"calculation-incomplete": "Starten Sie die Berechnung erneut, wenn Sie die Planung abschließen möchten."
},
"remainingCandidateReasons": {
"quality-no-improvement": "Diese Möglichkeit wurde in dieser Berechnungsphase nicht ausgewählt.",
"quality-protected-objective": "Diese Möglichkeit würde eine zu erhaltende Priorität verschlechtern.",
"quality-outperformed": "Die Berechnung fand später eine besser bewertete Möglichkeit.",
"earned-entitlement-order": "Diese Möglichkeit hält die Verfügbarkeitsdaten des erworbenen Urlaubs nicht ein.",
"unknown": "Der genaue Grund für diesen Versuch ist nicht verfügbar."
}
},
"dayTypes": {
@@ -218,7 +208,6 @@
"placementNavigation": "Navigation der Platzierungserklärungen",
"placement": "Urlaubstag",
"placements": "Platzierungen",
"decision": "Entscheidung",
"of": "von",
"first": "Erste Platzierung",
"previous": "Vorherige Platzierung",
@@ -231,13 +220,8 @@
"counterfactualReady": "Alternative Termine sind bereit.",
"counterfactualError": "Alternative Termine konnten nicht berechnet werden.",
"retryCounterfactual": "Datumsanalyse erneut versuchen",
"historicalEligibilityLoading": "Prüfe, an welchen Tagen dieses Urlaubsguthaben verfügbar war …",
"historicalEligibilityReady": "Die historische Datumseignung ist bereit.",
"historicalEligibilityError": "Die historische Datumseignung konnte nicht geprüft werden.",
"retryHistoricalEligibility": "Historische Datumseignung erneut prüfen",
"eligible": "Geeignet",
"ineligible": "Nicht geeignet",
"historical": "Status der ursprünglichen Suche",
"reason": "Grund",
"explanationError": "Die Erklärung konnte nicht erstellt werden.",
"conflictDetails": "Warum kein Plan die Verpflichtungen erfüllt",
@@ -251,60 +235,11 @@
"currentPlacement": "Aktuell erklärte Platzierung",
"explainedPeriod": "Erklärter Freizeitzeitraum",
"outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts",
"traceTitle": "Erweitert: ursprüngliche Entscheidungsspur",
"traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…",
"traceReady": "Der Plan ist fertig und seine Entscheidungsspur wurde geladen.",
"traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.",
"phases": "Spurphasen",
"firstPhase": "Erste Phase",
"previousPhase": "Vorherige Phase",
"nextPhase": "Nächste Phase",
"lastPhase": "Letzte Phase",
"decisions": "Atomare Entscheidungen",
"firstDecision": "Erste Entscheidung",
"previousDecision": "Vorherige Entscheidung",
"nextDecision": "Nächste Entscheidung",
"lastDecision": "Letzte Entscheidung",
"replay": "Entscheidungszustand",
"before": "Vorher",
"after": "Nachher",
"finalPlan": "Endgültiger Plan",
"dates": "Daten",
"candidates": "Kandidaten",
"previousCandidates": "Vorherige Kandidatenseite",
"nextCandidates": "Nächste Kandidatenseite",
"page": "Seite",
"compareAlternatives": "Diesen Kandidaten im gesamten Horizont vergleichen",
"deltas": "Zustandsänderungen",
"activations": "erworbene Aktivierungen",
"noDecisions": "Diese Phase enthält keine atomare Entscheidung.",
"traceStatuses": {
"chosen": "Ausgewählt",
"evaluated-rejected": "Bewertet und abgelehnt",
"pruned": "Verworfen",
"not-examined": "Nicht untersucht",
"ineligible": "Nicht geeignet"
},
"tracePhaseStatuses": {
"completed": "Abgeschlossen",
"interrupted": "Unterbrochen",
"not-run": "Nicht ausgeführt"
},
"traceChanges": {
"added": "Durch diese Entscheidung hinzugefügt",
"moved-from": "Von diesem Datum verschoben",
"moved-to": "Auf dieses Datum verschoben",
"removed": "Durch diese Entscheidung entfernt",
"shared": "Durch diese Entscheidung geteilt",
"unchanged": "Durch diese Entscheidung unverändert"
},
"calendarHelp": "Wählen Sie einen Urlaubstag, um seinen Beitrag zum Plan zu sehen.",
"explanationHelp": "Sehen Sie Ihre Auszeit und den Beitrag jedes Tages.",
"traceHelp": "Erkunden Sie die Berechnung, Entscheidung für Entscheidung.",
"technicalDetails": "Berechnungsdetails",
"compareDates": "Andere Termine vergleichen",
"stopComparing": "Datumsvergleich schließen",
"replayNotice": "Sie sehen einen Berechnungsschritt.",
"explanation": {
"breakTitle": "Ihre Auszeit",
"leaveDays": "eingesetzte Urlaubstage",
@@ -328,9 +263,6 @@
"alternative": "Mit diesem Datum",
"sameDate": "Dies ist das geplante Datum. Wählen Sie oben ein anderes Datum, um den Unterschied zu sehen.",
"moveHelp": "Verschieben Sie probeweise diesen Tag. Alle anderen Urlaubstermine bleiben gleich.",
"traceHelp": "Vergleichen Sie diesen Kandidaten mit dem Zustand vor seiner ursprünglichen Entscheidung.",
"beforeDecision": "Vor dieser Entscheidung",
"withCandidate": "Mit diesem Kandidaten",
"comparisonUnavailable": "Der Vergleich für das ursprüngliche Datum ist nicht verfügbar. Daher lässt sich die Änderung nicht anzeigen.",
"objectives": {
"obligation-validity": {
@@ -407,10 +339,6 @@
"expires": "Verfällt am",
"criteria": "Die Prioritäten des Planers",
"criteriaHelp": "Von oben nach unten lesen: Der erste Unterschied hat Vorrang. Wählen Sie ein Kriterium, um seine Berechnung zu sehen.",
"traceImpactTitle": "Auswirkung dieses Zweigs auf den Gesamtplan",
"traceImpactHelp": "Diese Werte vergleichen den vollständigen Zustand vor und nach diesem Zweig. Sie zeigen seine Auswirkungen, aber nicht, warum der Planer ihn gewählt hat.",
"traceStageTitle": "Tatsächlicher Vergleich in dieser Planungsstufe",
"traceStageHelp": "Diese Rohwerte wurden bei dieser Entscheidung verglichen; der erste Unterschied erklärt den Status des Zweigs. Die Tabelle zum Gesamtplan unten zeigt die Auswirkung dieser Wahl.",
"firstDifference": "Erster Unterschied",
"higher": "Höher ist besser",
"lower": "Niedriger ist besser",
@@ -431,7 +359,6 @@
},
"comparison": {
"title": "Daten vergleichen",
"candidate": "Datum des Kandidaten",
"preview": "Nur eine Vorschau. Ihr Plan bleibt unverändert.",
"nearby": "Verfügbare Termine in der Nähe",
"noAlternatives": "Dieser Urlaubstag kann auf kein anderes Datum gelegt werden. Sie können trotzdem ein Datum prüfen, um den Grund zu sehen.",
@@ -441,8 +368,7 @@
"changes": "Was sich ändern würde",
"available": "Verfügbares Datum",
"unavailable": "Dieses Datum ist nicht möglich",
"backToPlan": "Zurück zu meinem Plan",
"backToTrace": "Zurück zur Berechnung"
"backToPlan": "Zurück zu meinem Plan"
}
},
"benchmark": {
+1 -75
View File
@@ -34,25 +34,15 @@
"daySuffix": "day",
"daysSuffix": "days",
"expires": "exp.",
"remainingWhy": "See why",
"remainingValidity": "Usable from {available} through {expiry}.",
"remainingHorizon": "Calculated plan from {start} through {end}.",
"remainingEarnedFrom": "Leave earned through the rule “{rule}”.",
"remainingHistoryHelp": "These dates were actually examined during the calculation. The values compare each attempt with its starting state; the final plan may differ.",
"remainingNoEvaluations": "No attempt attributable to this balance was recorded. Its expiry category alone does not explain why it remains unused.",
"remainingSuggestions": {
"available-after-horizon": "You can extend the planning period beyond its availability date, then recalculate.",
"no-working-date": "Check the planning period and this balance’s validity dates.",
"all-dates-occupied": "You can extend the period if validity allows, or adjust your already booked days and rules, then recalculate.",
"preserved": "No action needed. To request its use, add a rule within its validity period, then recalculate.",
"calculation-incomplete": "Run the calculation again if you want it to finish planning."
},
"remainingCandidateReasons": {
"quality-no-improvement": "This option was not retained at this stage of the calculation.",
"quality-protected-objective": "This option would worsen a priority that must be preserved.",
"quality-outperformed": "The calculation later found a better-ranked option.",
"earned-entitlement-order": "This option does not respect the availability dates of earned leave.",
"unknown": "The precise reason for this attempt is unavailable."
}
},
"dayTypes": {
@@ -218,7 +208,6 @@
"placementNavigation": "Placement explanation navigation",
"placement": "Leave day",
"placements": "placements",
"decision": "Decision",
"of": "of",
"first": "First placement",
"previous": "Previous placement",
@@ -231,13 +220,8 @@
"counterfactualReady": "Date alternatives are ready.",
"counterfactualError": "Date alternatives could not be calculated.",
"retryCounterfactual": "Retry date analysis",
"historicalEligibilityLoading": "Checking which dates this leave could use…",
"historicalEligibilityReady": "Original date eligibility is ready.",
"historicalEligibilityError": "Original date eligibility could not be checked.",
"retryHistoricalEligibility": "Retry date eligibility check",
"eligible": "Eligible",
"ineligible": "Ineligible",
"historical": "Original search status",
"reason": "Reason",
"explanationError": "The explanation could not be produced.",
"conflictDetails": "Why no plan can satisfy the obligations",
@@ -251,60 +235,11 @@
"currentPlacement": "Current explained placement",
"explainedPeriod": "Explained time-off period",
"outsideHorizon": "Context date outside the planning horizon",
"traceTitle": "Advanced: original decision trace",
"traceLoading": "The plan is ready; its decision trace is still loading…",
"traceReady": "The plan is ready and its decision trace has loaded.",
"traceUnavailable": "No decision trace is available for this result.",
"phases": "Trace phases",
"firstPhase": "First phase",
"previousPhase": "Previous phase",
"nextPhase": "Next phase",
"lastPhase": "Last phase",
"decisions": "Atomic decisions",
"firstDecision": "First decision",
"previousDecision": "Previous decision",
"nextDecision": "Next decision",
"lastDecision": "Last decision",
"replay": "Decision state",
"before": "Before",
"after": "After",
"finalPlan": "Final plan",
"dates": "Dates",
"candidates": "Candidates",
"previousCandidates": "Previous candidate page",
"nextCandidates": "Next candidate page",
"page": "Page",
"compareAlternatives": "Compare this candidate across the horizon",
"deltas": "State changes",
"activations": "earned activations",
"noDecisions": "This phase recorded no atomic decision.",
"traceStatuses": {
"chosen": "Chosen",
"evaluated-rejected": "Evaluated and rejected",
"pruned": "Pruned",
"not-examined": "Not examined",
"ineligible": "Ineligible"
},
"tracePhaseStatuses": {
"completed": "Completed",
"interrupted": "Interrupted",
"not-run": "Not run"
},
"traceChanges": {
"added": "Added by this decision",
"moved-from": "Moved from this date",
"moved-to": "Moved to this date",
"removed": "Removed by this decision",
"shared": "Shared by this decision",
"unchanged": "Unchanged by this decision"
},
"calendarHelp": "Select a leave day to see its contribution to your plan.",
"explanationHelp": "See the break you get and what each day changes.",
"traceHelp": "Explore the calculation, decision by decision.",
"technicalDetails": "Calculation details",
"compareDates": "Compare other dates",
"stopComparing": "Close date comparison",
"replayNotice": "You are viewing a calculation step.",
"explanation": {
"breakTitle": "Your break",
"leaveDays": "leave days used",
@@ -328,9 +263,6 @@
"alternative": "With this date",
"sameDate": "This is the planned date. Choose another date above to see the difference.",
"moveHelp": "Try moving this one day. All other leave dates stay the same.",
"traceHelp": "Test this candidate against the state before its original decision.",
"beforeDecision": "Before this decision",
"withCandidate": "With this candidate",
"comparisonUnavailable": "The comparison for the original date is unavailable, so the change cannot be shown.",
"objectives": {
"obligation-validity": {
@@ -407,10 +339,6 @@
"expires": "Expires on",
"criteria": "The planner’s priorities",
"criteriaHelp": "Read from top to bottom: the first difference takes priority. Select a criterion to see how it is calculated.",
"traceImpactTitle": "Full-plan effect of this branch",
"traceImpactHelp": "These values compare the complete state before and after this branch. They show its impact; they do not explain why the planner chose it.",
"traceStageTitle": "Actual stage comparison",
"traceStageHelp": "These raw values were compared at this decision; the first difference explains the candidate’s status. The full-plan table below shows the effect after this choice.",
"firstDifference": "First difference",
"higher": "Higher is better",
"lower": "Lower is better",
@@ -431,7 +359,6 @@
},
"comparison": {
"title": "Compare dates",
"candidate": "Candidate date",
"preview": "Preview only. Your plan stays unchanged.",
"nearby": "Nearby available dates",
"noAlternatives": "No other date can use this leave day. You can still check a date to see why.",
@@ -441,8 +368,7 @@
"changes": "What would change",
"available": "Available date",
"unavailable": "This date can’t be used",
"backToPlan": "Back to my plan",
"backToTrace": "Back to the calculation"
"backToPlan": "Back to my plan"
}
},
"benchmark": {
+1 -75
View File
@@ -34,25 +34,15 @@
"daySuffix": "día",
"daysSuffix": "días",
"expires": "exp.",
"remainingWhy": "Ver por qué",
"remainingValidity": "Utilizable del {available} al {expiry}.",
"remainingHorizon": "Plan calculado del {start} al {end}.",
"remainingEarnedFrom": "Permiso obtenido gracias a la regla «{rule}».",
"remainingHistoryHelp": "Estas fechas se examinaron realmente durante el cálculo. Los valores comparan cada intento con su estado inicial; el plan final puede ser diferente.",
"remainingNoEvaluations": "No se registró ningún intento atribuible a este saldo. Su categoría de caducidad por sí sola no explica por qué sigue sin utilizarse.",
"remainingSuggestions": {
"available-after-horizon": "Puedes ampliar el período planificado más allá de su fecha de disponibilidad y volver a calcular.",
"no-working-date": "Comprueba el período planificado y las fechas de validez de este saldo.",
"all-dates-occupied": "Puedes ampliar el período si la validez lo permite, o ajustar los días ya reservados y tus reglas, y volver a calcular.",
"preserved": "No es necesario hacer nada. Para solicitar su uso, añade una regla dentro de su período de validez y vuelve a calcular.",
"calculation-incomplete": "Repite el cálculo si quieres que termine la planificación."
},
"remainingCandidateReasons": {
"quality-no-improvement": "Esta opción no se seleccionó en esta fase del cálculo.",
"quality-protected-objective": "Esta opción empeoraría una prioridad que debe conservarse.",
"quality-outperformed": "El cálculo encontró después una opción mejor clasificada.",
"earned-entitlement-order": "Esta opción no respeta las fechas de disponibilidad de los permisos obtenidos.",
"unknown": "El motivo preciso de este intento no está disponible."
}
},
"dayTypes": {
@@ -218,7 +208,6 @@
"placementNavigation": "Navegación por las explicaciones de las asignaciones",
"placement": "Día de permiso",
"placements": "asignaciones",
"decision": "Decisión",
"of": "de",
"first": "Primera asignación",
"previous": "Asignación anterior",
@@ -231,13 +220,8 @@
"counterfactualReady": "Las fechas alternativas están listas.",
"counterfactualError": "No se pudieron calcular las fechas alternativas.",
"retryCounterfactual": "Reintentar el análisis de fechas",
"historicalEligibilityLoading": "Comprobando qué fechas podía usar este permiso…",
"historicalEligibilityReady": "La elegibilidad histórica de las fechas está lista.",
"historicalEligibilityError": "No se pudo comprobar la elegibilidad histórica de las fechas.",
"retryHistoricalEligibility": "Reintentar la comprobación de elegibilidad",
"eligible": "Válida",
"ineligible": "No válida",
"historical": "Estado de la búsqueda original",
"reason": "Motivo",
"explanationError": "No se pudo generar la explicación.",
"conflictDetails": "Por qué ningún plan satisface las obligaciones",
@@ -251,60 +235,11 @@
"currentPlacement": "Asignación explicada actualmente",
"explainedPeriod": "Período de descanso explicado",
"outsideHorizon": "Fecha de contexto fuera del horizonte de planificación",
"traceTitle": "Avanzado: traza de decisiones original",
"traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…",
"traceReady": "El plan está listo y se ha cargado su traza de decisiones.",
"traceUnavailable": "No hay una traza de decisiones disponible para este resultado.",
"phases": "Fases de la traza",
"firstPhase": "Primera fase",
"previousPhase": "Fase anterior",
"nextPhase": "Fase siguiente",
"lastPhase": "Última fase",
"decisions": "Decisiones atómicas",
"firstDecision": "Primera decisión",
"previousDecision": "Decisión anterior",
"nextDecision": "Decisión siguiente",
"lastDecision": "Última decisión",
"replay": "Estado de la decisión",
"before": "Antes",
"after": "Después",
"finalPlan": "Plan final",
"dates": "Fechas",
"candidates": "Candidatos",
"previousCandidates": "Página de candidatos anterior",
"nextCandidates": "Página de candidatos siguiente",
"page": "Página",
"compareAlternatives": "Comparar este candidato en todo el horizonte",
"deltas": "Cambios de estado",
"activations": "activaciones obtenidas",
"noDecisions": "Esta fase no registró ninguna decisión atómica.",
"traceStatuses": {
"chosen": "Elegido",
"evaluated-rejected": "Evaluado y rechazado",
"pruned": "Podado",
"not-examined": "No examinado",
"ineligible": "No válido"
},
"tracePhaseStatuses": {
"completed": "Completada",
"interrupted": "Interrumpida",
"not-run": "No ejecutada"
},
"traceChanges": {
"added": "Añadido por esta decisión",
"moved-from": "Movido desde esta fecha",
"moved-to": "Movido a esta fecha",
"removed": "Eliminado por esta decisión",
"shared": "Compartido por esta decisión",
"unchanged": "Sin cambios por esta decisión"
},
"calendarHelp": "Selecciona un día de permiso para ver su aportación al plan.",
"explanationHelp": "Consulta tu descanso y qué cambia con cada día.",
"traceHelp": "Explora el cálculo, decisión a decisión.",
"technicalDetails": "Detalles del cálculo",
"compareDates": "Comparar otras fechas",
"stopComparing": "Cerrar comparación",
"replayNotice": "Estás viendo un paso del cálculo.",
"explanation": {
"breakTitle": "Tu descanso",
"leaveDays": "días de permiso usados",
@@ -328,9 +263,6 @@
"alternative": "Con esta fecha",
"sameDate": "Esta es la fecha prevista. Elige otra fecha arriba para ver la diferencia.",
"moveHelp": "Prueba a mover este día. Las demás fechas de permiso no cambian.",
"traceHelp": "Compara este candidato con el estado anterior a su decisión original.",
"beforeDecision": "Antes de esta decisión",
"withCandidate": "Con este candidato",
"comparisonUnavailable": "La comparación de la fecha original no está disponible, por lo que no se puede mostrar el cambio.",
"objectives": {
"obligation-validity": {
@@ -407,10 +339,6 @@
"expires": "Vence el",
"criteria": "Las prioridades del cálculo",
"criteriaHelp": "Lee de arriba abajo: la primera diferencia tiene prioridad. Selecciona un criterio para ver cómo se calcula.",
"traceImpactTitle": "Efecto de esta rama en el plan completo",
"traceImpactHelp": "Estos valores comparan el estado completo antes y después de esta rama. Muestran su efecto, no por qué el planificador la eligió.",
"traceStageTitle": "Comparación real en esta etapa",
"traceStageHelp": "Estos valores brutos se compararon en esta decisión; la primera diferencia explica el estado de la opción. La tabla del plan completo muestra el efecto de esta elección.",
"firstDifference": "Primera diferencia",
"higher": "Más es mejor",
"lower": "Menos es mejor",
@@ -431,7 +359,6 @@
},
"comparison": {
"title": "Comparar fechas",
"candidate": "Fecha del candidato",
"preview": "Solo una vista previa. Tu plan no cambia.",
"nearby": "Fechas cercanas disponibles",
"noAlternatives": "No hay otra fecha disponible para este día de permiso. Puedes comprobar una fecha para ver el motivo.",
@@ -441,8 +368,7 @@
"changes": "Qué cambiaría",
"available": "Fecha disponible",
"unavailable": "Esta fecha no se puede usar",
"backToPlan": "Volver a mi plan",
"backToTrace": "Volver al cálculo"
"backToPlan": "Volver a mi plan"
}
},
"benchmark": {
+1 -75
View File
@@ -34,25 +34,15 @@
"daySuffix": "jour",
"daysSuffix": "jours",
"expires": "exp.",
"remainingWhy": "Voir pourquoi",
"remainingValidity": "Utilisable du {available} au {expiry}.",
"remainingHorizon": "Planning calculé du {start} au {end}.",
"remainingEarnedFrom": "Congés gagnés grâce à la règle « {rule} ».",
"remainingHistoryHelp": "Ces dates ont réellement été examinées pendant le calcul. Les valeurs comparent chaque essai à son état de départ ; le planning final peut être différent.",
"remainingNoEvaluations": "Aucun essai attribuable à ce solde n’a été enregistré. La catégorie d’expiration ne suffit pas à expliquer pourquoi il reste inutilisé.",
"remainingSuggestions": {
"available-after-horizon": "Vous pouvez prolonger la période planifiée au-delà de sa date de disponibilité, puis recalculer.",
"no-working-date": "Vérifiez la période planifiée et les dates de validité de ce solde.",
"all-dates-occupied": "Vous pouvez élargir la période si la validité le permet, ou ajuster vos jours déjà posés et vos règles, puis recalculer.",
"preserved": "Aucune action nécessaire. Pour demander son utilisation, ajoutez une règle sur une période où il est utilisable, puis recalculez.",
"calculation-incomplete": "Relancez le calcul si vous souhaitez qu’il termine la planification."
},
"remainingCandidateReasons": {
"quality-no-improvement": "Cette possibilité n’a pas été retenue à cette étape du calcul.",
"quality-protected-objective": "Cette possibilité dégraderait une priorité à préserver.",
"quality-outperformed": "Le calcul a ensuite trouvé une possibilité mieux classée.",
"earned-entitlement-order": "Cette possibilité ne respecte pas les dates de disponibilité des congés gagnés.",
"unknown": "La raison précise de cet essai n’est pas disponible."
}
},
"dayTypes": {
@@ -218,7 +208,6 @@
"placementNavigation": "Navigation entre les explications des placements",
"placement": "Jour de congé",
"placements": "placements",
"decision": "Décision",
"of": "sur",
"first": "Premier placement",
"previous": "Placement précédent",
@@ -231,13 +220,8 @@
"counterfactualReady": "Les dates alternatives sont prêtes.",
"counterfactualError": "Les dates alternatives n’ont pas pu être calculées.",
"retryCounterfactual": "Relancer l’analyse des dates",
"historicalEligibilityLoading": "Vérification des dates utilisables pour ce congé…",
"historicalEligibilityReady": "L’éligibilité historique des dates est prête.",
"historicalEligibilityError": "L’éligibilité historique des dates n’a pas pu être vérifiée.",
"retryHistoricalEligibility": "Revérifier l’éligibilité historique",
"eligible": "Éligible",
"ineligible": "Inéligible",
"historical": "Statut de la recherche originale",
"reason": "Raison",
"explanationError": "L’explication n’a pas pu être produite.",
"conflictDetails": "Pourquoi aucun plan ne satisfait les obligations",
@@ -251,60 +235,11 @@
"currentPlacement": "Placement expliqué actuellement",
"explainedPeriod": "Période de repos expliquée",
"outsideHorizon": "Date de contexte hors de l’horizon de planification",
"traceTitle": "Avancé : trace des décisions originales",
"traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…",
"traceReady": "Le plan est prêt et sa trace de décision est chargée.",
"traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.",
"phases": "Phases de la trace",
"firstPhase": "Première phase",
"previousPhase": "Phase précédente",
"nextPhase": "Phase suivante",
"lastPhase": "Dernière phase",
"decisions": "Décisions atomiques",
"firstDecision": "Première décision",
"previousDecision": "Décision précédente",
"nextDecision": "Décision suivante",
"lastDecision": "Dernière décision",
"replay": "État de la décision",
"before": "Avant",
"after": "Après",
"finalPlan": "Plan final",
"dates": "Dates",
"candidates": "Candidats",
"previousCandidates": "Page de candidats précédente",
"nextCandidates": "Page de candidats suivante",
"page": "Page",
"compareAlternatives": "Comparer ce candidat sur tout l’horizon",
"deltas": "Changements d’état",
"activations": "activations acquises",
"noDecisions": "Cette phase n’a enregistré aucune décision atomique.",
"traceStatuses": {
"chosen": "Choisi",
"evaluated-rejected": "Évalué et rejeté",
"pruned": "Élagué",
"not-examined": "Non examiné",
"ineligible": "Inéligible"
},
"tracePhaseStatuses": {
"completed": "Terminée",
"interrupted": "Interrompue",
"not-run": "Non exécutée"
},
"traceChanges": {
"added": "Ajouté par cette décision",
"moved-from": "Déplacé depuis cette date",
"moved-to": "Déplacé vers cette date",
"removed": "Retiré par cette décision",
"shared": "Partagé par cette décision",
"unchanged": "Inchangé par cette décision"
},
"calendarHelp": "Sélectionnez un jour de congé pour voir son apport au planning.",
"explanationHelp": "Découvrez votre pause et ce que chaque jour change.",
"traceHelp": "Explorez le calcul, décision par décision.",
"technicalDetails": "Détails du calcul",
"compareDates": "Comparer d’autres dates",
"stopComparing": "Fermer la comparaison",
"replayNotice": "Vous consultez une étape du calcul.",
"explanation": {
"breakTitle": "Votre pause",
"leaveDays": "jours de congé posés",
@@ -328,9 +263,6 @@
"alternative": "Avec cette date",
"sameDate": "C’est la date prévue. Choisissez une autre date ci-dessus pour voir la différence.",
"moveHelp": "Essayez de déplacer ce jour. Les autres dates de congé restent identiques.",
"traceHelp": "Testez ce candidat par rapport à l’état précédant sa décision d’origine.",
"beforeDecision": "Avant cette décision",
"withCandidate": "Avec ce candidat",
"comparisonUnavailable": "La comparaison pour la date initiale est indisponible ; la différence ne peut donc pas être affichée.",
"objectives": {
"obligation-validity": {
@@ -407,10 +339,6 @@
"expires": "Expire le",
"criteria": "Les priorités du calcul",
"criteriaHelp": "Lisez de haut en bas : la première différence est prioritaire. Sélectionnez un critère pour comprendre son calcul.",
"traceImpactTitle": "Effet de cette branche sur le plan complet",
"traceImpactHelp": "Ces valeurs comparent l’état complet avant et après cette branche. Elles montrent son effet, pas pourquoi le calcul l’a choisie.",
"traceStageTitle": "Comparaison réelle à cette étape",
"traceStageHelp": "Ces valeurs brutes ont été comparées lors de cette décision ; la première différence explique le statut de la possibilité. Le tableau du plan complet ci-dessous montre l’effet de ce choix.",
"firstDifference": "Première différence",
"higher": "Plus, c’est mieux",
"lower": "Moins, c’est mieux",
@@ -431,7 +359,6 @@
},
"comparison": {
"title": "Comparer les dates",
"candidate": "Date du candidat",
"preview": "Simulation uniquement. Votre planning reste inchangé.",
"nearby": "Dates proches disponibles",
"noAlternatives": "Aucune autre date ne permet de poser ce jour. Vous pouvez vérifier une date pour comprendre pourquoi.",
@@ -441,8 +368,7 @@
"changes": "Ce qui changerait",
"available": "Date disponible",
"unavailable": "Cette date ne peut pas être utilisée",
"backToPlan": "Revenir à mon planning",
"backToTrace": "Revenir au calcul"
"backToPlan": "Revenir à mon planning"
}
},
"benchmark": {
-7
View File
@@ -1,7 +0,0 @@
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
export default bootstrap;
+174
View File
@@ -0,0 +1,174 @@
import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from './server';
const INDEX = '<!doctype html><title>fixture app</title>';
const SECRET = 'top-secret-outside-the-root';
const JS = 'main-ABC123.js';
const IMMUTABLE = 'public, max-age=31536000';
let workdir: string;
let server: ReturnType<typeof createServer>;
let emptyServer: ReturnType<typeof createServer>;
// fetch() normalizes dot segments, so traversal needs an unnormalized request line.
function rawGet(port: number, target: string): Promise<{ status: number; text: string }> {
return new Promise((resolve, reject) => {
let text = '';
const socket = connect(port, '127.0.0.1', () =>
socket.write(`GET ${target} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n`),
);
socket.on('data', (chunk) => (text += chunk));
socket.on('end', () => resolve({ status: Number(text.split(' ')[1]), text }));
socket.on('error', reject);
});
}
beforeAll(() => {
workdir = mkdtempSync(join(tmpdir(), 'vac-server-'));
const root = join(workdir, 'browser');
mkdirSync(join(root, 'assets'), { recursive: true });
writeFileSync(join(root, 'index.html'), INDEX);
writeFileSync(join(root, JS), 'console.log("app");');
writeFileSync(join(root, 'styles-DEF456.css'), 'body{margin:0}');
writeFileSync(join(root, 'assets', 'data.json'), '{"nested":true}');
writeFileSync(join(workdir, 'secret.txt'), SECRET);
mkdirSync(join(workdir, 'empty'));
server = createServer({ root, port: 0 });
emptyServer = createServer({ root: join(workdir, 'empty'), port: 0 });
});
afterAll(async () => {
await server.stop(true);
await emptyServer.stop(true);
rmSync(workdir, { recursive: true, force: true });
});
describe('direct navigation', () => {
it.each(['/', '/benchmark', '/benchmark/', '/unknown/client/route', '/assets'])(
'serves the application shell for %s',
async (path) => {
const response = await fetch(new URL(path, server.url));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toStartWith('text/html');
expect(response.headers.get('cache-control')).toBe('no-cache');
expect(await response.text()).toBe(INDEX);
},
);
it('never gives the shell the long asset cache when requested by name', async () => {
const response = await fetch(new URL('/index.html', server.url));
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-cache');
});
});
describe('static assets', () => {
it.each([
[JS, 'javascript', 'console.log("app");'],
['styles-DEF456.css', 'css', 'body{margin:0}'],
['assets/data.json', 'json', '{"nested":true}'],
])('serves /%s with its content type and the long cache', async (file, type, body) => {
const response = await fetch(new URL(`/${file}`, server.url));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain(type);
expect(response.headers.get('cache-control')).toBe(IMMUTABLE);
expect(await response.text()).toBe(body);
});
it('answers HEAD with the GET headers and no body', async () => {
const get = await fetch(new URL(`/${JS}`, server.url));
const head = await fetch(new URL(`/${JS}`, server.url), { method: 'HEAD' });
expect(head.status).toBe(200);
expect(head.headers.get('content-type')).toBe(get.headers.get('content-type'));
expect(head.headers.get('content-length')).toBe(get.headers.get('content-length'));
expect(head.headers.get('cache-control')).toBe(IMMUTABLE);
expect(await head.text()).toBe('');
});
it('answers HEAD for a client route with no body', async () => {
const head = await fetch(new URL('/benchmark', server.url), { method: 'HEAD' });
expect(head.status).toBe(200);
expect(await head.text()).toBe('');
});
it.each(['/missing.js', '/assets/missing.css', '/nope.js/', '/favicon.ico'])(
'answers 404, not the shell, for missing asset %s',
async (path) => {
const response = await fetch(new URL(path, server.url));
expect(response.status).toBe(404);
expect(response.headers.get('content-type')).toStartWith('text/plain');
expect(await response.text()).not.toContain('fixture app');
},
);
});
describe('methods', () => {
it.each([`/${JS}`, '/benchmark'])('rejects POST to %s with 405', async (path) => {
const response = await fetch(new URL(path, server.url), { method: 'POST', body: 'x' });
expect(response.status).toBe(405);
expect(response.headers.get('allow')).toBe('GET, HEAD');
});
});
describe('path protection', () => {
it.each([
'/../secret.txt',
'/%2e%2e/secret.txt',
'/..%2fsecret.txt',
'/%2e%2e%2f%2e%2e%2fetc/passwd',
'/..%5csecret.txt',
'/assets/..%2f..%2fsecret.txt',
'/%2e%2e%2fsecret.txt',
'//secret.txt',
'/%00',
'/assets%00/data.json',
])('does not leak files outside the root through %s', async (target) => {
const response = await rawGet(server.port!, target);
expect([400, 404]).toContain(response.status);
expect(response.text).not.toContain(SECRET);
expect(response.text).not.toContain('root:');
});
it.each(['/..%2fsecret.txt', '/%2e%2e%2f%2e%2e%2fetc/passwd', '/%00'])(
'rejects %s as a bad request',
async (target) => {
expect((await rawGet(server.port!, target)).status).toBe(400);
},
);
it('rejects malformed percent-encoding with 400 instead of failing', async () => {
for (const target of ['/%ZZ', '/%E0%A4%A', '/assets/%']) {
expect((await rawGet(server.port!, target)).status).toBe(400);
}
});
});
describe('errors', () => {
it('answers 500 as plain text and logs it when the build has no index.html', async () => {
const log = spyOn(console, 'error').mockImplementation(() => {});
const response = await fetch(new URL('/benchmark', emptyServer.url));
expect(response.status).toBe(500);
expect(response.headers.get('content-type')).toStartWith('text/plain');
expect(await response.text()).toBe('Internal Server Error');
expect(log).toHaveBeenCalledTimes(1);
log.mockRestore();
});
it('still answers a missing asset with 404 when index.html is absent', async () => {
expect((await fetch(new URL('/missing.js', emptyServer.url))).status).toBe(404);
});
});
+61 -56
View File
@@ -1,66 +1,71 @@
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
import { extname, join, resolve, sep } from 'node:path';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
const IMMUTABLE = 'public, max-age=31536000';
/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/{*splat}', (req, res) => {
* // Handle API request
* });
* ```
* Fetch handler for a client-only Angular build in `root`: existing files are served as-is,
* extension-less paths fall back to `index.html` (client routes) and missing assets get a 404.
*/
export function createHandler(root: string) {
const base = resolve(root);
const index = join(base, 'index.html');
const isFile = (path: string) =>
Bun.file(path)
.stat()
.then(
(stats) => stats.isFile(),
() => false,
);
// index.html is not content-hashed, so it must be revalidated on every deployment.
const send = (path: string) =>
new Response(Bun.file(path), {
headers: { 'Cache-Control': path === index ? 'no-cache' : IMMUTABLE },
});
/**
* Serve static files from /browser
*/
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
/**
* Handle all other requests by rendering the Angular application.
*/
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
/**
* Start the server if this module is the main entry point, or it is ran via PM2.
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
*/
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = process.env['PORT'] || 4000;
app.listen(port, (error) => {
if (error) {
throw error;
return async (request: Request): Promise<Response> => {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', {
status: 405,
headers: { Allow: 'GET, HEAD' },
});
}
console.log(`Node Express server listening on http://localhost:${port}`);
let path: string;
try {
path = decodeURIComponent(new URL(request.url).pathname);
} catch {
return new Response('Bad Request', { status: 400 });
}
const file = resolve(base, `.${path}`);
if (path.includes('\0') || (file !== base && !file.startsWith(base + sep))) {
return new Response('Bad Request', { status: 400 });
}
// shortcut: symlinks inside the build are followed; fine while the build output is trusted.
if (await isFile(file)) return send(file);
// A path with an extension names an asset: a missing one must not turn into HTML.
if (extname(file)) return new Response('Not Found', { status: 404 });
// A missing index.html is a broken deployment: fail loudly (500) instead of a silent 404.
if (!(await isFile(index))) throw new Error(`Missing ${index}`);
return send(index);
};
}
export function createServer({ root, port }: { root: string; port: number }) {
return Bun.serve({
port,
fetch: createHandler(root),
error(error) {
console.error(error);
return new Response('Internal Server Error', { status: 500 });
},
});
}
/**
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
*/
export const reqHandler = createNodeRequestHandler(app);
if (import.meta.main) {
const server = createServer({
root: join(import.meta.dir, '../browser'),
port: Number(process.env['PORT'] || 4000),
});
console.log(`Server listening on ${server.url}`);
}
+110 -7
View File
@@ -1,13 +1,116 @@
import '@angular/compiler';
import { vi } from 'vitest';
import '@analogjs/vitest-angular/setup-snapshots';
import '@analogjs/vitest-angular/setup-serializers';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { plugin } from 'bun';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import * as bt from 'bun:test';
// shortcut: imports Analog's internal jitTransform by path; replace when Angular or Analog exports a JIT transform.
import { jitTransform } from '../node_modules/@analogjs/vite-plugin-angular/src/lib/compiler/jit-transform.js';
import { vi } from './testing/vitest-compat';
setupTestBed();
// Inline templateUrl/styleUrl(s) before Bun transpiles: JIT has no resource loader.
plugin({
name: 'angular-jit',
setup(build) {
build.onLoad({ filter: /src\/app\/(?!.*\.spec\.ts$).*\.ts$/ }, ({ path }) => {
let src = readFileSync(path, 'utf8');
if (!/@(Component|Directive|Injectable|Pipe|NgModule)\(/.test(src)) {
return { contents: src, loader: 'ts' };
}
const read = (p: string) =>
JSON.stringify(readFileSync(resolve(dirname(path), p), 'utf8'));
src = src.replace(/templateUrl:\s*'([^']+)'/, (_, p) => `template: ${read(p)}`);
// Styles are not compiled; they do not matter to the tests.
src = src.replace(/styleUrl:\s*'([^']+)'/, 'styles: []');
src = src.replace(/styleUrls:\s*\[[^\]]*\]/, 'styles: []');
// Signal input()/output()/viewChild() become decorators and @Component becomes ɵcompileComponent.
return { contents: jitTransform(src, path).code, loader: 'ts' };
});
},
});
const { JSDOM } = await import('jsdom');
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost:3000/',
pretendToBeVisual: true,
});
const win = dom.window as any;
const keep = new Set([
'console',
'globalThis',
'undefined',
'Infinity',
'NaN',
'Object',
'Function',
'Array',
'Error',
'Promise',
'Symbol',
'Map',
'Set',
'JSON',
'Math',
'Date',
'process',
'window',
'self',
]);
const force = new Set([
'document',
'navigator',
'location',
'localStorage',
'sessionStorage',
'history',
'getComputedStyle',
]);
const domNames =
/^(HTML|SVG|DOM|Mutation|Node|Element|Event|Text|Comment|Document|Range|Selection|CSS|Shadow|Custom|Keyboard|Mouse|Focus|Input|UI|Wheel|Pointer|Touch|Animation|File|Blob|FormData|Window|Storage|Headers|Abort)/;
for (const k of Object.getOwnPropertyNames(win)) {
if (keep.has(k) || (k in globalThis && !force.has(k) && !domNames.test(k))) continue;
// Live accessors: spying on window.x also affects bare x (jsdom window !== globalThis here).
Object.defineProperty(globalThis, k, {
configurable: true,
get: () => {
const v = win[k];
return typeof v === 'function' && /^[a-z]/.test(k) ? v.bind(win) : v;
},
set: (v) => {
win[k] = v;
},
});
}
for (const k of ['window', 'self']) {
Object.defineProperty(globalThis, k, { configurable: true, writable: true, value: win });
}
Object.assign(globalThis, {
vi,
describe: bt.describe,
it: bt.it,
test: bt.test,
expect: bt.expect,
beforeEach: bt.beforeEach,
afterEach: bt.afterEach,
beforeAll: bt.beforeAll,
afterAll: bt.afterAll,
});
await import('@angular/compiler');
const { getTestBed, ɵgetCleanupHook } = await import('@angular/core/testing');
const { BrowserTestingModule, platformBrowserTesting } =
await import('@angular/platform-browser/testing');
const { provideZonelessChangeDetection } = await import('@angular/core');
getTestBed().initTestEnvironment([BrowserTestingModule], platformBrowserTesting(), {
teardown: { destroyAfterEach: true },
});
getTestBed().configureTestingModule({ providers: [provideZonelessChangeDetection()] });
bt.beforeEach(ɵgetCleanupHook(false));
bt.afterEach(ɵgetCleanupHook(true));
// Bun has a native Worker; the app must see none, as under jsdom.
delete (globalThis as any).Worker;
vi.stubGlobal('Worker', undefined);
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */
import 'bun:test';
// Vitest APIs that the compat shim adds to bun:test.
declare module 'bun:test' {
interface Expect {
// Vitest-style loose matcher arguments: Bun's strict ones reject readonly and widened values.
(actual?: unknown): Matchers<any>;
poll<T>(
fn: () => T | Promise<T>,
options?: { timeout?: number; interval?: number },
): Matchers<Awaited<T>> & { not: Matchers<Awaited<T>> } & Record<string, unknown>;
}
interface Matchers<T> {
toHaveBeenCalledOnce(): void;
toHaveBeenCalledExactlyOnceWith(...args: unknown[]): void;
}
}
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more