Compare commits

..
Author SHA1 Message Date
renovate Bot 68489bfefd chore(deps): update node.js to v24.22.0
CI / validate (push) Successful in 1m25s
CI / test (push) Successful in 5m3s
CI / benchmark (push) Successful in 7m17s
CI / build (push) Successful in 1m39s
CI / security (push) Successful in 1m39s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 1m29s
CI / test (pull_request) Successful in 5m19s
CI / benchmark (pull_request) Successful in 7m27s
CI / build (pull_request) Successful in 1m36s
CI / security (pull_request) Successful in 1m42s
CI / images (push) Successful in 1m49s
CI / required (push) Successful in 6s
CI / images (pull_request) Successful in 1m54s
CI / required (pull_request) Successful in 2s
CI / policy (push) Successful in 19s
2026-10-17 05:04:49 +00:00
nessar 40a11c245a perf(planning): incremental exact scoring for the loaded year
CI / policy (push) Successful in 6s
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 / images (push) Successful in 1m10s
CI / required (push) Successful in 1s
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 / policy (push) Successful in 7s
CI / validate (push) Successful in 1m5s
CI / build (push) Successful in 1m38s
CI / policy (pull_request) Successful in 10s
CI / security (push) Successful in 1m19s
CI / test (push) Successful in 3m29s
CI / validate (pull_request) Successful in 1m42s
CI / benchmark (push) Successful in 4m13s
CI / security (pull_request) Successful in 56s
CI / build (pull_request) Successful in 1m42s
CI / test (pull_request) Successful in 3m8s
CI / images (push) Successful in 1m15s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Successful in 3m37s
CI / images (pull_request) Successful in 1m11s
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 / policy (pull_request) Successful in 9s
CI / security (push) Successful in 1m12s
CI / validate (pull_request) Successful in 1m18s
CI / benchmark (push) Successful in 3m56s
CI / test (push) Successful in 4m6s
CI / build (pull_request) Successful in 36s
CI / security (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 3m2s
CI / images (push) Successful in 1m15s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Successful in 7m18s
CI / images (pull_request) Successful in 1m50s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 6s
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 / policy (push) Successful in 6s
CI / validate (push) Successful in 1m1s
CI / build (push) Successful in 1m27s
CI / policy (pull_request) Successful in 9s
CI / security (push) Successful in 1m16s
CI / validate (pull_request) Successful in 1m34s
CI / benchmark (push) Successful in 4m31s
CI / test (push) Successful in 4m36s
CI / build (pull_request) Successful in 39s
CI / test (pull_request) Successful in 3m24s
CI / security (pull_request) Successful in 1m11s
CI / images (push) Successful in 1m16s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Failing after 6m0s
CI / images (pull_request) Skipped
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 / images (push) Skipped
CI / required (push) Failing after 1s
CI / policy (push) Failing after 9s
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 / images (push) Skipped
CI / required (push) Failing after 2s
CI / policy (push) Successful in 18s
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 / policy (push) Successful in 8s
CI / validate (push) Successful in 1m13s
CI / build (push) Successful in 1m0s
CI / policy (pull_request) Successful in 7s
CI / security (push) Successful in 1m43s
CI / validate (pull_request) Successful in 54s
CI / test (push) Successful in 3m21s
CI / build (pull_request) Successful in 1m29s
CI / test (pull_request) Successful in 2m19s
CI / security (pull_request) Successful in 1m42s
CI / benchmark (push) Failing after 5m50s
CI / images (push) Skipped
CI / required (push) Failing after 2s
CI / benchmark (pull_request) Successful in 4m58s
CI / images (pull_request) Successful in 1m54s
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 / benchmark (push) Failing after 5m52s
CI / build (push) Successful in 1m31s
CI / security (push) Successful in 1m39s
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 / 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 / required (pull_request) Failing after 1s
CI / policy (push) Successful in 7s
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 / policy (push) Successful in 7s
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 / images (push) Successful in 1m53s
CI / required (push) Successful in 4s
close #77

See pull request #273
2026-10-16 13:57:53 +02:00
nessar 26a8b076ba ci: use reachable Trivy database mirror
CI / policy (push) Successful in 7s
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 / 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 / build (pull_request) Successful in 1m26s
CI / images (push) Successful in 2m46s
CI / security (pull_request) Successful in 5m57s
CI / required (push) Successful in 4s
CI / images (pull_request) Successful in 3m25s
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 / policy (push) Successful in 6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m19s
CI / test (push) Successful in 1m52s
CI / benchmark (push) Failing after 5m29s
CI / test (pull_request) Successful in 3m22s
CI / security (pull_request) Failing after 12m15s
CI / build (pull_request) Failing after 14m33s
CI / benchmark (pull_request) Failing after 17m21s
CI / security (push) Failing after 19m29s
CI / images (pull_request) Skipped
CI / images (push) Skipped
CI / required (push) Failing after 1s
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 / policy (push) Successful in 21s
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 / images (push) Successful in 3m7s
CI / required (push) Successful in 1s
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 / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 7s
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 / benchmark (push) Failing after 5m59s
CI / build (push) Successful in 1m16s
CI / security (push) Successful in 6m51s
CI / images (push) Skipped
CI / required (push) Failing after 1s
CI / policy (push) Failing after 8s
Reviewed-on: #271
2026-10-15 19:55:10 +02:00
nessar d74dfb9938 ci: migrate to Gitea Actions
CI / validate (push) Successful in 1m25s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 1m20s
CI / security (push) Successful in 1m31s
CI / benchmark (push) Failing after 5m52s
CI / images (push) Failing after 1m2s
CI / required (push) Failing after 1s
CI / policy (pull_request) Successful in 9s
CI / validate (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 3m30s
CI / benchmark (pull_request) Canceled after 3s
CI / build (pull_request) Successful in 1m5s
CI / security (pull_request) Successful in 1m27s
CI / images (pull_request) Successful in 2m24s
CI / required (pull_request) Successful in 5s
CI / policy (push) Successful in 48s
2026-10-13 19:43:08 +02:00
nessar 7d60157863 docs: migrate repository guidance to Gitea 2026-10-13 19:38:32 +02:00
nessar 0d900fd72d feat(planning): make optimization rules composable
Closes #70, #71, #72, #73, and #74

See merge request nessar/vac-optimizer!189
2026-10-10 14:56:48 +00:00
nessar ae68e31575 docs: describe the advisory planning benchmark 2026-10-10 16:42:04 +02:00
nessar 98e87fecee ci: make the deferred planning benchmark non-blocking
Run the unchanged benchmark as a separate advisory job while #78 is open.
Keep functional coverage and other checks required, and retain the 2 GB heap
and 300-second protocol limit. Record #78's obligation to restore the gate.

Refs: #75, #78
2026-10-10 16:36:11 +02:00
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00
nessar 209bf05f36 feat(docs): add detailed planning objectives and optimization rules documentation 2026-10-08 23:20:40 +02:00
nessar 0c266a4b9d fix(deps): update angular-cli monorepo to v22.2.2
See merge request nessar/vac-optimizer!185
2026-10-08 19:08:11 +00:00
nessar f786872a18 feat(ui): configure planning horizon alongside leave balances
See merge request nessar/vac-optimizer!183
2026-10-08 19:07:27 +00:00
nessar ad5ef69f5f docs: document GitFlow rules for agents
See merge request nessar/vac-optimizer!186
2026-10-08 08:10:35 +00:00
nessar 749348ec0d docs: document GitFlow rules for agents 2026-10-08 08:56:00 +02:00
nessar 6777cbc282 feat(ui): use Material datepickers for planning horizon 2026-10-08 06:49:06 +00:00
nessar 31d4333783 feat(ui): configure planning horizon alongside leave balances 2026-10-08 06:49:06 +00:00
renovate Bot db561a31e6 fix(deps): update angular-cli monorepo to v22.2.2 2026-10-08 06:49:01 +00:00
nessar 964abd3546 ci: enforce GitFlow and publish branch images
See merge request nessar/vac-optimizer!184
2026-10-08 06:48:18 +00:00
118 changed files with 15265 additions and 11093 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ Inspect `git status --short` and distinguish pre-existing user changes from task
Look for the originating spec, in this order:
1. The user's explicit spec, ticket, path, or acceptance criteria in the conversation. Preserve it as authoritative even when commits reference other work.
2. Issue/MR references in relevant commit messages — fetch via `docs/agents/issue-tracker.md` when no explicit source was supplied.
2. Issue/PR references in relevant commit messages — fetch via `docs/agents/issue-tracker.md` when no explicit source was supplied.
3. A PRD/spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
4. If nothing is found or the source is inaccessible, continue the Standards review and report the Spec coverage limitation. Ask for a source only when needed to answer the user's request; do not invent requirements or require tracker setup.
@@ -42,10 +42,11 @@ Preserve the configured tracker. For a new setup, infer a likely tracker from th
- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI)
- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI)
- **Gitea** — issues live in the repo's Gitea Issues (uses `tea-cli`, or `tea` where that is the installed binary; inspect its help for supported commands)
- **Local markdown** — issues live as files under `.scratch/<feature>/` in this repo (good for solo projects or repos without a remote)
- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose
Record the choice in `docs/agents/issue-tracker.md`. The GitHub and GitLab templates carry a "PRs as a request surface" flag, defaulted **off** — leave it off and don't raise it; a user who wants external PRs in the triage queue can flip the flag in the file later.
Record the choice in `docs/agents/issue-tracker.md`. The GitHub, GitLab, and Gitea templates carry a "PRs as a request surface" flag, defaulted **off** — leave it off and don't raise it; a user who wants external PRs in the triage queue can flip the flag in the file later.
**Section B — Triage label vocabulary.** Skip this section entirely if the `triage` skill isn't installed (exploration told you) — an uninstalled skill needs no labels.
@@ -105,6 +106,7 @@ Then write the docs files using the seed templates in this skill folder as a sta
- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker
- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker
- [issue-tracker-gitea.md](./issue-tracker-gitea.md) — Gitea issue tracker
- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker
- [triage-labels.md](./triage-labels.md) — label mapping (only if `triage` is installed)
- [domain.md](./domain.md) — domain doc consumer rules + layout
@@ -0,0 +1,48 @@
# Issue tracker: Gitea
Issues and specs for this repo live as Gitea issues. Use the [`tea`](https://gitea.com/gitea/tea) CLI, called `tea-cli` on some systems. Record the instance URL, repository owner/name, installed command version, and supported operations when adapting this template.
## Access and conventions
Use the existing configured login and infer the repository from `git remote -v`. Check the installed binary's help before selecting commands: tea 1.0 changes the command groups, authentication, flags, and output formats. The examples here use `tea-cli` 0.9.2; options precede positional issue or PR numbers.
- **Authenticate**: use the existing login; for a new login, run interactive `tea-cli login add` with the repository's instance URL.
- **Create an issue**: `tea-cli issues create --title "..." --description "..." --labels "needs-triage"`. Pass multiline descriptions as one quoted argument.
- **Read an issue**: `tea-cli issues --comments <number>`.
- **List issues**: `tea-cli issues --state open --labels "..." --fields index,title,body,state,labels,assignees,url --output yaml`. Paginate with `--page` and `--limit` until all matching issues have been read.
- **Comment**: `tea-cli comment <number> "..."`.
- **Close**: post the explanation first, then `tea-cli issues close <number>`.
- **Pull requests**: `tea-cli pulls create --base <integration-branch> --title "..." --description "..."`, `tea-cli pulls --comments <number>`, and `tea-cli pulls --state open --output yaml`.
- **Milestones**: `tea-cli milestones --state all --fields id,title --output tsv`. Verify migrated milestone IDs rather than copying source-tracker IDs.
This version has no JSON output, issue-edit command, or `api` command. Structured output flags apply to lists; detail views render for display. For unsupported operations, use the instance's `/api/swagger` explorer and `/swagger.v1.json` schema, or the Gitea UI. Authenticate API requests with an authorized token without displaying or committing it. API paths below are relative to the instance's `/api/v1`:
- Read issue details and comments with `GET /repos/<owner>/<repo>/issues/<number>` and its `/comments` endpoint; paginate comments.
- Resolve label IDs with `GET /repos/<owner>/<repo>/labels`. Add labels with `POST /repos/<owner>/<repo>/issues/<number>/labels`, body `{"labels":[<label-id>]}`; remove one with `DELETE` on `/labels/<label-id>`. Preserve unrelated labels.
- Edit a body or assignment with `PATCH /repos/<owner>/<repo>/issues/<number>`, body `{"body":"..."}` or `{"assignees":["<username>"]}`. Read current assignees before updating the list.
- Read a PR diff with `GET /repos/<owner>/<repo>/pulls/<number>.diff`.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external pull requests as feature requests; `/triage` reads this flag.)_
When enabled, read and list with `tea-cli pulls`; keep only external requests after checking collaborator or organization membership. Use `tea-cli comment`, the issue-label API, and `tea-cli pulls close <number>` for comment / label / close operations.
Resolve bare `#<number>` references by checking the resource kind with `tea-cli issues --kind all --fields index,kind,title,url --output yaml` or the issue API's `pull_request` field. Issue URLs use `/issues/<number>`; PR URLs use `/pulls/<number>`.
## When a skill says "publish to the issue tracker"
Create a Gitea issue with `tea-cli issues create`.
## When a skill says "fetch the relevant ticket"
Run `tea-cli issues --comments <number>` and read its labels and assignees.
## Wayfinding operations
- **Map**: an issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. Create it with `tea-cli issues create --title "..." --description "..." --labels "wayfinder:map"`.
- **Child ticket**: an issue with `Part of #<map>` at the top of its description, labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`), and an ordered link in the map's body.
- **Blocking**: `POST /repos/<owner>/<repo>/issues/<child>/dependencies`, body `{"owner":"<owner>","repo":"<repo>","index":<blocker-number>}`. `GET` lists blockers; `DELETE` with the same body removes one. Where native dependencies are unavailable, use `Blocked by: #<n>, #<n>` at the top of the description. Every blocker must be closed before the ticket is unblocked.
- **Frontier query**: list all open children with bodies and assignees, drop assigned tickets and those with an open native or description-line blocker; first in map order wins.
- **Claim**: assign the authenticated username through the issue API or Gitea UI — the session's first write.
- **Resolve**: comment with `tea-cli comment`, close with `tea-cli issues close`, and update the map's Decisions-so-far through the issue API or Gitea UI.
@@ -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.
+1 -1
View File
@@ -19,7 +19,7 @@ A wizard is ephemeral by default — built for one run, saved to a scratch or `s
Work out every manual step the human must take and every value that gets captured along the way. Read the repo first — don't ask cold:
- For setup: configuration examples, README, compose/framework configuration, and the actual CI files (this repository uses `.gitlab-ci.yml`). Inspect secret names and references without displaying existing secret values. Include only values needed for the requested procedure, not every variable in the repository.
- For setup: configuration examples, README, compose/framework configuration, and the actual CI files. This repository is hosted on Gitea; read `.gitea/workflows/` and `docs/gitea-actions.md` for the configured workflow and owner-activation requirements. Inspect secret names and references without displaying existing secret values. Include only values needed for the requested procedure, not every variable in the repository.
- For a migration or transition: the current state, the target state, and the irreversible actions between them.
Then show the user the ordered list of stages and the values each produces, and confirm — they may add, drop, or reorder.
+8 -5
View File
@@ -6,7 +6,8 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
- Verify coverage by running:
```bash
bun run test --watch=false --coverage
bun scripts/generate-langs.js
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
```
- 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.
@@ -24,10 +25,12 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
bun run build
```
4. **GitLab Pipeline Checks**:
- You must execute the local equivalent of the GitLab CI/CD pipeline steps before completing your task. All of the following commands must pass:
4. **CI Checks**:
- 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`
- **Unit Tests and Coverage**: `bun run test --watch=false --coverage`
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json`
- **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`
- **Production Build**: `bun run build -- --configuration production`
- 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 `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/ --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
Symlink
+1
View File
@@ -0,0 +1 @@
.agents/
@@ -0,0 +1,5 @@
${PullRequestTitle}
${ClosingIssues}
See pull request ${PullRequestReference}
+208
View File
@@ -0,0 +1,208 @@
name: CI
on:
push:
pull_request:
types: [opened, reopened, synchronize, edited]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
policy:
name: policy
runs-on: ubuntu-latest
container: node:24.22.0
outputs:
slug: ${{ steps.policy.outputs.slug }}
tag: ${{ steps.policy.outputs.tag }}
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Policy regression tests
run: node scripts/ci-policy.test.mjs && node scripts/ci-image.test.mjs
- name: Validate event and commit
id: policy
run: node scripts/ci-policy.mjs
validate:
name: validate
runs-on: ubuntu-latest
container: node:24.22.0
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- 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
test:
name: test
runs-on: ubuntu-latest
container: node:24.22.0
env:
NODE_OPTIONS: --max-old-space-size=2048
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- 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/**'
- name: Coverage report
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
with:
name: coverage
path: coverage/
retention-days: 7
if-no-files-found: error
benchmark:
name: benchmark
runs-on: ubuntu-latest
container: node:24.22.0
env:
NODE_OPTIONS: --max-old-space-size=2048
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Benchmark protocol with relative gate
run: timeout 600s bunx vitest run src/app/benchmark/ --maxWorkers=1
build:
name: build
runs-on: ubuntu-latest
container: node:24.22.0
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun run build -- --configuration production
- uses: https://gitea.com/actions/upload-artifact@v3
with:
name: dist
path: dist/
retention-days: 7
if-no-files-found: error
security:
name: security
runs-on: ubuntu-latest
container: node:24.22.0
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- name: Install scanners
run: |
apt-get update
apt-get install --yes --no-install-recommends python3-venv
python3 -m venv /tmp/semgrep
/tmp/semgrep/bin/pip install semgrep==1.180.0
case "$(uname -m)" in
x86_64) trivy_arch=64bit ;;
aarch64) trivy_arch=ARM64 ;;
*) echo 'Unsupported Trivy architecture' >&2; exit 1 ;;
esac
archive="trivy_0.75.0_Linux-${trivy_arch}.tar.gz"
curl --fail --location --silent --show-error "https://github.com/aquasecurity/trivy/releases/download/v0.75.0/${archive}" --output "/tmp/${archive}"
curl --fail --location --silent --show-error https://github.com/aquasecurity/trivy/releases/download/v0.75.0/trivy_0.75.0_checksums.txt --output /tmp/trivy-checksums.txt
(cd /tmp && sha256sum --check --ignore-missing trivy-checksums.txt)
tar -xzf "/tmp/${archive}" -C /usr/local/bin trivy
- name: JavaScript and TypeScript SAST
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 --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
with:
name: security
path: '*-results.sarif'
retention-days: 7
if-no-files-found: error
images:
name: images
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
concurrency:
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
cancel-in-progress: false
services:
docker:
image: docker:29.8.2-dind
env:
DOCKER_TLS_CERTDIR: ''
options: >-
--privileged
--health-cmd "docker info"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ''
defaults:
run:
shell: sh
steps:
- name: Install checkout and policy runtimes
run: apk add --no-cache nodejs git
- uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- 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
required:
name: required
if: ${{ always() }}
needs: [policy, validate, test, benchmark, build, security, images]
runs-on: ubuntu-latest
container: node:24.22.0
env:
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 success' || exit 1
if [ "$IMAGE_EXPECTED" = 'true' ]; then
test "$IMAGE_RESULT" = 'success'
else
test "$IMAGE_RESULT" = 'skipped'
fi
+2
View File
@@ -6,6 +6,8 @@
/out-tsc
/bazel-out
.scratch/
# Node
/node_modules
npm-debug.log
-172
View File
@@ -1,172 +0,0 @@
image: oven/bun:latest
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
when: never
- if: $CI_PIPELINE_SOURCE == "push"
default:
interruptible: true
stages:
- validate
- test
- build
- security
- docker
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ''
AST_ENABLE_MR_PIPELINES: 'true'
cache:
key:
files:
- bun.lock
paths:
- .bun/
.node_setup:
rules:
- when: on_success
before_script:
- bun install --frozen-lockfile
# -- Validate Stage --
ci-policy:
image: node:24.21.0
stage: validate
variables:
GIT_DEPTH: '0'
rules:
- when: on_success
script:
- node scripts/ci-policy.test.mjs
- node scripts/ci-policy.mjs
lint:
extends: .node_setup
stage: validate
script:
- bunx eslint "src/**/*.ts"
prettier:
extends: .node_setup
stage: validate
script:
- bunx prettier --check "src/**/*.{ts,html,scss,css,json}"
type-check:
extends: .node_setup
stage: validate
script:
- bunx tsc --noEmit
# -- Test Stage --
test:
image: node:24.21.0
extends: .node_setup
stage: test
variables:
NODE_OPTIONS: --max-old-space-size=2048
before_script:
- npm install --global bun@1.4.2
- bun install --frozen-lockfile
script:
- |
node -e 'console.log("CI memory (bytes)", { available: process.availableMemory(), heapLimit: require("node:v8").getHeapStatistics().heap_size_limit })'
- bun scripts/generate-langs.js
# ponytail: one worker bounds peak RAM; increase concurrency after adding runner memory.
- bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
# Keep the complete benchmark outside coverage instrumentation.
- bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
artifacts:
when: always
paths:
- coverage/
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
expire_in: 7 days
# -- Build Stage --
build:
image: node:24.21.0
extends: .node_setup
stage: build
before_script:
- npm install --global bun@1.4.2
- bun install --frozen-lockfile
script:
- bun run build -- --configuration production
artifacts:
paths:
- dist/
expire_in: 7 days
# -- Security Stage --
include:
- template: Security/SAST.gitlab-ci.yml
trivy_scan:
stage: security
image:
name: aquasec/trivy:latest
entrypoint: ['']
before_script: [] # override global before_script
rules:
- when: on_success
script:
# Trivy will scan the project directory
- trivy fs --format sarif --output trivy-results.sarif .
artifacts:
reports:
sast: trivy-results.sarif
expire_in: 7 days
# -- Docker Stage --
.docker:
stage: docker
image: docker:29.8.2
services:
- docker:29.8.2-dind
before_script:
- printf '%s' "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
cache: []
dependencies: []
docker-build:
extends: .docker
resource_group: image-$CI_COMMIT_REF_SLUG
script:
# ponytail: GitLab slugs truncate at 63 bytes; add a branch-name hash if colliding names are needed.
- docker build -t "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker push "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG"
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "detached" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID
docker-release:
extends: .docker
interruptible: false
resource_group: stable-image
script:
- docker pull "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
@@ -6,9 +6,9 @@
**Status:** ready-for-agent
**GitLab:** [#61](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/61)
**Gitea:** [#61](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter le glossaire du domaine et les ADR 0002, 0005, 0006 et 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter le glossaire du domaine et les ADR 0002, 0005, 0006 et 0007.
- [ ] Un mode explicite « Saisir les jours déjà posés » présente l’horizon actuellement choisi et permet de sélectionner CP, RTT ou Autre. Autre couvre toutes les autres raisons de ne pas travailler ; ANY reste uniquement un sélecteur de règle.
- [ ] Un clic sur une date admissible ajoute le type sélectionné ; un deuxième clic avec ce type supprime la déclaration ; un clic avec un autre type la remplace. Une date porte au maximum une déclaration.
@@ -4,13 +4,13 @@
**Blocked by:**
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/61)).
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61)).
**Status:** ready-for-agent
**GitLab:** [#62](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/62)
**Gitea:** [#62](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter les définitions de période de repos, gain en jours non travaillés, séquence de travail et semaine de jour férié du glossaire, ainsi que les ADR 0006 et 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter les définitions de période de repos, gain en jours non travaillés, séquence de travail et semaine de jour férié du glossaire, ainsi que les ADR 0006 et 0007.
- [ ] Un vendredi ordinaire déclaré Autre, sans solde ni règle, produit une période vendredi–dimanche de trois jours. Cette période existe sans nouveau placement et ne provoque aucun débit.
- [ ] Les déclarations, nouveaux placements, week-ends et jours fériés adjacents forment des périodes contiguës fusionnées. Chaque date contribue une seule fois à leur durée ; l’origine et le type des déclarations restent distincts des placements.
@@ -4,13 +4,13 @@
**Blocked by:**
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/61)).
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61)).
**Status:** ready-for-agent
**GitLab:** [#63](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/63)
**Gitea:** [#63](https://gitea.nessar.fr/nessar/vac-optimizer/issues/63)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter les quantités, fenêtres inclusives, obligations conjointes, priorités et contributions partagées du glossaire, ainsi que l’ADR 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter les quantités, fenêtres inclusives, obligations conjointes, priorités et contributions partagées du glossaire, ainsi que l’ADR 0007.
- [ ] Dans une semaine ordinaire, avec quatre CP restants, un mercredi déclaré CP et une obligation de cinq CP du lundi au vendredi, le calcul ajoute quatre CP et débite uniquement ces quatre jours.
- [ ] Les déclarations compatibles contribuent aux obligations exactes et aux préférences demandant jusqu’à une quantité donnée, dans l’intersection entre la fenêtre inclusive de la règle et l’horizon soumis. Une déclaration extérieure à ces limites ne contribue pas à la règle.
@@ -4,14 +4,14 @@
**Blocked by:**
- 02 — Optimiser les périodes de repos autour des jours déclarés ([#62](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/62)).
- 03 — Compter les jours déclarés dans les règles de période ([#63](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/63)).
- 02 — Optimiser les périodes de repos autour des jours déclarés ([#62](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62)).
- 03 — Compter les jours déclarés dans les règles de période ([#63](https://gitea.nessar.fr/nessar/vac-optimizer/issues/63)).
**Status:** ready-for-agent
**GitLab:** [#64](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/64)
**Gitea:** [#64](https://gitea.nessar.fr/nessar/vac-optimizer/issues/64)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter les occurrences et modes IF_THEN, les droits acquis et l’ordre des objectifs du glossaire, ainsi que les ADR sur les artefacts du même calcul et l’ADR 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter les occurrences et modes IF_THEN, les droits acquis et l’ordre des objectifs du glossaire, ainsi que les ADR sur les artefacts du même calcul et l’ADR 0007.
- [ ] Les motifs IF_THEN utilisent les véritables jours fériés français nationaux et les modes before, after, around et auto existants. Une déclaration ne crée pas une occurrence de jour férié et ne permet pas de déplacer le motif hors de ses limites admissibles.
- [ ] Une déclaration compatible occupe sa position normale dans le motif et réduit le nombre de nouveaux placements nécessaires. Une déclaration incompatible ne peut être retypée ou déplacée, ni être sautée pour reporter la position demandée sur un jour plus éloigné. Les obligations restent obligatoires pour chaque occurrence ; les préférences conservent leur exigence de motif complet et leurs priorités.
@@ -6,9 +6,9 @@
**Status:** ready-for-agent
**GitLab:** [#65](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/65)
**Gitea:** [#65](https://gitea.nessar.fr/nessar/vac-optimizer/issues/65)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), in milestone %3: V1 stats. Follow the domain glossary and ADRs 0002, 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), in milestone V1 stats. Follow the domain glossary and ADRs 0002, 0004, 0006, and 0007.
- [ ] A labelled calendar control opens one statistics popup for the latest calculated valid plan. Opening it runs no optimization and changes no planning data.
- [ ] The popup shows the number of leave placement dates with an explicit leave-day label. Declared non-working days consume no balance and are excluded from this count. A valid plan with no placements displays 0 and still permits opening the popup.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/62), which establishes time-off periods and work sequences that include declared non-working days.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62), which establishes time-off periods and work sequences that include declared non-working days.
**Status:** ready-for-agent
**GitLab:** [#66](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/66)
**Gitea:** [#66](https://gitea.nessar.fr/nessar/vac-optimizer/issues/66)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#7](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/7). The existing longest work-sequence metric from #28 is reused. Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including [#7](https://gitea.nessar.fr/nessar/vac-optimizer/issues/7). The existing longest work-sequence metric from #28 is reused. Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Add five labelled metrics: non-working-day gain, average work-sequence length, longest work-sequence length, average time-off period length, and longest time-off period length. Work lengths use working days; break lengths use calendar days.
- [ ] The work-sequence average includes every non-empty sequence inside the submitted horizon, including both edges. Weekends and French public holidays neither count nor split a sequence; leave placements and declared non-working days interrupt it. Consecutive days off introduce no zero-length samples.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#64 — Appliquer les motifs IF_THEN et les droits acquis aux jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/64), which establishes finalized earned entitlements and the fixed Europe/Paris calculation-date cutoff.
- [#64 — Appliquer les motifs IF_THEN et les droits acquis aux jours déclarés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/64), which establishes finalized earned entitlements and the fixed Europe/Paris calculation-date cutoff.
**Status:** ready-for-agent
**GitLab:** [#67](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/67)
**Gitea:** [#67](https://gitea.nessar.fr/nessar/vac-optimizer/issues/67)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#8](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/8). Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including [#8](https://gitea.nessar.fr/nessar/vac-optimizer/issues/8). Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Display gross earned leave from every applicable generating rule, including Samedi Malin, with a per-rule breakdown whose quantities sum to the total.
- [ ] A plan earning 3 days and consuming 2 reports 3 earned days. Fully consumed earned entitlements remain included even when absent from remaining balances; generating a future entitlement does not require consuming it inside the horizon.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/62), which provides shared time-off periods including declared non-working days and adjacent context dates.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62), which provides shared time-off periods including declared non-working days and adjacent context dates.
**Status:** ready-for-agent
**GitLab:** [#68](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/68)
**Gitea:** [#68](https://gitea.nessar.fr/nessar/vac-optimizer/issues/68)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#9](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/9). Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including [#9](https://gitea.nessar.fr/nessar/vac-optimizer/issues/9). Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Show the number of distinct Monday-to-Friday French public-holiday dates in planned time-off periods containing at least one Saturday or Sunday. Each qualifying period contains at least one leave placement or declared non-working day.
- [ ] Count direct Monday/Friday connections and connections through leave, such as a Tuesday holiday joined to a weekend by Monday off. Declared non-working days can provide the same connection without consuming a balance.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#61 — Saisir, sauvegarder et figer les jours déjà posés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/61), which carries fixed declared non-working days with the producing calculation and its outcomes.
- [#61 — Saisir, sauvegarder et figer les jours déjà posés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61), which carries fixed declared non-working days with the producing calculation and its outcomes.
**Status:** ready-for-agent
**GitLab:** [#69](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/69)
**Gitea:** [#69](https://gitea.nessar.fr/nessar/vac-optimizer/issues/69)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including the clarification that working only one day in a full week is undesirable. Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including the clarification that working only one day in a full week is undesirable. Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Count Monday-to-Sunday calendar weeks whose five Monday-to-Friday dates all lie inside the submitted half-open horizon and that contain exactly one remaining working day after excluding French public holidays, leave placements, and declared non-working days.
- [ ] Four weekdays off and one remaining workday qualify. Weeks with zero or two remaining workdays do not qualify, including two-workday weeks containing separate one-day work sequences.
@@ -0,0 +1,25 @@
# 01 — Unifier l’évaluation et la comparaison des objectifs
**Gitea:** [#70](https://gitea.nessar.fr/nessar/vac-optimizer/issues/70)
**What to build:** Le planificateur conserve ses résultats actuels pendant une préparation qui rend les six objectifs évaluables à partir de configurations de règles. Le contrat commun permet ensuite aux règles fournies et aux règles personnelles d’utiliser les mêmes mesures, sans dépendre de leur nom ou de leur identité.
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
Préparation du remplacement décrit par l’ADR 0008, justifiée par les comparaisons aujourd’hui dispersées dans le moteur. Ouvrir une branche d’intégration consacrée à cette évolution ; la mise en service de l’ensemble dépend du ticket 06. Préserver les données utilisateur est une décision explicite du cadrage.
**Hypothèses de travail :** Pour clore les détails techniques restés ouverts dans la spécification, partir du catalogue minimal nécessaire aux six préréglages : somme, comptage et maximum, directions minimiser ou maximiser, valeur zéro sur ensemble vide. Le déficit de reprise possède un seuil entier positif, initialement cinq ; la pondération des semaines fériées reste la mesure métier existante. Les autres agrégations et filtres ne sont pas requis par ce découpage.
- [ ] Décrire le contrat composé : éléments observés, conditions applicables, mesure, agrégation et direction. Définir une table finie des combinaisons compatibles et des paramètres acceptés, avec les unités en jours travaillés ou calendaires selon la mesure. Le catalogue permet de composer des configurations, sans se réduire à six identifiants d’objectifs.
- [ ] Exprimer dans ce contrat les six préréglages : somme des soldes à risque non placés à minimiser ; somme des valeurs des semaines fériées complètes à maximiser ; gain de jours non ouvrés à maximiser ; longueur maximale des séquences de travail à minimiser ; déficit des courtes séquences internes à minimiser ; nombre de placements d’appoint à minimiser.
- [ ] Conserver les définitions exactes : pondération des fériés du lundi au vendredi de 1, 2, 3, 2, 1 ; déficit de cinq moins la longueur pour chaque séquence interne non vide de moins de cinq jours ; séquences aux bords de l’horizon incluses dans le maximum et exclues du déficit interne.
- [ ] Définir explicitement zéro pour le comptage, la somme et le maximum sur un ensemble vide, en reprenant les résultats actuels. Valider le domaine et l’unité des paramètres ; une combinaison non prise en charge est identifiable.
- [ ] Une même configuration appliquée au même plan produit la même valeur quels que soient le nom et l’identité de la règle. Vérifier aussi une configuration paramétrée distincte du préréglage, par exemple le déficit à trois jours, sans inventer une nouvelle mesure métier.
- [ ] Regrouper l’évaluation et les comparaisons nécessaires à la sélection des solutions d’obligations, aux déplacements, aux placements d’appoint, aux départages et aux explications. Remplacer les calculs dupliqués concernés ; ne pas ajouter un second moteur ou un adaptateur de compatibilité.
- [ ] Cette préparation conserve l’ordre historique pour ses scénarios de référence : prévention des pertes, préférences, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises, placements d’appoint, expiration puis dates.
- [ ] Les tests existants de placements, soldes, obligations conjointes, congés gagnés et trace de qualité conservent leurs résultats observables pendant ce prefactoring. Les mesures sont également vérifiées sur des plans concrets comportant des jours déclarés et des périodes aux limites de l’horizon.
- [ ] Consigner le contrat minimal et ses cas limites pour les tickets suivants, en distinguant les décisions déjà confirmées des hypothèses techniques retenues. Les tickets suivants partagent ce contrat et cette branche d’intégration.
**Vérification de bout en bout :** Comparer les résultats et les explications des scénarios de référence avant et après la préparation ; vérifier que les six recettes et une variante paramétrée évaluent correctement les mêmes plans.
@@ -0,0 +1,24 @@
# 02 — Piloter la prévention des pertes depuis les règles
**Gitea:** [#71](https://gitea.nessar.fr/nessar/vac-optimizer/issues/71)
**What to build:** L’utilisateur crée une règle d’optimisation de prévention des pertes, l’enregistre, la déplace parmi ses préférences et constate son effet sur les congés placés et les soldes restants. La désactiver retire réellement sa priorité automatique.
**Blocked by:** 01 — Unifier l’évaluation et la comparaison des objectifs.
**Status:** ready-for-agent
Première tranche fonctionnelle du modèle commun de l’ADR 0008. Continuer la branche d’intégration du ticket 01 ; l’ajout des six préréglages aux données existantes et la mise en service sont traités au ticket 06. Les scénarios de cette tranche soumettent explicitement leurs règles.
- [ ] Proposer la famille Optimisation dans la liste existante et permettre de créer, nommer, éditer, activer, désactiver, supprimer et réordonner une règle utilisant la somme des soldes à risque non placés. Seul le mode préférence est disponible pour cette famille.
- [ ] L’édition, les résumés et les commandes utilisent le contrat du ticket 01, sont traduits et accessibles au clavier. La configuration complète est sauvegardée localement et restaurée, avec son identité, ses paramètres, son activation et sa position.
- [ ] Le calcul résout les obligations conjointement puis utilise l’ordre des préférences actives pour arbitrer les placements. Placer la prévention des pertes sous une préférence personnelle peut laisser davantage de congés à risque inutilisés ; la remonter restaure sa priorité.
- [ ] Remplacer l’exécution implicite de la prévention des pertes par l’exécution de sa configuration explicite. Mettre en place le parcours ordonné partagé des règles ; aucun objectif n’est ajouté implicitement à une requête pour compenser une règle absente ou une configuration non prise en charge.
- [ ] Une règle désactivée donne le même plan que son absence, à autres entrées identiques. Une requête sans règle active n’ajoute aucun congé, même avec des soldes à risque, et conserve les jours déclarés. Les départages par expiration et dates ne déclenchent aucun placement.
- [ ] Les quantités, disponibilités, expirations, jours éligibles et jours déclarés restent des contraintes de validité. Une configuration active incompatible ou mal formée produit une erreur d’entrée rattachable à la règle, sans devenir un conflit d’obligations ni réactiver un défaut.
- [ ] Les chemins direct et worker conservent tous les paramètres de la règle dans l’instantané du calcul. L’explication, la trace et les analyses contrefactuelles suivent le même ordre actif ; une modification ultérieure dans l’éditeur ne change pas le résultat soumis.
- [ ] Adapter les consommateurs concernés, notamment ceux qui supposent qu’une règle comporte toujours un nombre de jours à poser. L’estimation de durée reste finie et les règles existantes Si… alors… et Période restent utilisables.
- [ ] Les tests de comportement couvrent création et rechargement, déplacement au clavier, priorité inversée contre une préférence personnelle, désactivation équivalente à suppression, absence de règles, entrée invalide et passage par le worker. S’appuyer sur les suites existantes de gestion des règles, de planification et d’instantané immuable.
- [ ] Les fonctionnalités communes de cette tranche sont réutilisables par les familles des tickets 03 à 05. Ne pas conserver une exécution historique parallèle comme solution transitoire ; la série est livrée après sa validation commune.
**Vérification de bout en bout :** Avec un solde à risque et une préférence personnelle concurrente, déplacer la prévention des pertes, recalculer et comparer les placements et soldes ; désactiver ensuite toutes les règles et vérifier l’absence de nouveaux congés.
@@ -0,0 +1,23 @@
# 03 — Composer les règles de semaines fériées et de repos
**Gitea:** [#72](https://gitea.nessar.fr/nessar/vac-optimizer/issues/72)
**What to build:** L’utilisateur crée et règle les préférences « Compléter les semaines avec un jour férié » et « Profiter des week-ends et jours fériés », puis choisit laquelle prime dans le planning et dans ses explications.
**Blocked by:** 02 — Piloter la prévention des pertes depuis les règles.
**Status:** ready-for-agent
Tranche de calendrier de l’ADR 0008, sur la branche d’intégration commune. Elle utilise l’édition, la persistance, le transport et l’ordre actif introduits au ticket 02. Elle peut avancer indépendamment du ticket 04 ; la mise en service reste conditionnée au ticket 06.
- [ ] Permettre de composer, enregistrer et modifier les règles sur les semaines fériées et les périodes de repos avec les conditions, mesures et agrégations compatibles du contrat commun. Les deux préréglages sont des configurations du même modèle que les règles personnelles.
- [ ] Le préréglage des semaines fériées maximise la somme des valeurs des semaines complètes. Compter chaque férié du lundi au vendredi selon sa distance au week-end le plus proche ; additionner les contributions de plusieurs fériés dans une semaine sans compter la semaine deux fois.
- [ ] Le préréglage du gain maximise les dates distinctes de week-end et de jour férié contenues dans les périodes de repos. Les jours de congé posés ne constituent pas eux-mêmes ce gain.
- [ ] Conserver les contributions des jours déclarés et les règles d’adjacence aux limites de l’horizon. Les placements restent dans l’horizon et sur des dates éligibles ; une semaine ordinaire entièrement déclarée ne devient pas une semaine fériée.
- [ ] Les règles s’arbitrent selon leur position parmi toutes les préférences actives. Un scénario où la meilleure semaine fériée concurrence un gain de jours non ouvrés plus élevé démontre les deux ordres possibles.
- [ ] Une préférence de calendrier peut être placée avant ou après Samedi Malin et les autres préférences. Aucun ancien garde-fou ne rétablit automatiquement la priorité d’une semaine fériée ou d’une prévention des pertes désactivée.
- [ ] La recherche peut utiliser des placements d’appoint admissibles lorsque l’objectif actif les justifie et que les préférences supérieures et obligations sont préservées. Leur classification est conservée pour le ticket 05.
- [ ] La configuration éditée et sauvegardée est celle utilisée par le calcul direct, le worker, les explications, la trace et les comparaisons de dates. Les libellés et commandes sont traduits et accessibles.
- [ ] Adapter les scénarios de comportement existants sur les semaines fériées, le gain, les jours déclarés et les placements d’appoint : férié du mercredi, plusieurs fériés, horizon partiel, concurrence entre objectifs et désactivation équivalente à absence. Les requêtes de référence fournissent explicitement leurs règles.
**Vérification de bout en bout :** Créer les deux règles, inverser leur ordre sur un cas concurrent, recalculer et constater la modification du planning et du premier objectif décisif dans l’explication ; recharger et retrouver la configuration.
@@ -0,0 +1,23 @@
# 04 — Composer les règles de séquences de travail
**Gitea:** [#73](https://gitea.nessar.fr/nessar/vac-optimizer/issues/73)
**What to build:** L’utilisateur compose des préférences sur ses séquences de travail : réduire la plus longue séquence ou limiter le déficit des reprises courtes, avec un seuil configurable. Leur position parmi les autres préférences influence le planning.
**Blocked by:** 02 — Piloter la prévention des pertes depuis les règles.
**Status:** ready-for-agent
Tranche de séquences de travail de l’ADR 0008, sur la branche d’intégration commune. Elle utilise le contrat minimal et les fonctions de gestion du ticket 02 et peut avancer indépendamment du ticket 03. Le seuil initial de cinq jours reproduit le comportement existant.
- [ ] Permettre de créer, éditer, sauvegarder et réordonner une règle sur la longueur maximale des séquences de travail et une règle sur le déficit des séquences internes, à partir du même modèle composable.
- [ ] Le préréglage de la séquence maximale minimise son nombre de jours travaillés, en incluant les séquences situées aux deux bords de l’horizon. Les week-ends et jours fériés ne comptent pas et ne coupent pas une séquence.
- [ ] Le préréglage des reprises courtes minimise la somme des déficits pour les séquences internes non vides : avec le seuil initial de cinq jours, une reprise d’un jour contribue quatre et une reprise de quatre jours contribue un. Les séquences aux bords de l’horizon sont exclues de cette mesure.
- [ ] Le seuil de la mesure de déficit est un entier strictement positif, initialement cinq. Une règle personnelle utilisant un autre seuil, par exemple trois, calcule son propre déficit et peut produire un autre arbitrage ; il ne s’agit pas d’un seuil impératif de validité.
- [ ] Les conditions de sélection, mesures, unités et paramètres compatibles sont contrôlés à l’édition et à l’entrée du calcul. Une collection vide ou des séquences internes vides respectent la valeur zéro définie au ticket 01.
- [ ] Deux règles portant sur la même famille mais ayant des paramètres distincts sont évaluées séparément selon leur position. L’évaluation et les caches éventuels ne confondent pas les configurations sur la seule base du type de mesure.
- [ ] Les placements choisis respectent les obligations, les préférences supérieures et les jours déclarés. Désactiver une règle retire son influence, y compris lors de la recherche de placements d’appoint, sans conserver une protection fixe de la longueur maximale ou du déficit.
- [ ] L’interface, la restauration, le worker, l’explication, la trace et les comparaisons contrefactuelles utilisent la même configuration capturée. Les libellés distinguent jours travaillés, seuil de préférence et unité de la mesure.
- [ ] Les tests prolongent les suites de statistiques et de planification : bords de l’horizon, séquences vides, jours déclarés, seuil initial, autre seuil, deux règles paramétrées, priorité face à une règle personnelle et désactivation. Les vérifications portent sur les placements et valeurs observables, y compris après rechargement.
**Vérification de bout en bout :** Créer une préférence de reprise courte à cinq jours, constater sa mesure et le plan, puis la modifier à trois jours et recalculer un cas discriminant ; retrouver le seuil et son effet après rechargement.
@@ -0,0 +1,22 @@
# 05 — Piloter l’économie des jours d’appoint
**Gitea:** [#74](https://gitea.nessar.fr/nessar/vac-optimizer/issues/74)
**What to build:** L’utilisateur ajoute une préférence « Économiser les jours d’appoint » et décide, par sa position, jusqu’où les améliorations du planning peuvent consommer des congés qui expirent après l’horizon.
**Blocked by:** 03 — Composer les règles de semaines fériées et de repos ; 04 — Composer les règles de séquences de travail.
**Status:** ready-for-agent
Tranche d’économie des placements d’appoint de l’ADR 0008, sur la branche d’intégration commune. Les tickets 03 et 04 fournissent les objectifs qui peuvent justifier ces placements et contre lesquels cette préférence doit être vérifiée. La migration globale est réservée au ticket 06.
- [ ] Permettre de créer, éditer, activer, désactiver, supprimer, réordonner et sauvegarder une règle qui minimise le nombre de placements d’appoint selon le contrat commun.
- [ ] Compter uniquement les placements d’appoint au sens du domaine : des placements supplémentaires issus de soldes expirant après l’horizon pour améliorer le plan. Les placements explicitement demandés par une règle ne deviennent pas tous des placements d’appoint parce que leur solde expire plus tard.
- [ ] En position supérieure aux objectifs de calendrier ou de séquences de travail, cette préférence peut empêcher des ajouts d’appoint qui amélioreraient seulement ces objectifs inférieurs. En position inférieure, elle économise des placements sans dégrader les objectifs supérieurs ni les obligations.
- [ ] La suppression, le déplacement ou la réaffectation d’un placement conserve une classification et une provenance cohérentes. Les conditions chronologiques des congés gagnés et les soldes disponibles restent valides.
- [ ] Désactiver cette règle retire son critère de comparaison ; aucune étape fixe ne rétablit la minimisation du nombre de placements d’appoint. Sa désactivation ne déclenche pas à elle seule de nouveaux placements : un objectif actif doit justifier un ajout.
- [ ] Les cas à égalité sur les règles actives conservent les départages finaux par expiration puis dates. Un objectif supprimé ne réapparaît pas sous forme de départage caché.
- [ ] La gestion dans l’interface, la persistance et les commandes au clavier traversent jusqu’au calcul réel. Explications, trace et analyses contrefactuelles rendent compte de la règle active et de la contribution des placements concernés.
- [ ] Adapter les tests existants sur la conservation de provenance et l’économie des placements d’appoint. Ajouter des arbitrages vérifiables contre une amélioration de calendrier et contre une amélioration des séquences de travail, avec inversion des priorités et comparaison entre désactivation et absence.
**Vérification de bout en bout :** Comparer un plan qui ajoute des jours d’appoint pour améliorer le repos à un plan où la préférence d’économie est remontée ; montrer les jours consommés, les soldes conservés et l’objectif décisif.
@@ -0,0 +1,34 @@
# 06 — Migrer les règles sauvegardées et valider le comportement fonctionnel
**Gitea:** [#75](https://gitea.nessar.fr/nessar/vac-optimizer/issues/75)
Blocked by: #74
Les exigences de performance sont transférées à #78. La clôture de ce ticket dépend de la migration et de la validation fonctionnelle, pas du passage du protocole de performance. Les limites du benchmark restent inchangées. À la demande de l’utilisateur, son job CI est séparé et non bloquant tant que #78 est ouvert ; #78 doit rétablir ce contrôle obligatoire après réussite du protocole. Les tests fonctionnels et les autres contrôles CI restent obligatoires.
## What to build
Au prochain chargement, l’utilisateur retrouve ses règles et les six anciens comportements du moteur sous forme de préréglages actifs. Il peut ensuite les modifier ou les supprimer durablement, et l’ensemble est validé avant sa mise en service.
Livraison intégrée de l’ADR 0008 sur la branche commune aux tickets #70, #71, #72, #73, #74. La préservation des données enregistrées et la migration unique ont été explicitement approuvées : ne pas réinitialiser les données pour simplifier le changement de contrat.
## Acceptance criteria
- [x] À l’initialisation, fournir les six préréglages actifs avec cet ordre : prévention des pertes, règles personnelles ou règles initiales existantes dans leur ordre, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises et économie des placements d’appoint.
- [x] Migrer une seule fois les listes enregistrées avant cette évolution, y compris une ancienne liste vide. Préserver intégralement l’identité, le nom, les paramètres, les dates, la force, l’activation et l’ordre relatif des règles existantes.
- [x] Enregistrer de façon cohérente les règles migrées et le fait que la migration a eu lieu. Ne pas considérer l’opération terminée si les règles n’ont pas été sauvegardées ; gérer les échecs de stockage sans écraser les données utilisateur valides.
- [x] Les chargements suivants ne dupliquent pas les préréglages, ne rétablissent pas ceux supprimés et ne réactivent pas ceux désactivés. Une liste vidée volontairement après migration reste vide. Les nouveaux utilisateurs reçoivent directement la nouvelle configuration initiale.
- [x] La restauration et la migration précèdent le premier calcul utilisant ces données. L’utilisateur peut immédiatement éditer les six configurations avec les mêmes commandes que ses règles personnelles et retrouver ses choix après rechargement.
- [x] Vérifier de bout en bout les six préréglages dans l’ordre initial, leurs réordonnancements et leurs désactivations. Une configuration personnelle différente d’un préréglage traverse édition, sauvegarde, worker, calcul et explication ; les paramètres ont un effet vérifiable.
- [x] La liste entièrement désactivée, la liste supprimée après migration et une requête directe sans règle active n’ajoutent aucun congé malgré des soldes à risque. Les jours déclarés, les quantités restantes et les raisons pertinentes restent cohérents.
- [x] Les obligations conjointes, les conditions des congés gagnés et les contraintes de validité sont respectées pour tout ordre des préférences. Une annulation restitue le meilleur plan valide disponible selon la configuration soumise. La suppression de la trace originale est suivie séparément dans #76.
- [x] Les explications et analyses contrefactuelles utilisent le même ordre actif que le calcul. Modifier les règles durant le calcul ou pendant la livraison des explications ne change pas ses résultats. Les anciens objectifs implicites n’interviennent plus dans aucune comparaison ou protection.
- [x] Les fixtures et scénarios de benchmark qui dépendaient d’une optimisation implicite demandent explicitement les préréglages nécessaires. Les tests d’absence de règles n’utilisent aucun défaut implicite pour retrouver l’ancien résultat.
- [x] Exécuter les suites fonctionnelles concernées de règles, interface, moteur, worker réel, explication, annulation et estimation de durée, ainsi que les contrôles habituels de compilation et de qualité. Préserver les assertions de résultat et de déterminisme des fixtures, y compris les horizons longs. L’exécution complète du protocole de benchmark, la réactivité mesurée, les durées, la consommation mémoire et les optimisations nécessaires relèvent de #78 et ne conditionnent plus la clôture de #75. Ne pas introduire de délai d’arrêt automatique.
- [x] La vérification fonctionnelle commune des tickets #70, #71, #72, #73, #74, #75 est la condition de clôture fonctionnelle de ce ticket. Documenter les choix finaux du contrat composable ; ne laisser ni double moteur, ni chemin historique implicite, ni adaptateur provisoire ajouté pour rendre les étapes indépendamment livrables. Les limites de performance mesurées et leur amélioration sont suivies dans #78 ; les tests fonctionnels et les autres contrôles CI restent obligatoires, avec la seule exception temporaire du job de benchmark décrite ci-dessus.
**Vérification de bout en bout :** Partir d’une ancienne sauvegarde contenant des règles personnalisées et désactivées, charger la nouvelle version, vérifier le premier calcul, modifier ou supprimer des préréglages, recharger et constater la conservation exacte des choix.
## Blocked by
- #74 — Piloter l’économie des jours d’appoint
@@ -0,0 +1,29 @@
# 01 — Remove the original decision trace end to end
**Gitea:** [#76](https://gitea.nessar.fr/nessar/vac-optimizer/issues/76)
## What to build
Calculations no longer record, retain or deliver an original decision trace. Users receive the displayed plan, its stable explanation and remaining-balance reasons, and can still analyze an alternative date for a displayed placement. The advanced trace explorer, historical calendar replay and historical attempt lists disappear completely.
This is one cohesive contract replacement across the planner, calculation service, workers and UI. Use a scoped integration branch containing the existing #70–#75 implementation work; #75's final verification must not be treated as a prerequisite for removing tracing. The shared functional integration verification gate is #77 — Verify integrated planning after trace removal. Benchmark performance acceptance and any required optimization are owned by #78; performance is no longer a completion gate for #75.
## Acceptance criteria
- [ ] Delete trace capture, materialization, candidate-history retention, replay, historical eligibility and caches or evidence pools used exclusively for tracing. Remove observational work while preserving search breadth, allocation previews, rule priority and incumbent selection.
- [ ] Replace trace contracts directly and update all callers in the same change. Remove trace promises, callbacks, public types, streaming messages, status indexes, acknowledgements and backpressure queues across real-worker, browser-fallback and server-side calculation paths. Do not retain debug toggles, compatibility adapters, empty trace results or replacement history.
- [ ] Preserve immutable calculation, valid-incumbent and final-state snapshots needed by retained behavior. Rename retained state contracts independently of tracing; do not delete cancellation or explanation data because it currently has a trace-related name.
- [ ] Retain stable plan explanations, including placement entitlement and rule provenance, objective contributions and the first decisive objective, time-off periods, remaining balances and concrete non-placement reasons. Preserve invalid-input and conflict explanations and incomplete-calculation behavior.
- [ ] Keep alternative-date analysis for a displayed placement using the captured calculation configuration, the same entitlement, leave type and provenance, and fixed unrelated placements. Remove analysis based on historical decisions; hypothetical analysis never mutates the displayed plan.
- [ ] Remove the advanced trace panel, replay controls, historical calendar overlays and candidate statuses. Remove remaining-balance historical attempt lists, pagination and loading state while retaining classification, validity, earned-entitlement source and truthful displayed-plan reasons. Leave no dead controls or trace-unavailable messages.
- [ ] Preserve explicit cancellation before and after a valid incumbent, best-valid-partial-plan recovery, heartbeat monitoring, stale-response rejection and the existing technical-budget contract. Interrupted calculations do not imply an obligation conflict. Explanation delivery failures do not discard an available outcome, and planning workers do not wait for trace acknowledgements after required delivery.
- [ ] Preserve completed placements, balances, objective values, joint obligation satisfaction, earned-entitlement chronology, deterministic tie-breaking, user-controlled priorities and empty-active-rule behavior. Do not reset saved entitlements, declarations or rule configurations or change the existing migration.
- [ ] Update component styles and translations across supported languages, preserving keyboard access and accessibility for retained explanations, calendar interactions and date analysis.
- [ ] Adapt existing planner, service, worker, explanation, cancellation, calendar and remaining-balance tests. Delete tests exclusively for tracing while retaining assertions for supported behavior in mixed tests. Update benchmark callers, output and assertions to remove obsolete trace requirements without reducing scenarios or changing performance gates.
- [ ] Supersede trace-specific requirements in the domain vocabulary and explanation/cancellation ADRs, and update benchmark documentation. Preserve the remaining planning contracts and document that the original decision trace and historical analysis are removed.
No new dependency, replacement telemetry, optimization-quality change, allocation-preview reduction or benchmark deadline relaxation is part of this ticket. No separate prefactor is required: retained explanation and hypothetical-analysis behavior already exists.
## Blocked by
None — can start immediately.
@@ -0,0 +1,27 @@
# 02 — Verify integrated planning after trace removal
**Gitea:** [#77](https://gitea.nessar.fr/nessar/vac-optimizer/issues/77)
Blocked by: #76
## What to build
Demonstrate that the integrated #70–#75 work retains its supported behavior after trace removal. Provide a functionally verified trace-free baseline for #78, which now owns benchmark performance acceptance and optimization. Benchmark performance is no longer a completion gate for #75 or this functional verification ticket.
## Acceptance criteria
- [ ] Review the integrated contract replacement across all calculation paths and UI callers. Confirm there is no trace recorder, trace transport, acknowledgement wait, historical replay API or retained trace-dependent control or compatibility shell.
- [ ] Compare completed outcomes and stable explanations against existing deterministic fixtures, including typed joint obligations, earned-entitlement chains, custom objective formulas, reordered priorities, two- and three-year horizons and no active optimization rules. Do not rewrite placement expectations to hide a behavioral regression.
- [ ] Verify real-worker outcome and explanation delivery, heartbeat monitoring, stale-response rejection and explanation failure handling. Exercise cancellation before any valid incumbent and after a valid incumbent, and confirm that partial outcomes remain valid without false conflicts.
- [ ] Verify displayed-placement alternative-date analysis and remaining-balance reasons without historical data. Check that captured settings remain immutable and hypothetical alternatives do not change the displayed plan.
- [ ] Run the existing full correctness, coverage, static, formatting and build checks appropriate to the integrated change, and resolve regressions within the trace-removal scope. Preserve the repository's required thresholds.
- [ ] Complete browser QA for the retained explanation, calendar, cancellation and alternative-date flows, including keyboard interaction and a narrow viewport. Confirm the advanced trace explorer, historical overlays and historical attempt controls are absent.
- [ ] Verify that benchmark callers use the supported trace-free calculation contract and retain their correctness, determinism, horizon, placement-count and completed-stage assertions. Full protocol execution, the unchanged 2 GB/300-second performance gates and any required optimization belong to #78.
- [ ] Hand off the functionally verified integrated baseline and unchanged benchmark inputs to #78. Verify retained outcome and explanation delivery without waiting for removed trace artifacts or inventing replacement trace counters.
- [ ] Keep the original #70–#75 dependency order and report the functional integration result. Apply #75’s revised functional completion criteria: a benchmark performance failure alone does not block #75 or this ticket. Outstanding benchmark requirements remain open in #78, without deadline relaxation or an automatic engine cutoff.
The removal is agreed; its measured speedup is unresolved and is now evaluated in #78. This ticket owns functional verification after trace removal. It does not itself implement performance optimizations or close other issues.
## Blocked by
- #76 — Remove the original decision trace end to end (https://gitea.nessar.fr/nessar/vac-optimizer/issues/76).
@@ -0,0 +1,39 @@
# 03 — Verify and optimize planning to meet the benchmarks from #75
**Gitea:** [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78)
Blocked by: #75, #77
## Parent
#75 — Performance acceptance separated from the functional migration ticket at the user's request.
## What to build
Verify the integrated planner against the benchmark requirements originally attached to #75 and apply performance optimizations when needed until those requirements pass. Start from the functionally verified implementation after original-decision-trace removal. #75 can finish on its functional requirements; performance acceptance belongs to this ticket.
If the measured baseline already passes, keep the engine unchanged and record the evidence. Otherwise profile the real failing path, apply the smallest complete improvement and verify both performance and retained planning behavior.
## Acceptance criteria
- [ ] Keep the existing seven benchmark scenarios and inputs, including the loaded year, typed joint obligations, chained earned entitlements and two- and three-year horizons. Keep explicit optimization configurations, quotas, expected outcomes, placement counts, completed-stage and determinism assertions.
- [ ] Run the real existing benchmark protocol: one warm-up and two measured repetitions per scenario, outside coverage instrumentation, with a 2 GB Node heap limit and the 300-second timeout for the full protocol. All scenarios and repetitions must finish and pass; a timeout, skipped scenario or partial report does not satisfy this ticket.
- [ ] Restore the benchmark as a required CI check after the unchanged protocol passes. While this ticket is open, the user approved a separate automatic benchmark job with `allow_failure: true` so its failure does not block functional delivery. Passing CI during this exception does not satisfy benchmark acceptance or justify closing this ticket.
- [ ] After #76 removes tracing and #77 verifies the retained behavior, benchmark outcome and explanation delivery without obsolete trace counters, trace transfer or trace-acknowledgement waits. Do not reintroduce tracing as profiling infrastructure.
- [ ] When the baseline fails, profile CPU, allocation and repeated planning work; apply optimizations and rerun the unchanged protocol until it passes. Preserve planning semantics, rule ordering, joint obligations, earned-entitlement chronology, final-plan quality and deterministic tie-breaking. Use behavior-level or differential tests to demonstrate that caching, deduplication or pruning is valid across the relevant configurations.
- [ ] Preserve responsive browser interaction, explicit cancellation, heartbeat monitoring and best-valid-partial-plan recovery during representative and long-horizon calculations. Keep the existing duration-estimation contract and recalibrate estimates only when measured results require it.
- [ ] Do not increase the benchmark timeout or heap allowance, reduce scenarios or repetitions, weaken correctness assertions, add an automatic elapsed-time engine cutoff, or reduce search breadth or planning quality to make the protocol pass.
- [ ] Record reproducible before/after preparation, planning, total-duration, responsiveness and available peak-heap measurements, including environment and protocol. Clearly distinguish actual completed benchmark runs from diagnostics or interrupted measurements and document any reference-hardware checks that could not be performed.
- [ ] Run required correctness, coverage, static and build checks after any optimization. Preserve the captured configuration, stable explanations, displayed-placement alternative-date analysis and saved-user-data behavior.
- [ ] Update benchmark documentation with the final measured limits and evidence. Close this ticket only after the unchanged benchmark gates pass; a failed measurement remains an open performance requirement here rather than reopening #75 for performance alone.
## Existing evidence and scope
The previous real protocol timed out at 300 seconds without an out-of-memory failure. The loaded-year warm-up took about 184 seconds and its first measured repetition started about 252 seconds into the protocol. Original trace recording and repeated allocation previews were observed costs; the benefit of trace removal has not yet been measured. These are starting points for profiling, not permission to change optimization quality.
This ticket authorizes verification and implementation of performance improvements when needed. Functional migration remains in #75, trace removal in #76 and trace-removal functional verification in #77. Existing benchmark limits remain unchanged; only the benchmark job's blocking status is temporarily waived at the user's request until this ticket restores it.
## Blocked by
- #75 — Finish the functional saved-rule migration and integrated planning validation.
- #77 — Verify integrated planning after trace removal (itself blocked by #76).
+8 -6
View File
@@ -2,7 +2,7 @@
### Issue tracker
Issues and specs live in GitLab Issues; use `glab` for tracker operations. See `docs/agents/issue-tracker.md`.
Issues and specs live in Gitea Issues; use `tea-cli` for supported tracker operations. Before tracker work, read `docs/agents/issue-tracker.md` for commands and API alternatives.
### Triage labels
@@ -14,11 +14,13 @@ This is a single-context repo using root `CONTEXT.md` and `docs/adr/`. See `docs
## GitFlow
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their MRs to `develop`.
- Rebase working branches onto the latest `origin/develop` before opening or merging an MR. If `develop` advances, rebase and rerun the MR pipeline before merging.
- Integrate through MRs with successful pipelines and Maintainer-only merges. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden.
- Use Conventional Commits for new commits and MR titles.
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` MRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both MRs are merged.
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their PRs to `develop`.
- Rebase working branches onto the latest `origin/develop` before opening or merging a PR. If `develop` advances, rebase and rerun the PR checks before merging.
- Integrate through PRs with successful required checks and merges restricted to designated maintainers through Gitea's merge allowlist. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden.
- Use Conventional Commits for new commits and PR titles.
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` PRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both PRs are merged.
- Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch.
The CI workflow is `.gitea/workflows/ci.yml`. Activation is tracked in Gitea issue #268; Actions is enabled with owner approval, and the migration gate stays required until live workflow validation and merge-message review. Before changing CI, image publication, or activation settings, read [Gitea Actions](docs/gitea-actions.md).
Before preparing a release or hotfix, creating a tag, or changing image publication, read [GitFlow and releases](README.md#gitflow-and-releases) for package versions, explicit stable tags, and image promotion.
+20 -27
View File
@@ -83,17 +83,29 @@ The greatest number of working days in any work sequence within the planning hor
**Short work sequence**:
A work sequence of fewer than five working days between two planned time-off periods.
**Short work-sequence deficit**:
The total number of working days missing for all short work sequences to reach five working days each; empty sequences contribute zero.
**Week with one workday**:
An undesirable Monday-to-Sunday calendar week with exactly one working day remaining after excluding leave placements and declared non-working days. All five Monday-to-Friday dates must fall within the planning horizon.
**Planning rule**:
A user instruction that requests leave placements around a calendar opportunity or inside a date window. Its force is either an obligation or a preference.
A named instruction, supplied by the application or defined by the user, that guides leave placement. Only enabled rules influence the plan, and each rule's force is either an obligation or a preference.
**Optimization rule**:
A preference rule that minimizes or maximizes a measure over selected planning elements, such as leave balances, time-off periods, or work sequences.
**Optimization rule preset**:
A predefined optimization rule configuration supplied by the application that the user can use as an editable planning rule.
**Loss prevention rule**:
An optimization rule that minimizes unplaced at-risk leave.
**Obligation rule**:
A planning rule that every valid plan must satisfy. Obligation rules are resolved jointly; their order only breaks ties between valid plans.
**Preference rule**:
A planning rule that guides placement but may remain unsatisfied to preserve validity or prevent more at-risk leave from expiring.
A planning rule that guides placement but may remain unsatisfied to preserve validity or satisfy higher-priority preferences.
**IF_THEN rule**:
A planning rule evaluated independently for every French public holiday on its configured weekday. A preference requests a complete pattern around each occurrence when possible; an obligation requires it for every occurrence.
@@ -105,7 +117,7 @@ A planning rule evaluated independently for every French public holiday on its c
A planning rule counting compatible leave placements and declared non-working days in an inclusive date window. A preference requests up to its configured quantity; an obligation requires exactly that quantity.
**Rule priority**:
The user-defined lexicographic order of preference rules. Each earlier preference is satisfied as far as possible before a later preference is considered.
The user-defined lexicographic order shared by all active preference rules, including optimization rules. Each earlier preference is satisfied as far as possible before a later preference is considered.
**Rule satisfaction**:
The relationship in which a plan fulfils a planning rule. One compatible leave placement or declared non-working day may contribute to the satisfaction of multiple rules.
@@ -120,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.
@@ -154,17 +151,11 @@ 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.
**Planning objective order**:
The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize public-holiday week value; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. Complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A lower criterion never degrades a higher one.
The lexicographic quality order applied after joint obligation feasibility, with active preference rules, including optimization rules, sharing the user-defined rule priority. A lower criterion never degrades a higher one.
**Earned leave**:
The total quantity of earned leave entitlements generated by all applicable planning rules for a plan, counted before consumption and attributed to their generating rules. Only entitlements becoming available strictly after the calculation's Europe/Paris calendar date qualify, including those becoming available after the planning horizon.
@@ -180,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.
+35 -33
View File
@@ -2,6 +2,8 @@
VacOptimizer is a personal leave planner for turning dated leave balances into a deterministic plan around weekends and French public holidays. It handles mandatory rules, ordered preferences, earned leave, expiring balances, and interrupted calculations without relying on a backend solver.
Source code, issues, and pull requests are hosted on [Gitea](https://gitea.nessar.fr/nessar/vac-optimizer). The [issue-tracker guide](docs/agents/issue-tracker.md) documents `tea-cli` access and tracker conventions.
The planner answers these questions:
- Is the request valid?
@@ -9,7 +11,7 @@ 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
@@ -35,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 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. 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
@@ -84,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
@@ -132,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;
@@ -148,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
@@ -164,7 +165,7 @@ The trace explorer navigates original phases and atomic decisions, expands candi
- Vitest through the Angular test builder
- ESLint and Prettier
- Angular server build hosted by Express
- GitLab CI and a multi-stage Docker image
- 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.
@@ -221,9 +222,9 @@ Open <http://localhost:4200>. The development server reloads when source files c
## Performance benchmark
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, 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 requires the complete seven-scenario Node protocol to finish within 300 seconds; individual scenario timings remain diagnostic evidence.
## Docker
@@ -236,35 +237,34 @@ The image builds the Angular application with Bun and Node, then runs the genera
## Continuous integration
GitLab CI runs independent validation, test, build, security, and image stages:
[`.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.
- ESLint, Prettier, and TypeScript checks;
- Vitest with coverage artifacts;
- an optimized production build;
- GitLab SAST and Trivy filesystem scanning;
- GitFlow, Conventional Commit, and release-version checks;
- Docker image publication for every successful branch push, including branches with an open MR.
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.
Pipelines switch to MR pipelines when a branch has an open MR, avoiding duplicate builds. Docker publication from MR pipelines is limited to detached pipelines within this project. Tag pipelines promote the already-built commit image instead of rebuilding it.
Functional tests use one worker and a 2 GiB Node heap. The separate required benchmark job runs without coverage and keeps its five-minute protocol timeout, 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 test job runs Vitest directly with one worker and a 2 GiB Node heap limit. It runs application tests with coverage, then the complete benchmark protocol separately without coverage instrumentation. The benchmark uses a five-minute execution timeout to allow for shared-runner contention; all protocol assertions remain mandatory. Timing measurements are benchmark evidence, not CI performance thresholds.
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.
Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
### GitFlow and releases
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their MR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their PR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both MRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both PRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
Use Conventional Commits for new commits and MR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. GitLab generates merge messages from the validated MR title. Legacy history is not rewritten.
Use Conventional Commits for new commits and PR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. The merge commit subject must preserve the validated PR title. Gitea supports a [Git-based merge-message template](https://docs.gitea.com/usage/issues-prs/merge-message-templates/) at `.gitea/default_merge_message/MERGE_TEMPLATE.md`, using `${PullRequestTitle}` and `${PullRequestReference}`. The committed template and a checked example expansion are documented in the [activation guide](docs/gitea-actions.md); legacy history is not rewritten.
The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-optimizer`:
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-<GitLab ref slug>` and `sha-<full commit SHA>` |
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
For example, `feature/calendar` publishes `branch-feature-calendar`; integration publishes `branch-develop`. GitLab normalizes ref slugs to lowercase and truncates them to 63 bytes, so choose branch names with distinct slugs. Only valid release tags can update `latest`.
For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion begin after the owner provisions publisher credentials and validates publication permissions.
After the MR into `main` and its full pipeline have succeeded, tag the published main commit:
After owner activation, the PR into `main` has merged, and its full checks and SHA image publication have succeeded, tag the published main commit:
```bash
git fetch origin
@@ -272,11 +272,13 @@ git tag -a v1.6.0 origin/main -m "Release 1.6.0"
git push origin v1.6.0
```
The tag must match the checked-out `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main pipeline before tagging: the release job requires its `sha-<commit>` image. Tags do not increment the version automatically.
The tag must match the tagged commit's `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main checks and image publication before tagging: promotion requires its `sha-<commit>` image. Tags do not increment the version automatically.
GitLab project settings must use `develop` as the default branch, the **Merge commit** method, squashing disabled, and **Pipelines must succeed** with skipped pipelines disallowed. Protect `main` and `develop` with no direct or forced pushes and Maintainer-only merges. Set the merge commit template to `%{title}` followed by the MR reference, preserving Conventional Commit subjects. The CI permits only `release/*` and `hotfix/*` into `main`; working MRs target `develop`. Rebase checks validate the target head at pipeline time: if `develop` advances afterward, rebase and rerun the MR pipeline before merging.
The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. After merge-message approval and a successful reviewed PR run, replace the migration gate with the reported pull-request context for the `required` job, as described in the activation guide. These are required settings; the migration does not establish that every setting has already been applied.
Run the policy integration check locally with `node scripts/ci-policy.test.mjs`.
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.
## Domain and design references
@@ -284,6 +286,6 @@ Run the policy integration check locally with `node scripts/ci-policy.test.mjs`.
- [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.
+36 -11
View File
@@ -16,7 +16,7 @@
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.1",
"@angular/ssr": "22.2.2",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
@@ -27,8 +27,8 @@
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.1",
"@angular/cli": "22.2.1",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
@@ -42,6 +42,7 @@
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"fast-check": "4.10.2",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"jsdom": "^30.0.0",
@@ -67,17 +68,17 @@
"@angular-devkit/architect": ["@angular-devkit/architect@0.2202.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-WGRAxFj+R9LC8HuK3DnDgxHo/XrgfklNQeNgjq/phTEVq5ZdWxc8kDoPA4uQz4vF+HY2eycAAtOPfMvj2TBEiQ=="],
"@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-frHhFwu2TEfTZX5jfAYL2bztuG5mGggfJhiM9bo5/QtLAi49LtRbJxIktsu6vjJdujxeB2TKD7KqnXwNYgd41Q=="],
"@angular/animations": ["@angular/animations@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1" } }, "sha512-vTxZN6uL/m61lv7ZCvfmQ3VuFb4o/tniGLpc3SpkNHE6MLM8WN0IjfNBRLMYMovd1uUBSPwhcG4Llcmucr9dmw=="],
"@angular/build": ["@angular/build@22.2.1", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.1", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.1", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-VA8EkK5Y6sJbBFyn2x5jzd3KByh29DDpI7n6hucT0sK6YkMJEvsojPUWet9ifwF3OL+ZrGvxYlAHyZGKVlqw0g=="],
"@angular/build": ["@angular/build@22.2.2", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.2", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.2", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-QHJWkZdEWTqqb3+HiSp8lnuS4iQJCa7yTK9gg5TaMIjCoZzP9mD9L417stiVDZGj2J3Y9mrml044nEVvSbW0ig=="],
"@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="],
"@angular/cli": ["@angular/cli@22.2.1", "", { "dependencies": { "@angular-devkit/architect": "0.2202.1", "@angular-devkit/core": "22.2.1", "@angular-devkit/schematics": "22.2.1", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.0.0", "@schematics/angular": "22.2.1", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.5" }, "bin": { "ng": "bin/ng.js" } }, "sha512-tKMP68SZP+A9YCSRg0c+2e/+yWp7qiFrjCuQAomHK7owNaxHnLigAbra+8QA4xmWHoGaQk89NMmUPyA1BsBp8w=="],
"@angular/cli": ["@angular/cli@22.2.2", "", { "dependencies": { "@angular-devkit/architect": "0.2202.2", "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.3.1", "@schematics/angular": "22.2.2", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.5" }, "bin": { "ng": "bin/ng.js" } }, "sha512-AALIN+zai0CiA7HgSCGVPHyZyiw3yFWliCfbaS4ueHDJHVZ8QhVjRUrnfGZc3EJnigcyQ0w2DKeTxjwqpDjKew=="],
"@angular/common": ["@angular/common@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw=="],
@@ -99,7 +100,7 @@
"@angular/router": ["@angular/router@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.1", "@angular/core": "22.2.1", "@angular/platform-browser": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-HJfJ4yA1znKd9YFtbDOJfKmD12x0PhS7LkmYdWlJRu2WkQZRBVrdGKdmbvV5UvrqCRllVWKpzw1TIZJM69rlcw=="],
"@angular/ssr": ["@angular/ssr@22.2.1", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-I228uYQ53Eyc30B+7ZGzw35N36MG9F6E6IUPAlK2dhuxZsCLherbElIa1FPTh5R2qcr+xVJw0Ej6QEUj59+KvA=="],
"@angular/ssr": ["@angular/ssr@22.2.2", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-iMcwT5ZynIj33R0XSq+AtC5Ra6FGKOvV3qU0sjRr+lUaGfL6MjygPovPFsKLi8HzuGOA5dYDKffJfPedsC9Rxg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.1.3", "", { "dependencies": { "@csstools/css-calc": "^3.4.2", "@csstools/css-color-parser": "^4.2.5", "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" } }, "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ=="],
@@ -555,9 +556,9 @@
"@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.3.1", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" } }, "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
@@ -837,7 +838,7 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
"@schematics/angular": ["@schematics/angular@22.2.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "@angular-devkit/schematics": "22.2.1", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-L4Nl8D43znCzXwze9PtbQhG1GKlhWq9ip8Hz+e3LHgG3WpT5Tlr7aVsyHDqb9Qetuy0Mpd4tXE15STtGdA897w=="],
"@schematics/angular": ["@schematics/angular@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-JxpXKpxdmQcRsJbriWJOhB7HeVTzz9CEsPNw6fQ0LOuPFnvtyUIXLxyFbKv/LfoFUz8wFADCtNU5sjowME5Mvg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
@@ -1405,6 +1406,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
@@ -1969,7 +1972,7 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
@@ -2379,16 +2382,26 @@
"@analogjs/vite-plugin-nitro/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"@angular-devkit/architect/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/build/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular/build/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/build/oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="],
"@angular/build/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"@angular/cli/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular/cli/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@angular/compiler-cli/@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="],
"@angular/compiler-cli/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
@@ -2787,6 +2800,8 @@
"@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@schematics/angular/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@schematics/angular/oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
@@ -2937,6 +2952,8 @@
"jest-circus/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
"jest-circus/pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
"jest-cli/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
"jest-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -3235,6 +3252,10 @@
"@analogjs/vite-plugin-nitro/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@angular-devkit/architect/@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular-devkit/schematics/@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular/build/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.150.0", "", { "os": "android", "cpu": "arm" }, "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw=="],
"@angular/build/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.150.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA=="],
@@ -3277,6 +3298,8 @@
"@angular/build/vite/rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="],
"@angular/cli/@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/compiler-cli/@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"@angular/compiler-cli/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -5401,6 +5424,8 @@
"@oxc-parser/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@schematics/angular/@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@schematics/angular/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.150.0", "", { "os": "android", "cpu": "arm" }, "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw=="],
"@schematics/angular/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.150.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA=="],
@@ -2,4 +2,6 @@
The ten-second automatic deadline is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff.
The fixed ordering between user preferences and configurable optimization objectives, including the unconditional priority for at-risk loss prevention, is superseded by [ADR 0008](0008-user-controlled-planning-objectives.md); implementation of that decision is pending.
VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
@@ -1,5 +1,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://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58); removal of the automatic calculation deadline and heartbeat-based worker liveness are implemented by [issue #59](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/59). The indicative duration estimate is implemented by [issue #60](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/60); its initial model, session-local refinement and calendar-rendering measurement are documented in [planning-duration.md](../planning-duration.md).
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 settings' end date is the 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.
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.
@@ -4,7 +4,7 @@ status: accepted
# Declared Non-Working Days and Reported Leave Balances
For [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/13), users declare fixed non-working dates among normal working days, with a CP, RTT, or Other type; Other covers every other reason for not working. Reported balances already account for booked leave, so these declarations consume no remaining balance and cannot receive another placement; they count towards compatible planning rules, including rules that earn leave.
For [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), users declare fixed non-working dates among normal working days, with a CP, RTT, or Other type; Other covers every other reason for not working. Reported balances already account for booked leave, so these declarations consume no remaining balance and cannot receive another placement; they count towards compatible planning rules, including rules that earn leave.
Declared dates can form time-off periods without new leave placements, interrupt work sequences, and guide further optimization. Each calculation uses today's Europe/Paris calendar date, with reported balances assumed current as of that date: the planner adds only entitlements available strictly afterward, while earlier entitlements are already represented in the reported balances. This keeps existing bookings useful without debiting them again or asking users to manually add prospective gains; it relies on the user keeping reported balances current as the employer's system credits earned leave.
@@ -0,0 +1,25 @@
---
status: accepted
---
# User-Controlled Planning Objectives
VacOptimizer represents configurable plan-wide objectives as named optimization rules in the same user-controlled list as calendar-based planning rules, adding rule types where existing calendar patterns cannot express the objective. All active preference rules share one user-defined lexicographic order, so moving an optimization preference above Samedi Malin may deliberately forgo earned leave to improve that higher-priority objective. Making the displayed order authoritative supersedes the fixed ordering between user preferences and optimization objectives in [ADR 0003](0003-constraint-first-lexicographic-planning.md).
At-risk loss prevention also becomes an optimization rule, enabled in first position by default and freely disableable or reorderable. The planner follows the resulting preference order even when a higher-priority preference leaves more at-risk leave unplaced; this also supersedes ADR 0003's unconditional priority for loss prevention.
Optimization rules use preference mode. Supporting them as obligations would require explicit thresholds and additional conflict semantics, which are outside this change.
The shared model for user-created optimization rules selects planning elements and applicable conditions, a measure and its aggregation, and a direction to minimize or maximize. At minimum, the application supplies six presets expressed in that same model: loss prevention, public-holiday week value, non-working-day gain, longest work-sequence length, the deficit of internal work sequences shorter than five working days, and the number of supporting placements.
These presets retain their existing domain meanings, including the weekday-dependent value of complete public-holiday weeks and the total deficit of short work sequences rather than their count. Supporting placements are additional uses of balances expiring after the planning horizon to improve the plan. User-created rules can compose supported planning elements and measures; introducing a new domain measure requires extending the planner.
The six presets are initially enabled, ordered as loss prevention, existing calendar-based rules, then the other five optimization presets in their former engine order. Existing saved rule lists, including an empty list, receive this addition once while preserving every existing rule's identity, parameters, force, enabled state, and relative order. Subsequent loads restore the user's saved choices without reinserting deleted presets or reactivating disabled ones.
After all active rules compare equally, earlier balance expiry and then earlier placement dates remain deterministic tie-breakers.
Only active rules drive placement decisions and preference comparisons; disabled or deleted optimization rules have no residual influence through fixed optimization stages or protected metrics. With no active rules, the planner adds no leave placements, even when balances are at risk of expiring, and retains declared non-working days. Final tie-breakers cannot independently trigger new placements.
Implemented in the #70–#75 series. Saved-rule migration and functional verification
are covered by #75. Benchmark performance acceptance and any required optimization
are tracked separately in #78; the benchmark limits remain unchanged.
+36 -25
View File
@@ -1,46 +1,57 @@
# Issue tracker: GitLab
# Issue tracker: Gitea
Issues and specs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations.
Issues and specs live in [nessar/vac-optimizer on Gitea](https://gitea.nessar.fr/nessar/vac-optimizer). Use the configured [`tea-cli`](https://gitea.com/gitea/tea) login for supported operations.
## Access and command version
The installed binary is `tea-cli` 0.9.2. Its commands below use the pre-1.0 interface; other installations may call the binary `tea`. Check `tea-cli --help` and subcommand help before using commands from another version. This version supports YAML, TSV, CSV, simple, and table output; it has no JSON output, issue-edit command, or `api` command.
Run inside the clone so the CLI discovers the repository and login from `origin`. Use `--repo nessar/vac-optimizer` and `--login <login-name>` when an explicit selection is needed. Options go before positional issue or PR numbers. Existing authentication can be checked with `tea-cli whoami`; for a new login, use interactive `tea-cli login add` and select `https://gitea.nessar.fr`.
## Conventions
- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor.
- **Read an issue**: `glab issue view <number> --comments`. Use `-F json` for machine-readable output.
- **List issues**: `glab issue list -F json` with appropriate `--label` filters.
- **Comment on an issue**: `glab issue note <number> --message "..."`. GitLab calls comments "notes".
- **Apply / remove labels**: `glab issue update <number> --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag.
- **Close**: `glab issue close <number>`. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note <number> --message "..."`, then close.
- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`.
- **Create an issue**: `tea-cli issues create --title "..." --description "..." --labels "needs-triage"`. Multiple labels or assignees are comma-separated. For multiline text, prepare a draft and pass its contents as one quoted description argument.
- **Read an issue**: `tea-cli issues --comments <number>`. Detail views render for display even with `--output yaml`; use list output or the REST operations below for structured data.
- **List issues**: `tea-cli issues --state open --labels "needs-triage" --fields index,title,body,state,labels,assignees,url --output yaml`. Use `--page` and `--limit` to read all pages; the server may cap the requested page size.
- **Comment**: `tea-cli comment <number> "..."`; this works for issues and PRs.
- **Apply / remove labels, edit a body, or assign**: use the Gitea UI or the REST operations below; this CLI version does not support these edits. Preserve unrelated labels and assignees.
- **Close**: post the explanation with `tea-cli comment <number> "..."`, then run `tea-cli issues close <number>`.
- **Create a pull request**: `tea-cli pulls create --base develop --title "docs: update Gitea documentation" --description "..."`. Read with `tea-cli pulls --comments <number>`; list with `tea-cli pulls --state open --output yaml`.
- **List milestones**: `tea-cli milestones --state all --fields id,title --output tsv`. Issue numbers were preserved during migration, but milestone IDs changed; V1 stats is [milestone 13](https://gitea.nessar.fr/nessar/vac-optimizer/milestone/13).
Infer the repo from `git remote -v` — `glab` does this automatically when run inside a clone.
## REST operations for CLI gaps
## Merge requests as a triage surface
The instance's [API explorer](https://gitea.nessar.fr/api/swagger) and [schema](https://gitea.nessar.fr/swagger.v1.json) describe the supported endpoints. Paths below are relative to `https://gitea.nessar.fr/api/v1`; authenticate with an existing authorized Gitea API token without displaying or committing it. Check the installed CLI help first when it is upgraded.
**MRs as a request surface: no.** _(Set to `yes` if this repo treats external merge requests as feature requests; `/triage` reads this flag.)_
- **Read full issue data or comments as JSON**: `GET /repos/nessar/vac-optimizer/issues/<number>` and `GET /repos/nessar/vac-optimizer/issues/<number>/comments`. Paginate comment lists.
- **Find label IDs**: `GET /repos/nessar/vac-optimizer/labels`; paginate and match exact names from `triage-labels.md`.
- **Add labels**: `POST /repos/nessar/vac-optimizer/issues/<number>/labels` with `{"labels":[<label-id>]}`. **Remove one label**: `DELETE /repos/nessar/vac-optimizer/issues/<number>/labels/<label-id>`. These operations preserve other labels.
- **Edit a body or assign**: `PATCH /repos/nessar/vac-optimizer/issues/<number>` with `{"body":"..."}` or `{"assignees":["<username>"]}`. Read current assignees before updating the list.
- **Read a PR diff**: `GET /repos/nessar/vac-optimizer/pulls/<number>.diff`.
When set to `yes`, MRs run through the same labels and states as issues, using the `glab mr` equivalents:
## Pull requests as a triage surface
- **Read an MR**: `glab mr view <number> --comments` and `glab mr diff <number>` for the diff.
- **List external MRs for triage**: `glab mr list -F json`, then keep only MRs whose author is not a project member/owner (a contributor's MR, not a maintainer's in-flight work).
- **Comment / label / close**: `glab mr note`, `glab mr update --label`/`--unlabel`, `glab mr close`.
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external pull requests as feature requests; `/triage` reads this flag.)_
Unlike GitHub, GitLab numbers issues and MRs separately, so `#42` is unambiguous once you know which surface the maintainer means.
When enabled, use `tea-cli pulls --comments <number>` to read and `tea-cli pulls --state open --fields index,title,body,author,labels,url --output yaml` to list. Keep only external contributors' requests, checking repository collaborators or organization membership rather than guessing from the author's name. Comment with `tea-cli comment`, edit labels through the issue-label API, and close with `tea-cli pulls close <number>`.
For a bare `#<number>`, check the resource kind: `tea-cli issues --kind all --fields index,kind,title,url --output yaml`, or inspect the issue API's `pull_request` field. PR URLs use `/pulls/<number>`; issue URLs use `/issues/<number>`.
## When a skill says "publish to the issue tracker"
Create a GitLab issue.
Create a Gitea issue with `tea-cli issues create`.
## When a skill says "fetch the relevant ticket"
Run `glab issue view <number> --comments`.
Run `tea-cli issues --comments <number>` and read its labels and assignees.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `glab issue create --label wayfinder:map`. (On GitLab tiers with native epics, an epic may hold the map instead; a labelled issue works everywhere.)
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its description and labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitLab's **native blocking link** — the canonical, UI-visible representation. Add it with the `/blocked_by #<n>` quick action, posted as a note (`glab issue note <child> --message "/blocked_by #<blocker>"`). Native blocking links are a Premium/Ultimate feature; on the free tier (or where unavailable) fall back to a `Blocked by: #<n>, #<n>` line at the top of the description. A ticket is unblocked when every blocker is closed.
- **Frontier query**: `glab issue list -F json` scoped to the map's children, drop any with an open blocker — a native `blocked_by` link to an open issue (`glab api projects/:id/issues/:iid/links`), or an open issue in the `Blocked by` line — or an assignee; first in map order wins.
- **Claim**: `glab issue update <n> --assignee @me` — the session's first write.
- **Resolve**: `glab issue note <n> --message "<answer>"`, then `glab issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
- **Map**: create an issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body, with `tea-cli issues create --title "..." --description "..." --labels "wayfinder:map"`.
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its description and labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Maintain the ordered child links in the map's body. Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: use Gitea's native dependencies. `POST /repos/nessar/vac-optimizer/issues/<child>/dependencies` with `{"owner":"nessar","repo":"vac-optimizer","index":<blocker-number>}` makes the child depend on that blocker. Read blockers with `GET` on the same path; use `DELETE` with the same body to remove a dependency. Where dependencies are unavailable, retain a `Blocked by: #<n>, #<n>` line at the top of the description. A ticket is unblocked when every blocker is closed.
- **Frontier query**: list the map's open children with `tea-cli issues --state open --fields index,title,body,assignees,labels --output yaml`, reading all pages. Drop tickets with an assignee or an open native dependency or description-line blocker; first in map order wins.
- **Claim**: assign the authenticated username through the issue `PATCH` endpoint or Gitea UI — the session's first write.
- **Resolve**: `tea-cli comment <n> "<answer>"`, then `tea-cli issues close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far through the issue `PATCH` endpoint or Gitea UI.
+172 -32
View File
@@ -1,16 +1,159 @@
# Mobile leave-planner benchmark
## Composable optimization rules: current verification status
[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.
## Relative CI gate
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.
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 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> bunx vitest run src/app/services/frozen-engine.spec.ts
```
The check runs in the functional suite. On the development laptop it takes
about 32 seconds without coverage and 38 seconds with coverage
(`NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/services/frozen-engine.spec.ts --maxWorkers=1`).
A flipped objective direction in `compareObjectiveValues` made 26 of its tests
fail, starting at seed 280002; the change was reverted.
## Reference-phone protocol
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
@@ -33,23 +176,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
@@ -57,11 +193,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
@@ -90,10 +230,10 @@ and each flow sent exactly one planning request. All 1,949 and 3,956 trace
packets respectively were acknowledged, carrying 122,621 and 249,234 candidate
records without truncation. Heartbeats continued during the producing runs.
| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace |
| --- | --- | --- | --- | --- | --- | --- |
| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB |
| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB |
| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace |
| ----------- | --------------- | -------------------- | ---------- | ----------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------- |
| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB |
| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB |
A 50 ms page timer measured extra event-loop delay and sampled available
main-thread JS heap. RSS was sampled every 500 ms across that isolated Chrome
@@ -111,10 +251,10 @@ completed stage. An editable input accepted focus while the worker remained
active. Both flows kept a 44 px Cancel control, fit the viewport without
horizontal overflow, and delivered the original trace after their outcome.
| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements |
| --- | --- | --- | --- |
| Calendar preparation | 21.9 ms | 874.8 ms | 0 |
| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 |
| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements |
| ------------------------------------------ | ------------------ | ----------------------------- | ------------------- |
| Calendar preparation | 21.9 ms | 874.8 ms | 0 |
| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 |
No browser errors occurred. Cancellation issued one cancel request and no
additional calculation. Separate tests cover late artifact errors and stale
@@ -151,11 +291,11 @@ at the worker's terminal artifact delivery. The application's own calibration
uses its request timestamp and `afterNextRender` endpoint as described in
[planning-duration.md](../planning-duration.md).
| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements |
| --- | --- | --- | --- | --- | --- |
| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 |
| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 |
| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 |
| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements |
| ----------- | ---------------------------------------------- | ---------------------- | ------------------------- | ---------------------- | ---------- |
| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 |
| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 |
| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 |
All three outcomes completed through `dates`, with no intermediate placements,
implicit deadline, extra request or date movement. The three-year result
+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).
+141
View File
@@ -0,0 +1,141 @@
# Gitea Actions conversion
[Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) converts the
repository's CI to [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml).
The workflow and merge-message template have passed implementation review.
Repository Actions is enabled with owner approval. Publisher credentials and
live workflow validation are still pending.
The existing `gitlab-to-gitea/ci-conversion-required` status stays in place until
that review, a successful Gitea PR run, and approval of the prepared first merge
message.
## Jobs and triggers
Every branch and tag push runs CI. Pull requests run on opening, reopening,
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` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage with one worker and a 2 GiB Node heap. 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 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, including the benchmark, 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, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. |
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
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
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.
The instance currently has an online inherited runner named `srvhome`, labelled
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
were empty at implementation time. The owner must verify that this runner can
run the job containers, JavaScript actions, artifact uploads, and the privileged
Docker-in-Docker service before replacing the migration gate. Use isolated runners for untrusted
contributor code; the privileged publishing service belongs to trusted runs.
The workflow requests a read-only repository token. Checkout removes persisted
credentials; publication credentials are passed only to the image step. Fork PRs
and manual dispatches cannot run that step. Gitea's job token cannot publish
container packages, so provision the two repository Actions secrets below.
See [job-token permissions](https://docs.gitea.com/usage/actions/token-permissions/)
and [package authorization](https://docs.gitea.com/usage/actions/comparison/).
| Secret | Purpose |
| ------------------- | --------------------------------------------------------------------------- |
| `REGISTRY_USERNAME` | The package publisher's Gitea username, authorized to publish for `nessar`. |
| `REGISTRY_PASSWORD` | A dedicated personal access token with `write:package` permission. |
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.
## Images and releases
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
at `gitea.nessar.fr/nessar/vac-optimizer`. Branch pushes and same-repository PRs
publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs retain the previous
lowercase, non-alphanumeric-to-hyphen, 63-byte normalization; choose distinct
branch slugs.
Only stable `vX.Y.Z` tags matching the tagged commit's `package.json` version
and reachable from `main` may promote images. Promotion first pulls the existing
SHA image, then publishes the version tag and `latest`; it never rebuilds.
An absent SHA image fails before either stable tag changes. Wait for the main
image job before tagging. No version increment or deployment is automatic.
## Merge messages and activation
The [merge template](../.gitea/default_merge_message/MERGE_TEMPLATE.md) translates
the previous title, closing-issue references, and review reference using
[Gitea's supported variables](https://docs.gitea.com/usage/issues-prs/merge-message-templates/).
For a PR titled `ci: migrate to Gitea Actions`, closing #268, with reference #271,
the checked expansion is:
```text
ci: migrate to Gitea Actions
close #268
See pull request #271
```
The owner has enabled Actions and approved validation before adding publisher
secrets. Complete the remaining activation checks in this order:
1. Review this workflow against the protected administrator exports for runner
requirements, triggers/schedules, permissions, secrets, publishing, and
deployment dependencies. The original CI definition is retained in Git:
`git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
2. Publish the reviewed feature branch and open its PR to register the workflow.
Once registered, manual dispatch verifies validation and artifacts without
publishing images. Push and PR validation can also run now; their image and
aggregate checks remain blocked until publisher secrets are provisioned.
3. Verify runner isolation and Docker service support, then provision the two
publisher secrets. Review the package publisher's permissions and registry
namespace before permitting publication. Rerun the PR workflow and require a
successful **pull-request run** on the final commit, including PR title and
rebase checks, artifact handling, and actual image publication. A push run
or manual run alone does not validate the PR policy and publication.
4. Prepare and review the first PR's merge message before replacing the migration
gate. The target branch will not contain the new template until that first
merge, so explicitly use the validated PR title, closing references, and PR
reference for its merge message. Keep the migration gate throughout this
review.
5. After the successful PR run and merge-message approval, replace the migration
status requirement on both protected branches with the **actual reported
pull-request context** for the `required` job. Select it from the PR's reported
checks rather than a push run or a guessed context spelling. Fork PRs must
also be able to satisfy this check without base-repository push permissions.
Keep direct/force pushes disabled, merges restricted to maintainers, outdated
PRs blocked, and administrator protection bypass disabled. Select merge
commits and disable squash/rebase merge methods.
6. Merge the first PR using the approved message, then verify the resulting merge
commit. Subsequent merges read the committed template. No direct
integration-branch push is needed.
Historical job execution, reports, and downloadable artifacts remain in the
administrator rollback backups. They are not recreated or uploaded by this
conversion.
+13 -13
View File
@@ -1,6 +1,6 @@
# Cancelling a planning calculation
Issue [#58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58) implements the cancellation portion of ADR 0006. 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,25 +10,25 @@ 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
@@ -42,10 +42,10 @@ bunx tsc --noEmit -p tsconfig.app.json
bunx tsc --noEmit -p tsconfig.spec.json
```
The public cancellation tests cover immediate cancellation, queued trace updates on both sides of the request, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. A core regression compares the retained plan's full objective vector with every valid candidate actually evaluated before cancellation. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete trace delivery.
The 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 thread. 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.
+7 -8
View File
@@ -79,13 +79,12 @@ The existing initial calculation falls back to the start of `startCalculation`.
The endpoint is Angular's `afterNextRender` callback after the complete plan has
been projected into the calendar, its months and placements have been rendered,
and the loading overlay has been removed. This measures request scheduling,
calendar preparation, worker startup, search, trace capture that delays the
outcome, outcome transport, projection and Angular rendering. It is a DOM
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 +97,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).
+286
View File
@@ -0,0 +1,286 @@
# Composable planning objective contract
The common implementation for tickets #70–#75 is on `feature/optimization-rules-70-75`.
`src/app/services/planning-objectives.ts` evaluates supported configurations; rule names and
identifiers never enter the evaluator. Supplied and personal optimization rules share this
model, editor, persistence and ordered calculation pipeline.
## Configuration
`OptimizationConfiguration` contains `elements`, `conditions`, `measure`, `aggregation`,
`direction`, and, only for `work-sequence-deficit`, `thresholdDays`. Conditions are a list
of distinct selectors, combined with AND; an empty list selects every observed element.
Directions are `minimize` and `maximize`. The finite `OPTIMIZATION_CATALOG` contains the
following compatible combinations. Each row accepts any subset of its listed conditions.
| Elements | Applicable conditions | Measure | Aggregations | Measure unit | Parameters |
| ---------------------- | ----------------------- | --------------------------- | ------------------------- | --------------------- | ------------------------------------------------------ |
| `leave-balances` | `at-risk` | `unplaced-quantity` | `sum`, `count`, `maximum` | working days | none |
| `public-holiday-weeks` | `complete` | `public-holiday-week-value` | `sum`, `count`, `maximum` | calendar-day distance | none |
| `time-off-periods` | none | `non-working-day-gain` | `sum`, `count`, `maximum` | calendar days | none |
| `work-sequences` | `internal`, `non-empty` | `work-sequence-length` | `sum`, `count`, `maximum` | working days | none |
| `work-sequences` | `internal`, `non-empty` | `work-sequence-deficit` | `sum`, `count`, `maximum` | working days | `thresholdDays`: positive safe integer in working days |
| `leave-placements` | `supporting` | `placement-count` | `sum`, `count`, `maximum` | count | none |
`count` counts selected elements, including selected elements whose measured value is zero;
its unit is always count. `sum` and `maximum` aggregate the selected measured values.
All three return **zero on an empty collection**. An empty work sequence has zero deficit,
even if a configuration selects it. The maximum of an empty work-sequence collection is zero;
the existing optional average statistics keep their separate null semantics.
`validateOptimizationConfiguration(unknown)` returns `{ valid: true, configuration }` or
`{ valid: false, code, message }`. Malformed values use `invalid-optimization-configuration`;
unsupported elements/measure/condition/aggregation/parameter combinations use
`unsupported-optimization-configuration`. Unknown fields, duplicate conditions, missing,
fractional, non-positive, non-finite and unsafe thresholds are rejected. Other measures
reject `thresholdDays`. Direct and worker calculations attach each configuration error to
its rule, including malformed sparse selector lists; they never replace it with a preset.
For example, a personal rule can select internal non-empty work sequences, sum their
deficits at a threshold of three, and minimize the result. It uses the same measure as
the five-day preset. Another can maximize the longest sequence or count complete holiday
weeks; composition never dispatches by a preset identifier.
## Six supplied recipes
`OPTIMIZATION_PRESETS` provides configurations, without identities, display labels or enabled
states. `RuleService` wraps copies in named planning rules and persists the full configuration.
| Export key | Elements and conditions | Measure and aggregation | Direction |
| ---------------------- | --------------------------------------- | ---------------------------------- | --------- |
| `unplacedAtRisk` | leave balances, at risk | unplaced quantity, sum | minimize |
| `holidayWeekValue` | public-holiday weeks, complete | public-holiday week value, sum | maximize |
| `nonWorkingDayGain` | time-off periods | non-working-day gain, sum | maximize |
| `longestWorkSequence` | work sequences, including horizon edges | work-sequence length, maximum | minimize |
| `shortSequenceDeficit` | internal non-empty work sequences | deficit at `thresholdDays: 5`, sum | minimize |
| `supportingPlacements` | leave placements, supporting | placement count, count | minimize |
## Concrete observations and boundaries
`OptimizationObservations` carries collections from one concrete valid plan:
- Leave balances expose unplaced working-day quantity and the at-risk flag. Expiry on either
the horizon start or its exclusive end is at risk, preserving the existing classification;
placement dates themselves stay within the half-open horizon.
- Public-holiday weeks expose their weekday holiday value and whether every other working
date is a placement or declared non-working day. Monday through Friday contributions are
**1, 2, 3, 2, 1**; several holidays in one week are summed once in that week's observation.
Ordinary fully declared weeks never become holiday weeks. A partial horizon only admits
a week when every required date outside it is already declared, preserving the existing rule.
- Time-off periods carry distinct natural non-working dates; periods are disjoint and include
adjacent weekends, holidays and declared days at either horizon edge. Placements and declared
working days anchor periods but do not themselves contribute non-working-day gain.
- Work sequences carry a working-day length and an `internal` flag. Weekends and holidays
neither count nor split them. Both horizon-edge sequences contribute to the maximum and
are excluded by the `internal` condition. Internal zero-length observations contribute zero
deficit and are excluded by `non-empty`.
- Leave placements expose their supporting classification. Declared days are fixed observations
and do not consume balances or count as supporting placements.
`evaluateOptimization(configuration, observations)` validates the configuration and evaluates
these collections. The core's `optimizationObservations(state, context, periods?)` builds the
collections once; `evaluateStateOptimization(configuration, state, context)` is the shared core
seam for a personal rule. The preset metrics call the same evaluator, with no second engine.
## Ordered rule execution
Named `type: 'optimization'` rules contain a full `configuration` and preference-only
force. Storage, editor buffers, direct calculation snapshots and worker transport copy
the nested configuration. The core executes only explicitly submitted active rules;
it never adds presets to a request.
Obligations are solved jointly. Each active preference then runs in the displayed
rule order. Disabled rules are excluded before validation and search. A request
with no active rules preserves declared days and adds no placements, including
when balances are at risk of expiry. Statistics may still report preset metrics;
only the active configured objectives affect selection.
The shared core seams are:
- `activeObjectiveVector(state, context, throughIndex?)` evaluates active preferences
in display order, with unique `optimization:<rule-id>` and `preference:<rule-id>`
identities. Optimization measurements use their full configurations; calendar
preferences measure actual matching dates/patterns.
- `evaluateRuleCandidate(before, candidate, context, throughIndex, dates, sourceDate?,
addition?, baseline?, beforeVector?, incumbentVector?)` validates mandatory type/date/pattern memberships and earned
entitlement availability and compares the active prefix. Its comparison preserves
the source state; technical selection facts compare with the actual current best
candidate. It returns the valid state, its full vector and
optional trace evaluation. Additions must improve an active prefix objective.
- `optimizeRulePlacements(state, context, throughIndex, allowAdd, rule?)` generates
single additions/removals/moves, atomic balance reassignments and balance swaps.
`relocateForAddition` handles augmenting chains when a scarce balance needs an
occupied date. Complete typed calendar patterns are reassigned atomically too.
Coordinated family bundles can pass through `evaluateRuleCandidate` and rank through
`compareObligationPreviews(candidate, incumbent, context)` without fixed metric guards.
- `applyPreferenceStep` dispatches every optimization and calendar window through
the same kernel. Earlier preferences replay when a later rule changes their state
or creates an earned entitlement.
Earned entitlements follow actual completed active producer patterns, including patterns
already fulfilled by another rule or declared days. Shared finalization reconciles those
rights before candidate comparisons, cancellation retention and unchanged-state caching.
Availability starts after the complete merged producing period; its own reward cannot
finance a placement inside that period. Earning is a validity consequence, rather than
an extra objective that an active configuration can suppress.
`compareObjectiveValues`, `compareObjectiveLists` and `firstDifferentObjective`
serve both selection and explanation. Earlier balance expiry and placement dates
are final tie-breaks after all active objectives compare equally; they only reshape
existing placements. Equal expiry values are compared independently of arbitrary balance
identifiers, so earlier placement dates remain the next tie-break. Captured rule identities, names and configurations label the
result independently of subsequent editor changes.
Loaded plans retain the established sampling strategy for flexible move destinations
above 24 placements, including non-working-adjacent, weekly and calendar-window dates.
Excluded move destinations are recorded as `not-examined`, never as proven rejected.
There is no automatic elapsed-time cutoff. Complete holiday weeks, joined time-off
periods and work-sequence changes are compared as coordinated valid bundles.
Moves that preserve every active objective can be pruned when their final tie-breaks
cannot improve. Per-iteration vectors are reused; preset statistics are materialized
only for selected incumbents and published plans.
For large non-earned window obligation searches above the existing exact-branch
threshold, the joint feasibility search retains its existing limit. Feasible
solutions are ranked by actual active objectives and final ties; the first eight
receive full optimization previews. Other feasible solutions are recorded as
`not-examined`. Small searches and earned-entitlement chains keep all previews.
This deterministic sampling has no elapsed-time cutoff or user-facing setting.
A deferred optimization addition is supporting only when it provides one of the
calendar benefits in the domain definition, rather than merely extending a period.
Holiday occurrences and eligible patterns are immutable per request and cached
independently of mutable placement states.
## Saved rules and initialization
`vacationRules` uses one versioned envelope: `{ version: 1, rules: [...] }`.
`RuleService` restores it synchronously before the first duration estimate or calculation.
A legacy array, including `[]`, receives loss prevention first, every original record in
its original relative order, then the other five presets. Each original raw record is
preserved, including names, IDs, dates, parameters, disabled state and a missing legacy
force. Getters interpret missing force as preference without rewriting that record.
Preset IDs use collision-free suffixes when a personal rule already uses a proposed ID.
New users receive the six active presets around the existing Samedi Malin and Christmas
calendar defaults.
The migration and its version are written by one synchronous `setItem`; no separate
marker can succeed while the rules fail to save. A failed write leaves the original
key intact and permits a later complete save or a fresh reload to retry. Failed reads,
malformed saved data and unsupported versions disable writes for that service instance
so later editing cannot overwrite unknown persisted user data. Storage unavailable
in the runtime leaves an in-memory configuration usable.
Every later edit, toggle, reorder or deletion saves the full versioned list. Versioned
empty lists stay empty; disabled and deleted presets stay that way. With no active
rules, eligible unused leave remains visible with its original expiry classification
and a translated explanation that the active planning rules justified no addition.
Physical availability, occupation, expiry and incomplete-calculation reasons keep
precedence where they apply. Declared days remain fixed and consume no leave.
## Search and trace limits
These are deterministic search limits, rather than claims of global optimality:
- Above 24 placements, flexible moves examine the existing non-working-adjacent,
declared-adjacent, weekly and calendar-window destinations. Other destinations
retain `not-examined` trace records.
- Non-earned obligation searches whose estimated branch count exceeds 10,000 retain
at most 4,096 feasible solutions. Large window searches rank them by the actual
active vector and final ties, then fully preview the first eight; the other
feasible solutions are marked `not-examined`. Earned chains and small searches
retain all previews.
- Calendar bundles rank original donors once by the actual active vector of the
source plan with each donor released, then retain that order during recursive
allocation. They keep the first prefix-valid allocation and collapse equivalent
donor tokens. Work-sequence bundles use the nearest center per balance and the
first accepted allocation. These choices can miss another coordinated allocation.
- When a completed specialized bundle strictly improves the current active prefix,
the kernel commits its full-vector best before scanning generic alternatives for
the old state. The old generic neighborhood is marked `not-examined`, then bundles
and generic candidates continue from the improved state. Earlier preferences replay
and the final expiry/date pass still runs. This scheduling can make a better unseen
old-state generic branch unreachable; it is an explicit heuristic rather than an
exhaustive comparison of all bundles and moves.
- Additions require a strict improvement through the current rule. A custom
maximum/minimize unplaced-quantity rule with two tied at-risk CP1 balances can
therefore keep both unused: either single addition leaves the maximum at one,
while a coordinated addition of both would reduce it to zero. This balance
plateau has no specialized bundle generator; the supplied sum/minimize loss
preset does consume both.
A completely enumerated single obligation solution has no alternative to rank, so
its discarded full preference preview is skipped; the real ordered execution still
runs in full. Earlier rules reuse their completed evaluation only when the complete
ordered incoming state matches their last output, including balance quantities and
validity, earned activations, placement insertion order, supporting status and rule
provenance. The trace records that unchanged state as `not-examined` with the earlier
decisions retained. A candidate unable to beat a valid local incumbent already
offered to cancellation retention keeps its primary comparison but avoids a duplicate
global incumbent comparison. These are exact reuse decisions, not additional sampling.
At the first explicit sum/minimize quantity preference, complete single additions can
reuse an evaluated funding score when eligible at-risk balances have the same expiry
and matching signature across every enabled time-window rule, including obligations.
The first original-order funder is preserved; later equivalent alternatives are recorded
as `not-examined` without fabricated comparisons. This applies only without active
IF_THEN rules or earned activations, without a balance maximum objective, and with a
safe combined remaining quantity. Recursive bundles and augmenting allocation retain
every funding choice: equal current scores do not prove equivalent future capacity.
A first-position sum/minimize unplaced-quantity rule finishes its preference stage when
its reconciled value reaches zero. The same bound applies to a prefix consisting of
that zero quantity followed only by fully satisfied time-window preferences or maximizing
public-holiday-week preferences at their configured upper bound. Windows are capped at
their requested quantity. A holiday maximum is evaluated from the same immutable request
weeks, marking every week complete and using its configured sum, count or maximum;
partial horizon weeks retain the existing declaration and eligibility semantics. The whole bounded stage is recorded as
`not-examined`; later preferences, replay after actual state changes and the final
full-vector expiry/date pass still run. Other configurations and preference orders
retain their search. This schedules lower priorities later; it does not claim an
exhaustive full-vector search in the bounded stage.
When every balance is exhausted and there is no active IF_THEN rule, a complete generic
iteration that finds no full-vector improvement can be reused for the identical ordered
state at a later preference. Explicit balance assignments use the same dates, validity,
provenance and configured move sampling regardless of the current rule. Rule-specific
bundles still run, and any balance, placement, supporting, provenance or activation change
invalidates reuse. Bound returns and interrupted iterations never admit a state. Reuse
is recorded as `not-examined` with the earlier sampled generic decisions retained; the
final expiry/date pass still examines its neighborhood.
Captured trace vectors, scalar facts, placements, ordered date lists and deltas are immutable detached evidence. Bounded
request-local indexes share only identical complete values and metadata; the vector
index uses recorded date keys and holds at most 65,536 entries, including variants in
one bucket. Eviction changes allocation only, with every examined candidate and its
ordered factual fields retained. Mutable candidate shells and emitted replacement
snapshots remain independent. Search never reads these sharing indexes. Calendar facts also reuse a bounded 4,096-entry request-local cache keyed by the complete occupied date set; balance quantities, earned metadata, provenance and supporting classifications are refreshed separately. Removing a placement copies the placement map without editing surviving entries; later assignment and metadata edits still detach them. Candidate locations are resolved from the latest recorded decision only when a later update or selection needs them, avoiding a permanent index for observation-only candidates.
Cancellation retains the best valid examined plan under the captured active vector
and its original trace. Counterfactual and explanation comparisons use that same
snapshot; later editor changes and delayed artifact delivery do not alter it.
An explicit caller-owned planning-work budget remains supported; the application
introduces no automatic duration-based stop.
## Confirmed decisions and technical assumptions
Confirmed by ADR 0008: one composable model for supplied and personal optimization rules;
preference-only force; the six recipes' existing business meanings; declared-day and horizon
semantics; user-controlled active preference order; expiry then date tie-breaks; preserving
saved user data. The integrated implementation replaces the former implicit policy
with explicit active rules and performs the one-time saved-data addition.
The supported configuration boundary is only sum/count/maximum and both directions; empty aggregate
zero; deficit threshold is a positive working-day integer, initially five; every listed
compatible condition subset and aggregation is supported; no other filters or measures.
Public-holiday weights retain the existing business definition rather than becoming a parameter.
## Verification scope
Ticket #75 covers saved-rule migration and functional integration of #70–#75.
Performance acceptance and any further optimization are tracked in #78, after
original-decision-trace removal in #76 and functional verification in #77. The
seven-scenario benchmark, its warm-up and two measured repetitions, the 2 GB heap
limit and the 300-second timeout remain unchanged. CI runs the benchmark as a
separate non-blocking job at the user's request while #78 is open; functional
tests and other checks remain required. #78 must restore the required benchmark
gate after the protocol passes. Completing the functional migration or passing
CI during this exception does not establish a passing benchmark.
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan Statistics Popup
Local development draft for [issue #3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [issue #29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), within [milestone %3: V1 stats](https://gitlab.nessar.fr/nessar/vac-optimizer/-/milestones/3). The behavior below consolidates the decisions confirmed during grill-with-docs; proposals and remaining presentation choices are identified separately.
Local development draft for [issue #3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [issue #29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), within [milestone V1 stats](https://gitea.nessar.fr/nessar/vac-optimizer/milestone/13). The behavior below consolidates the decisions confirmed during grill-with-docs; proposals and remaining presentation choices are identified separately.
## Problem Statement
+151
View File
@@ -0,0 +1,151 @@
# 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. Angular hydration's `withEventReplay`
is unrelated to planning history. `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.
+4 -3
View File
@@ -38,7 +38,7 @@
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.1",
"@angular/ssr": "22.2.2",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
@@ -49,8 +49,8 @@
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.1",
"@angular/cli": "22.2.1",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
@@ -64,6 +64,7 @@
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"fast-check": "4.10.2",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"jsdom": "^30.0.0",
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
// Gitea exposes its event payload through the GitHub-compatible runner variables.
export function readCiContext() {
const env = process.env;
const eventName = env.GITHUB_EVENT_NAME;
if (!['push', 'pull_request', 'workflow_dispatch'].includes(eventName)) {
throw new Error('Only push, pull_request and workflow_dispatch events are supported.');
}
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
const commit = env.GITHUB_SHA;
if (!/^[a-f0-9]{40}$/.test(commit ?? '')) throw new Error('A commit SHA is required.');
const pullRequest = eventName === 'pull_request' ? event.pull_request : undefined;
if (eventName === 'pull_request' && pullRequest?.head?.sha !== commit) {
throw new Error('CI must validate the pull request head commit.');
}
const tag = env.GITHUB_REF?.startsWith('refs/tags/') ? env.GITHUB_REF.slice(10) : '';
const branch = pullRequest
? pullRequest.head.ref
: env.GITHUB_REF?.startsWith('refs/heads/')
? env.GITHUB_REF.slice(11)
: '';
if (!tag && !branch) throw new Error('A branch or tag ref is required.');
const slug = branch
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.slice(0, 63)
.replace(/^-+|-+$/g, '');
const publish =
eventName === 'push' ||
(eventName === 'pull_request' &&
Boolean(env.GITHUB_REPOSITORY) &&
pullRequest.head.repo?.full_name === env.GITHUB_REPOSITORY);
return { commit, tag, branch, slug, publish, pullRequest, before: event.before };
}
+40
View File
@@ -0,0 +1,40 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readCiContext } from './ci-context.mjs';
try {
const { commit, tag, slug, publish } = readCiContext();
if (!publish) throw new Error('Images can only be published by pushes or same-repository PRs.');
execFileSync(process.execPath, [fileURLToPath(new URL('./ci-policy.mjs', import.meta.url))]);
const env = process.env;
if (!env.REGISTRY_USERNAME || !env.REGISTRY_PASSWORD) {
throw new Error('REGISTRY_USERNAME and REGISTRY_PASSWORD are required for publication.');
}
const registry = new URL(env.GITHUB_SERVER_URL).host;
const image = `${registry}/${env.GITHUB_REPOSITORY.toLowerCase()}`;
const shaImage = `${image}:sha-${commit}`;
const docker = (...args) => execFileSync('docker', args, { stdio: 'inherit' });
execFileSync(
'docker',
['login', registry, '--username', env.REGISTRY_USERNAME, '--password-stdin'],
{
input: env.REGISTRY_PASSWORD,
stdio: ['pipe', 'inherit', 'inherit'],
},
);
if (tag) {
docker('pull', shaImage);
docker('tag', shaImage, `${image}:${tag}`);
docker('push', `${image}:${tag}`);
docker('tag', shaImage, `${image}:latest`);
docker('push', `${image}:latest`);
} else {
const branchImage = `${image}:branch-${slug}`;
docker('build', '-t', shaImage, '-t', branchImage, '.');
docker('push', shaImage);
docker('push', branchImage);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-image-'));
const script = fileURLToPath(new URL('./ci-image.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const image = 'gitea.nessar.fr/nessar/vac-optimizer';
const commandsPath = join(directory, 'docker-commands.jsonl');
const bin = join(directory, 'bin');
const run = (variables = {}, expected = 0) => {
const commit = git('rev-parse', 'HEAD');
const eventName = variables.eventName ?? 'push';
const ref = variables.tag
? `refs/tags/${variables.tag}`
: eventName === 'pull_request'
? 'refs/pull/42/head'
: 'refs/heads/feature/calendar';
const event = { ref };
if (eventName === 'pull_request') {
event.pull_request = {
title: 'feat: calendar',
head: {
sha: commit,
ref: 'feature/calendar',
repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
},
base: { ref: 'develop' },
};
}
writeFileSync(join(directory, 'event.json'), JSON.stringify(event));
writeFileSync(commandsPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: `${bin}:${process.env.PATH}`,
GITHUB_EVENT_NAME: eventName,
GITHUB_EVENT_PATH: join(directory, 'event.json'),
GITHUB_SHA: commit,
GITHUB_REF: ref,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_SERVER_URL: 'https://gitea.nessar.fr',
REGISTRY_USERNAME: 'publisher',
REGISTRY_PASSWORD: variables.registryPassword ?? 'test-token',
DOCKER_COMMANDS: commandsPath,
MISSING_IMAGE: variables.missingImage ? 'true' : '',
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(commandsPath, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
};
try {
git('init', '--initial-branch=main');
git('config', 'user.name', 'CI image test');
git('config', 'user.email', 'ci-image@example.invalid');
git('remote', 'add', 'origin', directory);
writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'chore: initial release');
git('branch', 'develop');
git('switch', '-c', 'feature/calendar');
git('commit', '--allow-empty', '-m', 'feat: calendar');
mkdirSync(bin);
const docker = join(bin, 'docker');
writeFileSync(
docker,
`#!/usr/bin/env node
import { appendFileSync, readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
appendFileSync(process.env.DOCKER_COMMANDS, JSON.stringify({ args, input }) + '\\n');
if (args[0] === 'pull' && process.env.MISSING_IMAGE === 'true') process.exit(1);
`,
);
chmodSync(docker, 0o755);
const commit = git('rev-parse', 'HEAD');
const commands = run();
assert.deepEqual(commands, [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{
args: [
'build',
'-t',
`${image}:sha-${commit}`,
'-t',
`${image}:branch-feature-calendar`,
'.',
],
input: '',
},
{ args: ['push', `${image}:sha-${commit}`], input: '' },
{ args: ['push', `${image}:branch-feature-calendar`], input: '' },
]);
assert.deepEqual(run({ eventName: 'pull_request' }), commands);
assert.deepEqual(
run({ eventName: 'pull_request', headRepository: 'contributor/vac-optimizer' }, 1),
[],
);
assert.deepEqual(run({ eventName: 'workflow_dispatch' }, 1), []);
assert.deepEqual(run({ registryPassword: '' }, 1), []);
assert.deepEqual(run({ tag: 'v1.5.0' }, 1), []);
git('switch', 'main');
git('tag', '-a', 'v1.5.0', '-m', 'Release 1.5.0');
const releaseCommit = git('rev-parse', 'HEAD');
assert.deepEqual(run({ tag: 'v1.5.0' }), [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{ args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
{ args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:v1.5.0`], input: '' },
{ args: ['push', `${image}:v1.5.0`], input: '' },
{ args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:latest`], input: '' },
{ args: ['push', `${image}:latest`], input: '' },
]);
assert.deepEqual(run({ tag: 'v1.5.0', missingImage: true }, 1), [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{ args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
]);
assert.deepEqual(run({ tag: 'v1.5.0-rc.1' }, 1), []);
assert.deepEqual(run({ tag: 'v1.6.0' }, 1), []);
console.log('CI image publication checks passed.');
} finally {
rmSync(directory, { recursive: true, force: true });
}
+17 -15
View File
@@ -1,5 +1,6 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { appendFileSync, readFileSync } from 'node:fs';
import { readCiContext } from './ci-context.mjs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
@@ -11,7 +12,7 @@ const requirePolicy = (condition, message) => {
};
try {
const commit = env.CI_COMMIT_SHA;
const { commit, tag, branch, slug, pullRequest, before } = readCiContext();
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
@@ -21,33 +22,32 @@ try {
'+refs/heads/develop:refs/remotes/origin/develop',
);
if (env.CI_COMMIT_TAG) {
if (tag) {
requirePolicy(
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_TAG),
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag),
'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
);
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
requirePolicy(env.CI_COMMIT_TAG === `v${version}`, 'Release tag must match package.json.');
requirePolicy(tag === `v${version}`, 'Release tag must match package.json.');
requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else {
const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event';
const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH;
const target = mr
? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
const target = pullRequest
? pullRequest.base?.ref
: branch === 'main' || branch?.startsWith('hotfix/')
? 'main'
: 'develop';
requirePolicy(branch, 'A branch name is required.');
requirePolicy(['main', 'develop'].includes(target), 'MRs must target main or develop.');
git('check-ref-format', `refs/heads/${branch}`);
requirePolicy(['main', 'develop'].includes(target), 'PRs must target main or develop.');
if (mr) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace(
if (pullRequest) {
const title = pullRequest.title?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
'MR title must use Conventional Commits.',
'PR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
@@ -65,9 +65,8 @@ try {
}
}
const before = env.CI_COMMIT_BEFORE_SHA;
const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit)
!pullRequest && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
@@ -78,6 +77,9 @@ try {
);
}
}
if (env.GITHUB_OUTPUT) {
appendFileSync(env.GITHUB_OUTPUT, `slug=${slug}\ntag=${tag}\n`);
}
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
+65 -38
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,26 +8,48 @@ import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const check = (variables, expected = 0) => {
const check = (variables = {}, expected = 0) => {
const commit = git('rev-parse', 'HEAD');
const branch = variables.branch ?? 'feature/test';
const eventName = variables.eventName ?? 'push';
const ref = variables.tag
? `refs/tags/${variables.tag}`
: eventName === 'pull_request'
? 'refs/pull/42/head'
: `refs/heads/${branch}`;
const event = { ref, before: variables.before };
if (eventName === 'pull_request') {
event.pull_request = {
title: variables.title ?? 'feat(test): add policy checks',
head: {
ref: branch,
sha: variables.headSha ?? commit,
repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
},
base: { ref: variables.target ?? 'develop' },
};
}
const eventPath = join(directory, 'event.json');
const outputPath = join(directory, 'outputs');
writeFileSync(eventPath, JSON.stringify(event));
writeFileSync(outputPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
CI_PIPELINE_SOURCE: 'push',
CI_COMMIT_SHA: git('rev-parse', 'HEAD'),
CI_COMMIT_BRANCH: 'feature/test',
...variables,
GITHUB_EVENT_NAME: eventName,
GITHUB_EVENT_PATH: eventPath,
GITHUB_REF: ref,
GITHUB_SHA: variables.sha ?? commit,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_OUTPUT: outputPath,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(outputPath, 'utf8');
};
const mr = {
CI_PIPELINE_SOURCE: 'merge_request_event',
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/test',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'develop',
CI_MERGE_REQUEST_TITLE: 'feat(test): add policy checks',
};
const pr = { eventName: 'pull_request' };
try {
git('init', '--initial-branch=main');
@@ -41,26 +63,31 @@ try {
git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({});
check(mr);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1);
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_SHA: '0'.repeat(40) }, 1);
check(pr);
check({ ...pr, headRepository: 'contributor/vac-optimizer' });
check({ eventName: 'workflow_dispatch' });
assert.match(check({ branch: 'Feature/My_calendar' }), /slug=feature-my-calendar\n/);
check({ ...pr, headSha: '0'.repeat(40) }, 1);
check({ eventName: 'pull_request_target' }, 1);
check({ ...pr, title: 'Draft: feat: test policy' });
check({ ...pr, title: '(Draft) feat: test policy' });
check({ ...pr, target: 'main' }, 1);
check({ ...pr, title: 'Add checks' }, 1);
check({ tag: 'v1.5.0' }, 1);
check({ sha: '0'.repeat(40) }, 1);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
check(mr, 1);
check(pr, 1);
git('rebase', 'develop');
check(mr);
check(pr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1);
check(mr, 1);
check({ before: before }, 1);
check(pr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before });
check({ before: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
@@ -69,33 +96,33 @@ try {
git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
...pr,
branch: 'release/1.6.0',
target: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0' });
check({ ...pr, branch: 'release/1.6.0' });
git('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
check({ CI_COMMIT_TAG: 'v1.6.0' });
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_TAG: 'v01.6.0' }, 1);
check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1);
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' });
check({ tag: 'v1.6.0' });
check({ tag: 'v1.5.0' }, 1);
check({ tag: 'v01.6.0' }, 1);
check({ tag: 'v1.6.0-rc.1' }, 1);
check({ ...pr, branch: 'main' });
git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
...pr,
branch: 'hotfix/1.6.1',
target: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1' });
check({ ...pr, branch: 'hotfix/1.6.1' });
// The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
check(mr, 1);
check(pr, 1);
console.log('CI policy integration checks passed.');
} finally {
+50 -3
View File
@@ -32,7 +32,7 @@ describe('Application Settings', () => {
localStorage.clear();
localStorage.setItem('app.language', JSON.stringify('en'));
localStorage.setItem('leaveBalances', savedBalances);
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
localStorage.setItem('unrelated.entry', 'keep this value');
mediaQuery = Object.assign(new EventTarget(), {
matches: true,
@@ -339,6 +339,53 @@ describe('Application Settings', () => {
expect(await dialog.getAllHarnesses(MatButtonHarness)).toHaveLength(1);
});
it('saves planning dates before balances and recalculates only when Calculate is selected', async () => {
const root = fixture.nativeElement as HTMLElement;
const period = root.querySelector<HTMLElement>('app-user-input .planning-dates')!;
expect(
period.compareDocumentPosition(root.querySelector('.listOfDays')!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(period.querySelector('legend')?.textContent).toBe('Planning period');
expect(period.querySelector('#planning-dates-help')?.textContent).toContain(
'Calculate plan',
);
const start = period.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = period.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
start.value = '29/02/2024';
start.dispatchEvent(new Event('input'));
last.value = '08/03/2024';
last.dispatchEvent(new Event('input'));
await fixture.whenStable();
expect(calculate).toHaveBeenCalledOnce();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2024-03-08',
});
root.querySelector<HTMLButtonElement>('.calculate-button')!.click();
await fixture.whenStable();
expect(calculate).toHaveBeenCalledTimes(2);
expect(calculate.mock.calls[1][0].horizon).toEqual({
start: '2024-02-29',
end: '2024-03-09',
});
const dialog = await openSettings();
expect(await dialog.getContentText()).not.toContain('Planning period');
const [, language] = await dialog.getAllHarnesses(MatSelectHarness);
await selectOption(language, 'Français');
expect(period.querySelector('legend')?.textContent).toBe('Période à planifier');
await dialog.close();
await revisitApp(true);
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
'29/02/2024',
);
expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe(
'08/03/2024',
);
});
it('supports keyboard theme selection and moves focus into the dialog', async () => {
const dialog = await openSettings();
const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
@@ -414,7 +461,7 @@ describe('Application Settings', () => {
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
expect(localStorage.getItem('vacationRules')).toBe('[]');
expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}');
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
0,
@@ -443,7 +490,7 @@ describe('Application Settings', () => {
TestBed.resetTestingModule();
await createApp();
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
expect(localStorage.getItem('vacationRules')).toBe('[]');
expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}');
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(
@@ -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 { 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);
@@ -41,10 +46,11 @@ describe('leave planner benchmark protocol', () => {
String(rttRule.startDate) <= String(cpRule.endDate),
).toBe(true);
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
'wednesday-holidays',
'thursday-consumer',
]);
expect(
chain?.request.rules
?.filter(({ type }) => type !== 'optimization')
.map((rule) => rule.id),
).toEqual(['wednesday-holidays', 'thursday-consumer']);
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -63,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 writes skip Vitest's per-call "stdout |" headers and appear immediately.
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],
@@ -74,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',
@@ -90,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);
});
+120 -34
View File
@@ -2,8 +2,8 @@ import {
createPlanningCalculation,
PlanningOutcome,
PlanningRequest,
PlanningTrace,
} from '../services/leave-planner.service';
import { createOptimizationPresetRules } from '../services/rule-service';
export interface BenchmarkScenario {
name: string;
@@ -15,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 = (
@@ -47,7 +41,8 @@ const balance = (
export function benchmarkScenarios(): BenchmarkScenario[] {
const horizon = { start: '2024-01-01', end: '2025-01-01' };
return [
const presets = createOptimizationPresetRules();
const scenarios: BenchmarkScenario[] = [
{
name: 'representative year',
request: {
@@ -169,6 +164,13 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
}),
),
];
return scenarios.map((scenario) => ({
...scenario,
request: {
...scenario.request,
rules: [presets[0], ...scenario.request.rules, ...presets.slice(1)],
},
}));
}
function resultSummary(outcome: PlanningOutcome): {
@@ -191,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;
}
@@ -210,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(
@@ -241,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 {
@@ -267,15 +263,12 @@ async function measureRepetition(
repetition,
preparationMs,
planningMs,
traceDeliveryMs,
maxUiDelayMs,
peakMainThreadHeapBytes,
...timings,
wallMs,
...summary,
deterministic,
traceStatus: trace?.status ?? 'not-produced',
traceCandidates,
},
stable,
};
@@ -286,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) =>
@@ -295,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) {
@@ -252,20 +197,6 @@ export class CalendarMonth implements OnInit {
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;
}
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from '../../services/rule-service';
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
@@ -13,7 +14,6 @@ import {
import {
CounterfactualAnalysis,
CounterfactualSelection,
HistoricalEligibilityReason,
LeavePlacement,
PlanOutcome,
PlanExplanation,
@@ -24,8 +24,6 @@ import {
InvalidInputOutcome,
PlanningOutcome,
PlanningObjectiveVector,
PlanningTrace,
PlanningTraceUpdate,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
@@ -146,14 +144,12 @@ function placementForSelection(selection: CounterfactualSelection): LeavePlaceme
(candidate) => candidate.date === selection || candidate.balanceId === selection,
) ?? plan.placements[0]
);
return 'decisionId' in selection ? selection.placement : selection;
return selection;
}
function calculationFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
tracePromise: Promise<PlanningTrace | undefined> = Promise.resolve(trace),
): PlanningCalculationContext {
const counterfactual: CounterfactualAnalysis = {
kind: 'Counterfactual',
@@ -174,7 +170,6 @@ function calculationFor(
date: '2024-05-04',
status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' },
historicalStatus: 'ineligible',
},
{
date: '2024-05-07',
@@ -185,7 +180,6 @@ function calculationFor(
date: '2024-05-08',
status: 'ineligible',
reason: { code: 'non-working', message: 'Public Holiday' },
historicalStatus: 'ineligible',
},
],
};
@@ -202,27 +196,15 @@ function calculationFor(
outcome: Promise.resolve(outcome),
explanation: Promise.resolve(stableExplanation),
explanationError: Promise.resolve(undefined),
trace: tracePromise,
analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({
...counterfactual,
placement: placementForSelection(selection),
})),
historicalEligibility: vi.fn(async () => [] as readonly HistoricalEligibilityReason[]),
};
}
function calendarFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
tracePromise?: Promise<PlanningTrace | undefined>,
) {
const calculation = calculationFor(
outcome,
stableExplanation,
trace,
tracePromise ?? Promise.resolve(trace),
);
function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExplanation) {
const calculation = calculationFor(outcome, stableExplanation);
return {
calculation,
calendar: {
@@ -252,160 +234,7 @@ function activateWithKeyboard(element: HTMLElement, key: 'Enter' | ' ' = 'Enter'
element.click();
}
function jointTrace(): PlanningTrace {
const placementComparison =
explanation.kind === 'PlanExplanation' ? explanation.placements[0].comparison : undefined;
return {
status: 'ready',
initialState: {
horizon: plan.horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
remaining: 2,
availableFrom: '2024-01-01',
expiryDate: '2024-12-31',
earned: false,
},
{
id: 'earned:spring:2024',
type: 'rtt',
quantity: 2,
remaining: 2,
availableFrom: '2024-05-04',
expiryDate: '2025-05-04',
earned: true,
sourceRuleId: 'spring',
},
],
placements: [plan.placements[1]],
earnedActivations: [
{
id: 'earned:spring:2024',
ruleId: 'spring',
occurrence: '2024',
dates: ['2024-05-03', '2024-05-06'],
},
],
},
phases: [
{
id: 'phase:obligations',
name: 'obligations',
completed: true,
status: 'completed',
decisions: [
{
id: 'decision:prior',
action: 'validate',
dates: ['2024-05-01'],
candidates: [],
deltas: [
{
kind: 'balance-changed',
balanceId: 'cp',
remainingBefore: 2,
remainingAfter: 1,
},
],
},
{
id: 'decision:joint',
action: 'choose',
dates: ['2024-05-03', '2024-05-06'],
branch: {
parentDecisionId: 'decision:prior',
deltas: [
{
kind: 'placement-added',
date: '2024-05-03',
balanceId: 'cp',
remainingBefore: 1,
remainingAfter: 0,
placement: plan.placements[0],
},
{
kind: 'balance-changed',
balanceId: 'earned:spring:2024',
remainingBefore: 2,
remainingAfter: 2,
balance: {
id: 'earned:spring:2024',
type: 'rtt',
quantity: 2,
remaining: 2,
availableFrom: '2024-05-07',
expiryDate: '2025-05-07',
earned: true,
sourceRuleId: 'spring',
},
},
],
},
candidates: [
{
status: 'evaluated-rejected',
dates: ['2024-05-10', '2024-05-13'],
reason: {
code: 'joint-date-conflict',
message: 'This branch cannot satisfy both required dates.',
},
technicalComparison: [
{
name: 'unplaced-at-risk',
direction: 'minimize',
before: 2,
after: 2,
},
{
name: 'preference:spring',
direction: 'maximize',
before: 1,
after: 0,
},
],
firstDecisiveComparator: 'preference:spring',
comparison: placementComparison,
},
{
status: 'chosen',
dates: ['2024-05-03', '2024-05-06'],
comparison: placementComparison,
},
],
deltas: [
{
kind: 'placement-added',
date: '2024-05-03',
balanceId: 'cp',
remainingBefore: 1,
remainingAfter: 0,
placement: plan.placements[0],
},
],
},
{
id: 'decision:later',
action: 'reject',
dates: ['2024-05-20'],
candidates: [],
deltas: [],
},
],
},
{
id: 'phase:dates',
name: 'dates',
completed: true,
status: 'completed',
decisions: [],
},
],
completedStages: ['obligations', 'dates'],
};
}
const presets = createOptimizationPresetRules();
describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
@@ -574,10 +403,9 @@ describe('CalendarYear planning outcome boundary', () => {
{ partial: false, placements: 0 },
{ partial: true, placements: 0 },
])(
'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
'opens calculated statistics for the displayed plan (partial: $partial, placements: $placements)',
async ({ partial, placements }) => {
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
const pendingTrace = deferred<PlanningTrace | undefined>();
const validPlan: PlanOutcome = {
...plan,
calculationDate: '2024-05-01',
@@ -629,12 +457,7 @@ describe('CalendarYear planning outcome boundary', () => {
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
}
: validPlan;
const { calendar, calculation } = calendarFor(
outcome,
undefined,
undefined,
pendingTrace.promise,
);
const { calendar, calculation } = calendarFor(outcome, undefined);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -690,7 +513,6 @@ describe('CalendarYear planning outcome boundary', () => {
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
pendingTrace.reject(new Error('Trace delivery failed'));
await fixture.whenStable();
fixture.detectChanges();
expect(statistics()).toEqual(expectedStatistics);
@@ -999,14 +821,12 @@ describe('CalendarYear planning outcome boundary', () => {
);
});
it('shows the retained earnings when cancellation interrupts a different generating candidate', async () => {
it('shows the retained earnings after cancelling with a valid generating plan', async () => {
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
// Flush the original trace deterministically so cancellation occurs at the observed branch.
// Yield between work slices so cancellation can follow a delivered valid checkpoint.
let elapsed = 0;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30));
let checkpoint: PlanOutcome | undefined;
let interrupted = false;
let latestGenerator: string | undefined;
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
try {
const core = await import('../../services/leave-planner.core');
@@ -1042,24 +862,11 @@ describe('CalendarYear planning outcome boundary', () => {
rewardType: 'other',
})),
],
traceProgress: (_: number, update: PlanningTraceUpdate) => {
if (interrupted) return;
for (const operation of update.operations) {
const deltas =
operation.kind === 'delta'
? [operation.delta]
: operation.kind === 'decision'
? (operation.decision.branch?.deltas ?? [])
: [];
for (const delta of deltas) {
if (delta.kind !== 'earned-activated') continue;
latestGenerator = delta.activation?.ruleId;
if (checkpoint && latestGenerator === 'before') interrupted = true;
}
}
},
},
(value) => (checkpoint = value),
(value) => {
if (value.ifThenContributions?.some(({ ruleId }) => ruleId === 'after'))
interrupted = true;
},
(value) => (artifacts = value),
undefined,
undefined,
@@ -1069,7 +876,6 @@ describe('CalendarYear planning outcome boundary', () => {
if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
throw new Error('Expected a real cancellation with a retained valid plan.');
expect(interrupted).toBe(true);
expect(latestGenerator).toBe('before');
expect(outcome.incumbent.ifThenContributions).toMatchObject([
{ ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } },
]);
@@ -1078,20 +884,7 @@ describe('CalendarYear planning outcome boundary', () => {
'2024-05-02',
'2024-05-10',
]);
const latestBranch = artifacts.trace?.phases
.flatMap(({ decisions }) => decisions)
.filter(({ branch }) => branch)
.at(-1);
expect(latestBranch?.candidates).toMatchObject([
{ status: 'evaluated-rejected', dates: ['2024-04-29', '2024-04-30'] },
]);
expect(latestBranch?.branch?.deltas).toContainEqual(
expect.objectContaining({
kind: 'earned-activated',
activation: expect.objectContaining({ ruleId: 'before' }),
}),
);
const { calendar } = calendarFor(outcome, artifacts.explanation, artifacts.trace);
const { calendar } = calendarFor(outcome, artifacts.explanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -1239,12 +1032,11 @@ describe('CalendarYear planning outcome boundary', () => {
'replaces open statistics after %s and ignores an older valid outcome',
async (_, replacement: PlanningOutcome, focusMoved) => {
const oldOutcome = deferred<PlanningOutcome>();
const oldTrace = deferred<PlanningTrace | undefined>();
const { calendar } = calendarFor(plan);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan))
.mockReturnValueOnce({
...calculationFor(plan, undefined, undefined, oldTrace.promise),
...calculationFor(plan, undefined),
outcome: oldOutcome.promise,
})
.mockReturnValueOnce(calculationFor(replacement));
@@ -1277,7 +1069,6 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
oldOutcome.resolve(plan);
oldTrace.resolve(jointTrace());
await fixture.whenStable();
fixture.detectChanges();
expect(input.planningOutcomeSignal()).toBe(replacement);
@@ -1342,7 +1133,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
@@ -1350,7 +1141,6 @@ describe('CalendarYear planning outcome boundary', () => {
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
});
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-08', '2024-01-12');
@@ -1421,7 +1211,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
@@ -1429,7 +1219,6 @@ describe('CalendarYear planning outcome boundary', () => {
const calculation = {
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
};
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
@@ -1481,7 +1270,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
it.each([false, true])(
'keeps adjacent context declarations attached to the historical anchors (fixed anchor: %s)',
'keeps adjacent context declarations attached to displayed anchors (fixed anchor: %s)',
async (fixedAnchor) => {
const core = await import('../../services/leave-planner.core');
const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31';
@@ -1496,7 +1285,7 @@ describe('CalendarYear planning outcome boundary', () => {
expiryDate: placementDate,
},
],
rules: [],
rules: [presets[0]],
declaredDays: [
{ date: '2024-08-01', type: 'cp' as const },
...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []),
@@ -1508,28 +1297,14 @@ describe('CalendarYear planning outcome boundary', () => {
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a plan and its original trace.');
const trace = artifacts.trace;
const phaseIndex = trace.phases.findIndex((phase) =>
phase.decisions.some((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
),
);
const decisionIndex = trace.phases[phaseIndex].decisions.findIndex((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
);
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculation = calculationFor(outcome, artifacts.explanation, trace);
const calculation = calculationFor(outcome, artifacts.explanation);
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
);
@@ -1540,29 +1315,10 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-explorer summary')!);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
activateWithKeyboard(
root.querySelectorAll<HTMLElement>('.trace-phases > button')[phaseIndex],
);
fixture.detectChanges();
for (let index = 0; index < decisionIndex; index++) {
activateWithKeyboard(
root.querySelector<HTMLElement>('[aria-label="Next decision"]')!,
);
fixture.detectChanges();
}
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-toolbar button')!);
fixture.detectChanges();
const before = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]');
if (fixedAnchor) {
expect(before?.classList).toContain('selected');
expect(before?.classList).toContain('cp');
} else expect(before).toBeNull();
activateWithKeyboard(root.querySelectorAll<HTMLElement>('.trace-toolbar button')[1]);
fixture.detectChanges();
const after = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]')!;
expect(after.classList).toContain('selected');
@@ -1570,7 +1326,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(after.classList).toContain('context-only');
expect(after.disabled).toBe(true);
expect(after.getAttribute('aria-label')).toContain('Already booked');
expect(fixture.componentInstance['traceState']()?.placements).toEqual(
expect(fixture.componentInstance['displayedPlan']()?.placements).toEqual(
outcome.placements,
);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt');
@@ -1582,7 +1338,7 @@ describe('CalendarYear planning outcome boundary', () => {
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
providers: [provideZonelessChangeDetection()],
@@ -1749,7 +1505,7 @@ describe('CalendarYear planning outcome boundary', () => {
it('edits declarations from configuration and restores the preceding result when switched off', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem(
'declaredNonWorkingDays',
@@ -1844,7 +1600,7 @@ describe('CalendarYear planning outcome boundary', () => {
});
it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
localStorage.setItem('leaveBalances', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
@@ -1868,11 +1624,9 @@ describe('CalendarYear planning outcome boundary', () => {
],
};
const lateExplanation = deferred<PlanningExplanation | undefined>();
const lateTrace = deferred<PlanningTrace | undefined>();
const initialCalculation = {
...calculationFor(initial, oldExplanation),
explanation: lateExplanation.promise,
trace: lateTrace.promise,
};
const rule = {
id: 'linked',
@@ -1943,26 +1697,10 @@ describe('CalendarYear planning outcome boundary', () => {
horizon: submitted.horizon,
declaredDays: submitted.declaredDays,
});
const originalTrace: PlanningTrace = {
status: 'ready',
initialState: {
horizon: initial.horizon,
declaredDays: initial.declaredDays,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: initial.completedStages,
};
lateExplanation.resolve(oldExplanation);
lateTrace.resolve(originalTrace);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['planningTrace']()).toBe(originalTrace);
expect(fixture.componentInstance['traceLoading']()).toBe(false);
expect(fixture.componentInstance['traceResolved']()).toBe(true);
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
details.open = true;
details.dispatchEvent(new Event('toggle'));
@@ -1983,19 +1721,14 @@ describe('CalendarYear planning outcome boundary', () => {
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'other' }]);
});
it('calibrates the wait through calendar rendering while a long trace remains pending', async () => {
it('calibrates the wait through calendar rendering independently of explanation delivery', async () => {
let now = 1_000;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
try {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
const pending = deferred<PlanningOutcome>();
const trace = deferred<PlanningTrace | undefined>();
const { calendar, calculation } = calendarFor(
plan,
undefined,
undefined,
trace.promise,
);
const delayedExplanation = deferred<PlanningExplanation | undefined>();
const { calendar, calculation } = calendarFor(plan, undefined);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2018,6 +1751,7 @@ describe('CalendarYear planning outcome boundary', () => {
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: pending.promise,
explanation: delayedExplanation.promise,
snapshot: { ...calculation.snapshot, ...submitted },
});
const fixture = TestBed.createComponent(CalendarYear);
@@ -2033,11 +1767,13 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull();
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
now = 321_000;
trace.reject(new Error('Late trace delivery failed'));
delayedExplanation.resolve(explanation);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['planningExplanation']()).toBe(explanation);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
@@ -2100,7 +1836,7 @@ describe('CalendarYear planning outcome boundary', () => {
let now = 1_000;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
try {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
const old = deferred<PlanningOutcome>();
const latest = deferred<PlanningOutcome>();
const { calendar, calculation } = calendarFor(plan);
@@ -2173,19 +1909,13 @@ describe('CalendarYear planning outcome boundary', () => {
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
async (withIncumbent) => {
const pending = deferred<PlanningOutcome>();
const delayedTrace = deferred<PlanningTrace | undefined>();
const cancelled: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
incumbent: withIncumbent ? plan : undefined,
completedStages: ['validation'],
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
};
const { calendar, calculation } = calendarFor(
cancelled,
undefined,
undefined,
delayedTrace.promise,
);
const { calendar, calculation } = calendarFor(cancelled, undefined);
calendar.createPlanningCalculation
.mockReturnValueOnce({ ...calculation, outcome: pending.promise })
.mockReturnValueOnce(calculationFor(plan, explanation));
@@ -2223,7 +1953,6 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
delayedTrace.resolve({ ...jointTrace(), status: 'incomplete' });
await fixture.whenStable();
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
},
@@ -2301,14 +2030,13 @@ describe('CalendarYear planning outcome boundary', () => {
},
);
it('ignores an older outcome, explanation and trace after a new horizon is submitted', async () => {
it('ignores an older outcome and explanation after a new horizon is submitted', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const oldOutcome = deferred<PlanningOutcome>();
const oldExplanation = deferred<PlanningExplanation | undefined>();
const oldTrace = deferred<PlanningTrace | undefined>();
const calendar = TestBed.inject(CalendarService);
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
@@ -2316,7 +2044,6 @@ describe('CalendarYear planning outcome boundary', () => {
...calculationFor(plan),
outcome: oldOutcome.promise,
explanation: oldExplanation.promise,
trace: oldTrace.promise,
})
.mockReturnValueOnce(calculationFor(conflict));
const settings = TestBed.inject(CalendarSettingsService);
@@ -2329,14 +2056,12 @@ describe('CalendarYear planning outcome boundary', () => {
await fixture.whenStable();
oldOutcome.resolve(plan);
oldExplanation.resolve(explanation);
oldTrace.resolve(jointTrace());
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(calculate).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance['planningOutcome']()).toBe(conflict);
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
expect(fixture.componentInstance['planningTrace']()).toBeNull();
expect(fixture.componentInstance['planningHorizon']()).toEqual({
start: '2021-05-14',
end: '2021-05-15',
@@ -2633,9 +2358,7 @@ describe('CalendarYear planning outcome boundary', () => {
const root = fixture.nativeElement as HTMLElement;
const planDetails = root.querySelector<HTMLDetailsElement>('.plan-details')!;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
expect(planDetails.open).toBe(false);
expect(traceDetails.open).toBe(false);
activateWithKeyboard(planDetails.querySelector<HTMLElement>('summary')!);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
@@ -3125,108 +2848,6 @@ describe('CalendarYear planning outcome boundary', () => {
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it('announces trace loading and completion while keeping the completed plan available', async () => {
const outcomeDelivery = deferred<PlanningOutcome>();
const traceDelivery = deferred<PlanningTrace | undefined>();
const { calendar, calculation } = calendarFor(
plan,
explanation,
undefined,
traceDelivery.promise,
);
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: outcomeDelivery.promise,
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.loading-overlay')).not.toBeNull();
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
expect(calendar.selectedDatesForPlan).not.toHaveBeenCalled();
expect(root.querySelector('.cancel-calculation')).not.toBeNull();
outcomeDelivery.resolve(plan);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const plannedButtons = Array.from(root.querySelectorAll('button.selected'));
expect(plannedButtons).toHaveLength(2);
expect(root.querySelector('.loading-overlay')).toBeNull();
const liveMessage = root.querySelector<HTMLElement>('.calendar-year > p[aria-live]')!;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
fixture.detectChanges();
expect(traceDetails.querySelector('[role="status"]')?.textContent).toContain(
'decision trace is still loading',
);
expect(liveMessage.getAttribute('aria-atomic')).toBe('true');
expect(root.querySelector('.plan-summary')).not.toBeNull();
traceDelivery.resolve(jointTrace());
await fixture.whenStable();
fixture.detectChanges();
expect(liveMessage.textContent).toContain('decision trace has loaded');
expect(Array.from(root.querySelectorAll('button.selected'))).toEqual(plannedButtons);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
expect(calendar.selectedDatesForPlan).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull());
root.querySelector<HTMLButtonElement>(
'.trace-decisions nav button[aria-label="Next decision"]',
)!.click();
fixture.detectChanges();
expect(root.querySelector('.candidate-list details')).not.toBeNull();
});
it('announces trace delivery failure without discarding the completed plan', async () => {
const traceDelivery = deferred<PlanningTrace | undefined>();
const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
fixture.detectChanges();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
traceDelivery.reject(new Error('trace transfer failed'));
await vi.waitFor(() => {
fixture.detectChanges();
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
'No decision trace is available',
);
});
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
'No decision trace is available',
);
expect(traceDetails.querySelector('[role="alert"]')?.textContent).toContain(
'trace transfer failed',
);
expect(root.querySelector('.plan-summary')).not.toBeNull();
});
it('announces an explicit comparison failure and retries inside the dialog', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
const firstAttempt = deferred<CounterfactualAnalysis>();
@@ -3301,13 +2922,13 @@ describe('CalendarYear planning outcome boundary', () => {
expect(document.activeElement).toBe(focusedBeforeResult);
});
it('renders conflict causes and replays an atomic trace decision', async () => {
it('renders conflict causes with their reported obligations', async () => {
const conflictExplanation: PlanningExplanation = {
kind: 'ConflictExplanation',
conflictingRules: [{ id: 'rule', label: 'Required spring leave' }],
bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' },
};
let setup = calendarFor(conflict, conflictExplanation);
const setup = calendarFor(conflict, conflictExplanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -3316,7 +2937,7 @@ describe('CalendarYear planning outcome boundary', () => {
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
let fixture = TestBed.createComponent(CalendarYear);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -3327,208 +2948,6 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
'No CP entitlement is available.',
);
TestBed.resetTestingModule();
const trace = jointTrace();
setup = calendarFor(plan, explanation, trace);
vi.mocked(setup.calculation.historicalEligibility).mockResolvedValue([
{
date: '2024-01-02',
reason: {
code: 'unavailable',
message: 'This balance is not available until January 4.',
},
},
{
date: '2024-01-08',
reason: { code: 'expired', message: 'This balance expired on January 7.' },
},
]);
const eligibilityRetry = deferred<readonly HistoricalEligibilityReason[]>();
vi.mocked(setup.calculation.historicalEligibility)
.mockRejectedValueOnce(new Error('Historical eligibility unavailable.'))
.mockImplementationOnce(() => eligibilityRetry.promise);
setup.calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: setup.calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(traceDetails.open).toBe(true);
const nextDecision = root.querySelector<HTMLButtonElement>(
'.trace-decisions nav button[aria-label="Next decision"]',
)!;
activateWithKeyboard(nextDecision);
await vi.waitFor(() => {
expect(fixture.componentInstance['historicalEligibilityError']()).toBe(
'Historical eligibility unavailable.',
);
});
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(true);
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'Historical eligibility unavailable.',
);
expect(root.querySelector('.sr-only[role="status"]')?.textContent).toContain(
'Original date eligibility could not be checked.',
);
const retryEligibility = Array.from(root.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Retry date eligibility check'),
)!;
activateWithKeyboard(retryEligibility);
fixture.detectChanges();
expect(root.querySelector('[role="status"]')?.textContent).toContain(
'Checking which dates this leave could use',
);
eligibilityRetry.resolve([
{
date: '2024-01-02',
reason: {
code: 'unavailable',
message: 'This balance is not available until January 4.',
},
},
{
date: '2024-01-08',
reason: { code: 'expired', message: 'This balance expired on January 7.' },
},
]);
await vi.waitFor(() => {
expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([
'2024-01-02',
'2024-01-08',
]);
});
fixture.detectChanges();
expect(setup.calculation.historicalEligibility).toHaveBeenCalledWith({
decisionId: 'decision:joint',
placement: plan.placements[0],
});
expect(fixture.componentInstance['traceReplay']()).toBe(true);
expect(fixture.componentInstance['historicalEligibilityError']()).toBeNull();
expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([
'2024-01-02',
'2024-01-08',
]);
const historicalAvailability = fixture.componentInstance['dateStatuses']();
expect(historicalAvailability['2024-01-02']).toMatchObject({
kind: 'ineligible',
reason: 'This leave balance cannot be used on this date.',
});
expect(historicalAvailability['2024-01-08']).toMatchObject({
kind: 'ineligible',
reason: 'This date is after the leave balance expires.',
});
let earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) =>
item.textContent?.includes('earned:spring:2024'),
)!;
expect(earnedBalance.textContent).toContain('Available from 4 May 2024');
expect(earnedBalance.textContent).toContain('Expires on 4 May 2025');
const jointAfterButton =
root.querySelectorAll<HTMLButtonElement>('.trace-toolbar button')[1]!;
activateWithKeyboard(jointAfterButton);
fixture.detectChanges();
earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) =>
item.textContent?.includes('earned:spring:2024'),
)!;
expect(earnedBalance.textContent).toContain('Available from 7 May 2024');
expect(earnedBalance.textContent).toContain('Expires on 7 May 2025');
expect(
root
.querySelector('.trace-decisions output[aria-current="step"]')
?.getAttribute('aria-label'),
).toContain('2024-05-03, 2024-05-06');
const candidates = Array.from(
root.querySelectorAll<HTMLDetailsElement>('.candidate-list > details'),
);
expect(candidates).toHaveLength(2);
expect(candidates[0].querySelector('summary')?.textContent).toContain(
'2024-05-10, 2024-05-13',
);
expect(candidates[1].querySelector('summary')?.textContent).toContain(
'2024-05-03, 2024-05-06',
);
expect(candidates.every((candidate) => !candidate.open)).toBe(true);
const technicalComparison = candidates[0].querySelector('.technical-comparison')!;
expect(technicalComparison.closest('details')).toBe(candidates[0]);
expect(candidates[0].open).toBe(false);
activateWithKeyboard(candidates[0].querySelector('summary')!, 'Enter');
fixture.detectChanges();
expect(candidates[0].open).toBe(true);
expect(candidates[0].textContent).toContain(
'This branch cannot satisfy both required dates.',
);
expect(technicalComparison.textContent).toContain('Actual stage comparison');
const traceImpact = candidates[0].querySelector('.criteria-detail')!;
expect(traceImpact.textContent).toContain('Full-plan effect of this branch');
expect(traceImpact.querySelectorAll('.decisive-row')).toHaveLength(0);
expect(traceImpact.textContent).not.toContain('First difference');
const decisiveComparator = candidates[0].querySelector(
'.technical-comparison tbody tr.decisive-row',
)!;
expect(decisiveComparator.textContent).toContain('preference:spring');
expect(decisiveComparator.textContent).toContain('1');
expect(decisiveComparator.textContent).toContain('0');
expect(
candidates[0].textContent!.indexOf('This branch cannot satisfy both required dates.'),
).toBeLessThan(candidates[0].textContent!.indexOf('preference:spring'));
expect(document.activeElement).toBe(candidates[0].querySelector('summary'));
const beforeButton = root.querySelector('.trace-toolbar button') as HTMLButtonElement;
const afterButton = root.querySelectorAll('.trace-toolbar button')[1] as HTMLButtonElement;
activateWithKeyboard(beforeButton, ' ');
fixture.detectChanges();
expect(beforeButton.getAttribute('aria-pressed')).toBe('true');
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'1 earned activations',
);
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'cp: 1 remaining',
);
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('1 placements');
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(['2', '4', '6', '6', '—', '—', '0', '0']);
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
fixture.detectChanges();
expect(afterButton.getAttribute('aria-pressed')).toBe('true');
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('2 placements');
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'1 earned activations',
);
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'cp: 0 remaining',
);
const weekend = root.querySelector<HTMLButtonElement>('button[data-date="2024-05-04"]')!;
expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
expect(weekend.querySelector('.status-marker')?.textContent).toBe('×');
const legend = root.querySelector('.calendar-legend')?.textContent ?? '';
expect(legend).toContain('Evaluated and rejected');
expect(legend).toContain('Not examined');
expect(root.querySelector('.counterfactual-result')).toBeNull();
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
@@ -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 {
+39 -616
View File
@@ -30,17 +30,12 @@ import {
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import {
CalendarMonth,
CalendarDayStatus,
DeclarationPreview,
} from '../calendar-month/calendar-month';
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
calendarProjectionHorizon,
DayType,
SelectedDates,
SelectableDayType,
VacDay,
@@ -50,10 +45,8 @@ import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import {
applyReplayDeltas,
CounterfactualAnalysis,
CounterfactualSelection,
LeavePlacement,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
@@ -64,15 +57,9 @@ import {
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceCandidate,
PlanningTraceDecision,
PlanningTraceState,
} from '../../services/leave-planner.service';
type PlacementExplanation = PlanExplanation['placements'][number];
type TraceMoment = 'before' | 'after';
type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
@Component({
selector: 'app-calendar-year',
@@ -121,12 +108,8 @@ export class CalendarYear {
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
protected readonly explanationError = signal<PlanningReason | null>(null);
protected readonly planningTrace = this.userInput.planningTraceSignal;
protected readonly traceLoading = signal(false);
protected readonly traceResolved = this.userInput.traceResolvedSignal;
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
protected readonly traceOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
@@ -134,18 +117,7 @@ export class CalendarYear {
protected readonly counterfactualError = signal<string | null>(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly tracePhaseIndex = signal(0);
protected readonly traceDecisionIndex = signal(0);
protected readonly traceCandidatePage = signal(0);
protected readonly traceMoment = signal<TraceMoment>('before');
protected readonly traceReplay = signal(false);
protected readonly selectedTraceDate = signal<string | null>(null);
protected readonly liveMessage = signal('');
private readonly historicalIneligibility = signal<ReadonlyMap<string, PlanningReason>>(
new Map(),
);
protected readonly historicalEligibilityLoading = signal(false);
protected readonly historicalEligibilityError = signal<string | null>(null);
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
@@ -179,21 +151,14 @@ export class CalendarYear {
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
: (this.traceState()?.declaredDays ??
this.displayedPlan()?.declaredDays ??
this.resultDeclaredDays());
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
private readonly traceDecisionIndices = new WeakMap<
PlanningTrace,
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
>();
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -245,28 +210,6 @@ export class CalendarYear {
() => this.planExplanation()?.placements[this.selectedPlacementIndex()],
);
protected readonly selectedTracePhase = computed(
() => this.planningTrace()?.phases[this.tracePhaseIndex()],
);
protected readonly selectedTraceDecision = computed(
() => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()],
);
protected readonly selectedTraceTransition = computed(() =>
this.traceReplay()
? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision())
: undefined,
);
protected readonly visibleTraceCandidates = computed(() => {
const start = this.traceCandidatePage() * 50;
return this.selectedTraceDecision()?.candidates.slice(start, start + 50) ?? [];
});
protected readonly traceCandidatePages = computed(() =>
Math.max(1, Math.ceil((this.selectedTraceDecision()?.candidates.length ?? 0) / 50)),
);
protected readonly selectedCounterfactualResult = computed(() =>
this.counterfactual()?.results.find(
(result) => result.date === this.selectedCounterfactualDate(),
@@ -314,12 +257,9 @@ export class CalendarYear {
const analysis = this.counterfactual();
const comparison = this.selectedCounterfactualResult()?.comparison;
if (!analysis || !comparison) return undefined;
// Final-plan alternatives share a removed-day baseline. Compare their restored
// plans; a trace candidate instead uses the exact state before its decision.
const before = this.traceReplay()
? comparison.before
: analysis.results.find((result) => result.date === analysis.placement.date)?.comparison
?.after;
// Compare restored alternatives against the displayed plan.
const before = analysis.results.find((result) => result.date === analysis.placement.date)
?.comparison?.after;
return before
? {
before,
@@ -337,10 +277,6 @@ export class CalendarYear {
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly traceState = computed(() => {
return this.selectedTraceTransition()?.[this.traceMoment()];
});
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
@@ -352,176 +288,22 @@ export class CalendarYear {
})
: new SelectedDates();
}
const state = this.traceState();
return state
? this.calendarService.selectedDatesForPlan({
placements: [...state.placements],
declaredDays: state.declaredDays,
horizon: { ...state.horizon },
})
: this.selectedDates();
return this.selectedDates();
});
protected readonly currentPlacementDate = computed(() => {
if (this.traceReplay())
return (
this.selectedCounterfactualDate() ??
this.selectedTraceDate() ??
this.selectedTraceDecision()?.dates[0] ??
null
);
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
});
protected readonly explainedPeriod = computed(() =>
this.planDetailsOpen() && !this.traceReplay()
? this.selectedExplanation()?.timeOffPeriod
: undefined,
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
);
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
const statuses: Record<string, CalendarDayStatus> = {};
const trace = this.planningTrace();
const closedDayDates = new Set<string>();
for (const selected of this.selectedDates().datesSelected) {
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
const day = selected.range.start;
closedDayDates.add(
`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
);
}
if (this.traceReplay() && trace) {
const priority = new Map([
['ineligible', 0],
['not-examined', 1],
['pruned', 2],
['evaluated-rejected', 3],
['chosen', 4],
]);
for (
let timestamp = Date.parse(`${trace.initialState.horizon.start}T00:00:00Z`),
end = Date.parse(`${trace.initialState.horizon.end}T00:00:00Z`);
timestamp < end;
timestamp += 86_400_000
) {
const date = new Date(timestamp).toISOString().slice(0, 10);
statuses[date] = {
kind: 'not-examined',
label: this.traceStatusLabel('not-examined'),
};
}
for (const date of Object.keys(statuses)) {
const reason = this.nonWorkingDateReason(date, closedDayDates);
if (reason)
statuses[date] = {
kind: 'ineligible',
label: this.traceStatusLabel('ineligible'),
reason,
};
}
const explicitPriorities = new Map<string, number>();
for (const candidate of this.selectedTraceDecision()?.candidates ?? []) {
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
const candidatePriority = priority.get(candidate.status)!;
if (candidatePriority > (explicitPriorities.get(date) ?? -1)) {
statuses[date] = {
kind: candidate.status,
label: this.traceStatusLabel(candidate.status),
reason: candidate.reason?.message,
};
explicitPriorities.set(date, candidatePriority);
}
}
}
for (const [date, reason] of this.historicalIneligibility()) {
if (statuses[date]?.kind !== 'not-examined') continue;
statuses[date] = {
kind: 'ineligible',
label: this.traceStatusLabel('ineligible'),
reason: this.reasonLabel(reason),
};
}
const decision = this.selectedTraceDecision();
if (decision) {
const changedDates = new Set<string>();
const setChange = (
date: string | undefined,
change: NonNullable<CalendarDayStatus['change']>,
) => {
if (!date) return;
changedDates.add(date);
statuses[date] = {
...(statuses[date] ?? {
kind: 'not-examined',
label: this.traceStatusLabel('not-examined'),
}),
change,
changeLabel: this.traceChangeLabel(change),
};
};
for (const delta of decision.branch?.deltas ?? decision.deltas) {
if (delta.kind === 'placement-added') setChange(delta.date, 'added');
else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed');
else if (delta.kind === 'placement-moved') {
setChange(delta.fromDate, 'moved-from');
setChange(delta.toDate, 'moved-to');
} else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
}
const transition = this.selectedTraceTransition();
for (const placement of [
...(transition?.before.placements ?? []),
...(transition?.after.placements ?? []),
]) {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
}
}
for (const result of this.counterfactual()?.results ?? []) {
const status = statuses[result.date];
const historicalStatus =
result.historicalStatus ??
(status && status.kind !== 'eligible' ? status.kind : 'not-examined');
statuses[result.date] = {
...status,
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: status?.reason,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
hypotheticalReason: result.reason?.message,
};
}
return statuses;
}
for (const result of this.counterfactual()?.results ?? []) {
const nonWorkingReason = this.nonWorkingDateReason(result.date, closedDayDates);
const historicalStatus =
result.historicalStatus ?? (nonWorkingReason ? 'ineligible' : 'not-examined');
statuses[result.date] = {
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
reason: result.historicalReason
? this.reasonLabel(result.historicalReason)
: historicalStatus === 'ineligible'
? nonWorkingReason
: undefined,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
hypotheticalReason: result.reason?.message,
};
}
return statuses;
});
protected readonly months = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return [];
const plan = this.traceState() ?? this.displayedPlan();
const plan = this.displayedPlan();
const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
@@ -555,7 +337,7 @@ export class CalendarYear {
effect(() => {
if (this.declarationEntry())
untracked(() => {
this.showFinalPlan();
this.clearCounterfactual();
this.liveMessage.set(this.tr().bookedDays.enter);
});
});
@@ -670,8 +452,6 @@ export class CalendarYear {
};
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
this.explanationError.set(reason);
this.traceLoading.set(false);
this.traceResolved.set(true);
this.loadingProgress.set(100);
this.isLoading.set(false);
}
@@ -683,20 +463,9 @@ export class CalendarYear {
this.planningOutcome.set(null);
this.planningExplanation.set(null);
this.explanationError.set(null);
this.planningTrace.set(null);
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.traceOpen.set(false);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -736,44 +505,6 @@ export class CalendarYear {
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
void calculation.trace
.then((trace) => {
if (calculation !== this.activeCalculation) return;
this.planningTrace.set(trace ?? null);
this.traceResolved.set(true);
this.liveMessage.set(
trace?.status === 'failed'
? this.tr().planning.traceUnavailable
: this.tr().planning.traceReady,
);
})
.catch((error: unknown) => {
if (calculation !== this.activeCalculation) return;
this.planningTrace.set({
status: 'failed',
initialState: {
calculationDate: calculation.snapshot.calculationDate,
horizon: calculation.snapshot.horizon,
...(calculation.snapshot.declaredDays
? { declaredDays: calculation.snapshot.declaredDays }
: {}),
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
error: {
code: 'trace-error',
message: error instanceof Error ? error.message : String(error),
},
});
this.traceResolved.set(true);
this.liveMessage.set(this.tr().planning.traceUnavailable);
})
.finally(() => {
if (calculation === this.activeCalculation) this.traceLoading.set(false);
});
}
private applyOutcome(outcome: PlanningOutcome): void {
@@ -833,7 +564,6 @@ export class CalendarYear {
if (!explanation?.placements.length) return;
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
this.selectedPlacementIndex.set(selected);
this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
if (announce) this.liveMessage.set(this.placementAnnouncement());
@@ -863,19 +593,10 @@ export class CalendarYear {
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) {
this.traceOpen.set(false);
this.selectPlacement(this.selectedPlacementIndex());
} else this.clearCounterfactual();
}
protected toggleTrace(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.traceOpen()) return;
this.traceOpen.set(open);
if (open) this.planDetailsOpen.set(false);
this.showFinalPlan();
}
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex();
@@ -1064,7 +785,7 @@ export class CalendarYear {
return;
}
const declaredType = this.calendarDeclaredDays()[date];
if (declaredType && !this.traceReplay() && !this.counterfactual()) {
if (declaredType && !this.counterfactual()) {
this.liveMessage.set(
`${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
);
@@ -1074,212 +795,34 @@ export class CalendarYear {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
}
if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
if (!this.counterfactual() && !this.counterfactualLoading()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
return;
}
}
if (this.traceReplay()) {
this.selectedTraceDate.set(date);
if (this.counterfactual()?.results.some((result) => result.date === date))
this.selectedCounterfactualDate.set(date);
this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
return;
}
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.selectedCounterfactualDate.set(date);
this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
this.liveMessage.set(this.dateStatusAnnouncement(date));
}
}
private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string {
private dateStatusAnnouncement(date: string): string {
const result = this.counterfactual()?.results.find((result) => result.date === date);
return [
date,
status?.label,
status?.reason,
status?.hypotheticalLabel
? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}`
: undefined,
status?.hypotheticalReason
? `${this.tr().planning.reason}: ${status.hypotheticalReason}`
: undefined,
status?.changeLabel,
this.tr().planning.counterfactual,
result ? this.tr().planning[result.status] : undefined,
result?.reason ? this.reasonLabel(result.reason) : undefined,
]
.filter(Boolean)
.join('. ');
}
protected selectTracePhase(index: number): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last));
this.tracePhaseIndex.set(index);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.clearCounterfactual();
this.liveMessage.set(this.tracePhaseAnnouncement());
}
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
const current = this.tracePhaseIndex();
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1;
if (target !== current) this.selectTracePhase(target);
}
protected selectTraceDecision(index: number): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last));
this.traceDecisionIndex.set(index);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(true);
this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
this.clearCounterfactual();
this.liveMessage.set(this.traceDecisionAnnouncement());
void this.loadHistoricalEligibility();
}
private selectedDecisionPlacement(decision: PlanningTraceDecision): LeavePlacement | undefined {
const placements = [
...decision.candidates.flatMap((candidate) =>
candidate.placement ? [candidate.placement] : [],
),
...(decision.branch?.deltas ?? decision.deltas).flatMap((delta) =>
delta.placement ? [delta.placement] : [],
),
];
const selectedDate = this.selectedTraceDate() ?? decision.dates[0];
return placements.find((placement) => placement.date === selectedDate) ?? placements[0];
}
private clearHistoricalEligibility(): void {
this.historicalEligibilityVersion++;
this.historicalIneligibility.set(new Map());
this.historicalEligibilityLoading.set(false);
this.historicalEligibilityError.set(null);
}
private async loadHistoricalEligibility(): Promise<void> {
const version = ++this.historicalEligibilityVersion;
this.historicalIneligibility.set(new Map());
this.historicalEligibilityLoading.set(false);
this.historicalEligibilityError.set(null);
const calculation = this.activeCalculation;
const decision = this.selectedTraceDecision();
const placement = decision && this.selectedDecisionPlacement(decision);
if (!this.traceReplay() || !calculation || !decision || !placement) return;
this.historicalEligibilityLoading.set(true);
this.liveMessage.set(this.tr().planning.historicalEligibilityLoading);
try {
const reasons = await calculation.historicalEligibility({
decisionId: decision.id,
placement,
});
if (
version !== this.historicalEligibilityVersion ||
calculation !== this.activeCalculation
)
return;
this.historicalIneligibility.set(
new Map(reasons.map(({ date, reason }) => [date, reason])),
);
this.liveMessage.set(this.tr().planning.historicalEligibilityReady);
} catch (error) {
if (version !== this.historicalEligibilityVersion) return;
this.historicalIneligibility.set(new Map());
const message = error instanceof Error ? error.message : String(error);
this.historicalEligibilityError.set(message);
this.liveMessage.set(`${this.tr().planning.historicalEligibilityError} ${message}`);
} finally {
if (version === this.historicalEligibilityVersion)
this.historicalEligibilityLoading.set(false);
}
}
protected retryHistoricalEligibility(): void {
void this.loadHistoricalEligibility();
}
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
const current = this.traceDecisionIndex();
const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
: current + 1;
if (target !== current) this.selectTraceDecision(target);
}
protected stepTraceCandidatePage(direction: -1 | 1): void {
const page = Math.max(
0,
Math.min(this.traceCandidatePage() + direction, this.traceCandidatePages() - 1),
);
if (page === this.traceCandidatePage()) return;
this.traceCandidatePage.set(page);
this.liveMessage.set(
`${this.tr().planning.page} ${this.traceCandidatePage() + 1} ${this.tr().planning.of} ${this.traceCandidatePages()}`,
);
}
protected showTraceMoment(moment: TraceMoment): void {
this.clearCounterfactual();
this.traceMoment.set(moment);
this.traceReplay.set(true);
this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`);
void this.loadHistoricalEligibility();
}
protected showFinalPlan(): void {
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.clearCounterfactual();
this.liveMessage.set(this.tr().planning.finalPlan);
}
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
const decision = this.selectedTraceDecision();
if (!candidate.placement || !decision) return;
this.traceMoment.set('before');
this.traceReplay.set(true);
this.selectedTraceDate.set(candidate.placement.date);
void this.analyzeSelection(
{ decisionId: decision.id, placement: candidate.placement },
true,
);
}
private nonWorkingDateReason(
date: string,
closedDayDates: ReadonlySet<string>,
): string | undefined {
const day = new Date(`${date}T00:00:00`);
if (day.getDay() === 0 || day.getDay() === 6) return this.tr().dayTypes.weekend;
if (closedDayDates.has(date)) return this.tr().dayTypes.closedDay;
return undefined;
}
private counterfactualSelection: CounterfactualSelection | null = null;
private async analyzeSelection(
@@ -1295,12 +838,7 @@ export class CalendarYear {
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (openDialog) this.openComparisonDialog();
const placement =
typeof selection === 'string'
? selection
: 'placement' in selection
? selection.placement
: selection;
const placement = selection;
this.liveMessage.set(
`${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
);
@@ -1312,9 +850,7 @@ export class CalendarYear {
this.counterfactual.set(result);
if (openDialog || this.comparisonDialog)
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
this.nearbyComparisonDates()[0]?.date ?? result.placement.date,
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
@@ -1362,113 +898,6 @@ export class CalendarYear {
this.comparisonDateInput.set(null);
}
private replayTraceTransition(
trace: PlanningTrace | null,
decision: PlanningTraceDecision | undefined,
): TraceTransition | undefined {
if (!trace || !decision) return undefined;
const state = {
balances: trace.initialState.balances.map((balance) => ({ ...balance })),
placements: new Map(
trace.initialState.placements.map((placement) => [
placement.date,
{ ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
};
const snapshot = (): PlanningTraceState => ({
calculationDate: trace.initialState.calculationDate,
horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays }
: {}),
balances: state.balances.map((balance) => ({ ...balance })),
placements: [...state.placements.values()].map((placement) => ({
...placement,
ruleIds: [...placement.ruleIds],
})),
earnedActivations: state.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
});
if (decision.branch) {
const decisionIndex = this.getTraceDecisionIndex(trace);
const target = decisionIndex.get(decision.id);
if (!target) return undefined;
const branchAncestors: PlanningTraceDecision[] = [];
const visited = new Set([decision.id]);
let current = decision;
let committedParentOrdinal: number | undefined;
while (current.branch) {
branchAncestors.unshift(current);
const parentId = current.branch.parentDecisionId;
if (!parentId) break;
if (visited.has(parentId)) return undefined;
visited.add(parentId);
const parent = decisionIndex.get(parentId);
if (!parent) return undefined;
if (!parent.decision.branch) {
committedParentOrdinal = parent.ordinal;
break;
}
current = parent.decision;
}
const branchRoot = branchAncestors[0];
const branchRootIndex = branchRoot && decisionIndex.get(branchRoot.id)?.ordinal;
if (branchRootIndex === undefined || target.ordinal < branchRootIndex) return undefined;
const replayThrough = committedParentOrdinal ?? branchRootIndex - 1;
if (replayThrough >= branchRootIndex) return undefined;
let ordinal = 0;
for (const phase of trace.phases) {
for (const prior of phase.decisions) {
if (ordinal++ > replayThrough) break;
applyReplayDeltas(state, prior.deltas);
}
if (ordinal > replayThrough) break;
}
for (const ancestor of branchAncestors.slice(0, -1))
applyReplayDeltas(state, ancestor.branch!.deltas);
const before = snapshot();
applyReplayDeltas(state, decision.branch.deltas);
return { before, after: snapshot() };
}
for (const phase of trace.phases) {
for (const prior of phase.decisions) {
if (prior.id === decision.id) {
const before = snapshot();
applyReplayDeltas(state, prior.deltas);
return { before, after: snapshot() };
}
applyReplayDeltas(state, prior.deltas);
}
}
return undefined;
}
private getTraceDecisionIndex(
trace: PlanningTrace,
): ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }> {
let index = this.traceDecisionIndices.get(trace);
if (!index) {
const decisions = new Map<
string,
{ decision: PlanningTraceDecision; ordinal: number }
>();
let ordinal = 0;
for (const phase of trace.phases)
for (const decision of phase.decisions)
decisions.set(decision.id, { decision, ordinal: ordinal++ });
index = decisions;
this.traceDecisionIndices.set(trace, index);
}
return index;
}
protected placementAnnouncement(): string {
const selected = this.selectedExplanation();
const count = this.planExplanation()?.placements.length ?? 0;
@@ -1480,18 +909,6 @@ export class CalendarYear {
return explanation.contributions.some((contribution) => contribution.kind === 'at-risk');
}
protected tracePhaseAnnouncement(): string {
const phases = this.planningTrace()?.phases ?? [];
const phase = phases[this.tracePhaseIndex()];
return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`;
}
protected traceDecisionAnnouncement(): string {
const decisions = this.selectedTracePhase()?.decisions ?? [];
const decision = decisions[this.traceDecisionIndex()];
return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`;
}
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
return (
@@ -1536,6 +953,24 @@ export class CalendarYear {
}
protected objectiveCopy(id: string) {
if (id.startsWith('optimization:')) {
const ruleId = id.slice('optimization:'.length);
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
const copy = this.tr().planning.explanation.objectives['optimization'];
return {
...copy,
label: copy.label
.replace('{rule}', this.ruleLabel(ruleId))
.replace(
'{summary}',
rule && 'configuration' in rule
? this.translationService.optimizationSummary(rule.configuration)
: '',
),
};
}
const key = id.startsWith('preference:') ? 'preference' : id;
const copy = this.tr().planning.explanation.objectives[key];
const rule =
@@ -1609,22 +1044,10 @@ export class CalendarYear {
return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10);
}
protected traceStatusLabel(status: PlanningTraceCandidate['status']): string {
return this.tr().planning.traceStatuses[status];
}
protected reasonLabel(reason: PlanningReason): string {
return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message;
}
protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
return this.tr().planning.tracePhaseStatuses[status];
}
protected traceChangeLabel(change: NonNullable<CalendarDayStatus['change']>): string {
return this.tr().planning.traceChanges[change];
}
protected formatIdentifier(value: string): string {
return value.replaceAll('-', ' ').replaceAll(':', ': ');
}
@@ -1645,7 +1068,7 @@ export class CalendarYear {
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:')) return 50;
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;
@@ -0,0 +1,241 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RulesPanel } from './rules-panel';
import { RuleService, RuleType, type OptimizationRule } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import {
OPTIMIZATION_PRESETS,
type OptimizationConfiguration,
} from '../../services/planning-objectives';
import { calculatePlanningInternal } from '../../services/leave-planner.core';
describe('calendar rule editing and persistence', () => {
let fixture: ComponentFixture<RulesPanel>;
let service: RuleService;
let translations: TranslationService;
async function openEditor() {
await TestBed.configureTestingModule({
imports: [RulesPanel],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
service = TestBed.inject(RuleService);
translations = TestBed.inject(TranslationService);
translations.setLanguage('fr');
fixture = TestBed.createComponent(RulesPanel);
fixture.detectChanges();
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await openEditor();
for (const rule of service.rules) service.deleteRule(rule.id);
fixture.detectChanges();
});
afterEach(() => localStorage.clear());
async function select(label: string, value: string) {
const fields: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('mat-form-field'),
);
const field = fields.find(
(candidate) => candidate.querySelector('mat-label')?.textContent?.trim() === label,
);
const control = field?.querySelector<HTMLElement>('mat-select');
if (!control) throw new Error(`Missing labelled control: ${label}`);
const labels = (control.getAttribute('aria-labelledby') ?? '')
.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent?.trim());
expect(labels).toContain(label);
control.click();
await fixture.whenStable();
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
(candidate) => candidate.textContent?.trim() === value,
);
if (!option) throw new Error(`Missing translated option: ${value}`);
option.click();
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
await fixture.whenStable();
fixture.detectChanges();
}
async function add(name: string, configuration: OptimizationConfiguration) {
const tr = translations.tr().rules;
const buttons: HTMLButtonElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.add-buttons button'),
);
buttons.find((button) => button.textContent?.includes(tr.optimization.newRule))!.click();
await fixture.whenStable();
const nameInput: HTMLInputElement =
fixture.nativeElement.querySelector('.rule-editor input');
nameInput.value = name;
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
await select(
tr.optimization.elementsLabel,
tr.optimization.elements[configuration.elements],
);
for (const condition of configuration.conditions)
await select(tr.optimization.conditionsLabel, tr.optimization.conditions[condition]);
await select(
tr.optimization.aggregationLabel,
tr.optimization.aggregations[configuration.aggregation],
);
await select(
tr.optimization.directionLabel,
tr.optimization.directions[configuration.direction],
);
const actions: HTMLButtonElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.editor-actions button'),
);
actions.find((button) => button.textContent?.trim() === tr.add)!.click();
await fixture.whenStable();
fixture.detectChanges();
}
function calculate() {
let objectiveValues: readonly { id: string; value: number | string }[] = [];
const plan = calculatePlanningInternal(
{
calculationDate: '2024-04-01',
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules: service.rules,
},
undefined,
({ explanation }) => {
if (explanation?.kind === 'PlanExplanation')
objectiveValues = explanation.placements[0]?.objectiveVector.objectives ?? [];
},
);
if (plan.kind !== 'Plan')
throw new Error('Expected a plan from saved editor configuration');
return { plan, objectiveValues };
}
it('creates both recipes, reverses their order, edits a compatible aggregation and restores it on reload', async () => {
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
await add('Mes week-ends', OPTIMIZATION_PRESETS.nonWorkingDayGain);
expect(calculate().plan.metrics).toMatchObject({
holidayWeekValue: 5,
nonWorkingDayGain: 6,
});
const items: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.rule-item'),
);
items[1].querySelector<HTMLButtonElement>('.rule-actions button')!.click();
await fixture.whenStable();
fixture.detectChanges();
expect(calculate().plan.metrics).toMatchObject({
holidayWeekValue: 0,
nonWorkingDayGain: 9,
});
const holiday = service.rules.find(
(rule): rule is OptimizationRule =>
rule.type === RuleType.OPTIMIZATION &&
rule.configuration.elements === 'public-holiday-weeks',
)!;
fixture.componentInstance.startEdit(holiday, new Event('click'));
await fixture.whenStable();
await select(
translations.tr().rules.optimization.aggregationLabel,
translations.tr().rules.optimization.aggregations.count,
);
fixture.componentInstance.saveEdit();
await fixture.whenStable();
await expect
.poll(
() =>
JSON.parse(localStorage.getItem('vacationRules')!).rules[1]?.configuration
?.aggregation,
)
.toBe('count');
const saved = service.rules;
TestBed.resetTestingModule();
await openEditor();
expect(service.rules).toEqual(saved);
expect(calculate().plan.metrics).toMatchObject({
holidayWeekValue: 0,
nonWorkingDayGain: 9,
});
fixture.componentInstance.moveRule(1, -1, new Event('click'));
const edited = calculate();
expect(edited.plan.metrics.holidayWeekValue).toBe(5);
expect(
edited.objectiveValues.find(
(objective) => objective.id === `optimization:${holiday.id}`,
)?.value,
).toBe(1);
});
it('creates economy, edits its name, moves it with the focusable control and restores disable/delete choices', async () => {
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
await add('Économiser les jours d’appoint', OPTIMIZATION_PRESETS.supportingPlacements);
const economy = service.rules[1];
expect(economy).toMatchObject({
force: 'preference',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
const move: HTMLButtonElement = fixture.nativeElement
.querySelectorAll('.rule-item')[1]
.querySelector('.rule-actions button');
move.focus();
expect(document.activeElement).toBe(move);
move.click();
await fixture.whenStable();
expect(calculate().plan.placements).toEqual([]);
fixture.componentInstance.startEdit(service.rules[0], new Event('click'));
await fixture.whenStable();
const input: HTMLInputElement = fixture.nativeElement.querySelector('.rule-editor input');
input.value = 'Mes jours conservés';
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.componentInstance.saveEdit();
await fixture.whenStable();
const toggle: HTMLButtonElement = fixture.nativeElement
.querySelector('.rule-item')
.querySelector('button[role="switch"]');
toggle.click();
await fixture.whenStable();
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
await expect
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules[0]?.enabled)
.toBe(false);
const saved = service.rules;
TestBed.resetTestingModule();
await openEditor();
expect(service.rules).toEqual(saved);
expect(service.rules[0]).toMatchObject({ id: economy.id, name: 'Mes jours conservés' });
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
fixture.componentInstance.toggleRule(economy.id);
await fixture.whenStable();
expect(calculate().plan.placements).toEqual([]);
const remove: HTMLButtonElement = fixture.nativeElement
.querySelector('.rule-item')
.querySelector(`[aria-label="${translations.tr().rules.deleteRule}"]`);
remove.click();
await fixture.whenStable();
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
await expect
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules.length)
.toBe(1);
TestBed.resetTestingModule();
await openEditor();
expect(service.rules.map(({ id }) => id)).not.toContain(economy.id);
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
});
});
@@ -183,6 +183,8 @@
</mat-select>
</mat-form-field>
</div>
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
<ng-container [ngTemplateOutlet]="optimizationEditor" />
} @else {
<div class="editor-row">
<mat-form-field>
@@ -239,6 +241,9 @@
</div>
}
@if (validationError()) {
<p role="alert">{{ validationError() }}</p>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">
{{ tr().rules.cancel }}
@@ -338,6 +343,8 @@
</mat-select>
</mat-form-field>
</div>
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
<ng-container [ngTemplateOutlet]="optimizationEditor" />
} @else {
<div class="editor-row">
<mat-form-field>
@@ -394,6 +401,9 @@
</div>
}
@if (validationError()) {
<p role="alert">{{ validationError() }}</p>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">{{ tr().rules.cancel }}</button>
<button mat-flat-button color="primary" (click)="saveEdit()">
@@ -411,6 +421,89 @@
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
<mat-icon>add</mat-icon> {{ tr().rules.newTimeWindowRule }}
</button>
<button mat-stroked-button (click)="startAddNew(RuleType.OPTIMIZATION)">
<mat-icon aria-hidden="true">add</mat-icon> {{ tr().rules.optimization.newRule }}
</button>
</div>
</div>
</mat-expansion-panel>
<ng-template #optimizationEditor>
@if (optimizationConfiguration; as configuration) {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.optimization.elementsLabel }}</mat-label>
<mat-select
[ngModel]="configuration.elements"
(ngModelChange)="changeOptimizationElements($event)"
>
@for (elements of optimizationElements; track elements) {
<mat-option [value]="elements">{{
tr().rules.optimization.elements[elements]
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.optimization.measureLabel }}</mat-label>
<mat-select
[ngModel]="configuration.measure"
(ngModelChange)="changeOptimizationMeasure($event)"
>
@for (row of optimizationMeasures; track row.measure) {
<mat-option [value]="row.measure">{{
tr().rules.optimization.measures[row.measure]
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (optimizationConditions.length) {
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.optimization.conditionsLabel }}</mat-label>
<mat-select multiple [(ngModel)]="configuration.conditions">
@for (condition of optimizationConditions; track condition) {
<mat-option [value]="condition">{{
tr().rules.optimization.conditions[condition]
}}</mat-option>
}
</mat-select>
</mat-form-field>
}
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.optimization.aggregationLabel }}</mat-label>
<mat-select [(ngModel)]="configuration.aggregation">
@for (aggregation of optimizationAggregations; track aggregation) {
<mat-option [value]="aggregation">{{
tr().rules.optimization.aggregations[aggregation]
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.optimization.directionLabel }}</mat-label>
<mat-select [(ngModel)]="configuration.direction">
@for (direction of optimizationDirections; track direction) {
<mat-option [value]="direction">{{
tr().rules.optimization.directions[direction]
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (configuration.measure === 'work-sequence-deficit') {
<mat-form-field>
<mat-label>{{ tr().rules.optimization.thresholdLabel }}</mat-label>
<input
matInput
type="number"
min="1"
step="1"
required
[(ngModel)]="configuration.thresholdDays"
/>
</mat-form-field>
}
</div>
}
</ng-template>
@@ -7,6 +7,8 @@ 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 { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { MatExpansionPanel } from '@angular/material/expansion';
describe('RulesPanel', () => {
let component: RulesPanel;
@@ -36,7 +38,7 @@ describe('RulesPanel', () => {
});
it('should display initial rules from service', () => {
expect(component.rules.length).toBe(2);
expect(component.rules.length).toBe(8);
});
it('should start adding new If-Then rule', () => {
@@ -116,6 +118,69 @@ describe('RulesPanel', () => {
expect(component.rules[0].id).toBe(secondRuleId);
});
it('creates, edits and moves an optimization using the keyboard controls with preference-only mode', async () => {
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
component.startAddNew(RuleType.OPTIMIZATION);
fixture.detectChanges();
expect(component['forceOptions'].map((option) => option.value)).toEqual(['preference']);
expect(fixture.nativeElement.textContent).toContain('Éléments du planning');
component['editBuffer'].name = 'Mes pertes';
component.saveEdit();
await fixture.whenStable();
const rule = component.rules.at(-1)!;
expect(rule).toMatchObject({
name: 'Mes pertes',
type: RuleType.OPTIMIZATION,
force: 'preference',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
expect(component.getRuleSummary(rule)).toContain('Quantité non placée');
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
const buttons = fixture.nativeElement.querySelectorAll('.rule-item');
const moveUp: HTMLButtonElement =
buttons[buttons.length - 1].querySelector('.rule-actions button');
moveUp.focus();
moveUp.click();
await fixture.whenStable();
expect(component.rules[7].id).toBe(rule.id);
expect(document.activeElement).toBe(moveUp);
expect(announce).toHaveBeenCalledWith('Mes pertes : position 8 sur 9.');
component.startEdit(rule, new Event('click'));
component['editBuffer'].name = 'Pertes modifiées';
component.saveEdit();
expect(component.rules[7].name).toBe('Pertes modifiées');
component.toggleRule(rule.id);
expect(component.rules[7].enabled).toBe(false);
component.deleteRule(rule.id, new Event('click'));
expect(component.rules.some((candidate) => candidate.id === rule.id)).toBe(false);
});
it('keeps saved optimization parameters when an edit is cancelled and reports invalid parameters', () => {
component.startAddNew(RuleType.OPTIMIZATION);
component.saveEdit();
const rule = component.rules.at(-1)!;
component.startEdit(rule, new Event('click'));
const configuration = component['optimizationConfiguration']!;
configuration.conditions = [];
component.cancelEdit();
expect(component.rules.at(-1)).toMatchObject({
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
component.startEdit(rule, new Event('click'));
component.changeOptimizationMeasure('work-sequence-deficit');
const invalid = component['optimizationConfiguration'];
if (invalid?.measure !== 'work-sequence-deficit') throw new Error('Expected deficit');
invalid.thresholdDays = 0;
component.saveEdit();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
'seuil entier positif',
);
expect(component.rules.at(-1)).toMatchObject({
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
});
it('keeps cancelled drops unchanged and announces the position after a completed drop', () => {
const initialIds = component.rules.map((rule) => rule.id);
const movedName = component.rules[0].name;
@@ -133,8 +198,12 @@ describe('RulesPanel', () => {
expect(announce).not.toHaveBeenCalled();
list.dropped.emit({ ...drop, isPointerOverContainer: true });
expect(component.rules.map((rule) => rule.id)).toEqual([...initialIds].reverse());
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 2.`);
expect(component.rules.map((rule) => rule.id)).toEqual([
initialIds[1],
initialIds[0],
...initialIds.slice(2),
]);
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 8.`);
// CDK restores the original DOM before emitting dropped. The new order must
// be rendered before this callback returns, without waiting for another frame.
expect(fixture.nativeElement.querySelectorAll('.rule-name')[1].textContent).toContain(
+106 -5
View File
@@ -11,6 +11,7 @@ import {
RuleType,
IfThenRule,
TimeWindowRule,
OptimizationRule,
VacationRule,
PlacementStrategy,
} from '../../services/rule-service';
@@ -25,7 +26,15 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import {
OPTIMIZATION_CATALOG,
OPTIMIZATION_PRESETS,
validateOptimizationConfiguration,
type OptimizationElements,
type OptimizationMeasure,
} from '../../services/planning-objectives';
import { DateAdapter } from '@angular/material/core';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -43,6 +52,7 @@ import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/d
MatSelectModule,
MatDatepickerModule,
FormsModule,
NgTemplateOutlet,
CdkDropList,
CdkDrag,
CdkDragHandle,
@@ -74,10 +84,67 @@ export class RulesPanel {
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly forceOptions = [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
{ value: 'preference' as const, label: () => this.tr().rules.preference },
protected get forceOptions() {
const preference = {
value: 'preference' as const,
label: () => this.tr().rules.preference,
};
return this.editBuffer.type === RuleType.OPTIMIZATION
? [preference]
: [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
preference,
];
}
protected readonly optimizationElements = [
...new Set(OPTIMIZATION_CATALOG.map((row) => row.elements)),
];
protected get optimizationConfiguration() {
return this.editBuffer.type === RuleType.OPTIMIZATION
? this.editBuffer.configuration
: undefined;
}
protected get optimizationMeasures() {
return OPTIMIZATION_CATALOG.filter(
(row) => row.elements === this.optimizationConfiguration?.elements,
);
}
protected get optimizationConditions() {
return (
this.optimizationMeasures.find(
(row) => row.measure === this.optimizationConfiguration?.measure,
)?.conditions ?? []
);
}
protected readonly optimizationAggregations = ['sum', 'count', 'maximum'] as const;
protected readonly optimizationDirections = ['minimize', 'maximize'] as const;
protected validationError = signal('');
public changeOptimizationElements(elements: OptimizationElements) {
this.changeOptimizationMeasure(
OPTIMIZATION_CATALOG.find((row) => row.elements === elements)!.measure,
);
}
public changeOptimizationMeasure(measure: OptimizationMeasure) {
if (this.editBuffer.type !== RuleType.OPTIMIZATION) return;
const row = OPTIMIZATION_CATALOG.find((item) => item.measure === measure)!;
const previous = this.editBuffer.configuration;
const conditions: readonly string[] = row.conditions;
this.editBuffer.configuration = {
elements: row.elements,
conditions:
previous?.conditions.filter((condition) => conditions.includes(condition)) ?? [],
aggregation: previous?.aggregation ?? 'sum',
direction: previous?.direction ?? 'minimize',
...(measure === 'work-sequence-deficit'
? {
measure,
thresholdDays: previous?.measure === measure ? previous.thresholdDays : 5,
}
: { measure }),
};
}
protected get daysOfWeek() {
const tr = this.tr();
@@ -128,9 +195,20 @@ export class RulesPanel {
public moveRule(index: number, offset: -1 | 1, event: Event) {
event.stopPropagation();
const trigger = event.currentTarget;
const destination = index + offset;
if (destination >= 0 && destination < this.rules.length)
if (destination >= 0 && destination < this.rules.length) {
this.reorderRule(index, destination);
this.changeDetector.detectChanges();
if (trigger instanceof HTMLButtonElement) {
const next = trigger.disabled
? trigger.parentElement?.querySelector<HTMLButtonElement>(
'button:not([disabled])',
)
: trigger;
next?.focus();
}
}
}
private reorderRule(from: number, to: number) {
@@ -152,16 +230,29 @@ export class RulesPanel {
event.stopPropagation();
this.editingRuleId.set(rule.id);
this.isAddingNew.set(false);
this.editBuffer = { ...rule };
this.editBuffer = structuredClone(rule);
this.validationError.set('');
}
public cancelEdit() {
this.editingRuleId.set(null);
this.isAddingNew.set(false);
this.editBuffer = {};
this.validationError.set('');
}
public saveEdit() {
if (!this.editBuffer.name?.trim()) {
this.validationError.set(this.tr().rules.optimization.nameRequired);
return;
}
if (this.editBuffer.type === RuleType.OPTIMIZATION) {
if (!validateOptimizationConfiguration(this.editBuffer.configuration).valid) {
this.validationError.set(this.tr().rules.optimization.invalidConfiguration);
return;
}
this.editBuffer.force = 'preference';
}
if (this.editBuffer.id) {
this.ruleService.updateRule(this.editBuffer as VacationRule);
} else {
@@ -183,6 +274,7 @@ export class RulesPanel {
this.isAddingNew.set(true);
this.editingRuleId.set(null);
this.newRuleType.set(type);
this.validationError.set('');
if (type === RuleType.IF_THEN) {
this.editBuffer = {
@@ -196,6 +288,13 @@ export class RulesPanel {
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
} as Partial<IfThenRule>;
} else if (type === RuleType.OPTIMIZATION) {
this.editBuffer = {
name: this.tr().rules.optimization.defaultName,
type,
force: 'preference',
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
} satisfies Partial<OptimizationRule>;
} else {
this.editBuffer = {
name: this.tr().rules.defaultNewPeriodName,
@@ -216,6 +315,8 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
if (rule.type === RuleType.OPTIMIZATION)
return this.translationService.optimizationSummary(rule.configuration);
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
@@ -0,0 +1,172 @@
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 { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { calculatePlanningInternal } from '../../services/leave-planner.core';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { RulesPanel } from './rules-panel';
describe('work-sequence rule editor and reload', () => {
let fixture: ComponentFixture<RulesPanel>;
let component: RulesPanel;
async function choose(label: string, text: string) {
const fields: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('mat-form-field'),
);
const select = fields
.find((field) => field.querySelector('mat-label')?.textContent?.trim() === label)
?.querySelector<HTMLElement>('mat-select');
if (!select) throw new Error(`Missing select ${label}`);
select.click();
await fixture.whenStable();
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
(candidate) => candidate.textContent?.trim() === text,
);
if (!option) throw new Error(`Missing option ${text}`);
option.click();
await fixture.whenStable();
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
await fixture.whenStable();
}
async function setInput(selector: string, value: string) {
const input: HTMLInputElement = fixture.nativeElement.querySelector(selector);
input.value = value;
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
}
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
await TestBed.configureTestingModule({
imports: [RulesPanel],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('fr');
fixture = TestBed.createComponent(RulesPanel);
component = fixture.componentInstance;
fixture.detectChanges();
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
});
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('creates threshold five, edits it to three, and restores its effect, order and activation after reload', async () => {
component.startAddNew(RuleType.OPTIMIZATION);
await fixture.whenStable();
await setInput('input:not([type="number"])', 'Mes reprises');
await choose('Éléments du planning', 'Séquences de travail');
await choose('Mesure', 'Déficit des séquences de travail');
await choose('Conditions (toutes doivent être vérifiées)', 'Internes');
await choose('Conditions (toutes doivent être vérifiées)', 'Non vides');
const threshold: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="number"]');
expect(threshold.value).toBe('5');
expect(threshold.min).toBe('1');
expect(threshold.step).toBe('1');
expect(fixture.nativeElement.textContent).toContain('Seuil de préférence en jours ouvrés');
component.saveEdit();
await fixture.whenStable();
const rule = component.rules[0];
expect(rule).toMatchObject({ configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit });
expect(component.getRuleSummary(rule)).toContain(
'Déficit des séquences de travail (jours ouvrés)',
);
const request = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
};
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-09' }, { date: '2024-01-10' }, { date: '2024-01-11' }],
});
component.startEdit(rule, new Event('click'));
await fixture.whenStable();
await setInput('input[type="number"]', '3');
component.saveEdit();
await fixture.whenStable();
expect(component.getRuleSummary(component.rules[0])).toContain(
'seuil de préférence : 3 jours ouvrés',
);
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
kind: 'Plan',
placements: [],
});
component.startAddNew(RuleType.OPTIMIZATION);
await fixture.whenStable();
await setInput('input:not([type="number"])', 'Ma séquence maximale');
await choose('Éléments du planning', 'Séquences de travail');
await choose('Agrégation', 'Maximum');
component.saveEdit();
await fixture.whenStable();
const maximum = component.rules[1];
expect(maximum).toMatchObject({ configuration: OPTIMIZATION_PRESETS.longestWorkSequence });
component.toggleRule(maximum.id);
await fixture.whenStable();
const moveUp: HTMLButtonElement = fixture.nativeElement
.querySelectorAll('.rule-actions')[1]
.querySelector('button');
moveUp.focus();
moveUp.click();
await fixture.whenStable();
const saved = component.rules;
await new Promise((resolve) => setTimeout(resolve, 350));
TestBed.resetTestingModule();
const restored = TestBed.inject(RuleService).rules;
expect(restored).toEqual(saved);
expect(restored.map(({ id }) => id)).toEqual([maximum.id, rule.id]);
expect(restored[0].enabled).toBe(false);
expect(restored[1]).toMatchObject({
name: 'Mes reprises',
enabled: true,
configuration: { thresholdDays: 3 },
});
expect(calculatePlanningInternal({ ...request, rules: restored })).toMatchObject({
kind: 'Plan',
placements: [],
});
});
it('keeps the saved threshold and shows an accessible error for fractional input', async () => {
component.startAddNew(RuleType.OPTIMIZATION);
component.changeOptimizationMeasure('work-sequence-deficit');
await fixture.whenStable();
await setInput('input[type="number"]', '1.5');
component.saveEdit();
await fixture.whenStable();
expect(component.rules).toEqual([]);
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
'seuil entier positif',
);
expect(fixture.nativeElement.querySelector('input[type="number"]').value).toBe('1.5');
});
it('distinguishes aggregation units from the working-day threshold', () => {
const translations = TestBed.inject(TranslationService);
expect(
translations.optimizationSummary(OPTIMIZATION_PRESETS.longestWorkSequence),
).toContain('Longueur des séquences de travail (jours ouvrés)');
expect(
translations.optimizationSummary({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'count',
}),
).toContain('Déficit des séquences de travail (Nombre)');
expect(
translations.optimizationSummary({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'count',
}),
).toContain('seuil de préférence : 5 jours ouvrés');
});
});
+56 -111
View File
@@ -7,6 +7,60 @@
>
</header>
<fieldset class="planning-dates">
<legend>{{ tr().settings.planningHorizon }}</legend>
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.planningStart }}</mat-label>
<input
matInput
[matDatepicker]="planningStartPicker"
name="planningStart"
required
[ngModel]="planningDates().start"
(ngModelChange)="setPlanningDate('start', $event)"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'planning-dates-help planning-dates-error'
: 'planning-dates-help'
"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="planningStartPicker"
></mat-datepicker-toggle>
<mat-datepicker #planningStartPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
<input
matInput
[matDatepicker]="lastPlanningDatePicker"
name="lastPlanningDate"
required
[ngModel]="planningDates().lastPlanningDate"
(ngModelChange)="setPlanningDate('lastPlanningDate', $event)"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'planning-dates-help planning-dates-error'
: 'planning-dates-help'
"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="lastPlanningDatePicker"
></mat-datepicker-toggle>
<mat-datepicker #lastPlanningDatePicker></mat-datepicker>
</mat-form-field>
@if (calendarSettings.planningHorizonError(); as error) {
<p id="planning-dates-error" class="validation-error" role="alert">
{{ tr().settings[error] }}
</p>
}
</fieldset>
<div class="listOfDays">
@for (day of daysOff; track day.id) {
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
@@ -80,7 +134,7 @@
[disabled]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'invalid-planning-horizon'
? 'planning-dates-error'
: 'planning-duration-estimate'
"
(click)="triggerComputation()"
@@ -88,9 +142,6 @@
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
{{ tr().userInput.calculate }}
</button>
@if (calendarSettings.planningHorizonError()) {
<p id="invalid-planning-horizon" role="alert">{{ tr().settings.invalidHorizon }}</p>
}
@if (hasRemainingDays()) {
<mat-card class="warning-card" [class.is-informational]="!hasAtRiskDays()">
@@ -122,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>
+39 -21
View File
@@ -31,6 +31,41 @@
}
}
.planning-dates {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-column: 1 / -1;
column-gap: 1rem;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
legend {
font-weight: 600;
}
mat-form-field {
min-width: 0;
width: 100%;
}
p {
grid-column: 1 / -1;
margin: 0.5rem 0 1rem;
color: var(--ui-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.validation-error {
color: var(--ui-error-text);
background: var(--ui-error-bg);
padding: 0.75rem;
border-radius: 0.5rem;
}
}
.listOfDays {
display: flex;
flex-direction: column;
@@ -204,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;
@@ -250,6 +265,9 @@ form {
}
@container user-input (min-width: 40rem) {
.planning-dates {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.listOfDays {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from '../../services/rule-service';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
@@ -14,6 +15,8 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
@@ -43,6 +46,7 @@ describe('UserInput', () => {
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
fixture.detectChanges();
await fixture.whenStable();
}
async function revisit(): Promise<void> {
@@ -94,11 +98,9 @@ describe('UserInput', () => {
} finally {
vi.stubGlobal('window', browserWindow);
}
const [outcome, trace] = await Promise.all([calculation.outcome, calculation.trace]);
const outcome = await calculation.outcome;
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
userInputService.planningTraceSignal.set(trace ?? null);
userInputService.traceResolvedSignal.set(true);
fixture.detectChanges();
}
@@ -130,7 +132,7 @@ describe('UserInput', () => {
expect(card.textContent).toBe(explanation);
});
it('shows genuine recorded attempts on demand for usable leave kept for later', async () => {
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' },
@@ -144,7 +146,7 @@ describe('UserInput', () => {
},
],
declaredDays: [{ date: '2024-06-07', type: 'cp' }],
rules: [],
rules: [presets[3]],
});
const root = fixture.nativeElement as HTMLElement;
const card = root.querySelector<HTMLElement>('.warning-card')!;
@@ -154,17 +156,9 @@ describe('UserInput', () => {
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('Maximum de jours travaillés entre congés');
expect(details.textContent).toContain('Évalué et rejeté');
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
expect(JSON.stringify(userInputService.planningTraceSignal())).toBe(trace);
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
@@ -173,17 +167,11 @@ describe('UserInput', () => {
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', () => {
@@ -200,26 +188,103 @@ describe('UserInput', () => {
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-03-01', '2024-02-29');
fixture.detectChanges();
settings.setPlanningDates('2024-02-29', '2024-03-08');
TestBed.inject(TranslationService).setLanguage('fr');
await fixture.whenStable();
const toggles = fixture.nativeElement.querySelectorAll(
'.planning-dates mat-datepicker-toggle button',
);
expect(toggles).toHaveLength(2);
for (const [index, day] of ['27', '9'].entries()) {
toggles[index].click();
await fixture.whenStable();
const calendar = document.querySelector<HTMLElement>('mat-calendar')!;
if (index === 0)
expect(
calendar
.querySelector('.mat-calendar-period-button')
?.textContent?.toLowerCase(),
).toContain('févr');
Array.from(calendar.querySelectorAll<HTMLButtonElement>('.mat-calendar-body-cell'))
.find((cell) => cell.textContent?.trim() === day)!
.click();
await fixture.whenStable();
await expect.poll(() => document.querySelector('mat-calendar')).toBeNull();
}
expect(settings.planningHorizon()).toEqual({ start: '2024-02-27', end: '2024-03-10' });
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-03-01', '2024-03-01');
await fixture.whenStable();
editInput('input[name="lastPlanningDate"]', '29/02/2024');
const root = fixture.nativeElement as HTMLElement;
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
expect(calculate.disabled).toBe(true);
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'planning dates in Settings',
);
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
settings.setPlanningDates('2024-02-29', '2024-02-29');
fixture.detectChanges();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
'reports an invalid planning date without saving it: %s',
async (value) => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
editInput('input[name="planningStart"]', value);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
value,
);
expect(
fixture.nativeElement.querySelector('#planning-dates-error')?.textContent,
).toContain('two valid calendar dates');
expect(settings.planningHorizon()).toBeNull();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2027-02-28',
});
},
);
it('edits inclusive dates with inline validation while preserving the saved planning horizon', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('29/02/2024');
expect(last.value).toBe('28/02/2027');
expect(root.textContent).toContain('Last planning date (inclusive)');
editInput('input[name="lastPlanningDate"]', '28/02/2024');
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
expect(last.getAttribute('aria-invalid')).toBe('true');
expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error');
expect(settings.planningHorizon()).toBeNull();
editInput('input[name="lastPlanningDate"]', '29/02/2024');
expect(root.querySelector('#planning-dates-error')).toBeNull();
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);
});
it('should initialize with vacation days from service', () => {
const vacationDays = userInputService.vacationNumberSignal();
expect(vacationDays).toBeDefined();
@@ -449,7 +514,7 @@ describe('UserInput', () => {
);
await rule.uncheck();
await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull();
expect(JSON.parse(localStorage.getItem('vacationRules')!)[0].enabled).toBe(false);
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules[0].enabled).toBe(false);
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
+33 -85
View File
@@ -1,12 +1,10 @@
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';
import { DateAdapter } from '@angular/material/core';
import { FormsModule } from '@angular/forms';
import dayjs from 'dayjs';
import { UserInputService } from '../../services/user-input-service';
import { RulesPanel } from '../rules-panel/rules-panel';
import { MatCard, MatCardContent } from '@angular/material/card';
@@ -18,12 +16,16 @@ import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays } from '../../services/planning-date';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@Component({
selector: 'app-user-input',
imports: [
MatInputModule,
MatFormFieldModule,
MatDatepickerModule,
FormsModule,
RulesPanel,
MatCard,
MatCardContent,
@@ -34,6 +36,7 @@ import { addPlanningDays } from '../../services/planning-date';
templateUrl: './user-input.html',
styleUrl: './user-input.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class UserInput {
private userInputService: UserInputService = inject(UserInputService);
@@ -43,6 +46,16 @@ export class UserInput {
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
protected readonly planningDates = computed(() => {
const { start, lastPlanningDate } = this.calendarSettings.planningDates();
return {
start: isPlanningDate(start) ? new Date(`${start}T00:00:00`) : null,
lastPlanningDate: isPlanningDate(lastPlanningDate)
? new Date(`${lastPlanningDate}T00:00:00`)
: null,
};
});
protected readonly durationLabel = computed(() => {
const estimate = this.duration.estimate();
if (!estimate) return '';
@@ -58,10 +71,6 @@ 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(
(total, balance) => total + balance.quantity,
@@ -82,7 +91,8 @@ export class UserInput {
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
incomplete && ['preserved', 'not-established'].includes(balance.reason.code)
incomplete &&
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
? 'calculation-incomplete'
: balance.reason.code;
return {
@@ -124,56 +134,16 @@ 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.userInputService.planningOutcomeSignal();
this.historyBalanceId.set(null);
this.historyPage.set(0);
});
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
}
protected setPlanningDate(field: 'start' | 'lastPlanningDate', value: Date | null): void {
const dates = {
...this.calendarSettings.planningDates(),
[field]: value ? dayjs(value).format('YYYY-MM-DD') : '',
};
this.calendarSettings.setPlanningDates(dates.start, dates.lastPlanningDate);
}
private ruleName(id: string): string {
@@ -183,28 +153,6 @@ export class UserInput {
);
}
private criterionLabel(name: string): string | undefined {
const id = name.replace(/^protected:/, '');
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);
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
protected get daysOff() {
@@ -2,53 +2,6 @@
<mat-dialog-content>
<p>{{ tr().settings.description }}</p>
<div class="settings-controls">
<fieldset class="planning-dates">
<legend>{{ tr().settings.planningHorizon }}</legend>
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.planningStart }}</mat-label>
<input
matInput
type="date"
name="planningStart"
required
#planningStart
[value]="calendarSettings.planningDates().start"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
aria-describedby="planning-dates-help planning-dates-error"
(input)="
calendarSettings.setPlanningDates(
planningStart.value,
lastPlanningDate.value
)
"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
<input
matInput
type="date"
name="lastPlanningDate"
required
#lastPlanningDate
[value]="calendarSettings.planningDates().lastPlanningDate"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
aria-describedby="planning-dates-help planning-dates-error"
(input)="
calendarSettings.setPlanningDates(
planningStart.value,
lastPlanningDate.value
)
"
/>
</mat-form-field>
@if (calendarSettings.planningHorizonError(); as error) {
<p id="planning-dates-error" class="validation-error" role="alert">
{{ tr().settings[error] }}
</p>
}
</fieldset>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
<mat-select
@@ -20,26 +20,3 @@ mat-dialog-content > p {
mat-form-field {
width: 100%;
}
.planning-dates {
min-width: 0;
margin: 0 0 1rem;
padding: 0;
border: 0;
legend {
font-weight: 600;
}
p {
margin: 0.5rem 0 1rem;
color: var(--ui-text-secondary);
}
.validation-error {
color: var(--ui-error-text);
background: var(--ui-error-bg);
padding: 0.75rem;
border-radius: 0.5rem;
}
}
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { UserSettings } from './user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { TranslationService } from '../../services/translation-service';
describe('UserSettings', () => {
@@ -16,7 +15,6 @@ describe('UserSettings', () => {
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28');
fixture = TestBed.createComponent(UserSettings);
component = fixture.componentInstance;
fixture.detectChanges();
@@ -24,49 +22,8 @@ describe('UserSettings', () => {
afterEach(() => localStorage.clear());
it.each(['', '2024-02-30', 'not-a-date'])(
'reports an invalid date entry without saving it: %s',
(value) => {
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
start.value = value;
start.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'two valid calendar dates',
);
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2027-02-28',
});
},
);
it('edits inclusive dates with an inline validation indication while preserving the saved horizon', () => {
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('2024-02-29');
expect(last.value).toBe('2027-02-28');
expect(root.textContent).toContain('Last planning date (inclusive)');
last.value = '2024-02-28';
last.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')?.textContent).toContain('on or after');
expect(last.getAttribute('aria-invalid')).toBe('true');
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
last.value = '2024-02-29';
last.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')).toBeNull();
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toEqual({
start: '2024-02-29',
end: '2024-03-01',
});
});
it('should create', () => {
expect(component).toBeTruthy();
expect(fixture.nativeElement.querySelector('input[type="date"]')).toBeNull();
});
});
@@ -9,7 +9,6 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatInputModule } from '@angular/material/input';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { ThemeService } from '../../services/theme-service';
import { TranslationService } from '../../services/translation-service';
@@ -25,7 +24,6 @@ import { TranslationService } from '../../services/translation-service';
MatFormFieldModule,
MatSelectModule,
MatSlideToggle,
MatInputModule,
],
templateUrl: './user-settings.html',
styleUrl: './user-settings.scss',
@@ -0,0 +1,215 @@
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
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 { createPlanningCalculation } from './leave-planner.service';
import type { OptimizationPlanningRule } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
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-'));
bundle = join(directory, 'worker.mjs');
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort } from 'node:worker_threads';
globalThis.self = { postMessage: (message) => parentPort.postMessage(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]);
});
beforeEach(() => {
messages.length = 0;
stopped = 0;
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
class BrowserWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private readonly thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
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();
}
}
vi.stubGlobal('Worker', BrowserWorker);
});
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it.each([false, true])(
'retains the submitted configuration, plan and artifact order (gain first: %s)',
async (gainFirst) => {
const holiday: OptimizationPlanningRule = {
id: 'holiday',
type: 'optimization' as const,
name: 'Captured holiday rule',
configuration: structuredClone(OPTIMIZATION_PRESETS.holidayWeekValue),
};
const gain: OptimizationPlanningRule = {
id: 'gain',
type: 'optimization' as const,
name: 'Captured gain rule',
configuration: structuredClone(OPTIMIZATION_PRESETS.nonWorkingDayGain),
};
const rules = gainFirst ? [gain, holiday] : [holiday, gain];
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules,
});
holiday.configuration = {
...holiday.configuration,
conditions: [],
direction: 'minimize',
};
const plan = await calculation.outcome;
if (plan.kind !== 'Plan')
throw new Error(`Expected a worker plan, received ${plan.kind}`);
expect(plan.metrics).toMatchObject(
gainFirst
? { holidayWeekValue: 0, nonWorkingDayGain: 9 }
: { holidayWeekValue: 5, nonWorkingDayGain: 6 },
);
expect(calculation.snapshot.rules.find((rule) => rule.id === 'holiday')).toMatchObject({
name: 'Captured holiday rule',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
});
const first = `optimization:${rules[0].id}`;
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a worker explanation');
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
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');
expect(
comparison.results.find((result) => result.date === plan.placements[0].date)
?.comparison?.firstDecisiveObjective,
).toBe(first);
},
);
it.each([false, true])(
'lets economy block or permit the complete holiday bundle in a real worker (economy first: %s)',
async (economyFirst) => {
const economy: OptimizationPlanningRule = {
id: 'economy',
name: 'Captured supporting economy',
type: 'optimization',
configuration: structuredClone(OPTIMIZATION_PRESETS.supportingPlacements),
};
const holiday: OptimizationPlanningRule = {
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
};
const rules = economyFirst ? [economy, holiday] : [holiday, economy];
const calculation = createPlanningCalculation({
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-09',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules,
});
economy.name = 'Edited later';
economy.configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.metrics).toMatchObject(
economyFirst
? { holidayWeekValue: 0, supportingPlacements: 0 }
: { holidayWeekValue: 3, supportingPlacements: 2 },
);
expect(calculation.snapshot.rules.find(({ id }) => id === 'economy')).toMatchObject({
name: 'Captured supporting economy',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
if (economyFirst) {
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: 2,
classification: 'deferred',
});
expect(explanation.placements).toEqual([]);
return;
}
expect(outcome.remainingBalances).toEqual([]);
expect(explanation.placements.every(({ supporting }) => supporting)).toBe(true);
expect(explanation.placements[0].firstDecisiveObjective).toBe('optimization:holiday');
expect(explanation.placements[0].objectiveVector.objectives).toContainEqual(
expect.objectContaining({
id: 'optimization:economy',
value: 2,
label: 'Captured supporting economy',
}),
);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison
?.after.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 2 }));
},
);
});
+3 -3
View File
@@ -11,7 +11,7 @@ import {
LeaveType,
PlanOutcome,
PlanningReason,
PlanningTraceState,
PlanningStateSnapshot,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
@@ -19,8 +19,8 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni
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'];
};
+328
View File
@@ -0,0 +1,328 @@
import { afterAll, describe, expect, it } from 'vitest';
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
@@ -0,0 +1,499 @@
import { describe, expect, it } from 'vitest';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanningArtifacts,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
const holiday: OptimizationPlanningRule = {
id: 'holiday',
name: 'Complete public-holiday weeks',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
};
const gain: OptimizationPlanningRule = {
id: 'gain',
name: 'Enjoy weekends and public holidays',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const samedi: PlanningRequest['rules'][number] = {
id: 'samedi',
type: 'if_then',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
};
const concurrent: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules: [],
};
function calculate(request: PlanningRequest, calculationDate = '2024-04-01') {
let artifacts: PlanningArtifacts = {};
const plan = calculatePlanningInternal(
{ ...request, calculationDate },
undefined,
(value) => (artifacts = value),
);
if (plan.kind !== 'Plan') throw new Error(`Expected a plan, received ${plan.kind}`);
return { plan, artifacts };
}
describe('composed calendar objectives', () => {
it('follows either displayed order when a completed holiday week competes with greater natural gain', () => {
const holidayFirst = calculate({ ...concurrent, rules: [holiday, gain] }).plan;
const gainFirst = calculate({ ...concurrent, rules: [gain, holiday] }).plan;
expect(holidayFirst.placements.map((placement) => placement.date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(holidayFirst.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
expect(gainFirst.placements.map((placement) => placement.date)).toEqual([
'2024-04-29',
'2024-05-10',
'2024-05-17',
]);
expect(gainFirst.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 9 });
});
it('regroups existing placements as a complete week without leaving higher-priority at-risk leave unused', () => {
const loss: OptimizationPlanningRule = {
id: 'loss',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
};
const { plan } = calculate({
...concurrent,
balances: [{ ...concurrent.balances[0], expiryDate: '2024-05-17' }],
rules: [loss, holiday, gain],
});
expect(plan.metrics).toMatchObject({
unplacedAtRisk: 0,
holidayWeekValue: 5,
nonWorkingDayGain: 6,
});
expect(plan.placements.map((placement) => placement.date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
});
it('preserves an earlier preference that minimizes supporting placements', () => {
const { plan } = calculate({
...concurrent,
rules: [
{
id: 'economy',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
holiday,
],
});
expect(plan.placements).toEqual([]);
});
it('uses edited holiday conditions and aggregation without dispatching by a preset identity', () => {
const counted: OptimizationPlanningRule = {
...holiday,
id: 'my-complete-weeks',
configuration: { ...holiday.configuration, aggregation: 'count' },
};
const { plan, artifacts } = calculate({ ...concurrent, rules: [counted] });
expect(plan.metrics.holidayWeekValue).toBe(5);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
expect(artifacts.explanation.placements[0].objectiveVector.objectives[1]).toMatchObject({
id: 'optimization:my-complete-weeks',
value: 1,
});
const allWeeks = calculate({
...concurrent,
rules: [{ ...counted, configuration: { ...counted.configuration, conditions: [] } }],
}).plan;
expect(allWeeks.placements).toEqual([]);
});
it('attributes a coordinated holiday addition and its explanation to the active rule', () => {
const { plan, artifacts } = calculate(
{
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [
{
id: 'deferred-cp',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-01',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [holiday],
},
'2026-11-01',
);
expect(plan.placements).toEqual([
expect.objectContaining({ date: '2026-11-12', ruleIds: ['holiday'], supporting: true }),
expect.objectContaining({ date: '2026-11-13', ruleIds: ['holiday'], supporting: true }),
]);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
for (const placement of artifacts.explanation.placements) {
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'holiday' }),
);
}
});
it.each<OptimizationConfiguration>([
{ ...gain.configuration, aggregation: 'maximum' },
{ ...gain.configuration, aggregation: 'count', direction: 'minimize' },
])(
'bridges fixed periods for the personal configuration $aggregation/$direction',
(configuration) => {
const { plan, artifacts } = calculate({
horizon: { start: '2024-01-05', end: '2024-01-13' },
balances: [
{
id: 'rtt',
type: 'rtt',
quantity: 4,
availableFrom: '2024-01-05',
expiryDate: '2025-01-01',
},
],
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [
{
...gain,
id: 'personal-maximum',
configuration,
},
],
});
expect(plan.placements.map((placement) => placement.date)).toEqual([
'2024-01-08',
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(plan.timeOffPeriods).toHaveLength(1);
expect(plan.timeOffPeriods[0].nonWorkingDates).toEqual([
'2024-01-06',
'2024-01-07',
'2024-01-13',
'2024-01-14',
]);
expect(plan.metrics.nonWorkingDayGain).toBe(4);
expect(
plan.placements.every(({ ruleIds }) => ruleIds.includes('personal-maximum')),
).toBe(true);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
for (const placement of artifacts.explanation.placements)
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'personal-maximum' }),
);
},
);
it.each([false, true])(
'finishes a rejected bridge with an irrelevant typed rule: %s',
(withSamedi) => {
const { plan } = calculate({
horizon: { start: '2024-01-05', end: '2024-02-06' },
balances: (['cp', 'rtt', 'other'] as const).map((type) => ({
id: type,
type,
quantity: 20,
availableFrom: '2024-01-05',
expiryDate: '2025-01-01',
})),
declaredDays: ['2024-01-05', '2024-02-05'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'economy',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
{
...gain,
configuration: {
...gain.configuration,
aggregation: 'count',
direction: 'minimize',
},
},
...(withSamedi ? [samedi] : []),
],
deadlineMs: 2_000,
});
expect(plan.placements).toEqual([]);
expect(plan.timeOffPeriods).toHaveLength(2);
},
);
it('does not label a period-count bridge supporting when it adds no domain calendar benefit', () => {
const { plan } = calculate({
horizon: { start: '2024-01-02', end: '2024-01-31' },
balances: [{ ...concurrent.balances[0], quantity: 5, availableFrom: '2024-01-02' }],
declaredDays: ['2024-01-05', '2024-01-15'].map((date) => ({ date, type: 'other' })),
rules: [
{
...gain,
configuration: {
...gain.configuration,
aggregation: 'count',
direction: 'minimize',
},
},
],
});
expect(plan.timeOffPeriods).toHaveLength(1);
expect(plan.placements).toHaveLength(5);
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
expect(plan.metrics.nonWorkingDayGain).toBe(4);
});
it('can place the holiday preference before or after Samedi Malin', () => {
const request: PlanningRequest = {
horizon: { start: '2021-11-01', end: '2021-12-29' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 4,
availableFrom: '2021-11-01',
expiryDate: '2022-01-01',
},
],
rules: [],
};
const first = calculate({ ...request, rules: [holiday, samedi] }, '2021-10-01').plan;
const second = calculate({ ...request, rules: [samedi, holiday] }, '2021-10-01').plan;
expect(first.placements.map((placement) => placement.date)).toEqual([
'2021-11-08',
'2021-11-09',
'2021-11-10',
'2021-11-12',
]);
expect(first.metrics.holidayWeekValue).toBe(2);
expect(first.preferences[0].satisfied).toBe(0);
expect(second.placements.map((placement) => placement.date)).toEqual([
'2021-12-23',
'2021-12-24',
]);
expect(second.metrics.holidayWeekValue).toBe(0);
expect(second.preferences[0].satisfied).toBe(1);
expect(second.remainingBalances.find((balance) => balance.earned)).toMatchObject({
id: 'earned:samedi:2021-12-25',
quantity: 1,
});
});
it.each(['obligation', 'preference'] as const)(
'preserves a higher %s when completing a week would take its last balance',
(force) => {
const { plan } = calculate({
...concurrent,
rules: [
{
id: 'monday',
type: 'time_window',
force,
startDate: '2024-04-29',
endDate: '2024-04-29',
placeDays: 1,
placeType: 'cp',
},
holiday,
],
});
expect(plan.placements).toMatchObject([{ date: '2024-04-29', ruleIds: ['monday'] }]);
expect(plan.metrics.holidayWeekValue).toBe(0);
},
);
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' },
balances: [{ ...concurrent.balances[0], quantity: 5, expiryDate: '2024-05-03' }],
rules: [holiday],
};
const absent = calculate(request).plan;
const disabled = calculate({
...request,
rules: [
holiday,
{
id: 'loss',
type: 'optimization',
enabled: false,
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
},
{ ...gain, enabled: false },
],
}).plan;
expect(disabled).toEqual(absent);
expect(absent.metrics.unplacedAtRisk).toBe(1);
expect(absent.metrics.holidayWeekValue).toBe(3);
expect(absent.placements).toHaveLength(4);
expect(
calculate({ ...request, rules: [{ ...holiday, enabled: false }] }).plan.placements,
).toEqual([]);
});
it.each([
{ start: '2024-05-06', end: '2024-05-11' },
{ start: '2024-05-10', end: '2024-05-11' },
])(
'uses declarations outside the horizon to complete a genuine holiday week ($start)',
(horizon) => {
const { plan } = calculate({
horizon,
balances: [{ ...concurrent.balances[0], quantity: 1, availableFrom: '2024-05-06' }],
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
rules: [holiday],
});
expect(plan.placements).toMatchObject([{ date: '2024-05-10', supporting: true }]);
expect(plan.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
},
);
it('cannot fill missing weekdays outside the horizon or with unavailable balances', () => {
const request: PlanningRequest = {
horizon: { start: '2024-05-10', end: '2024-05-11' },
balances: [{ ...concurrent.balances[0], quantity: 1 }],
rules: [holiday],
};
expect(calculate(request).plan.placements).toEqual([]);
const unavailable = calculate({
...request,
balances: [{ ...request.balances[0], availableFrom: '2024-05-11' }],
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
}).plan;
expect(unavailable.placements).toEqual([]);
expect(unavailable.metrics.holidayWeekValue).toBe(0);
});
it('measures only distinct natural dates and leaves ordinary declared weeks ordinary', () => {
const christmas = calculate({
horizon: { start: '2021-12-24', end: '2021-12-25' },
balances: [],
declaredDays: [{ date: '2021-12-24', type: 'other' }],
rules: [holiday, gain],
}).plan;
expect(christmas.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 2 });
expect(christmas.timeOffPeriods[0].nonWorkingDates).toEqual(['2021-12-25', '2021-12-26']);
const ordinary = calculate({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [],
declaredDays: ['08', '09', '10', '11', '12'].map((day) => ({
date: `2024-01-${day}`,
type: 'other',
})),
rules: [holiday],
}).plan;
expect(ordinary.metrics.holidayWeekValue).toBe(0);
});
it('uses the same first calendar objective in the explanation and date comparison', () => {
for (const rules of [
[holiday, gain],
[gain, holiday],
]) {
const request = { ...concurrent, rules };
const { plan, artifacts } = calculate(request);
const explanation = artifacts.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const first = `optimization:${rules[0].id}`;
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
expect(
explanation.placements[0].objectiveVector.objectives.map(
(objective) => objective.id,
),
).toEqual([
'obligation-validity',
first,
`optimization:${rules[1].id}`,
'balance-expiry',
'placement-date',
]);
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(
request,
plan,
placement,
artifacts.finalState,
);
const original = comparison.results.find((result) => result.date === placement.date)
?.comparison?.after;
const alternativeVector = comparison.results.find(
(result) => result.date === alternative,
)?.comparison?.after;
expect(
alternativeVector?.objectives.find(
(objective, index) => objective.value !== original?.objectives[index].value,
)?.id,
).toBe(first);
}
});
});
@@ -1,9 +1,6 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
createPlanningCalculation,
type PlanningIncompleteOutcome,
type PlanningTraceUpdate,
} from './leave-planner.service';
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
const request = {
horizon: { start: '2024-01-01', end: '2025-01-01' },
@@ -14,6 +11,8 @@ const request = {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('user cancellation', () => {
const workers: CancellableWorker[] = [];
const currentWorker = () => workers[workers.length - 1];
@@ -43,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 () => {
@@ -51,7 +49,7 @@ describe('user cancellation', () => {
const submitted = {
horizon: { start: '2024-01-02', end: '2024-01-09' },
balances: [],
rules: [],
rules: [presets[2]],
declaredDays: [{ date: '2024-01-05', type: 'other' as const }],
};
let interrupted = false;
@@ -60,7 +58,7 @@ describe('user cancellation', () => {
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interrupted = true;
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
},
undefined,
@@ -93,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);
});
@@ -115,14 +112,17 @@ describe('user cancellation', () => {
{ date: '2024-01-11', type: 'rtt' as const },
],
rules: [
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
].map((rule) => ({
...rule,
type: 'time_window',
startDate: '2024-01-08',
endDate: '2024-01-12',
})),
presets[1],
...[
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
].map((rule) => ({
...rule,
type: 'time_window',
startDate: '2024-01-08',
endDate: '2024-01-12',
})),
],
};
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -130,7 +130,7 @@ describe('user cancellation', () => {
{
...submitted,
progress: (stage: string) => {
if (stage === 'holiday-week-value') interrupted = true;
if (stage === 'optimization:default-holiday-week-value') interrupted = true;
},
},
undefined,
@@ -187,20 +187,20 @@ 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: [
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',
@@ -211,11 +211,23 @@ describe('user cancellation', () => {
placeType: 'cp',
},
],
progress: (stage: string) => {
if (stage === '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,
@@ -225,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();
@@ -275,27 +276,23 @@ 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 = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
rules: [],
rules: presets,
};
const cancelled = await core.calculatePlanningAsyncInternal(
{
...submitted,
traceProgress: (_: number, update: PlanningTraceUpdate) =>
updates.push(structuredClone(update)),
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interrupted = true;
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
},
undefined,
@@ -304,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 });
@@ -326,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])(
@@ -389,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 () => {
@@ -414,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);
});
@@ -443,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);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,186 @@
import { createOptimizationPresetRules } from './rule-service';
import { describe, expect, it } from 'vitest';
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
import {
evaluateOptimization,
OPTIMIZATION_PRESETS,
type OptimizationObservations,
} from './planning-objectives';
const presets = createOptimizationPresetRules();
describe('planning objective observations', () => {
it.each([
{ start: '2024-05-06', end: '2024-05-13' },
{ start: '2024-05-10', end: '2024-05-11' },
])(
'evaluates the same declared holiday week and supporting placement at horizon boundaries',
(horizon) => {
const result = calculatePlanningInternal({
horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
},
],
rules: presets,
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind !== 'Plan') return;
expect(result.placements).toEqual([
{
date: '2024-05-10',
balanceId: 'cp',
type: 'cp',
ruleIds: ['default-holiday-week-value'],
supporting: true,
},
]);
const observations: OptimizationObservations = {
leaveBalances: [{ unplacedQuantity: 0, atRisk: false }],
publicHolidayWeeks: [{ value: 5, complete: true }],
timeOffPeriods: result.timeOffPeriods,
workSequences: [
{ length: 0, internal: false },
{ length: 0, internal: false },
],
leavePlacements: result.placements,
};
expect(
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
evaluateOptimization(configuration, observations),
),
).toEqual([0, 5, 6, 0, 0, 1]);
expect(result.metrics).toMatchObject({
unplacedAtRisk: 0,
holidayWeekValue: 5,
nonWorkingDayGain: 6,
longestWorkSequence: 0,
shortSequenceDeficit: 0,
supportingPlacements: 1,
});
},
);
it('uses internal working-day deficits while retaining both horizon edges in the maximum', () => {
const result = calculatePlanningInternal({
horizon: { start: '2024-01-02', end: '2024-01-18' },
balances: [],
rules: [],
declaredDays: ['2024-01-05', '2024-01-09'].map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind !== 'Plan') return;
expect(result.metrics).toMatchObject({
longestWorkSequence: 6,
shortSequenceDeficit: 4,
nonWorkingDayGain: 2,
});
const observations: OptimizationObservations = {
leaveBalances: [],
publicHolidayWeeks: [],
timeOffPeriods: result.timeOffPeriods,
workSequences: [
{ length: 3, internal: false },
{ length: 1, internal: true },
{ length: 6, internal: false },
],
leavePlacements: result.placements,
};
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
4,
);
expect(
evaluateOptimization(
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
observations,
),
).toBe(2);
});
it.each([
['2024-01-01', '2024-01-06', ['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'], 1],
['2018-04-30', '2018-05-05', ['2018-04-30', '2018-05-02', '2018-05-03', '2018-05-04'], 2],
['2024-04-29', '2024-05-04', ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], 3],
['2024-08-12', '2024-08-17', ['2024-08-12', '2024-08-13', '2024-08-14', '2024-08-16'], 2],
['2024-10-28', '2024-11-02', ['2024-10-28', '2024-10-29', '2024-10-30', '2024-10-31'], 1],
] as const)(
'retains the weekday holiday weight in the completed week starting %s',
(start, end, dates, value) => {
const result = calculatePlanningInternal({
horizon: { start, end },
balances: [],
rules: [],
declaredDays: dates.map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind === 'Plan') expect(result.metrics.holidayWeekValue).toBe(value);
},
);
it('keeps the historical objective order in the explanation of a jointly valid plan', () => {
let artifacts: PlanningArtifacts | undefined;
const result = calculatePlanningInternal(
{
horizon: { start: '2024-01-02', end: '2024-01-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-01-02',
expiryDate: '2024-01-17',
},
],
rules: [
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
{
id: 'personal',
type: 'time_window',
force: 'preference',
startDate: '2024-01-09',
endDate: '2024-01-09',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
expect(result.kind).toBe('Plan');
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
const placement = artifacts.explanation.placements[0];
expect(placement.objectiveVector.objectives.map((objective) => objective.id)).toEqual([
'obligation-validity',
'optimization:default-unplaced-at-risk',
'preference:personal',
'optimization:default-holiday-week-value',
'optimization:default-non-working-day-gain',
'optimization:default-longest-work-sequence',
'optimization:default-short-sequence-deficit',
'optimization:default-supporting-placements',
'balance-expiry',
'placement-date',
]);
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
});
});
@@ -1,87 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals());
describe('quality calculation trace', () => {
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: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
if (outcome.kind !== 'Plan' || !trace) return;
const phase = trace.phases.find((item) => item.name === 'non-working-day-gain');
const jan3Decision = phase?.decisions.find((decision) =>
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'),
);
const jan2Decision = phase?.decisions.find((decision) =>
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'),
);
const jan5 = jan3Decision?.candidates.find(
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05',
);
const jan8 = jan3Decision?.candidates.find(
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08',
);
expect(jan2Decision).toBeDefined();
expect(jan3Decision).toBeDefined();
expect(jan3Decision).not.toBe(jan2Decision);
expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']);
expect(jan5?.status).toBe('chosen');
expect(jan8?.status).toBe('evaluated-rejected');
expect(jan3Decision?.deltas).toContainEqual(
expect.objectContaining({
kind: 'placement-moved',
fromDate: '2024-01-03',
toDate: '2024-01-05',
}),
);
const sourceValue = (
candidate: typeof jan5,
state: 'before' | 'after',
objective: string,
) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value;
expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3);
expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5);
expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20);
expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18);
expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3);
expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5);
expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20);
expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17);
expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain');
expect(jan8?.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'non-working-day-gain',
direction: 'maximize',
before: 5,
after: 5,
}),
]),
);
expect(
jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'),
).toBe(false);
expect(jan8?.firstDecisiveComparator).toBeUndefined();
expect(jan8?.reason?.message).toContain('did not improve the current best result');
});
});
@@ -0,0 +1,735 @@
import { vi } from 'vitest';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanOutcome,
type PlanningArtifacts,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { createPlanningCalculation } from './leave-planner.service';
const economy: OptimizationPlanningRule = {
id: 'economy',
name: 'Économiser les jours d’appoint',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
};
const gain: OptimizationPlanningRule = {
id: 'gain',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const requested: PlanningRequest['rules'][number] = {
id: 'requested',
type: 'time_window',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
};
const weekend: PlanningRequest = {
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
rules: [],
};
const bundles: {
name: string;
rule: OptimizationPlanningRule;
request: PlanningRequest;
dates: string[];
}[] = [
{
name: 'complete public-holiday week',
rule: {
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
},
request: {
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [],
},
dates: ['2026-11-12', '2026-11-13'],
},
{
name: 'non-working-day gain',
rule: gain,
request: weekend,
dates: ['2024-01-05'],
},
{
name: 'maximum non-working gain of a joined period',
rule: {
...gain,
configuration: { ...gain.configuration, aggregation: 'maximum' },
},
request: {
horizon: { start: '2024-01-05', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [],
},
dates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11'],
},
{
name: 'complete short internal work sequence',
rule: {
id: 'deficit',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
},
request: {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [],
},
dates: ['2024-01-09', '2024-01-10', '2024-01-11'],
},
{
name: 'tied longest work sequences',
rule: {
id: 'longest',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
},
request: {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
declaredDays: [{ date: '2024-01-10', type: 'other' }],
rules: [],
},
dates: ['2024-01-08', '2024-01-11'],
},
];
function plan(request: PlanningRequest): PlanOutcome {
const outcome = calculatePlanningInternal(request);
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
return outcome;
}
describe('supporting placement economy and provenance', () => {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
it.each(bundles)(
'compares the whole $name bundle against economy in either order',
({ rule, request, dates }) => {
const improve = plan({ ...request, rules: [rule, economy] });
const preserve = plan({ ...request, rules: [economy, rule] });
expect(improve.placements.map(({ date }) => date)).toEqual(dates);
expect(improve.placements.every(({ supporting }) => supporting)).toBe(true);
expect(improve.metrics.supportingPlacements).toBe(dates.length);
expect(improve.remainingBalances).toEqual([]);
expect(preserve.placements).toEqual([]);
expect(preserve.metrics.supportingPlacements).toBe(0);
expect(preserve.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: request.balances[0].quantity,
classification: 'deferred',
reason: { code: 'preserved' },
});
},
);
it.each(bundles)(
'makes disabled economy identical to absence for the $name bundle',
({ rule, request }) => {
const absent = plan({ ...request, rules: [rule] });
for (const rules of [
[{ ...economy, enabled: false }, rule],
[rule, { ...economy, enabled: false }],
])
expect(plan({ ...request, rules })).toEqual(absent);
},
);
it('does not add placements merely because economy is disabled or removed', () => {
for (const rules of [[], [economy], [{ ...economy, enabled: false }]]) {
const outcome = plan({ ...weekend, rules });
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0].quantity).toBe(1);
}
});
it('credits an earlier supporting placement to a later explicit window without counting an extra day', () => {
const outcome = plan({
...weekend,
rules: [gain, economy, requested],
});
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-01-05',
balanceId: 'cp',
ruleIds: ['gain', 'requested'],
supporting: false,
}),
]);
expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 2, supportingPlacements: 0 });
expect(outcome.preferences[0].satisfied).toBe(1);
});
it('exempts only the requested window quantity, including fixed declared days', () => {
const outcome = plan({
...weekend,
horizon: { start: '2024-01-05', end: '2024-01-20' },
balances: [{ ...weekend.balances[0], quantity: 3 }],
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
rules: [gain, economy, { ...requested, endDate: '2024-01-19', placeDays: 2 }],
});
expect(outcome.placements.map(({ date, supporting }) => ({ date, supporting }))).toEqual([
{ date: '2024-01-05', supporting: false },
{ date: '2024-01-12', supporting: true },
{ date: '2024-01-19', supporting: true },
]);
expect(outcome.metrics.supportingPlacements).toBe(2);
expect(outcome.timeWindowContributions?.[0]).toMatchObject({
requested: 2,
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
placementDates: ['2024-01-05'],
});
});
it.each([false, true])(
'bounds explicit calendar credit before accepting further window improvements (declared: %s)',
(declared) => {
const windowRule = { ...requested, endDate: '2024-01-19' };
const request: PlanningRequest = {
...weekend,
horizon: { start: '2024-01-05', end: '2024-01-20' },
balances: [{ ...weekend.balances[0], quantity: 4 }],
declaredDays: declared ? [{ date: '2024-01-09', type: 'cp' }] : [],
rules: [economy, gain, windowRule],
};
const preserve = plan(request);
expect(preserve.placements).toEqual(
declared
? []
: [
expect.objectContaining({
date: '2024-01-05',
ruleIds: ['requested'],
supporting: false,
}),
],
);
expect(preserve.metrics.supportingPlacements).toBe(0);
expect(preserve.preferences[0].satisfied).toBe(1);
expect(preserve.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: declared ? 4 : 3,
});
const improve = plan({ ...request, rules: [gain, economy, windowRule] });
expect(improve.placements.map(({ date }) => date)).toEqual([
'2024-01-05',
'2024-01-12',
'2024-01-19',
]);
expect(
improve.placements.filter(({ ruleIds }) => ruleIds.includes('requested')),
).toHaveLength(declared ? 0 : 1);
expect(improve.metrics.supportingPlacements).toBe(declared ? 3 : 2);
expect(
plan({ ...request, rules: [{ ...economy, enabled: false }, gain, windowRule] }),
).toEqual(plan({ ...request, rules: [gain, windowRule] }));
},
);
it('restores supporting classification when a moved placement loses its explicit calendar request', () => {
const outcome = plan({
...weekend,
horizon: { start: '2024-01-05', end: '2024-01-20' },
balances: [{ ...weekend.balances[0], quantity: 4 }],
rules: [
gain,
{ ...requested, startDate: '2024-01-08', endDate: '2024-01-09' },
bundles[4].rule,
economy,
],
});
expect(outcome.metrics).toMatchObject({
nonWorkingDayGain: 6,
longestWorkSequence: 3,
supportingPlacements: 3,
});
expect(outcome.placements.filter(({ supporting }) => !supporting)).toEqual([
expect.objectContaining({
date: '2024-01-08',
ruleIds: ['gain', 'requested'],
}),
]);
});
it.each(['preference', 'obligation'] as const)(
'keeps explicitly requested deferred days outside economy (%s)',
(force) => {
const outcome = plan({ ...weekend, rules: [economy, { ...requested, force }, gain] });
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-01-05',
ruleIds: ['requested'],
supporting: false,
}),
]);
expect(outcome.metrics).toMatchObject({
nonWorkingDayGain: 2,
supportingPlacements: 0,
});
},
);
it('credits the complete explicit IF_THEN pattern without treating its deferred day as support', () => {
const outcome = plan({
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
rules: [
gain,
economy,
{
id: 'thursday',
type: 'if_then',
dayOfWeek: 4,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 0,
},
],
});
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-05-10',
ruleIds: ['gain', 'thursday'],
supporting: false,
}),
]);
expect(outcome.metrics.supportingPlacements).toBe(0);
expect(outcome.preferences[0].satisfied).toBe(1);
});
it.each(['preference', 'obligation'] as const)(
'limits IF_THEN credit to its declared producer pattern when another complete pattern improves rest (%s)',
(force) => {
const request: PlanningRequest = {
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
rules: [
economy,
gain,
{
id: 'producer',
type: 'if_then',
force,
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
],
};
const preserve = plan(request);
expect(preserve.placements).toEqual([]);
expect(preserve.metrics.supportingPlacements).toBe(0);
expect(preserve.remainingBalances).toEqual([
expect.objectContaining({ id: 'cp', quantity: 4 }),
expect.objectContaining({
id: 'earned:producer:2024-05-08',
quantity: 1,
availableFrom: '2024-05-10',
}),
]);
expect(preserve.ifThenContributions?.[0]).toMatchObject({
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
placementDates: [],
earnedEntitlement: { availableFrom: '2024-05-10', added: true },
});
const improve = plan({ ...request, rules: [gain, economy, request.rules[2]] });
expect(improve.placements).toEqual([
expect.objectContaining({
date: '2024-05-06',
ruleIds: ['gain'],
supporting: true,
}),
expect.objectContaining({
date: '2024-05-10',
ruleIds: ['gain'],
supporting: true,
}),
]);
expect(improve.metrics).toMatchObject({
nonWorkingDayGain: 6,
supportingPlacements: 2,
});
expect(improve.remainingBalances).toEqual([
expect.objectContaining({ id: 'cp', quantity: 2 }),
expect.objectContaining({
id: 'earned:producer:2024-05-08',
quantity: 1,
availableFrom: '2024-05-13',
}),
]);
expect(improve.ifThenContributions?.[0]).toMatchObject({
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
placementDates: [],
earnedEntitlement: { availableFrom: '2024-05-13', added: true },
});
expect(
plan({
...request,
rules: [{ ...economy, enabled: false }, gain, request.rules[2]],
}),
).toEqual(plan({ ...request, rules: [gain, request.rules[2]] }));
},
);
it('uses expiry then dates when active rules tie, without restoring a removed gain objective', () => {
const request: PlanningRequest = {
horizon: { start: '2024-01-09', end: '2024-01-13' },
balances: [
{ id: 'later', type: 'cp', quantity: 1, expiryDate: '2024-03-01' },
{ id: 'earlier', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
],
rules: [economy, { ...requested, startDate: '2024-01-09', endDate: '2024-01-12' }],
};
const outcome = plan(request);
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-01-09',
balanceId: 'earlier',
supporting: false,
}),
]);
expect(outcome.metrics.nonWorkingDayGain).toBe(0);
expect(
plan({ ...request, rules: [{ ...gain, enabled: false }, ...request.rules] }),
).toEqual(outcome);
});
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
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 }) => ({
date,
balanceId,
supporting,
})),
).toEqual([
{ date: '2024-05-06', balanceId: 'deferred', supporting: true },
{ date: '2024-05-07', balanceId: 'at-risk', supporting: false },
{ date: '2024-05-10', balanceId: 'deferred', supporting: true },
]);
expect(outcome.metrics.supportingPlacements).toBe(2);
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
});
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
const holiday = bundles[0].rule;
const early: PlanningRequest['rules'][number] = {
id: 'early',
type: 'time_window',
startDate: '2026-11-02',
endDate: '2026-11-03',
placeDays: 2,
placeType: 'cp',
};
const request: PlanningRequest = {
horizon: { start: '2026-11-02', end: '2026-11-14' },
balances: [
{ id: 'at-risk', type: 'cp', quantity: 2, expiryDate: '2026-11-13' },
{
id: 'deferred',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-12',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [],
};
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
let artifacts: PlanningArtifacts | undefined;
const windowFirst = calculatePlanningInternal(
{
...request,
rules: [holiday, early, economy],
},
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
(value) => (artifacts = value),
);
if (windowFirst.kind !== 'Plan') throw new Error('Expected a plan');
expect(economyFirst.placements).toEqual([
expect.objectContaining({
date: '2026-11-12',
balanceId: 'at-risk',
supporting: false,
}),
expect.objectContaining({
date: '2026-11-13',
balanceId: 'at-risk',
supporting: false,
}),
]);
expect(economyFirst.metrics.supportingPlacements).toBe(0);
expect(economyFirst.preferences[0].satisfied).toBe(0);
expect(economyFirst.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 2 });
expect(windowFirst.placements).toEqual([
expect.objectContaining({
date: '2026-11-02',
balanceId: 'at-risk',
ruleIds: ['early'],
supporting: false,
}),
expect.objectContaining({
date: '2026-11-03',
balanceId: 'at-risk',
ruleIds: ['early'],
supporting: false,
}),
expect.objectContaining({
date: '2026-11-12',
balanceId: 'deferred',
ruleIds: ['holiday'],
supporting: true,
}),
expect.objectContaining({
date: '2026-11-13',
balanceId: 'deferred',
ruleIds: ['holiday'],
supporting: true,
}),
]);
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)
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,
);
for (const placement of artifacts.explanation.placements) {
const contribution = expect.objectContaining({ kind: 'rule', ruleId: 'holiday' });
if (placement.supporting) {
expect(placement.ruleIds).toEqual(['holiday']);
expect(placement.contributions).toContainEqual(contribution);
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
} else {
expect(placement.ruleIds).toEqual(['early']);
expect(placement.contributions).not.toContainEqual(contribution);
}
}
let cancelled = false;
let cancelledArtifacts: PlanningArtifacts | undefined;
let published: PlanOutcome | undefined;
const partial = await calculatePlanningAsyncInternal(
{
...request,
rules: [holiday, early, economy],
progress: (stage: string) => {
if (stage === 'optimization:economy') cancelled = true;
},
},
(checkpoint, state) => {
expect(state?.placements).toEqual(checkpoint.placements);
if (checkpoint.lastCompletedStage === 'preference:early') published = checkpoint;
},
(value) => (cancelledArtifacts = value),
undefined,
undefined,
() => cancelled,
);
if (
partial.kind !== 'PlanningIncomplete' ||
!partial.incumbent ||
cancelledArtifacts?.explanation?.kind !== 'PlanExplanation'
)
throw new Error('Expected an explained cancelled incumbent');
expect(partial.reasons[0].code).toBe('user-cancelled');
expect(partial.incumbent.placements).toEqual(windowFirst.placements);
expect(partial.incumbent.placements).toEqual(published?.placements);
expect(partial.incumbent.preferences).toMatchObject([{ ruleId: 'early', satisfied: 2 }]);
expect(cancelledArtifacts.finalState?.placements).toEqual(partial.incumbent.placements);
expect(cancelledArtifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
partial.incumbent.placements,
);
for (const placement of cancelledArtifacts.explanation.placements.slice(0, 2)) {
expect(placement.ruleIds).toEqual(['early']);
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
);
}
});
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
let artifacts: PlanningArtifacts | undefined;
const outcome = calculatePlanningInternal(
{
horizon: { start: '2024-05-01', end: '2024-05-11' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-05-03' }],
rules: [
{ ...gain, configuration: { ...gain.configuration, aggregation: 'maximum' } },
{
id: 'producer',
type: 'if_then',
dayOfWeek: 3,
placement: 'after',
placeDays: 1,
placeType: 'cp',
rewardDays: 2,
rewardType: 'rtt',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || artifacts?.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explained plan');
expect(outcome.placements).toMatchObject([
{ date: '2024-05-02', ruleIds: ['gain', 'producer'], supporting: false },
{ date: '2024-05-03', ruleIds: ['gain'], supporting: false },
{ date: '2024-05-10', ruleIds: ['gain'], supporting: true },
]);
for (const placement of artifacts.explanation.placements.slice(0, 2)) {
expect(placement.comparison.delta).toContainEqual(
expect.objectContaining({ id: 'optimization:gain', value: 0 }),
);
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'gain' }),
);
}
});
it('counts reverse deferred reassignment for an edited optimization without explicit calendar credit', () => {
const request = bundles[0].request;
const forgoAtRisk: OptimizationPlanningRule = {
id: 'forgo',
type: 'optimization',
configuration: { ...OPTIMIZATION_PRESETS.unplacedAtRisk, direction: 'maximize' },
};
const outcome = plan({
...request,
balances: [
{ ...request.balances[0], id: 'at-risk', expiryDate: '2026-11-13' },
{ ...request.balances[0], id: 'deferred' },
],
rules: [bundles[0].rule, forgoAtRisk, economy],
});
expect(
outcome.placements.map(({ balanceId, supporting }) => ({ balanceId, supporting })),
).toEqual([
{ balanceId: 'deferred', supporting: true },
{ balanceId: 'deferred', supporting: true },
]);
expect(outcome.metrics).toMatchObject({
holidayWeekValue: 3,
unplacedAtRisk: 2,
supportingPlacements: 2,
});
});
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
vi.stubGlobal('window', undefined);
try {
const capturedEconomy = structuredClone(economy);
const request: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-08' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
rules: [
gain,
capturedEconomy,
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
],
};
const calculation = createPlanningCalculation(request);
capturedEconomy.configuration.conditions = [];
capturedEconomy.name = 'Changed later';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-05-02',
ruleIds: ['producer'],
supporting: false,
}),
expect.objectContaining({
date: '2024-05-06',
balanceId: 'earned:producer:2024-05-01',
supporting: true,
}),
]);
expect(outcome.metrics).toMatchObject({
nonWorkingDayGain: 3,
supportingPlacements: 1,
});
expect(calculation.snapshot.rules[1]).toMatchObject({
name: 'Économiser les jours d’appoint',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const supporting = explanation.placements.find(({ supporting }) => supporting)!;
expect(supporting.firstDecisiveObjective).toBe('optimization:gain');
expect(supporting.objectiveVector.objectives.map(({ id }) => id)).toEqual([
'obligation-validity',
'optimization:gain',
'optimization:economy',
'balance-expiry',
'placement-date',
]);
expect(supporting.contributions).toContainEqual(
expect.objectContaining({ kind: 'supporting' }),
);
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({
status: 'ineligible',
reason: { code: 'unavailable' },
});
expect(
analysis.results.find(({ date }) => date === '2024-05-06')?.comparison?.after
.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
expect(
analysis.results.find(({ date }) => date === '2024-05-07')?.comparison?.after
.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
expect(outcome.placements[1]).toMatchObject({ date: '2024-05-06', supporting: true });
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -0,0 +1,453 @@
import { vi } from 'vitest';
import {
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanOutcome,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { createPlanningCalculation } from './leave-planner.service';
const deficit: OptimizationPlanningRule = {
id: 'deficit',
name: 'Internal work-sequence deficit',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
};
const longest: OptimizationPlanningRule = {
id: 'longest',
name: 'Longest work sequence',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
};
const gap: PlanningRequest = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [deficit],
};
function plan(request: PlanningRequest): PlanOutcome {
const outcome = calculatePlanningInternal(request);
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
return outcome;
}
describe('composed work-sequence preferences', () => {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
it('fills a complete internal sequence despite the worse deficit of partial additions', () => {
const outcome = plan(gap);
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(outcome.placements.every(({ supporting }) => supporting)).toBe(true);
expect(outcome.declaredDays).toEqual(gap.declaredDays);
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
expect(outcome.remainingBalances).toEqual([]);
});
it('splits tied longest sequences together when neither single addition improves the maximum', () => {
const outcome = plan({
...gap,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
declaredDays: [{ date: '2024-01-10', type: 'other' }],
rules: [longest],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-11']);
expect(outcome.metrics.longestWorkSequence).toBe(1);
});
it('leaves a three-day internal sequence intact at threshold three instead of filling it at five', () => {
const outcome = plan({
...gap,
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays: 3,
},
},
],
});
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0].quantity).toBe(3);
expect(outcome.metrics.shortSequenceDeficit).toBe(2);
});
it('counts selected internal sequences even when their measured deficit is zero', () => {
const outcome = plan({
...gap,
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays: 3,
aggregation: 'count',
},
},
],
});
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
});
it('evaluates two parameterized configurations separately and follows their order', () => {
const maximizeThree: OptimizationPlanningRule = {
...deficit,
id: 'three',
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays: 3,
direction: 'maximize',
},
};
const fiveFirst = plan({ ...gap, rules: [deficit, maximizeThree] });
const threeFirst = plan({ ...gap, rules: [maximizeThree, deficit] });
expect(fiveFirst.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(threeFirst.placements.map(({ date }) => date)).toEqual(['2024-01-10']);
expect(threeFirst.metrics.shortSequenceDeficit).toBe(8);
});
it('obeys the order between the longest-sequence preference and a personal window', () => {
const personal = {
id: 'personal',
type: 'time_window' as const,
startDate: '2024-01-08',
endDate: '2024-01-08',
placeDays: 1,
placeType: 'cp',
};
const request: PlanningRequest = {
horizon: { start: '2024-01-08', end: '2024-01-20' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }],
rules: [],
};
const longestFirst = plan({ ...request, rules: [longest, personal] });
const personalFirst = plan({ ...request, rules: [personal, longest] });
expect(longestFirst.placements.map(({ date }) => date)).toEqual(['2024-01-12']);
expect(longestFirst.metrics.longestWorkSequence).toBe(5);
expect(personalFirst.placements.map(({ date }) => date)).toEqual(['2024-01-08']);
expect(personalFirst.metrics.longestWorkSequence).toBe(9);
});
it('keeps obligations and earlier preferences when a complete gap would consume their balance', () => {
const personal = {
id: 'personal',
type: 'time_window' as const,
startDate: '2024-01-15',
endDate: '2024-01-15',
placeDays: 1,
placeType: 'cp',
};
const request = { ...gap, horizon: { start: '2024-01-08', end: '2024-01-16' } };
expect(
plan({ ...request, rules: [deficit, personal] }).placements.map(({ date }) => date),
).toEqual(['2024-01-09', '2024-01-10', '2024-01-11']);
for (const force of ['preference', 'obligation'] as const) {
const outcome = plan({ ...request, rules: [{ ...personal, force }, deficit] });
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-15']);
expect(outcome.metrics.shortSequenceDeficit).toBe(2);
}
});
it('uses only eligible balance dates when completing the entire gap', () => {
const outcome = plan({
...gap,
balances: [
{ id: 'early', type: 'rtt', quantity: 1, expiryDate: '2024-01-09' },
{
id: 'late',
type: 'cp',
quantity: 2,
availableFrom: '2024-01-10',
expiryDate: '2024-02-01',
},
],
});
expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([
{ date: '2024-01-09', balanceId: 'early' },
{ date: '2024-01-10', balanceId: 'late' },
{ date: '2024-01-11', balanceId: 'late' },
]);
expect(outcome.placements.map(({ supporting }) => supporting)).toEqual([false, true, true]);
const unavailable = plan({
...gap,
balances: [
{
id: 'late',
type: 'cp',
quantity: 3,
availableFrom: '2024-01-10',
expiryDate: '2024-02-01',
},
],
});
expect(unavailable.placements).toEqual([]);
});
it('adds no neutral placement when the budget cannot split all tied maxima', () => {
const outcome = plan({
...gap,
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }],
declaredDays: [{ date: '2024-01-10', type: 'other' }],
rules: [longest],
});
expect(outcome.placements).toEqual([]);
expect(outcome.metrics.longestWorkSequence).toBe(2);
});
it('splits tied sequences at eligible dates when their centers are unavailable', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-19' },
declaredDays: [{ date: '2024-01-12', type: 'other' }],
balances: [
{ id: 'early', type: 'cp', quantity: 1, expiryDate: '2024-01-08' },
{
id: 'late',
type: 'cp',
quantity: 1,
availableFrom: '2024-01-18',
expiryDate: '2024-02-01',
},
],
rules: [longest],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-18']);
expect(outcome.metrics.longestWorkSequence).toBe(3);
});
it('coordinates scarce balances when individually eligible centers cannot be funded together', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-19' },
declaredDays: [{ date: '2024-01-12', type: 'other' }],
balances: [
{ id: 'early', type: 'cp', quantity: 1, expiryDate: '2024-01-08' },
{ id: 'flexible', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
],
rules: [longest],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-15']);
expect(outcome.metrics.longestWorkSequence).toBe(3);
});
it('eliminates tied maximum deficits as one complete gap bundle', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-17' },
declaredDays: ['2024-01-08', '2024-01-11', '2024-01-16'].map((date) => ({
date,
type: 'other',
})),
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2024-02-01' }],
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'maximum',
},
},
],
});
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-12',
'2024-01-15',
]);
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
});
it('includes both horizon edges in the maximum while excluding them from the deficit', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-25' },
balances: [],
declaredDays: ['2024-01-11', '2024-01-15', '2024-01-22'].map((date) => ({
date,
type: 'other',
})),
rules: [longest, deficit],
});
expect(outcome.metrics).toMatchObject({ longestWorkSequence: 4, shortSequenceDeficit: 5 });
});
it.each([
{ horizon: { start: '2024-05-06', end: '2024-05-15' }, dates: [], longest: 5 },
{
horizon: { start: '2024-01-08', end: '2024-01-11' },
dates: ['2024-01-08', '2024-01-09', '2024-01-10'],
longest: 0,
},
{ horizon: { start: '2024-01-06', end: '2024-01-08' }, dates: [], longest: 0 },
{
horizon: { start: '2024-01-08', end: '2024-01-13' },
dates: ['2024-01-09', '2024-01-10'],
longest: 2,
},
])(
'keeps holiday/weekend continuity and zero for empty sequences: $horizon.start',
({ horizon, dates, longest: expected }) => {
const outcome = plan({
horizon,
balances: [],
declaredDays: dates.map((date) => ({ date, type: 'other' })),
rules: [longest, deficit],
});
expect(outcome.metrics.longestWorkSequence).toBe(expected);
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
},
);
it('disabling work-sequence rules removes their influence even with deferred balances', () => {
const absent = plan({ ...gap, rules: [] });
const disabled = plan({
...gap,
rules: [
{ ...deficit, enabled: false },
{ ...longest, enabled: false },
],
});
expect(disabled).toEqual(absent);
const personal = {
id: 'personal',
type: 'time_window' as const,
startDate: '2024-01-09',
endDate: '2024-01-09',
placeDays: 1,
placeType: 'cp',
};
const requested = plan({ ...gap, rules: [{ ...deficit, enabled: false }, personal] });
expect(requested.placements.map(({ date }) => date)).toEqual(['2024-01-09']);
expect(requested.metrics.shortSequenceDeficit).toBe(3);
});
it('finishes a rejected tied-sequence bundle without retrying equivalent balance allocations', () => {
const outcome = plan({
deadlineMs: 1500,
horizon: { start: '2024-01-08', end: '2024-02-10' },
declaredDays: [
'2024-01-08',
'2024-01-10',
'2024-01-12',
'2024-01-16',
'2024-01-18',
'2024-01-22',
'2024-01-24',
'2024-01-26',
'2024-01-30',
'2024-02-01',
'2024-02-05',
'2024-02-07',
'2024-02-09',
].map((date) => ({ date, type: 'other' })),
balances: ['cp', 'rtt', 'other'].map((type) => ({
id: type,
type,
quantity: 12,
expiryDate: '2024-03-01',
})),
rules: [
{
id: 'support',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
longest,
{
id: 'typed',
type: 'time_window',
startDate: '2024-01-09',
endDate: '2024-01-09',
placeType: 'cp',
placeDays: 1,
},
],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-09']);
expect(outcome.metrics.longestWorkSequence).toBe(1);
expect(outcome.metrics.supportingPlacements).toBe(0);
});
it.each([0, -1, 1.5, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
'rejects an invalid preference threshold %s at the planning boundary',
(thresholdDays) => {
const outcome = calculatePlanningInternal({
...gap,
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays,
},
},
],
});
expect(outcome).toMatchObject({
kind: 'InvalidInput',
reasons: [
expect.objectContaining({
code: 'invalid-optimization-configuration',
ruleIds: ['deficit'],
}),
],
});
},
);
it('captures distinct threshold values in explanations and date comparisons', async () => {
vi.stubGlobal('window', undefined);
try {
const five = structuredClone(deficit);
const three: OptimizationPlanningRule = {
...deficit,
id: 'three',
configuration: { ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
};
const calculation = createPlanningCalculation({ ...gap, rules: [three, five] });
if (five.configuration.measure !== 'work-sequence-deficit')
throw new Error('Expected deficit');
five.configuration.thresholdDays = 1;
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(outcome.placements).toHaveLength(3);
expect(
calculation.snapshot.rules.map((rule) =>
'configuration' in rule ? rule.configuration : undefined,
),
).toMatchObject([{ thresholdDays: 3 }, { thresholdDays: 5 }]);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
expect(
explanation.placements[0].comparison.before.objectives
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ value }) => value),
).toEqual([2, 4]);
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual')
throw new Error('Expected a counterfactual');
expect(
counterfactual.baseline.objectives
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ id, value }) => ({ id, value })),
).toEqual([
{ id: 'optimization:three', value: 2 },
{ id: 'optimization:deficit', value: 4 },
]);
} finally {
vi.unstubAllGlobals();
}
});
});
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
+204 -78
View File
@@ -15,11 +15,14 @@ import { CalendarYear } from '../components/calendar-year/calendar-year';
import { CalendarService, SelectableDayType } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import { UserInputService } from './user-input-service';
import { RuleService, RuleType } from './rule-service';
import { RuleService, RuleType, createOptimizationPresetRules } from './rule-service';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
@@ -30,6 +33,9 @@ describe('real planning worker cancellation', () => {
let simulateLongCalculation = false;
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-'));
@@ -43,7 +49,7 @@ const originalNow = performance.now.bind(performance);
if (workerData.simulateLongCalculation)
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
globalThis.self = { postMessage: (message) => {
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'at-risk')
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);
} };
@@ -62,6 +68,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
heartbeats = 0;
simulateLongCalculation = false;
simulatedElapsed = 0;
sentTypes.length = 0;
receivedTypes.length = 0;
terminationCount = 0;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
@@ -73,15 +82,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;
}
@@ -91,10 +93,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();
}
}
@@ -109,6 +113,159 @@ 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,
);
configuration.aggregation = 'maximum';
const submitted = structuredClone(configuration);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-04' }],
rules: [
{
id: 'personal-loss',
name: 'Captured loss rule',
type: 'optimization',
force: 'preference',
configuration,
},
],
});
configuration.conditions = [];
configuration.direction = 'maximize';
configuration.aggregation = 'count';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements).toHaveLength(1);
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(calculation.snapshot.rules[0]).toMatchObject({
name: 'Captured loss rule',
configuration: submitted,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected worker explanation');
expect(
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
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([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
});
it('keeps the submitted work-sequence threshold in the real worker and date comparisons', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.shortSequenceDeficit,
);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'deficit',
name: 'Captured five-day preference',
type: 'optimization',
configuration,
},
],
});
configuration.thresholdDays = 3;
configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(calculation.snapshot.rules[0]).toMatchObject({
configuration: { thresholdDays: 5, conditions: ['internal', 'non-empty'] },
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
expect(
explanation.placements[0].comparison.before.objectives.find(
({ id }) => id === 'optimization:deficit',
)?.value,
).toBe(4);
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
counterfactual.baseline.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(4);
expect(
counterfactual.results
.find(({ date }) => date === '2024-01-09')
?.comparison?.after.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(0);
});
it.each([
[false, false],
[true, false],
@@ -120,7 +277,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
localStorage.clear();
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -131,6 +288,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp');
input.toggleDeclaredDay('2024-05-06', 'other');
const rules = TestBed.inject(RuleService);
rules.addRule(presets[0]);
rules.addRule({
id: 'producer',
name: 'CP autour du férié',
@@ -155,6 +313,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
placeDays: 2,
placeType: SelectableDayType.OTHER,
});
for (const preset of presets.slice(1)) rules.addRule(preset);
const calendar = TestBed.inject(CalendarService);
const original = calendar.createPlanningCalculation.bind(calendar);
let calculation!: PlanningCalculationContext;
@@ -168,7 +327,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
...request,
progress: (stage) => {
request.progress?.(stage);
if (cancelled && stage === 'non-working-day-gain') {
if (
cancelled &&
stage === 'optimization:default-non-working-day-gain'
) {
fixture.detectChanges();
fixture.nativeElement
.querySelector('button.cancel-calculation')
@@ -249,7 +411,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
input.toggleDeclaredDay('2024-04-30', 'rtt');
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
rules.updateRule({
...rules.rules.find(({ id }) => id === 'producer')!,
name: 'Règle modifiée après calcul',
});
rules.deleteRule('producer');
input.updateBalances(() => []);
if (worker) {
@@ -262,7 +427,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');
@@ -288,11 +452,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')
@@ -322,24 +481,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');
@@ -355,7 +496,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
balances: [
{ id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' },
],
rules: [],
rules: presets,
declaredDays: [
{ date: '2024-01-02', type: 'cp' as const },
{ date: '2024-01-03', type: 'rtt' as const },
@@ -370,7 +511,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interruption = true;
if (stage === 'optimization:default-non-working-day-gain')
interruption = true;
},
},
undefined,
@@ -383,7 +525,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const calculation = createPlanningCalculation({
...submitted,
progress: (stage) => {
if (cancelled && stage === 'non-working-day-gain') calculation.cancel();
if (cancelled && stage === 'optimization:default-non-working-day-gain')
calculation.cancel();
},
});
const outcome = await calculation.outcome;
@@ -411,9 +554,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)
@@ -447,10 +587,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);
});
@@ -481,10 +617,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;
@@ -533,39 +668,37 @@ 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 calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
progress: (stage) => {
if (stage === 'non-working-day-gain') calculation.cancel();
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.completedStages).toContain('holiday-week-value');
expect(outcome.completedStages).not.toContain('non-working-day-gain');
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
const incumbent = outcome.incumbent!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
@@ -582,12 +715,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 === 'non-working-day-gain')?.status).toBe(
'interrupted',
);
const frozenOutcome = structuredClone(outcome);
calculation.cancel();
expect(await calculation.outcome).toEqual(frozenOutcome);
@@ -605,7 +732,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expiryDate: '2026-12-24',
},
],
rules: [],
rules: presets,
};
const { calculatePlanningInternal } = await import('./leave-planner.core');
const expected = calculatePlanningInternal(submitted);
@@ -614,7 +741,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();
});
});
+7 -272
View File
@@ -7,18 +7,13 @@ import type {
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningTrace,
PlanningTraceUpdate,
PlanningSnapshot,
PlanningTraceState,
TraceCandidateStatus,
PlanningStateSnapshot,
} from './leave-planner.service';
import {
analyzeCounterfactualInternal,
calculatePlanningAsyncInternal,
explainIncompleteCheckpoint,
historicalCounterfactualReasons,
type CounterfactualBaselineOptions,
type PlanningArtifacts,
} from './leave-planner.core';
@@ -39,271 +34,52 @@ 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;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningTraceState;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningTraceState;
finalState?: PlanningStateSnapshot;
}
| {
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 +94,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 +119,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 +131,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 +144,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,
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more