Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56577d23b5 | ||
|
|
2f4af14869 | ||
|
|
d9d9befa12 | ||
|
|
ea4256107f | ||
|
|
d74dfb9938 | ||
|
|
7d60157863 |
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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 remains advisory while issue #78 is open. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
|
||||
@@ -0,0 +1,5 @@
|
||||
${PullRequestTitle}
|
||||
|
||||
${ClosingIssues}
|
||||
|
||||
See pull request ${PullRequestReference}
|
||||
@@ -0,0 +1,210 @@
|
||||
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.21.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.21.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.21.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.21.0
|
||||
# shortcut: advisory while #78 is open; restore the gate after the protocol passes.
|
||||
continue-on-error: true
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=2048
|
||||
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: Unchanged benchmark protocol
|
||||
run: timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24.21.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.21.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 --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, 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, build, security, images]
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24.21.0
|
||||
env:
|
||||
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
||||
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'
|
||||
if [ "$IMAGE_EXPECTED" = 'true' ]; then
|
||||
test "$IMAGE_RESULT" = 'success'
|
||||
else
|
||||
test "$IMAGE_RESULT" = 'skipped'
|
||||
fi
|
||||
-179
@@ -1,179 +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/**'
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- coverage/
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage/cobertura-coverage.xml
|
||||
expire_in: 7 days
|
||||
|
||||
# shortcut: benchmark failures are advisory while #78 is open; restore the gate when #78 passes.
|
||||
benchmark:
|
||||
extends: test
|
||||
allow_failure: true
|
||||
script:
|
||||
- bun scripts/generate-langs.js
|
||||
- bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
artifacts: null
|
||||
|
||||
# -- 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
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+3
-3
@@ -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
-4
@@ -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.
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 01 — Unifier l’évaluation et la comparaison des objectifs
|
||||
|
||||
**GitLab:** [#70](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/70)
|
||||
**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é.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 02 — Piloter la prévention des pertes depuis les règles
|
||||
|
||||
**GitLab:** [#71](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/71)
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 03 — Composer les règles de semaines fériées et de repos
|
||||
|
||||
**GitLab:** [#72](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/72)
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 04 — Composer les règles de séquences de travail
|
||||
|
||||
**GitLab:** [#73](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/73)
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 05 — Piloter l’économie des jours d’appoint
|
||||
|
||||
**GitLab:** [#74](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/74)
|
||||
**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.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 06 — Migrer les règles sauvegardées et valider le comportement fonctionnel
|
||||
|
||||
**GitLab:** [#75](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/75)
|
||||
**Gitea:** [#75](https://gitea.nessar.fr/nessar/vac-optimizer/issues/75)
|
||||
|
||||
Blocked by: #74
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 01 — Remove the original decision trace end to end
|
||||
|
||||
**GitLab:** [#76](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/76)
|
||||
**Gitea:** [#76](https://gitea.nessar.fr/nessar/vac-optimizer/issues/76)
|
||||
|
||||
## What to build
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 02 — Verify integrated planning after trace removal
|
||||
|
||||
**GitLab:** [#77](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/77)
|
||||
**Gitea:** [#77](https://gitea.nessar.fr/nessar/vac-optimizer/issues/77)
|
||||
|
||||
Blocked by: #76
|
||||
|
||||
@@ -24,4 +24,4 @@ The removal is agreed; its measured speedup is unresolved and is now evaluated i
|
||||
|
||||
## Blocked by
|
||||
|
||||
- #76 — Remove the original decision trace end to end (https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/76).
|
||||
- #76 — Remove the original decision trace end to end (https://gitea.nessar.fr/nessar/vac-optimizer/issues/76).
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 03 — Verify and optimize planning to meet the benchmarks from #75
|
||||
|
||||
**GitLab:** [#78](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/78)
|
||||
**Gitea:** [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78)
|
||||
|
||||
Blocked by: #75, #77
|
||||
|
||||
|
||||
@@ -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.
|
||||
+4
-23
@@ -132,23 +132,8 @@ The numerical summary of a calculated valid plan's leave usage, time-off periods
|
||||
A stable account of why an optimization result or incomplete calculation contains its displayed leave placements, expressed through placement provenance, planning objectives, time-off periods, and remaining balances. It reports every contribution of a placement while highlighting the first planning objective that made its position decisive, and describes the displayed plan rather than the calculation history.
|
||||
_Avoid_: calculation explanation
|
||||
|
||||
**Calculation trace**:
|
||||
The complete, untruncated, ordered history of the planning decisions and candidate evaluations from the same calculation that produced a planning outcome, grouped by calculation phase. It is not reconstructed by replaying the calculation afterward; for an incomplete calculation it ends at the actual interruption point.
|
||||
_Avoid_: plan explanation
|
||||
|
||||
**Planning decision**:
|
||||
An atomic choice within a calculation phase that accepts, moves, removes, or rejects one or more related leave placements. A joint obligation decision may therefore concern several dates.
|
||||
_Avoid_: calendar day, step
|
||||
|
||||
**Candidate evaluation**:
|
||||
The comparison the planner actually performs between a possible leave-placement change and the current plan. It belongs to the calculation trace whether the candidate is accepted or rejected.
|
||||
|
||||
**Candidate date status**:
|
||||
The historical relationship between a date and a planning decision: chosen, evaluated and rejected, not examined, or ineligible with a reason. A later counterfactual date analysis never changes this historical status.
|
||||
|
||||
**Counterfactual date analysis**:
|
||||
An on-demand evaluation that changes only the date of one selected leave placement while preserving its leave balance, leave type, and placement provenance. The planner did not necessarily examine that date; the analysis explains a hypothetical alternative but never influences the planning outcome and is not part of the calculation trace.
|
||||
_Avoid_: candidate evaluation, calculation trace
|
||||
An on-demand hypothetical comparison of another date for a displayed leave placement. It uses the immutable calculation configuration and final-state snapshot, preserving the same leave entitlement, leave type, and placement provenance while keeping unrelated placements fixed. It never mutates the displayed plan.
|
||||
|
||||
**Conflict explanation**:
|
||||
A stable account of why no plan can jointly satisfy a set of obligation rules within the planning horizon and available leave balances.
|
||||
@@ -166,12 +151,6 @@ The result of a calculation: invalid input, a planning conflict, an optimization
|
||||
**Stable plan explanation**:
|
||||
A deterministic explanation derived from the final displayed plan. It identifies each placement's entitlement and rule provenance, contributions, full objective vector, and the first objective that distinguishes the plan with that placement restored from the same plan with only that placement removed.
|
||||
|
||||
**Original decision trace**:
|
||||
The untruncated phase-by-phase record captured by the calculation that produced the displayed outcome, including atomic decisions, candidate statuses, and state deltas. It is historical evidence and is never recomputed from the final plan.
|
||||
|
||||
**Counterfactual date analysis**:
|
||||
A hypothetical comparison that keeps the calculation snapshot and all unrelated placements fixed while testing one selected placement across every date in the half-open planning horizon. It neither mutates the plan nor rewrites the original trace.
|
||||
|
||||
**Incomplete calculation**:
|
||||
A calculation stopped by user cancellation or another interruption before it completed the planning objective order. On user cancellation it exposes its best available valid plan as partial, if one exists, and never implies that the obligations conflict.
|
||||
|
||||
@@ -192,4 +171,6 @@ A valid deterministic plan produced after completing the planning objective orde
|
||||
Any quantity left unspent by the optimization result, reported as unavoidable expiry, an unplaced at-risk balance, or a deferred balance.
|
||||
|
||||
**Non-placement reason**:
|
||||
A concrete explanation of why a quantity of a leave balance remains unspent in the displayed plan, distinguishing a blocking condition from a decision to preserve leave for later. It includes the relevant dates, is distinct from the balance's expiry-based classification, and can be supported by recorded candidate evaluations.
|
||||
A concrete explanation of why a quantity of a leave balance remains unspent in the displayed plan, distinguishing a blocking condition from a decision to preserve leave for later. It includes the relevant dates, is distinct from the balance's expiry-based classification and is derived from the displayed plan.
|
||||
|
||||
The original decision trace, historical date analysis, historical calendar replay, candidate statuses, and remaining-balance attempt lists were removed by issue #76. Calculations retain only the snapshots needed for outcomes, cancellation recovery, stable explanations, and displayed-placement date analysis.
|
||||
@@ -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,7 +222,7 @@ 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.
|
||||
|
||||
@@ -236,37 +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 benchmark job runs without coverage and keeps its five-minute protocol timeout. At the user's request, it has `continue-on-error: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. #78 must restore the benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
|
||||
|
||||
The required `test` job runs application tests with coverage, one worker and a 2 GiB Node heap limit. A separate automatic `benchmark` job uses the same environment outside coverage instrumentation, with the unchanged five-minute timeout and protocol assertions.
|
||||
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.
|
||||
|
||||
At the user's request, the benchmark job has `allow_failure: true` while #78 is open, so benchmark failure does not block functional delivery. #78 must restore the required benchmark gate after the protocol passes. A green pipeline during this exception does not establish benchmark acceptance.
|
||||
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
|
||||
@@ -274,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
|
||||
|
||||
@@ -286,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.
|
||||
@@ -5,17 +5,17 @@
|
||||
"": {
|
||||
"name": "vac-optimizer",
|
||||
"dependencies": {
|
||||
"@angular/animations": "22.2.3",
|
||||
"@angular/animations": "22.2.1",
|
||||
"@angular/cdk": "^22.0.0",
|
||||
"@angular/common": "22.2.3",
|
||||
"@angular/compiler": "22.2.3",
|
||||
"@angular/core": "22.2.3",
|
||||
"@angular/forms": "22.2.3",
|
||||
"@angular/common": "22.2.1",
|
||||
"@angular/compiler": "22.2.1",
|
||||
"@angular/core": "22.2.1",
|
||||
"@angular/forms": "22.2.1",
|
||||
"@angular/material": "^22.0.0",
|
||||
"@angular/platform-browser": "22.2.3",
|
||||
"@angular/platform-browser-dynamic": "22.2.3",
|
||||
"@angular/platform-server": "22.2.3",
|
||||
"@angular/router": "22.2.3",
|
||||
"@angular/platform-browser": "22.2.1",
|
||||
"@angular/platform-browser-dynamic": "22.2.1",
|
||||
"@angular/platform-server": "22.2.1",
|
||||
"@angular/router": "22.2.1",
|
||||
"@angular/ssr": "22.2.2",
|
||||
"date-holidays": "^3.30.2",
|
||||
"dayjs": "^1.11.21",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@analogjs/vitest-angular": "^2.7.5",
|
||||
"@angular/build": "22.2.2",
|
||||
"@angular/cli": "22.2.2",
|
||||
"@angular/compiler-cli": "22.2.3",
|
||||
"@angular/compiler-cli": "22.2.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nx/devkit": "^23.0.0",
|
||||
"@nx/vite": "^23.0.0",
|
||||
@@ -65,13 +65,13 @@
|
||||
|
||||
"@analogjs/vitest-angular": ["@analogjs/vitest-angular@2.8.0", "", { "peerDependencies": { "@analogjs/vite-plugin-angular": "*", "@angular-devkit/architect": ">=0.1700.0 < 0.2300.0 || >=0.2200.0 < 0.2300.0", "@angular-devkit/schematics": ">=17.0.0", "vitest": "^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "zone.js": ">=0.14.0" }, "optionalPeers": ["zone.js"] }, "sha512-ZNwjOf8ACEOW8yTC9iAu3uXt8q0GABgNXoo9lBUN0RuV7SOubpcabZewirETP5NqgyhJ5S96Ql1WFeW/jAznqA=="],
|
||||
|
||||
"@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-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.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.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-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.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.3" } }, "sha512-sslKnTrKwHwtTvT/pWj7UoYq0cfuX3PijZM7v0JtCajQkK1Nj0Ljob0mebxXBXUVBrOixfCnfBS0EG4JNEznFA=="],
|
||||
"@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.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=="],
|
||||
|
||||
@@ -79,25 +79,25 @@
|
||||
|
||||
"@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.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-sRzcvxzurbkCDVFJMguNVgsHds3NvDj5qbZ44NHjH7J9MBk5oZYXHppdHVm8AolBhCXAWvMk0vx+NO8Bt4rS7A=="],
|
||||
"@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=="],
|
||||
|
||||
"@angular/compiler": ["@angular/compiler@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-GjDkmOBqW+6RJTeW5mlLRivf6Zy7Ed0auVh+DYD67d4UF4Sk17F1vIt8puF4CQ2PIezlHh7oOnermCBNHAOGkQ=="],
|
||||
"@angular/compiler": ["@angular/compiler@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-KRSz0c/T5eEiFa+F1l9OppAebJj9pT8kU3ArBMlxR3v/vzhpCfuQsFVQ4xEFC7dEm3zZIdv9ASKAP6ocNC4D/Q=="],
|
||||
|
||||
"@angular/compiler-cli": ["@angular/compiler-cli@22.2.3", "", { "dependencies": { "@babel/core": "8.0.1", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", "yargs": "^18.0.0" }, "peerDependencies": { "@angular/compiler": "22.2.3", "typescript": ">=6.0 <6.1" }, "optionalPeers": ["typescript"], "bin": { "ngc": "bundles/src/bin/ngc.js", "ng-xi18n": "bundles/src/bin/ng_xi18n.js" } }, "sha512-em7Ht0Dv7cN7vZgvl2fl1yscAs+zUG2Vr2YKpVZ/rOWuTL2JZyT1qIfKhC4wQoo/3lN8fiUIwQMpflSGzIEPng=="],
|
||||
"@angular/compiler-cli": ["@angular/compiler-cli@22.2.1", "", { "dependencies": { "@babel/core": "8.0.1", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", "yargs": "^18.0.0" }, "peerDependencies": { "@angular/compiler": "22.2.1", "typescript": ">=6.0 <6.1" }, "optionalPeers": ["typescript"], "bin": { "ngc": "bundles/src/bin/ngc.js", "ng-xi18n": "bundles/src/bin/ng_xi18n.js" } }, "sha512-Nh8sCBl0JF6x+H7/1hemY76JeTRC66EfMULT4B3ryF9srRSLYc6X6RfPRtYvrIyjgr6h3jZgrNHlV76wiJaOcA=="],
|
||||
|
||||
"@angular/core": ["@angular/core@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "22.2.3", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-ponarFrAZsPutT+htRt3PEmkt2/IB7DyrBy2f48sxsIx3ZA6xw09i2HOEBo+VBphlN6B/ynK9q+o04HPhdJ+dQ=="],
|
||||
"@angular/core": ["@angular/core@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-a8WlYTRiaMPz1KvLE5xjeWrLyZUuQX8bh6Ak7zajUci9iWsTUQefschjSwP1WKlin3JhM3XjmsTEDyZvSLIfRg=="],
|
||||
|
||||
"@angular/forms": ["@angular/forms@22.2.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "peerDependencies": { "@angular/common": "22.2.3", "@angular/core": "22.2.3", "@angular/platform-browser": "22.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-TKP8FpeRCY/++vKWsY8GC1J2sNtEkdNNBPdgnMAMLTwheW5JzjwVatuQlbuZEx3WaAjbll1Ux3d+eHeqAVrtQg=="],
|
||||
"@angular/forms": ["@angular/forms@22.2.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "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-xHM+PhrvYDJ+edPxh6PWhkE99tXWbsMcxdXCWF6QcyByX+bsW2rVlNZ7dumOsUle9MYa8Yj0lZFACUWbwFw+4w=="],
|
||||
|
||||
"@angular/material": ["@angular/material@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/cdk": "22.2.1", "@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-qw+/aJBV4lqqoJgv1VBWbMBVnE7431SX0Mew1j7PO+s1UcTushbG7CIAEo1hwmRq3kyo4Qqch8+uzFq4R631HQ=="],
|
||||
|
||||
"@angular/platform-browser": ["@angular/platform-browser@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.2.3", "@angular/common": "22.2.3", "@angular/core": "22.2.3" }, "optionalPeers": ["@angular/animations"] }, "sha512-eWZGsbG4bSrSg9UB/NE8wORGVrh1u+x6rirPGpMWqq/mTi6q+nS+pnPxAxmiEs8wmPH5nCz5b2pRKQeYUkhAGA=="],
|
||||
"@angular/platform-browser": ["@angular/platform-browser@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.2.1", "@angular/common": "22.2.1", "@angular/core": "22.2.1" }, "optionalPeers": ["@angular/animations"] }, "sha512-KQ3oguemaSqyNP00yqEvPouY7NC4Lji+6S8yfjaWaUZjSth/GckpeP/Fo9HtMqd04y6MZ6lEb2hbQtTqFcSdEQ=="],
|
||||
|
||||
"@angular/platform-browser-dynamic": ["@angular/platform-browser-dynamic@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.3", "@angular/compiler": "22.2.3", "@angular/core": "22.2.3", "@angular/platform-browser": "22.2.3" } }, "sha512-+WBe2JjaEeK0NpMlBzV7UBkO+ltUBYTDtMfGYkew2xHdIviITnzXX7yUXijB1Y6jxALgz3jEuJa9iLid8s11rg=="],
|
||||
"@angular/platform-browser-dynamic": ["@angular/platform-browser-dynamic@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.1", "@angular/compiler": "22.2.1", "@angular/core": "22.2.1", "@angular/platform-browser": "22.2.1" } }, "sha512-Yg0JcTy3OzhvAM0LQT/XCx8eN3gFsR/BmnQ75Fg0Cvyog7NvFxVXxv/MWE2DNW96lRZNV8ECaVNdcU893ZITTw=="],
|
||||
|
||||
"@angular/platform-server": ["@angular/platform-server@22.2.3", "", { "dependencies": { "tslib": "^2.3.0", "xhr2": "^0.2.0" }, "peerDependencies": { "@angular/common": "22.2.3", "@angular/compiler": "22.2.3", "@angular/core": "22.2.3", "@angular/platform-browser": "22.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-ROuJklv7iQVI1zIulo4MBDnk3Z0ZzihBWuN4nxFY8MTWxVMk97p92j1Wuyt4Kmx2kb5PB+kpcR1Jl6e13idftA=="],
|
||||
"@angular/platform-server": ["@angular/platform-server@22.2.1", "", { "dependencies": { "tslib": "^2.3.0", "xhr2": "^0.2.0" }, "peerDependencies": { "@angular/common": "22.2.1", "@angular/compiler": "22.2.1", "@angular/core": "22.2.1", "@angular/platform-browser": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-nnk4wuwI0u6AmMZpmpQXvJic+aFwG7+tkoWMtAr9kY2V9yl/WZIOhk8r4QtYHOzmxT3k7YRIpQ4BOlIg+jUXNw=="],
|
||||
|
||||
"@angular/router": ["@angular/router@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.3", "@angular/core": "22.2.3", "@angular/platform-browser": "22.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-h4C3iJ3OXM9HItErmTo9fFrFRDj1DSqbvweHAWl8eYJvyuhDD80gBNgdpN8/5gpt9K1HV+PXk0YbyjyQ0zMfPA=="],
|
||||
"@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.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=="],
|
||||
|
||||
@@ -2379,18 +2379,32 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@angular/forms/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -2783,6 +2797,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=="],
|
||||
@@ -3231,6 +3247,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=="],
|
||||
@@ -3273,8 +3293,14 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@angular/compiler-cli/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
@@ -5393,6 +5419,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=="],
|
||||
@@ -5973,6 +6001,10 @@
|
||||
|
||||
"@angular/build/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="],
|
||||
|
||||
"@angular/compiler-cli/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@angular/compiler-cli/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
@@ -7471,6 +7503,8 @@
|
||||
|
||||
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@angular/compiler-cli/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/@babel/core/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@babel/helper-create-regexp-features-plugin/@babel/core/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+37
-32
@@ -3,31 +3,43 @@
|
||||
## Composable optimization rules: current verification status
|
||||
|
||||
The #70–#75 implementation uses explicit active optimization configurations in
|
||||
the benchmark fixtures. The latest full functional coverage run passes 652 tests
|
||||
the benchmark fixtures. Before trace removal, the full functional coverage run passed 652 tests
|
||||
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
|
||||
This does not establish a passing performance protocol.
|
||||
That run does not establish a passing performance protocol.
|
||||
|
||||
MR pipeline 320 passed all 652 functional tests in 109.43 seconds, then the
|
||||
seven-scenario protocol timed out at its unchanged 300-second limit without an
|
||||
out-of-memory failure. In an earlier diagnostic run, the loaded-year warm-up took
|
||||
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).
|
||||
|
||||
On 2026-10-15, the #76 worktree based on `ea42561` passed 630 functional tests
|
||||
across 33 files with coverage under the 2 GB Node heap limit. Complete outcome
|
||||
comparisons against that baseline matched for all seven benchmark scenarios.
|
||||
The separate, unchanged benchmark did not complete within its 300-second limit
|
||||
(external timeout exit 124). Performance acceptance and any further optimization
|
||||
remain with #78.
|
||||
|
||||
Before the Gitea migration, GitLab pipeline 320 passed all 652 functional tests
|
||||
in 109.43 seconds, then the seven-scenario protocol timed out at its unchanged
|
||||
300-second limit without an out-of-memory failure. In an earlier diagnostic run, the loaded-year warm-up took
|
||||
184.36 seconds and its first measured repetition began about 251.78 seconds into
|
||||
the protocol. Those diagnostic timings precede the final period-projection cache
|
||||
and the planned original-decision-trace removal, and are not a result for either.
|
||||
and the original-decision-trace removal in #76, and are not a result for either.
|
||||
|
||||
At the user's request, #75 finishes on saved-rule migration and functional
|
||||
verification. #78 owns benchmark verification and implementation of further
|
||||
optimizations when needed, after #76 removes tracing and #77 verifies the retained
|
||||
behavior. All seven scenarios, one warm-up and two measured repetitions per
|
||||
optimizations when needed, after trace removal in #76 and integrated functional verification in #77. All seven scenarios, one warm-up and two measured repetitions per
|
||||
scenario, the 2 GB heap limit and the 300-second timeout remain required for #78.
|
||||
At the user's request, CI runs the benchmark in a separate job with
|
||||
`allow_failure: true` while #78 is open. Functional coverage tests and all other
|
||||
required checks remain blocking. #78 must restore the required benchmark gate
|
||||
At the user's request, the Gitea CI configuration defines the benchmark in a
|
||||
separate job with `continue-on-error: true` while #78 is open. Functional coverage
|
||||
tests and all other required checks remain blocking. #78 must restore the required benchmark gate
|
||||
after the unchanged protocol passes. A green pipeline during this exception
|
||||
does not establish a passing benchmark. No automatic elapsed-time planning
|
||||
cutoff is introduced.
|
||||
cutoff is introduced. Gitea Actions is enabled; live workflow validation is tracked in
|
||||
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). The converted
|
||||
workflow preserves the separate advisory benchmark job. See the
|
||||
[CI migration status](../../README.md#continuous-integration).
|
||||
|
||||
The results below document earlier implementations and protocols. They are
|
||||
historical measurements rather than the performance result for composable rules.
|
||||
historical measurements rather than the performance result for composable rules or trace removal. Their trace counts, timings and controls describe the former implementation only.
|
||||
|
||||
## Reference-phone protocol
|
||||
|
||||
@@ -35,13 +47,9 @@ Open the deployed application at `/benchmark` on the reference phone. Tap
|
||||
**Run benchmark**, wait for the seven scenarios to finish, then tap **Copy
|
||||
report** and save the tab-separated output with the date and build identifier.
|
||||
|
||||
The harness measures preparation, planning-to-outcome latency, later trace
|
||||
delivery, and total wall time separately. It also reports planning work, trace
|
||||
capture, trace materialization, and trace transfer independently, plus the
|
||||
largest extra delay of a 50 ms UI heartbeat. It records the outcome, last
|
||||
completed quality stage, placement count, determinism, trace status, and trace
|
||||
candidate count. `PlanningIncomplete` is not a conflict: its last completed
|
||||
stage and incumbent are retained.
|
||||
The harness measures preparation, planning-to-outcome latency, planning work, and total wall time through stable explanation delivery. It also reports the largest extra delay of a 50 ms UI heartbeat, available main-thread heap, outcome, last completed quality stage, placement count and determinism. `PlanningIncomplete` retains its last completed stage and valid incumbent and is distinct from a conflict.
|
||||
|
||||
Issue #76 removes all trace timing, status and candidate-count columns, along with trace promises and delivery requirements. The seven scenarios, warm-ups, repetitions, 2 GB heap limit, 300-second timeout and performance gates are unchanged. #77 owns integrated functional verification; #78 owns performance acceptance and any necessary optimization.
|
||||
|
||||
## Protocol
|
||||
|
||||
@@ -64,23 +72,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
|
||||
@@ -88,11 +89,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
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 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` | Runs outside coverage with the same worker/heap limits and 300-second protocol timeout. Advisory while #78 is open. |
|
||||
| `build` | Builds browser/server production bundles and retains `dist` for seven days. |
|
||||
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. |
|
||||
| `images` | Publishes branch/SHA images after required validation, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
|
||||
| `required` | Fails on failed, cancelled, or skipped required validation. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. Excludes the advisory benchmark. |
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+10
-10
@@ -27,17 +27,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "22.2.3",
|
||||
"@angular/animations": "22.2.1",
|
||||
"@angular/cdk": "^22.0.0",
|
||||
"@angular/common": "22.2.3",
|
||||
"@angular/compiler": "22.2.3",
|
||||
"@angular/core": "22.2.3",
|
||||
"@angular/forms": "22.2.3",
|
||||
"@angular/common": "22.2.1",
|
||||
"@angular/compiler": "22.2.1",
|
||||
"@angular/core": "22.2.1",
|
||||
"@angular/forms": "22.2.1",
|
||||
"@angular/material": "^22.0.0",
|
||||
"@angular/platform-browser": "22.2.3",
|
||||
"@angular/platform-browser-dynamic": "22.2.3",
|
||||
"@angular/platform-server": "22.2.3",
|
||||
"@angular/router": "22.2.3",
|
||||
"@angular/platform-browser": "22.2.1",
|
||||
"@angular/platform-browser-dynamic": "22.2.1",
|
||||
"@angular/platform-server": "22.2.1",
|
||||
"@angular/router": "22.2.1",
|
||||
"@angular/ssr": "22.2.2",
|
||||
"date-holidays": "^3.30.2",
|
||||
"dayjs": "^1.11.21",
|
||||
@@ -51,7 +51,7 @@
|
||||
"@analogjs/vitest-angular": "^2.7.5",
|
||||
"@angular/build": "22.2.2",
|
||||
"@angular/cli": "22.2.2",
|
||||
"@angular/compiler-cli": "22.2.3",
|
||||
"@angular/compiler-cli": "22.2.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nx/devkit": "^23.0.0",
|
||||
"@nx/vite": "^23.0.0",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
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);
|
||||
@@ -75,8 +75,6 @@ describe('leave planner benchmark protocol', () => {
|
||||
expect(longer.map((item) => item.lastCompletedStage)).toEqual(['dates', 'dates']);
|
||||
expect(longer.map((item) => item.placements)).toEqual([placements, placements]);
|
||||
expect(longer.map((item) => item.deterministic)).toEqual(['baseline', 'yes']);
|
||||
expect(longer.map((item) => item.traceStatus)).toEqual(['ready', 'ready']);
|
||||
expect(longer[0].traceCandidates).toBeGreaterThan(0);
|
||||
}
|
||||
const chained = measurements.find(
|
||||
(measurement) => measurement.scenario === 'chained earned entitlements',
|
||||
@@ -91,21 +89,16 @@ 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);
|
||||
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');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createPlanningCalculation,
|
||||
PlanningOutcome,
|
||||
PlanningRequest,
|
||||
PlanningTrace,
|
||||
} from '../services/leave-planner.service';
|
||||
import { createOptimizationPresetRules } from '../services/rule-service';
|
||||
|
||||
@@ -16,21 +15,15 @@ export interface BenchmarkMeasurement {
|
||||
repetition: number;
|
||||
preparationMs: number;
|
||||
planningMs: number;
|
||||
traceDeliveryMs: number;
|
||||
maxUiDelayMs: number;
|
||||
/** Chromium's main-thread heap only; worker memory needs a separate browser profile. */
|
||||
peakMainThreadHeapBytes?: number;
|
||||
planningWorkMs?: number;
|
||||
traceCaptureMs?: number;
|
||||
traceMaterializationMs?: number;
|
||||
traceTransferMs?: number;
|
||||
wallMs: number;
|
||||
outcome: PlanningOutcome['kind'];
|
||||
lastCompletedStage?: string;
|
||||
placements: number;
|
||||
deterministic: 'baseline' | 'yes' | 'no';
|
||||
traceStatus: PlanningTrace['status'] | 'not-produced';
|
||||
traceCandidates: number;
|
||||
}
|
||||
|
||||
const balance = (
|
||||
@@ -219,7 +212,8 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
|
||||
async function warmUpScenario(scenario: BenchmarkScenario): Promise<void> {
|
||||
const calculation = createPlanningCalculation(structuredClone(scenario.request));
|
||||
await calculation.outcome;
|
||||
await calculation.trace;
|
||||
await calculation.explanation;
|
||||
await calculation.timings;
|
||||
}
|
||||
|
||||
async function measureRepetition(
|
||||
@@ -250,24 +244,11 @@ async function measureRepetition(
|
||||
const calculation = createPlanningCalculation(request);
|
||||
const outcome = await calculation.outcome;
|
||||
const planningMs = performance.now() - calculationStart;
|
||||
const traceStart = performance.now();
|
||||
const trace = await calculation.trace;
|
||||
const traceDeliveryMs = performance.now() - traceStart;
|
||||
const timings = await calculation.timings;
|
||||
const summary = resultSummary(outcome);
|
||||
const stable = JSON.stringify(outcome);
|
||||
const deterministic =
|
||||
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
|
||||
const traceCandidates =
|
||||
trace?.phases.reduce(
|
||||
(total, phase) =>
|
||||
total +
|
||||
phase.decisions.reduce(
|
||||
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
) ?? 0;
|
||||
const wallMs = performance.now() - wallStart;
|
||||
measureDelay();
|
||||
return {
|
||||
@@ -276,15 +257,12 @@ async function measureRepetition(
|
||||
repetition,
|
||||
preparationMs,
|
||||
planningMs,
|
||||
traceDeliveryMs,
|
||||
maxUiDelayMs,
|
||||
peakMainThreadHeapBytes,
|
||||
...timings,
|
||||
wallMs,
|
||||
...summary,
|
||||
deterministic,
|
||||
traceStatus: trace?.status ?? 'not-produced',
|
||||
traceCandidates,
|
||||
},
|
||||
stable,
|
||||
};
|
||||
@@ -295,7 +273,7 @@ async function measureRepetition(
|
||||
|
||||
export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
||||
const header =
|
||||
'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms\tpeak_main_thread_heap_bytes';
|
||||
'scenario\trepetition\tpreparation_ms\tplanning_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\tmax_ui_delay_ms\tplanning_work_ms\tpeak_main_thread_heap_bytes';
|
||||
return [
|
||||
header,
|
||||
...measurements.map((item) =>
|
||||
@@ -304,19 +282,13 @@ 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'),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import {
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
HistoricalEligibilityReason,
|
||||
LeavePlacement,
|
||||
PlanOutcome,
|
||||
PlanExplanation,
|
||||
@@ -25,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';
|
||||
@@ -147,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',
|
||||
@@ -175,7 +170,6 @@ function calculationFor(
|
||||
date: '2024-05-04',
|
||||
status: 'ineligible',
|
||||
reason: { code: 'non-working', message: 'Weekend' },
|
||||
historicalStatus: 'ineligible',
|
||||
},
|
||||
{
|
||||
date: '2024-05-07',
|
||||
@@ -186,7 +180,6 @@ function calculationFor(
|
||||
date: '2024-05-08',
|
||||
status: 'ineligible',
|
||||
reason: { code: 'non-working', message: 'Public Holiday' },
|
||||
historicalStatus: 'ineligible',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -203,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: {
|
||||
@@ -253,161 +234,6 @@ 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', () => {
|
||||
@@ -577,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',
|
||||
@@ -632,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: [
|
||||
@@ -693,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);
|
||||
@@ -1002,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');
|
||||
@@ -1045,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,
|
||||
@@ -1072,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 } },
|
||||
]);
|
||||
@@ -1081,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: [
|
||||
@@ -1242,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));
|
||||
@@ -1280,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);
|
||||
@@ -1353,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');
|
||||
@@ -1432,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,
|
||||
@@ -1484,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';
|
||||
@@ -1511,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', '{"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,
|
||||
);
|
||||
@@ -1543,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');
|
||||
@@ -1573,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');
|
||||
@@ -1871,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',
|
||||
@@ -1946,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'));
|
||||
@@ -1986,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', '{"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: [
|
||||
@@ -2021,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);
|
||||
@@ -2036,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();
|
||||
@@ -2176,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));
|
||||
@@ -2226,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);
|
||||
},
|
||||
@@ -2304,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')
|
||||
@@ -2319,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);
|
||||
@@ -2332,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',
|
||||
@@ -2636,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();
|
||||
@@ -3128,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>();
|
||||
@@ -3304,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: [
|
||||
@@ -3319,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));
|
||||
@@ -3330,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 {
|
||||
|
||||
@@ -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 (
|
||||
@@ -1627,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(':', ': ');
|
||||
}
|
||||
|
||||
@@ -173,113 +173,7 @@
|
||||
@if (day.suggestion) {
|
||||
<p>{{ day.suggestion }}</p>
|
||||
}
|
||||
<details
|
||||
name="remaining-balances"
|
||||
[open]="historyBalanceId() === day.id"
|
||||
(toggle)="toggleRemainingHistory(day.id, $event)"
|
||||
>
|
||||
<summary
|
||||
[attr.aria-label]="
|
||||
tr().userInput.remainingWhy +
|
||||
' : ' +
|
||||
day.title +
|
||||
'. ' +
|
||||
day.validity
|
||||
"
|
||||
>
|
||||
{{ tr().userInput.remainingWhy }}
|
||||
</summary>
|
||||
<p>{{ day.horizon }}</p>
|
||||
@if (historyBalanceId() === day.id) {
|
||||
@if (!traceResolved()) {
|
||||
<p role="status">{{ tr().loading }}</p>
|
||||
} @else {
|
||||
@if (!trace() || trace()?.status === 'failed') {
|
||||
<p>{{ tr().planning.traceUnavailable }}</p>
|
||||
}
|
||||
@if (remainingHistory().length) {
|
||||
<p>{{ tr().userInput.remainingHistoryHelp }}</p>
|
||||
<ol
|
||||
class="remaining-history"
|
||||
[attr.start]="historyPage() * 20 + 1"
|
||||
>
|
||||
@for (
|
||||
attempt of visibleHistory();
|
||||
track $index
|
||||
) {
|
||||
<li>
|
||||
<strong>{{ attempt.date }}</strong> ·
|
||||
{{ attempt.status }}
|
||||
@if (attempt.phase) {
|
||||
<p>{{ attempt.phase }}</p>
|
||||
}
|
||||
@if (attempt.reason) {
|
||||
<p>{{ attempt.reason }}</p>
|
||||
}
|
||||
@for (
|
||||
criterion of attempt.criteria;
|
||||
track $index
|
||||
) {
|
||||
<p
|
||||
[class.decisive]="
|
||||
criterion.decisive
|
||||
"
|
||||
>
|
||||
@if (criterion.decisive) {
|
||||
{{
|
||||
tr().planning.details
|
||||
.firstDifference
|
||||
}}
|
||||
:
|
||||
}
|
||||
{{ criterion.text }}
|
||||
</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@if (historyPageCount() > 1) {
|
||||
<nav
|
||||
class="history-pagination"
|
||||
[attr.aria-label]="tr().planning.candidates"
|
||||
>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="historyPage() === 0"
|
||||
(click)="
|
||||
historyPage.set(historyPage() - 1)
|
||||
"
|
||||
>
|
||||
{{ tr().planning.previousCandidates }}
|
||||
</button>
|
||||
<span
|
||||
>{{ tr().planning.page }}
|
||||
{{ historyPage() + 1 }}
|
||||
{{ tr().planning.of }}
|
||||
{{ historyPageCount() }}</span
|
||||
>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
historyPage() + 1 >=
|
||||
historyPageCount()
|
||||
"
|
||||
(click)="
|
||||
historyPage.set(historyPage() + 1)
|
||||
"
|
||||
>
|
||||
{{ tr().planning.nextCandidates }}
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
} @else if (trace() && trace()?.status !== 'failed') {
|
||||
<p>{{ tr().userInput.remainingNoEvaluations }}</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</details>
|
||||
<p>{{ day.horizon }}</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -239,33 +239,13 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
.remaining-balances,
|
||||
.remaining-history {
|
||||
.remaining-balances {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
.remaining-day-detail summary {
|
||||
min-height: 2.75rem;
|
||||
align-content: center;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.25em;
|
||||
}
|
||||
|
||||
.decisive {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
container: user-input / inline-size;
|
||||
|
||||
@@ -98,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();
|
||||
}
|
||||
|
||||
@@ -134,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' },
|
||||
@@ -158,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(presets[3].name);
|
||||
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({
|
||||
@@ -177,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', () => {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { Component, computed, effect, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
@@ -78,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,
|
||||
@@ -145,57 +134,8 @@ export class UserInput {
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly remainingHistory = computed(() => {
|
||||
const id = this.historyBalanceId();
|
||||
if (!id) return [];
|
||||
return (
|
||||
this.trace()?.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) =>
|
||||
decision.candidates
|
||||
.filter((candidate) => candidate.placement?.balanceId === id)
|
||||
.map((candidate) => ({ candidate, phase: phase.name })),
|
||||
),
|
||||
) ?? []
|
||||
);
|
||||
});
|
||||
protected readonly historyPageCount = computed(() =>
|
||||
Math.ceil(this.remainingHistory().length / 20),
|
||||
);
|
||||
protected readonly visibleHistory = computed(() =>
|
||||
this.remainingHistory()
|
||||
.slice(this.historyPage() * 20, (this.historyPage() + 1) * 20)
|
||||
.map(({ candidate, phase }) => ({
|
||||
date: (candidate.dates ?? (candidate.date ? [candidate.date] : []))
|
||||
.map((date) => this.translationService.formatPlanningDate(date))
|
||||
.join(', '),
|
||||
phase: this.tr().planning.explanation.stages[phase] ?? this.criterionLabel(phase),
|
||||
status: this.tr().planning.traceStatuses[candidate.status],
|
||||
reason: candidate.reason
|
||||
? (this.tr().userInput.remainingCandidateReasons[candidate.reason.code] ??
|
||||
this.tr().planning.explanation.unavailable[candidate.reason.code] ??
|
||||
this.tr().userInput.remainingCandidateReasons['unknown'])
|
||||
: '',
|
||||
criteria: (candidate.technicalComparison ?? []).flatMap((value) => {
|
||||
const label = this.criterionLabel(value.name);
|
||||
return label
|
||||
? [
|
||||
{
|
||||
text: `${label} : ${value.before} → ${value.after}`,
|
||||
decisive: value.name === candidate.firstDecisiveComparator,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
|
||||
effect(() => {
|
||||
this.userInputService.planningOutcomeSignal();
|
||||
this.historyBalanceId.set(null);
|
||||
this.historyPage.set(0);
|
||||
});
|
||||
}
|
||||
|
||||
protected setPlanningDate(field: 'start' | 'lastPlanningDate', value: Date | null): void {
|
||||
@@ -213,29 +153,6 @@ export class UserInput {
|
||||
);
|
||||
}
|
||||
|
||||
private criterionLabel(name: string): string | undefined {
|
||||
const id = name.replace(/^protected:/, '');
|
||||
if (id.startsWith('optimization:')) return this.ruleName(id.slice('optimization:'.length));
|
||||
const key =
|
||||
id === 'obligations'
|
||||
? 'obligation-validity'
|
||||
: id.startsWith('preference:')
|
||||
? 'preference'
|
||||
: id;
|
||||
return this.tr().planning.explanation.objectives[key]?.label.replace(
|
||||
'{rule}',
|
||||
this.ruleName(id.slice('preference:'.length)),
|
||||
);
|
||||
}
|
||||
|
||||
protected toggleRemainingHistory(id: string, event: Event): void {
|
||||
const open = (event.currentTarget as HTMLDetailsElement).open;
|
||||
if (open && id !== this.historyBalanceId()) {
|
||||
this.historyPage.set(0);
|
||||
this.historyBalanceId.set(id);
|
||||
} else if (!open && id === this.historyBalanceId()) this.historyBalanceId.set(null);
|
||||
}
|
||||
|
||||
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
|
||||
|
||||
protected get daysOff() {
|
||||
|
||||
@@ -13,6 +13,8 @@ describe('calendar objectives in the real worker', () => {
|
||||
let directory: string;
|
||||
let bundle: string;
|
||||
const threads = new Set<NodeWorker>();
|
||||
const messages: string[] = [];
|
||||
let stopped = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'vac-calendar-worker-'));
|
||||
@@ -30,6 +32,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
messages.length = 0;
|
||||
stopped = 0;
|
||||
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
|
||||
class BrowserWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
@@ -37,13 +41,17 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
private readonly thread = new NodeWorker(bundle);
|
||||
constructor() {
|
||||
threads.add(this.thread);
|
||||
this.thread.on('message', (data) => this.onmessage?.({ data } as MessageEvent));
|
||||
this.thread.on('message', (data) => {
|
||||
messages.push(data.type);
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
});
|
||||
this.thread.on('error', () => this.onerror?.());
|
||||
}
|
||||
postMessage(message: unknown) {
|
||||
this.thread.postMessage(message);
|
||||
}
|
||||
terminate() {
|
||||
stopped++;
|
||||
void this.thread.terminate();
|
||||
}
|
||||
}
|
||||
@@ -109,14 +117,19 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
if (explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected a worker explanation');
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
|
||||
expect((await calculation.trace)?.phases.map((phase) => phase.name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
first,
|
||||
`optimization:${rules[1].id}`,
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
await calculation.timings;
|
||||
expect(new Set(messages)).toEqual(
|
||||
new Set([
|
||||
'heartbeat',
|
||||
'progress',
|
||||
'checkpoint',
|
||||
'result',
|
||||
'explanation',
|
||||
'artifacts',
|
||||
]),
|
||||
);
|
||||
expect(messages.indexOf('result')).toBeLessThan(messages.indexOf('explanation'));
|
||||
expect(stopped).toBe(1);
|
||||
const comparison = await calculation.analyzeCounterfactual(plan.placements[0]);
|
||||
if (comparison.kind !== 'Counterfactual')
|
||||
throw new Error('Expected a worker comparison');
|
||||
@@ -169,29 +182,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
name: 'Captured supporting economy',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
});
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
`optimization:${rules[0].id}`,
|
||||
`optimization:${rules[1].id}`,
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const candidates = trace?.phases.flatMap(({ decisions }) =>
|
||||
decisions.flatMap(({ candidates }) => candidates),
|
||||
);
|
||||
expect(candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: economyFirst ? 'evaluated-rejected' : 'chosen',
|
||||
dates: ['2026-11-12', '2026-11-13'],
|
||||
comparison: expect.objectContaining({
|
||||
firstDecisiveObjective: economyFirst
|
||||
? 'optimization:economy'
|
||||
: 'optimization:holiday',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
if (economyFirst) {
|
||||
|
||||
@@ -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'];
|
||||
};
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('composed calendar objectives', () => {
|
||||
expect(ordinary.metrics.holidayWeekValue).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the same first calendar objective in the plan explanation, trace and date comparison', () => {
|
||||
it('uses the same first calendar objective in the explanation and date comparison', () => {
|
||||
for (const rules of [
|
||||
[holiday, gain],
|
||||
[gain, holiday],
|
||||
@@ -439,15 +439,6 @@ describe('composed calendar objectives', () => {
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
const comparisons = artifacts.trace?.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.candidates)
|
||||
.filter((candidate) => candidate.status === 'chosen' && candidate.comparison);
|
||||
expect(
|
||||
comparisons?.some(
|
||||
(candidate) => candidate.comparison?.firstDecisiveObjective === first,
|
||||
),
|
||||
).toBe(true);
|
||||
const placement = rules[0].id === 'holiday' ? plan.placements[1] : plan.placements[0];
|
||||
const alternative = rules[0].id === 'holiday' ? '2024-04-30' : '2024-05-02';
|
||||
const comparison = analyzeCounterfactualInternal(
|
||||
@@ -455,11 +446,16 @@ describe('composed calendar objectives', () => {
|
||||
plan,
|
||||
placement,
|
||||
artifacts.finalState,
|
||||
{ removeDate: placement.date, compareOriginalState: true },
|
||||
);
|
||||
const original = comparison.results.find((result) => result.date === placement.date)
|
||||
?.comparison?.after;
|
||||
const alternativeVector = comparison.results.find(
|
||||
(result) => result.date === alternative,
|
||||
)?.comparison?.after;
|
||||
expect(
|
||||
comparison.results.find((result) => result.date === alternative)?.comparison
|
||||
?.firstDecisiveObjective,
|
||||
alternativeVector?.objectives.find(
|
||||
(objective, index) => objective.value !== original?.objectives[index].value,
|
||||
)?.id,
|
||||
).toBe(first);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
createPlanningCalculation,
|
||||
type PlanningIncompleteOutcome,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.service';
|
||||
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
|
||||
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
@@ -46,7 +42,6 @@ describe('user cancellation', () => {
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
});
|
||||
|
||||
it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => {
|
||||
@@ -96,7 +91,6 @@ describe('user cancellation', () => {
|
||||
placements: [],
|
||||
incomplete: { reason: { code: 'user-cancelled' } },
|
||||
});
|
||||
expect(artifacts.trace?.status).toBe('incomplete');
|
||||
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
|
||||
});
|
||||
|
||||
@@ -193,39 +187,47 @@ describe('user cancellation', () => {
|
||||
kind: 'IncompleteExplanation',
|
||||
reason: { code: 'stale-calculation' },
|
||||
});
|
||||
expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
|
||||
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
|
||||
await current.trace;
|
||||
});
|
||||
|
||||
it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
|
||||
it('retains a plan at least as good as every valid checkpoint before cancellation', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let cancelled = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const outcome = await core.calculatePlanningAsyncInternal(
|
||||
{
|
||||
horizon: request.horizon,
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
|
||||
rules: [
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'january',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
const checkpoints: (readonly import('./leave-planner.core').PlanningObjective[])[] = [];
|
||||
const submitted = {
|
||||
horizon: request.horizon,
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
|
||||
rules: [
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'january',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
|
||||
},
|
||||
};
|
||||
const outcome = await core.calculatePlanningAsyncInternal(
|
||||
submitted,
|
||||
(plan, state) => {
|
||||
if (!state) return;
|
||||
const explained = core.explainIncompleteCheckpoint(submitted, plan, state, {
|
||||
code: 'user-cancelled',
|
||||
message: 'Cancelled',
|
||||
}).explanation;
|
||||
if (explained?.kind === 'PlanExplanation' && explained.placements.length)
|
||||
checkpoints.push(explained.placements[0].objectiveVector.objectives);
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -235,36 +237,25 @@ describe('user cancellation', () => {
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
|
||||
throw new Error('Expected a retained plan explanation and its original trace.');
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected a retained plan explanation.');
|
||||
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
|
||||
const evaluated = artifacts.trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) =>
|
||||
decision.candidates.flatMap((candidate) =>
|
||||
candidate.comparison?.after.objectives[0].value === 0
|
||||
? [candidate.comparison.after.objectives]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(evaluated.length).toBeGreaterThan(0);
|
||||
for (const candidate of evaluated) {
|
||||
expect(checkpoints.length).toBeGreaterThan(0);
|
||||
for (const candidate of checkpoints) {
|
||||
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
|
||||
const firstDifference = retained.findIndex(
|
||||
const first = retained.findIndex(
|
||||
(objective, index) => objective.value !== candidate[index].value,
|
||||
);
|
||||
if (firstDifference < 0) continue;
|
||||
const chosen = retained[firstDifference];
|
||||
const alternative = candidate[firstDifference];
|
||||
if (first < 0) continue;
|
||||
expect(
|
||||
chosen.direction === 'minimize'
|
||||
? chosen.value < alternative.value
|
||||
: chosen.value > alternative.value,
|
||||
retained[first].direction === 'minimize'
|
||||
? retained[first].value < candidate[first].value
|
||||
: retained[first].value > candidate[first].value,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('requests interruption before a valid plan without terminating trace delivery', async () => {
|
||||
it('requests interruption before a valid plan and terminates after required delivery', async () => {
|
||||
const calculation = createPlanningCalculation(request);
|
||||
calculation.cancel();
|
||||
calculation.cancel();
|
||||
@@ -285,13 +276,11 @@ describe('user cancellation', () => {
|
||||
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
|
||||
expect(currentWorker().terminate).not.toHaveBeenCalled();
|
||||
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||
await calculation.trace;
|
||||
expect(currentWorker().terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
|
||||
it('keeps a cancelled partial plan after explanation delivery fails', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
const updates: PlanningTraceUpdate[] = [];
|
||||
let interrupted = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const submitted = {
|
||||
@@ -302,8 +291,6 @@ describe('user cancellation', () => {
|
||||
const cancelled = await core.calculatePlanningAsyncInternal(
|
||||
{
|
||||
...submitted,
|
||||
traceProgress: (_: number, update: PlanningTraceUpdate) =>
|
||||
updates.push(structuredClone(update)),
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
|
||||
},
|
||||
@@ -314,20 +301,9 @@ describe('user cancellation', () => {
|
||||
undefined,
|
||||
() => interrupted,
|
||||
);
|
||||
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
|
||||
throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
|
||||
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent)
|
||||
throw new Error('Expected a cancelled fixture with a valid plan.');
|
||||
const calculation = createPlanningCalculation(submitted);
|
||||
const operations = updates.flatMap(({ operations }) => operations);
|
||||
const split = Math.floor(operations.length / 2);
|
||||
currentWorker().emit({
|
||||
type: 'trace-update',
|
||||
totalMs: 0,
|
||||
acknowledge: true,
|
||||
update: {
|
||||
...updates[0],
|
||||
operations: operations.slice(0, split),
|
||||
},
|
||||
});
|
||||
calculation.cancel();
|
||||
currentWorker().emit({ type: 'result', outcome: cancelled });
|
||||
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
|
||||
@@ -336,29 +312,14 @@ describe('user cancellation', () => {
|
||||
explanationError: { code: 'explanation-error', message: 'Unavailable' },
|
||||
});
|
||||
currentWorker().emit({
|
||||
type: 'trace-update',
|
||||
totalMs: 0,
|
||||
acknowledge: true,
|
||||
update: {
|
||||
operations: operations.slice(split),
|
||||
},
|
||||
});
|
||||
const trace = artifacts.trace;
|
||||
currentWorker().emit({
|
||||
type: 'artifacts-start',
|
||||
type: 'artifacts',
|
||||
artifacts: {},
|
||||
trace: {
|
||||
status: trace.status,
|
||||
initialState: trace.initialState,
|
||||
completedStages: trace.completedStages,
|
||||
nonWorkingDates: trace.nonWorkingDates,
|
||||
error: trace.error,
|
||||
},
|
||||
});
|
||||
currentWorker().emit({ type: 'artifacts-complete' });
|
||||
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
|
||||
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
|
||||
expect(await calculation.trace).toEqual(trace);
|
||||
expect(currentWorker().terminate).toHaveBeenCalledOnce();
|
||||
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(artifacts.finalState?.placements).toEqual(cancelled.incumbent.placements);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
@@ -399,7 +360,6 @@ describe('user cancellation', () => {
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'worker-error' }],
|
||||
});
|
||||
expect((await calculation.trace)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
|
||||
@@ -424,7 +384,6 @@ describe('user cancellation', () => {
|
||||
kind: 'PlanExplanation',
|
||||
incomplete: { reason: { code: 'user-cancelled' } },
|
||||
});
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -453,7 +412,6 @@ describe('user cancellation', () => {
|
||||
currentWorker().emit({ type: 'result', outcome });
|
||||
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||
expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' });
|
||||
await newer.trace;
|
||||
const sent = currentWorker().postMessage.mock.calls.length;
|
||||
newer.cancel();
|
||||
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
const balance = (
|
||||
id: string,
|
||||
@@ -18,8 +18,92 @@ afterEach(() => vi.useRealTimers());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('planner explanation and trace seam', () => {
|
||||
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
|
||||
describe('planner explanation and alternative dates', () => {
|
||||
it('delivers only the displayed plan, explanation and state needed for alternative dates', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-09')],
|
||||
rules: [presets[0]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
expect(Object.keys(calculation).sort()).toEqual([
|
||||
'analyzeCounterfactual',
|
||||
'cancel',
|
||||
'explanation',
|
||||
'explanationError',
|
||||
'id',
|
||||
'outcome',
|
||||
'snapshot',
|
||||
'timings',
|
||||
]);
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
expect(
|
||||
core.calculatePlanningInternal(
|
||||
calculation.snapshot,
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
),
|
||||
).toEqual(outcome);
|
||||
expect(Object.keys(artifacts).sort()).toEqual([
|
||||
'explanation',
|
||||
'explanationError',
|
||||
'finalState',
|
||||
'timings',
|
||||
]);
|
||||
expect(artifacts.finalState?.placements).toEqual(outcome.placements);
|
||||
expect(Object.keys(artifacts.timings ?? {})).toEqual(['planningWorkMs']);
|
||||
const original = structuredClone(outcome);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
expect(analysis.results).toHaveLength(8);
|
||||
for (const result of analysis.results) {
|
||||
expect(result).not.toHaveProperty('historicalStatus');
|
||||
expect(result).not.toHaveProperty('historicalReason');
|
||||
}
|
||||
expect(outcome).toEqual(original);
|
||||
});
|
||||
|
||||
it('excludes observers from the technical budget and keeps the outcome after explanation delivery fails', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let elapsed = 0;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
|
||||
let delivered = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
try {
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-01-02', end: '2024-01-10' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
deadlineMs: 10,
|
||||
progress: () => (elapsed += 100),
|
||||
},
|
||||
() => (elapsed += 500),
|
||||
(value) => (artifacts = value),
|
||||
() => (delivered = true),
|
||||
() => {
|
||||
expect(delivered).toBe(true);
|
||||
elapsed += 1000;
|
||||
throw new Error('Explanation delivery failed.');
|
||||
},
|
||||
);
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(artifacts.explanationError).toMatchObject({
|
||||
code: 'explanation-error',
|
||||
message: 'Explanation delivery failed.',
|
||||
});
|
||||
expect(artifacts.finalState?.horizon).toEqual({
|
||||
start: '2024-01-02',
|
||||
end: '2024-01-10',
|
||||
});
|
||||
expect(artifacts.timings?.planningWorkMs).toBe(0);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps earned availability after a reported occurrence is extended', async () => {
|
||||
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
@@ -67,42 +151,25 @@ describe('planner explanation and trace seam', () => {
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
|
||||
throw new Error('Expected the extended generator to finance a traced plan.');
|
||||
const replay = {
|
||||
balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })),
|
||||
placements: new Map(
|
||||
artifacts.trace.initialState.placements.map((item) => [item.date, item]),
|
||||
),
|
||||
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
|
||||
};
|
||||
if (outcome.kind !== 'Plan' || !artifacts.finalState)
|
||||
throw new Error('Expected the extended generator to finance a plan.');
|
||||
const earnedId = 'earned:producer:2024-05-01';
|
||||
let recognized = false;
|
||||
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) {
|
||||
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
|
||||
if (
|
||||
decision.deltas.some(
|
||||
({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId,
|
||||
)
|
||||
) {
|
||||
recognized = true;
|
||||
expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId);
|
||||
expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId);
|
||||
}
|
||||
}
|
||||
expect(recognized).toBe(true);
|
||||
expect(replay.balances).toEqual(artifacts.finalState.balances);
|
||||
expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements);
|
||||
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
|
||||
expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({
|
||||
remaining: 0,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
});
|
||||
expect(artifacts.finalState.balances).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: earnedId,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
remaining: 0,
|
||||
}),
|
||||
);
|
||||
expect(outcome.placements.find(({ balanceId }) => balanceId === earnedId)?.date).toBe(
|
||||
'2024-05-07',
|
||||
);
|
||||
expect(artifacts.finalState.placements).toEqual(outcome.placements);
|
||||
});
|
||||
|
||||
it.each([5, 8])(
|
||||
'keeps %s unrelated obligation branches linear when a declared entitlement is already reported',
|
||||
'keeps %s unrelated obligations valid without duplicating a reported entitlement',
|
||||
async (count) => {
|
||||
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
|
||||
const core = await import('./leave-planner.core');
|
||||
@@ -116,74 +183,58 @@ describe('planner explanation and trace seam', () => {
|
||||
'2024-04-25',
|
||||
'2024-04-26',
|
||||
].slice(0, count);
|
||||
const branchCounts: number[] = [];
|
||||
for (const rewardDays of [0, 1]) {
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-04-17', end: '2024-05-08' },
|
||||
declaredDays: [
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-04-30', type: 'cp' },
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: 'other',
|
||||
type: 'other',
|
||||
quantity: dates.length,
|
||||
availableFrom: '2024-04-17',
|
||||
expiryDate: '2025-12-31',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
...dates.map((date) => ({
|
||||
id: date,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
placeDays: 1,
|
||||
placeType: 'other' as const,
|
||||
})),
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placement: 'before',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
rewardDays,
|
||||
rewardType: 'other',
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || !artifacts.trace)
|
||||
throw new Error('Expected a traced completed plan.');
|
||||
const outcome = core.calculatePlanningInternal({
|
||||
horizon: { start: '2024-04-17', end: '2024-05-08' },
|
||||
declaredDays: [
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-04-30', type: 'cp' },
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: 'other',
|
||||
type: 'other',
|
||||
quantity: dates.length,
|
||||
availableFrom: '2024-04-17',
|
||||
expiryDate: '2025-12-31',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
...dates.map((date) => ({
|
||||
id: date,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
placeDays: 1,
|
||||
placeType: 'other' as const,
|
||||
})),
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placement: 'before',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
rewardDays,
|
||||
rewardType: 'other',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a completed plan.');
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
|
||||
expect(outcome.remainingBalances).toEqual([]);
|
||||
branchCounts.push(
|
||||
artifacts.trace.phases
|
||||
.find(({ name }) => name === 'obligations')
|
||||
?.decisions.filter(({ deltas }) =>
|
||||
deltas.some(({ placement }) => placement !== undefined),
|
||||
).length ?? 0,
|
||||
);
|
||||
if (rewardDays)
|
||||
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||||
availableFrom: '2024-05-02',
|
||||
added: false,
|
||||
});
|
||||
}
|
||||
expect(branchCounts[0]).toBeGreaterThan(0);
|
||||
expect(branchCounts[1]).toBe(branchCounts[0]);
|
||||
},
|
||||
);
|
||||
|
||||
it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
|
||||
it('keeps reported entitlements out of the final state under explicit priorities', async () => {
|
||||
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
@@ -224,69 +275,17 @@ describe('planner explanation and trace seam', () => {
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
|
||||
throw new Error('Expected a traced completed plan.');
|
||||
if (outcome.kind !== 'Plan' || !artifacts.finalState)
|
||||
throw new Error('Expected a completed plan.');
|
||||
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
|
||||
expect(outcome.remainingBalances).toEqual([]);
|
||||
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||||
availableFrom: '2024-05-06',
|
||||
added: false,
|
||||
});
|
||||
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
|
||||
const deltas = decisions.flatMap(({ deltas }) => deltas);
|
||||
expect(deltas).toContainEqual(
|
||||
expect.objectContaining({ kind: 'balance-changed', balanceId: 'cp' }),
|
||||
);
|
||||
expect(deltas).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'balance-changed',
|
||||
balanceId: 'earned:producer:2024-05-01',
|
||||
}),
|
||||
);
|
||||
const initial = artifacts.trace.initialState;
|
||||
const replay = {
|
||||
balances: initial.balances.map((balance) => ({ ...balance })),
|
||||
placements: new Map(
|
||||
initial.placements.map((placement) => [
|
||||
placement.date,
|
||||
{ ...placement, ruleIds: [...placement.ruleIds] },
|
||||
]),
|
||||
),
|
||||
earnedActivations: initial.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
})),
|
||||
};
|
||||
for (const decision of decisions) applyReplayDeltas(replay, decision.deltas);
|
||||
expect(replay.balances).toEqual(artifacts.finalState.balances);
|
||||
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
|
||||
expect([...replay.placements.values()]).toEqual(outcome.placements);
|
||||
});
|
||||
|
||||
it('records only actual evaluations and publishes non-working dates as metadata', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-07' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(trace?.status).toBe('ready');
|
||||
expect(trace?.nonWorkingDates?.map(({ date }) => date)).toEqual([
|
||||
'2024-01-01',
|
||||
'2024-01-06',
|
||||
]);
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.candidates),
|
||||
).toEqual([]);
|
||||
expect(trace?.phases.every((phase) => phase.status === 'completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps joint obligation branches atomic and associates the selected shared placement', async () => {
|
||||
it('associates every joint obligation with its shared placement', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-12-31')],
|
||||
@@ -312,151 +311,13 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const obligations = trace.phases.find((phase) => phase.name === 'obligations');
|
||||
const branch = obligations?.decisions
|
||||
.flatMap((decision) => decision.candidates)
|
||||
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 2);
|
||||
expect(branch?.dates).toHaveLength(2);
|
||||
expect(
|
||||
branch?.dates?.every((date) => outcome.placements.some((item) => item.date === date)),
|
||||
).toBe(true);
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const sharedPlacement = outcome.placements.find(
|
||||
(placement) =>
|
||||
placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'),
|
||||
);
|
||||
expect(sharedPlacement).toBeDefined();
|
||||
const acceptedMutation = obligations?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) =>
|
||||
(delta.kind === 'placement-added' || delta.kind === 'placement-updated') &&
|
||||
delta.placement !== undefined &&
|
||||
delta.placement.date === sharedPlacement?.date &&
|
||||
delta.placement.ruleIds.includes('joint') &&
|
||||
delta.placement.ruleIds.includes('shared'),
|
||||
),
|
||||
);
|
||||
expect(acceptedMutation).toBeDefined();
|
||||
expect(acceptedMutation?.branch?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
placement: expect.objectContaining({
|
||||
date: sharedPlacement?.date,
|
||||
ruleIds: ['joint', 'shared'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const sameDatesOnRejectedParent = obligations?.decisions.filter(
|
||||
(decision) =>
|
||||
decision.id !== acceptedMutation?.id &&
|
||||
decision.candidates.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'evaluated-rejected' &&
|
||||
candidate.dates?.includes(sharedPlacement?.date ?? ''),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
sameDatesOnRejectedParent?.some((decision) => decision.branch?.parentDecisionId),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('records at-risk assignments as sequential decisions with exact prior-state deltas', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-07')],
|
||||
rules: [presets[0]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const atRisk = trace.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
|
||||
);
|
||||
const jan2 = atRisk?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
|
||||
),
|
||||
);
|
||||
const jan3 = atRisk?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
|
||||
),
|
||||
);
|
||||
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(jan2?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
|
||||
technicalComparison: [
|
||||
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
|
||||
{
|
||||
name: `optimization:${presets[0].id}`,
|
||||
direction: 'minimize',
|
||||
before: 2,
|
||||
after: 1,
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: `optimization:${presets[0].id}`,
|
||||
});
|
||||
expect(jan2?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'balance-changed',
|
||||
remainingBefore: 2,
|
||||
remainingAfter: 1,
|
||||
}),
|
||||
);
|
||||
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(jan3?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
|
||||
technicalComparison: [
|
||||
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
|
||||
{
|
||||
name: `optimization:${presets[0].id}`,
|
||||
direction: 'minimize',
|
||||
before: 1,
|
||||
after: 0,
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: `optimization:${presets[0].id}`,
|
||||
});
|
||||
expect(jan3?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'balance-changed',
|
||||
remainingBefore: 1,
|
||||
remainingAfter: 0,
|
||||
}),
|
||||
);
|
||||
for (const [date, decision] of [
|
||||
['2024-01-02', jan2],
|
||||
['2024-01-03', jan3],
|
||||
] as const) {
|
||||
expect(
|
||||
decision?.candidates.filter(
|
||||
(candidate) => candidate.status === 'chosen' && candidate.date === date,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
decision?.deltas.filter(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === date,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
const selected = jan3?.deltas.find(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
|
||||
)?.placement;
|
||||
expect(selected).toBeDefined();
|
||||
if (!jan3 || !selected) return;
|
||||
const analysis = await calculation.analyzeCounterfactual({
|
||||
decisionId: jan3.id,
|
||||
placement: selected,
|
||||
});
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind === 'Counterfactual')
|
||||
expect(analysis.results.find((item) => item.date === '2024-01-02')).toMatchObject({
|
||||
status: 'ineligible',
|
||||
reason: { code: 'occupied' },
|
||||
});
|
||||
});
|
||||
|
||||
it('puts actual obligation validity first in stable plan explanations', async () => {
|
||||
@@ -478,6 +339,7 @@ describe('planner explanation and trace seam', () => {
|
||||
const outcome = await calculation.outcome;
|
||||
const explanation = await calculation.explanation;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind !== 'PlanExplanation') return;
|
||||
|
||||
@@ -498,7 +360,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
|
||||
});
|
||||
|
||||
it('records the balance order used for a shared ANY preference placement', async () => {
|
||||
it('uses the earlier-expiring balance for shared ANY preferences', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [
|
||||
@@ -528,9 +390,8 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
|
||||
const sharedPlacement = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('any-preference'),
|
||||
@@ -540,116 +401,9 @@ describe('planner explanation and trace seam', () => {
|
||||
balanceId: 'rtt-first',
|
||||
ruleIds: ['any-preference', 'rtt-preference'],
|
||||
});
|
||||
const candidate = trace.phases
|
||||
.find((phase) => phase.name === 'preference:any-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates)
|
||||
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
|
||||
expect(candidate).toMatchObject({
|
||||
firstDecisiveComparator: 'preference:rtt-preference',
|
||||
placement: expect.objectContaining({ balanceId: 'rtt-first' }),
|
||||
});
|
||||
const compared = trace.phases
|
||||
.find((phase) => phase.name === 'preference:any-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates);
|
||||
expect(compared).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: 'evaluated-rejected',
|
||||
placement: expect.objectContaining({ balanceId: 'rtt-second' }),
|
||||
technicalComparison: expect.arrayContaining([
|
||||
{
|
||||
name: 'preference:any-preference',
|
||||
direction: 'maximize',
|
||||
before: 1,
|
||||
after: 1,
|
||||
},
|
||||
{
|
||||
name: 'preference:rtt-preference',
|
||||
direction: 'maximize',
|
||||
before: 1,
|
||||
after: 1,
|
||||
},
|
||||
{
|
||||
name: 'balance-expiry',
|
||||
direction: 'minimize',
|
||||
before: '2025-01-05',
|
||||
after: '2025-01-06',
|
||||
},
|
||||
]),
|
||||
firstDecisiveComparator: 'balance-expiry',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records actual IfThen leaf comparisons and selected branch identity', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-12-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'if-then-preference',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const candidates = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) =>
|
||||
decision.candidates
|
||||
.filter((candidate) =>
|
||||
candidate.technicalComparison?.some(
|
||||
(item) => item.name === 'preference:if-then-preference',
|
||||
),
|
||||
)
|
||||
.map((candidate) => ({ candidate, decision })),
|
||||
);
|
||||
const chosen = candidates.find(({ candidate }) => candidate.status === 'chosen');
|
||||
const preferenceLeaves =
|
||||
trace.phases
|
||||
.find((phase) => phase.name === 'preference:if-then-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates)
|
||||
.filter((candidate) =>
|
||||
candidate.technicalComparison?.some(
|
||||
(item) => item.name === 'preference:if-then-preference',
|
||||
),
|
||||
) ?? [];
|
||||
expect(preferenceLeaves.length).toBeGreaterThan(1);
|
||||
expect(preferenceLeaves.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(
|
||||
preferenceLeaves.some((candidate) => candidate.status === 'evaluated-rejected'),
|
||||
).toBe(true);
|
||||
expect(candidates.some(({ candidate }) => candidate.status === 'evaluated-rejected')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(chosen?.candidate.technicalComparison).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'preference:if-then-preference',
|
||||
direction: 'maximize',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
(chosen?.decision.branch?.deltas ?? chosen?.decision.deltas ?? []).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
chosen?.candidate.dates?.every((date) =>
|
||||
outcome.placements.some((p) => p.date === date),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('records earned obligation branches with the activated entitlement and its consumer', async () => {
|
||||
it('keeps an earned obligation entitlement and its consumer chronological', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
||||
balances: [
|
||||
@@ -680,29 +434,13 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
|
||||
const earned = outcome.placements.find((placement) =>
|
||||
placement.balanceId.startsWith('earned:'),
|
||||
);
|
||||
expect(earned?.ruleIds).toContain('consumer');
|
||||
const decisions = trace.phases.flatMap((phase) => phase.decisions);
|
||||
const activation = decisions
|
||||
.flatMap((decision) => decision.deltas)
|
||||
.find(
|
||||
(delta) =>
|
||||
delta.kind === 'earned-activated' && delta.balanceId === earned?.balanceId,
|
||||
);
|
||||
expect(activation?.activation).toMatchObject({ ruleId: 'producer', dates: ['2024-05-02'] });
|
||||
const branch = decisions
|
||||
.flatMap((decision) => decision.candidates)
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'chosen' && candidate.dates?.includes(earned?.date ?? ''),
|
||||
);
|
||||
expect(branch).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a producer move that delays its earned entitlement past the consumer', async () => {
|
||||
@@ -737,7 +475,7 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const producer = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('producer'),
|
||||
);
|
||||
@@ -802,7 +540,7 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const atRisk = outcome.placements.find(
|
||||
(placement) =>
|
||||
placement.balanceId === 'cp2' &&
|
||||
@@ -823,7 +561,7 @@ describe('planner explanation and trace seam', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records earned balance availability metadata changes when a shared period extends', async () => {
|
||||
it('keeps earned balance metadata consistent when a shared period extends', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||||
balances: [
|
||||
@@ -863,30 +601,13 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
|
||||
const earnedBalanceId = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('rtt-preference'),
|
||||
)?.balanceId;
|
||||
expect(earnedBalanceId).toBeDefined();
|
||||
const changes = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.deltas)
|
||||
.filter(
|
||||
(delta) => delta.kind === 'balance-changed' && delta.balanceId === earnedBalanceId,
|
||||
);
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
remainingBefore: 1,
|
||||
remainingAfter: 1,
|
||||
balance: expect.objectContaining({
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares every horizon date while preserving the selected placement provenance', async () => {
|
||||
@@ -907,7 +628,7 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const selected = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('exact-date'),
|
||||
);
|
||||
@@ -1022,27 +743,6 @@ describe('planner explanation and trace seam', () => {
|
||||
if (outcome.kind !== 'PlanningConflict') continue;
|
||||
expect(outcome.reasons[0].code).toBe(conflictCodes[index]);
|
||||
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true);
|
||||
expect(
|
||||
trace?.phases.some(
|
||||
(phase) => phase.name === 'optimization:default-holiday-week-value',
|
||||
),
|
||||
).toBe(false);
|
||||
if (index === 1)
|
||||
expect(
|
||||
trace?.phases
|
||||
.find((phase) => phase.name === 'obligations')
|
||||
?.decisions.flatMap((decision) => decision.candidates).length,
|
||||
).toBeGreaterThan(0);
|
||||
if (index === 2)
|
||||
expect(
|
||||
trace?.phases
|
||||
.find((phase) => phase.name === 'obligations')
|
||||
?.decisions.some((decision) =>
|
||||
decision.candidates.some((candidate) => candidate.dates?.length),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = createPlanningCalculation({
|
||||
@@ -1052,7 +752,6 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
expect((await invalid.outcome).kind).toBe('InvalidInput');
|
||||
expect(await invalid.explanation).toBeUndefined();
|
||||
expect(await invalid.trace).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not classify a joint-date conflict as earned when only a preference generates leave', async () => {
|
||||
@@ -1094,7 +793,7 @@ describe('planner explanation and trace seam', () => {
|
||||
it('recovers an incumbent explanation from the exact delivered checkpoint state', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let checkpointPlan: import('./leave-planner.core').PlanOutcome | undefined;
|
||||
let checkpointState: import('./leave-planner.core').PlanningTraceState | undefined;
|
||||
let checkpointState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
||||
@@ -1124,7 +823,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(recovered.explanation.incomplete?.reason.code).toBe('worker-error');
|
||||
expect(recovered.explanation.placements).toHaveLength(checkpointPlan.placements.length);
|
||||
|
||||
let completedState: import('./leave-planner.core').PlanningTraceState | undefined;
|
||||
let completedState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
|
||||
const completed = core.calculatePlanningInternal(
|
||||
{ ...request, progress: undefined },
|
||||
(plan, state) => {
|
||||
@@ -1144,116 +843,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(completedRecovery.explanation.incomplete).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not charge a deliberately slow incremental trace consumer to planning work', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let clock = 0;
|
||||
vi.stubGlobal('performance', { now: () => ++clock });
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-15' },
|
||||
balances: [balance('cp', 'cp', 3, '2024-01-31')],
|
||||
rules: presets,
|
||||
deadlineMs: 100_000,
|
||||
};
|
||||
const plain = core.calculatePlanningInternal(request);
|
||||
clock = 0;
|
||||
let callbackCount = 0;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
|
||||
const traced = core.calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
traceProgress: (
|
||||
_totalMs: number,
|
||||
update: import('./leave-planner.core').PlanningTraceUpdate,
|
||||
) => {
|
||||
callbackCount++;
|
||||
clock += 10_000;
|
||||
operations.push(...update.operations);
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(traced).toEqual(plain);
|
||||
expect(callbackCount).toBeGreaterThan(0);
|
||||
expect(artifacts?.trace?.status).toBe('ready');
|
||||
expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs);
|
||||
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000);
|
||||
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThan(request.deadlineMs);
|
||||
const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
|
||||
const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate');
|
||||
expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true);
|
||||
const streamedCandidates = new Map<string, unknown>();
|
||||
for (const operation of candidateUpdates)
|
||||
if (operation.kind === 'candidate')
|
||||
streamedCandidates.set(
|
||||
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
|
||||
operation.candidate,
|
||||
);
|
||||
expect(streamedCandidates.size).toBe(
|
||||
decisions.flatMap((decision) => decision.candidates).length,
|
||||
);
|
||||
expect(operations.filter((operation) => operation.kind === 'delta')).toHaveLength(
|
||||
decisions.flatMap((decision) => decision.deltas).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces pending candidate snapshots without losing later flushed replacements', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
vi.stubGlobal('performance', { now: () => 0 });
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-07')],
|
||||
rules: [presets[0]],
|
||||
};
|
||||
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
|
||||
plainArtifacts = value;
|
||||
});
|
||||
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
|
||||
let streamedArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const streamed = core.calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
traceProgress: (
|
||||
_totalMs: number,
|
||||
update: import('./leave-planner.core').PlanningTraceUpdate,
|
||||
) => operations.push(...update.operations),
|
||||
},
|
||||
undefined,
|
||||
(value) => (streamedArtifacts = value),
|
||||
);
|
||||
|
||||
expect(streamed).toEqual(plain);
|
||||
expect(streamedArtifacts?.trace).toEqual(plainArtifacts?.trace);
|
||||
const decisions =
|
||||
streamedArtifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
|
||||
const candidates = decisions.flatMap((decision) =>
|
||||
decision.candidates.map((candidate, index) => ({
|
||||
key: `${decision.id}/${index}`,
|
||||
candidate,
|
||||
})),
|
||||
);
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
const candidateOperations = operations.filter(
|
||||
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
|
||||
operation.kind === 'candidate',
|
||||
);
|
||||
expect(candidateOperations).toHaveLength(candidates.length);
|
||||
const finalStreamedCandidates = new Map(
|
||||
candidateOperations.map((operation) => [
|
||||
`${operation.decisionId}/${operation.index}`,
|
||||
operation,
|
||||
]),
|
||||
);
|
||||
for (const { key, candidate } of candidates) {
|
||||
expect(finalStreamedCandidates.get(key)?.candidate).toEqual(candidate);
|
||||
if (candidate.status === 'chosen')
|
||||
expect(finalStreamedCandidates.get(key)?.mode).toBe('append');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports matching completed, interrupted, and not-run phases at both interruption boundaries', async () => {
|
||||
it('explains incomplete calculations before and after a valid plan', async () => {
|
||||
const early = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
||||
@@ -1261,22 +851,9 @@ describe('planner explanation and trace seam', () => {
|
||||
deadlineMs: 0,
|
||||
});
|
||||
const earlyOutcome = await early.outcome;
|
||||
const earlyTrace = await early.trace;
|
||||
expect(earlyOutcome.kind).toBe('PlanningIncomplete');
|
||||
if (earlyOutcome.kind === 'PlanningIncomplete')
|
||||
expect(earlyOutcome.completedStages).toEqual(['validation']);
|
||||
expect(earlyTrace?.completedStages).toEqual(['validation']);
|
||||
expect(earlyTrace?.phases.find((phase) => phase.name === 'validation')?.status).toBe(
|
||||
'completed',
|
||||
);
|
||||
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
|
||||
'interrupted',
|
||||
);
|
||||
expect(
|
||||
earlyTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
|
||||
)?.status,
|
||||
).toBe('not-run');
|
||||
|
||||
const quality = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
@@ -1289,7 +866,6 @@ describe('planner explanation and trace seam', () => {
|
||||
},
|
||||
});
|
||||
const qualityOutcome = await quality.outcome;
|
||||
const qualityTrace = await quality.trace;
|
||||
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
|
||||
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
|
||||
@@ -1298,16 +874,5 @@ describe('planner explanation and trace seam', () => {
|
||||
'obligations',
|
||||
'optimization:default-unplaced-at-risk',
|
||||
]);
|
||||
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
|
||||
expect(
|
||||
qualityTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-holiday-week-value',
|
||||
)?.status,
|
||||
).toBe('interrupted');
|
||||
expect(
|
||||
qualityTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-non-working-day-gain',
|
||||
)?.status,
|
||||
).toBe('not-run');
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
import {
|
||||
calculatePlanningInternal,
|
||||
type PlanningArtifacts,
|
||||
type PlanningTraceCandidate,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
|
||||
beforeEach(() => vi.stubGlobal('window', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('quality calculation trace', () => {
|
||||
it('keeps emitted comparison metadata unchanged when candidate snapshots are replaced', () => {
|
||||
const updates: PlanningTraceUpdate[] = [];
|
||||
const captured: string[] = [];
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
let clock = 0;
|
||||
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => (clock += 30));
|
||||
try {
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2026-11-01',
|
||||
horizon: { start: '2026-11-03', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
|
||||
declaredDays: [
|
||||
{ date: '2026-11-09', type: 'other' },
|
||||
{ date: '2026-11-10', type: 'other' },
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'holiday',
|
||||
name: 'Holiday weeks',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
},
|
||||
...[3, 5].map((thresholdDays) => ({
|
||||
id: `deficit-${thresholdDays}`,
|
||||
name: `Deficit ${thresholdDays} days`,
|
||||
type: 'optimization',
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
conditions: ['non-empty'],
|
||||
thresholdDays,
|
||||
},
|
||||
})),
|
||||
],
|
||||
traceProgress: (_elapsed: number, update: PlanningTraceUpdate) => {
|
||||
updates.push(update);
|
||||
captured.push(JSON.stringify(update));
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(artifacts.trace?.status).toBe('ready');
|
||||
expect(updates.map((update) => JSON.stringify(update))).toEqual(captured);
|
||||
const emitted = new Map<string, PlanningTraceCandidate>();
|
||||
let replacements = 0;
|
||||
for (const update of updates)
|
||||
for (const operation of update.operations) {
|
||||
if (operation.kind !== 'candidate') continue;
|
||||
if (operation.mode === 'replace') replacements++;
|
||||
emitted.set(
|
||||
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
|
||||
operation.candidate,
|
||||
);
|
||||
}
|
||||
expect(replacements).toBeGreaterThan(0);
|
||||
expect(emitted.size).toBeGreaterThan(0);
|
||||
for (const phase of artifacts.trace!.phases)
|
||||
for (const decision of phase.decisions)
|
||||
decision.candidates.forEach((candidate, index) =>
|
||||
expect(emitted.get(`${phase.id}/${decision.id}/${index}`)).toEqual(
|
||||
candidate,
|
||||
),
|
||||
);
|
||||
const comparison = [...emitted.values()].find(
|
||||
(candidate) => candidate.comparison,
|
||||
)?.comparison;
|
||||
expect(comparison?.after.objectives).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'optimization:deficit-3',
|
||||
label: 'Deficit 3 days',
|
||||
direction: 'minimize',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'optimization:deficit-5',
|
||||
label: 'Deficit 5 days',
|
||||
direction: 'minimize',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
[...emitted.values()].some(({ comparison }) => {
|
||||
const values = comparison?.after.objectives;
|
||||
return (
|
||||
values?.find(({ id }) => id === 'optimization:deficit-3')?.value === 0 &&
|
||||
values?.find(({ id }) => id === 'optimization:deficit-5')?.value === 1
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
clockSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('separates source effects from the actual incumbent comparison', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2024-01-01',
|
||||
expiryDate: '2024-01-31',
|
||||
},
|
||||
],
|
||||
rules: presets,
|
||||
});
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.status).toBe('ready');
|
||||
const phase = trace?.phases.find(({ name }) => name === `optimization:${presets[0].id}`);
|
||||
const decision = phase?.decisions.find(({ candidates }) =>
|
||||
candidates.some(({ status, date }) => status === 'chosen' && date === '2024-01-02'),
|
||||
);
|
||||
const rejected = decision?.candidates.find(
|
||||
({ date, comparison }) => date === '2024-01-05' && comparison,
|
||||
);
|
||||
expect(rejected?.status).toBe('evaluated-rejected');
|
||||
const sourceValue = (state: 'before' | 'after', id: string) =>
|
||||
rejected?.comparison?.[state].objectives.find((objective) => objective.id === id)
|
||||
?.value;
|
||||
expect(sourceValue('before', `optimization:${presets[0].id}`)).toBe(2);
|
||||
expect(sourceValue('after', `optimization:${presets[0].id}`)).toBe(1);
|
||||
expect(sourceValue('before', `optimization:${presets[2].id}`)).toBe(0);
|
||||
expect(sourceValue('after', `optimization:${presets[2].id}`)).toBe(2);
|
||||
expect(rejected?.comparison?.firstDecisiveObjective).toBe(`optimization:${presets[0].id}`);
|
||||
// The January 2 candidate already gains three natural non-working days.
|
||||
// January 5 improves its source state, but loses to that current best candidate.
|
||||
expect(rejected?.technicalComparison).toContainEqual({
|
||||
name: `optimization:${presets[2].id}`,
|
||||
direction: 'maximize',
|
||||
before: 3,
|
||||
after: 2,
|
||||
});
|
||||
expect(
|
||||
rejected?.technicalComparison?.some(
|
||||
({ name }) => name === `optimization:${presets[3].id}`,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(rejected?.firstDecisiveComparator).toBe(`optimization:${presets[2].id}`);
|
||||
expect(rejected?.reason?.message).toContain('did not improve the current best result');
|
||||
expect(decision?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-added',
|
||||
date: '2024-01-02',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
type PlanOutcome,
|
||||
type PlanningArtifacts,
|
||||
type PlanningRequest,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
import { applyReplayDeltas, createPlanningCalculation } from './leave-planner.service';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
const economy: OptimizationPlanningRule = {
|
||||
id: 'economy',
|
||||
@@ -415,25 +414,20 @@ describe('supporting placement economy and provenance', () => {
|
||||
});
|
||||
|
||||
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [
|
||||
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
|
||||
{
|
||||
id: 'at-risk',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-07',
|
||||
expiryDate: '2024-05-07',
|
||||
},
|
||||
],
|
||||
rules: [bundles[0].rule, economy],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const outcome = calculatePlanningInternal({
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [
|
||||
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
|
||||
{
|
||||
id: 'at-risk',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-07',
|
||||
expiryDate: '2024-05-07',
|
||||
},
|
||||
],
|
||||
rules: [bundles[0].rule, economy],
|
||||
});
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(
|
||||
outcome.placements.map(({ date, balanceId, supporting }) => ({
|
||||
@@ -448,20 +442,6 @@ describe('supporting placement economy and provenance', () => {
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(2);
|
||||
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
|
||||
expect(
|
||||
artifacts?.trace?.phases.flatMap(({ decisions }) =>
|
||||
decisions.flatMap(({ deltas }) => deltas),
|
||||
),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
previousPlacement: expect.objectContaining({
|
||||
balanceId: 'deferred',
|
||||
supporting: true,
|
||||
}),
|
||||
placement: expect.objectContaining({ balanceId: 'at-risk', supporting: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
|
||||
@@ -491,13 +471,10 @@ describe('supporting placement economy and provenance', () => {
|
||||
};
|
||||
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const updates: { update: PlanningTraceUpdate; snapshot: string }[] = [];
|
||||
const windowFirst = calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
rules: [holiday, early, economy],
|
||||
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
|
||||
updates.push({ update, snapshot: JSON.stringify(update) }),
|
||||
},
|
||||
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
|
||||
(value) => (artifacts = value),
|
||||
@@ -547,12 +524,8 @@ describe('supporting placement economy and provenance', () => {
|
||||
expect(windowFirst.metrics.supportingPlacements).toBe(2);
|
||||
expect(windowFirst.preferences[0].satisfied).toBe(2);
|
||||
expect(windowFirst.metrics.holidayWeekValue).toBe(3);
|
||||
if (
|
||||
artifacts?.explanation?.kind !== 'PlanExplanation' ||
|
||||
!artifacts.finalState ||
|
||||
!artifacts.trace
|
||||
)
|
||||
throw new Error('Expected matching explanation and trace artifacts');
|
||||
if (artifacts?.explanation?.kind !== 'PlanExplanation' || !artifacts.finalState)
|
||||
throw new Error('Expected matching explanation and state snapshots');
|
||||
expect(artifacts.finalState.placements).toEqual(windowFirst.placements);
|
||||
expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
windowFirst.placements,
|
||||
@@ -568,33 +541,6 @@ describe('supporting placement economy and provenance', () => {
|
||||
expect(placement.contributions).not.toContainEqual(contribution);
|
||||
}
|
||||
}
|
||||
const replay = {
|
||||
balances: artifacts.trace.initialState.balances.map((balance) => ({ ...balance })),
|
||||
placements: new Map(
|
||||
artifacts.trace.initialState.placements.map((placement) => [
|
||||
placement.date,
|
||||
placement,
|
||||
]),
|
||||
),
|
||||
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
|
||||
};
|
||||
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions))
|
||||
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
|
||||
expect(
|
||||
[...replay.placements.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
).toEqual(windowFirst.placements);
|
||||
expect(
|
||||
artifacts.trace.phases
|
||||
.filter(({ name }) => name === 'preference:early')
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates)),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: 'chosen',
|
||||
placement: expect.objectContaining({ date: '2026-11-03', ruleIds: ['early'] }),
|
||||
}),
|
||||
);
|
||||
expect(updates.length).toBeGreaterThan(0);
|
||||
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
|
||||
let cancelled = false;
|
||||
let cancelledArtifacts: PlanningArtifacts | undefined;
|
||||
let published: PlanOutcome | undefined;
|
||||
@@ -605,8 +551,6 @@ describe('supporting placement economy and provenance', () => {
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'optimization:economy') cancelled = true;
|
||||
},
|
||||
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
|
||||
updates.push({ update, snapshot: JSON.stringify(update) }),
|
||||
},
|
||||
(checkpoint, state) => {
|
||||
expect(state?.placements).toEqual(checkpoint.placements);
|
||||
@@ -637,7 +581,6 @@ describe('supporting placement economy and provenance', () => {
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
|
||||
);
|
||||
}
|
||||
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
|
||||
@@ -708,28 +651,6 @@ describe('supporting placement economy and provenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records the calendar credit and classification change in the original calendar-rule phase', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
calculatePlanningInternal(
|
||||
{ ...weekend, rules: [gain, economy, requested] },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const changes = artifacts?.trace?.phases
|
||||
.find(({ name }) => name === 'preference:requested')
|
||||
?.decisions.flatMap(({ deltas }) => deltas);
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
previousPlacement: expect.objectContaining({ supporting: true }),
|
||||
placement: expect.objectContaining({
|
||||
ruleIds: ['gain', 'requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
@@ -792,15 +713,6 @@ describe('supporting placement economy and provenance', () => {
|
||||
expect(supporting.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'supporting' }),
|
||||
);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:gain',
|
||||
'optimization:economy',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]);
|
||||
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
|
||||
expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({
|
||||
|
||||
@@ -407,7 +407,7 @@ describe('composed work-sequence preferences', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('captures distinct threshold values in explanations, atomic trace and counterfactual comparisons', async () => {
|
||||
it('captures distinct threshold values in explanations and date comparisons', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const five = structuredClone(deficit);
|
||||
@@ -435,18 +435,6 @@ describe('composed work-sequence preferences', () => {
|
||||
.filter(({ id }) => id.startsWith('optimization:'))
|
||||
.map(({ value }) => value),
|
||||
).toEqual([2, 4]);
|
||||
const trace = await calculation.trace;
|
||||
const chosen = trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.find(
|
||||
(candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3,
|
||||
);
|
||||
expect(
|
||||
chosen?.comparison?.before.objectives
|
||||
.filter(({ id }) => id.startsWith('optimization:'))
|
||||
.map(({ value }) => value),
|
||||
).toEqual([0, 2]);
|
||||
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
|
||||
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (counterfactual.kind !== 'Counterfactual')
|
||||
throw new Error('Expected a counterfactual');
|
||||
|
||||
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
@@ -33,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-'));
|
||||
@@ -65,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;
|
||||
@@ -76,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;
|
||||
}
|
||||
@@ -94,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();
|
||||
}
|
||||
}
|
||||
@@ -112,6 +113,59 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
});
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
it('delivers the outcome and explanation and releases the worker without acknowledgements', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-04' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
|
||||
rules: [presets[0]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2024-01-02', balanceId: 'cp', type: 'cp' }],
|
||||
lastCompletedStage: 'dates',
|
||||
});
|
||||
expect(await calculation.explanation).toMatchObject({
|
||||
kind: 'PlanExplanation',
|
||||
placements: [{ placement: { date: '2024-01-02', balanceId: 'cp' } }],
|
||||
});
|
||||
expect(await calculation.explanationError).toBeUndefined();
|
||||
expect(await calculation.timings).toEqual({ planningWorkMs: expect.any(Number) });
|
||||
expect(sentTypes).toEqual(['plan']);
|
||||
expect(receivedTypes.slice(-3)).toEqual(['result', 'explanation', 'artifacts']);
|
||||
expect(new Set(receivedTypes)).toEqual(
|
||||
new Set(['heartbeat', 'progress', 'checkpoint', 'result', 'explanation', 'artifacts']),
|
||||
);
|
||||
expect(terminationCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores a replaced real worker’s late explanation without losing either outcome', async () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-02', end: '2024-01-04' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
|
||||
rules: [presets[0]],
|
||||
};
|
||||
holdArtifacts = true;
|
||||
const first = createPlanningCalculation(request);
|
||||
const original = await first.outcome;
|
||||
expect(original.kind).toBe('Plan');
|
||||
await vi.waitFor(() => expect(heldArtifactMessages).toHaveLength(2));
|
||||
holdArtifacts = false;
|
||||
const latest = createPlanningCalculation({ ...request, rules: [] });
|
||||
const outcome = await latest.outcome;
|
||||
expect(outcome).toMatchObject({ kind: 'Plan', placements: [] });
|
||||
const explanation = await latest.explanation;
|
||||
expect(explanation).toMatchObject({ kind: 'PlanExplanation', placements: [] });
|
||||
await latest.timings;
|
||||
for (const deliver of heldArtifactMessages) deliver();
|
||||
expect(await first.outcome).toEqual(original);
|
||||
expect(await first.explanation).toBeUndefined();
|
||||
expect(await latest.outcome).toEqual(outcome);
|
||||
expect(await latest.explanation).toEqual(explanation);
|
||||
expect(await latest.explanationError).toBeUndefined();
|
||||
expect(terminationCount).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
|
||||
const configuration: OptimizationConfiguration = structuredClone(
|
||||
OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
@@ -152,14 +206,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map((phase) => phase.name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:personal-loss',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison');
|
||||
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([
|
||||
@@ -170,7 +216,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the submitted work-sequence threshold in the real worker, trace and counterfactuals', async () => {
|
||||
it('keeps the submitted work-sequence threshold in the real worker and date comparisons', async () => {
|
||||
const configuration: OptimizationConfiguration = structuredClone(
|
||||
OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
);
|
||||
@@ -206,15 +252,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
({ id }) => id === 'optimization:deficit',
|
||||
)?.value,
|
||||
).toBe(4);
|
||||
const trace = await calculation.trace;
|
||||
const chosen = trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3);
|
||||
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
|
||||
expect(
|
||||
chosen?.comparison?.before.objectives.find(({ id }) => id === 'optimization:deficit')
|
||||
?.value,
|
||||
).toBe(2);
|
||||
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
|
||||
expect(
|
||||
@@ -390,7 +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');
|
||||
@@ -416,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')
|
||||
@@ -450,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');
|
||||
@@ -541,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)
|
||||
@@ -577,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);
|
||||
});
|
||||
@@ -611,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;
|
||||
@@ -663,15 +668,9 @@ 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' }],
|
||||
@@ -716,14 +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 === 'optimization:default-non-working-day-gain',
|
||||
)?.status,
|
||||
).toBe('interrupted');
|
||||
const frozenOutcome = structuredClone(outcome);
|
||||
calculation.cancel();
|
||||
expect(await calculation.outcome).toEqual(frozenOutcome);
|
||||
@@ -750,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,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,
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('integrated optimization and earned leave', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('runs the complete configuration once after the sole obligation state and records exact reused evaluations', () => {
|
||||
it('uses the complete configuration after the sole obligation state', () => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
composedRequest,
|
||||
@@ -54,27 +54,6 @@ describe('integrated optimization and earned leave', () => {
|
||||
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
const trace = artifacts.trace;
|
||||
expect(trace?.status).toBe('ready');
|
||||
expect(
|
||||
trace?.phases
|
||||
.find(({ name }) => name === 'obligations')
|
||||
?.decisions.flatMap(({ candidates }) => candidates)
|
||||
.some(({ comparison }) => comparison),
|
||||
).toBe(false);
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(({ reason }) => reason?.code === 'unchanged-rule-state'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' && reason?.code === 'unchanged-generic-state',
|
||||
),
|
||||
).toBe(true);
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explanation.');
|
||||
expect(
|
||||
@@ -93,33 +72,28 @@ describe('integrated optimization and earned leave', () => {
|
||||
});
|
||||
|
||||
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
...composedRequest,
|
||||
balances: ['first-cp', 'second-cp', 'rtt'].map((id) => ({
|
||||
id,
|
||||
type: id === 'rtt' ? 'rtt' : 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2024-05-17',
|
||||
})),
|
||||
rules: [
|
||||
presets[0],
|
||||
{
|
||||
id: 'typed-window',
|
||||
type: 'time_window',
|
||||
startDate: '2024-05-07',
|
||||
endDate: '2024-05-07',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const outcome = calculatePlanningInternal({
|
||||
...composedRequest,
|
||||
balances: ['first-cp', 'second-cp', 'rtt'].map((id) => ({
|
||||
id,
|
||||
type: id === 'rtt' ? 'rtt' : 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2024-05-17',
|
||||
})),
|
||||
rules: [
|
||||
presets[0],
|
||||
{
|
||||
id: 'typed-window',
|
||||
type: 'time_window',
|
||||
startDate: '2024-05-07',
|
||||
endDate: '2024-05-07',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [
|
||||
@@ -130,100 +104,56 @@ describe('integrated optimization and earned leave', () => {
|
||||
preferences: [{ ruleId: 'typed-window', satisfied: 1 }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
const candidates = artifacts.trace?.phases
|
||||
.find(({ name }) => name === `optimization:${presets[0].id}`)
|
||||
?.decisions.flatMap(({ candidates }) => candidates);
|
||||
const skipped = candidates?.filter(
|
||||
({ reason }) => reason?.code === 'equivalent-date-funding',
|
||||
);
|
||||
expect(skipped?.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
skipped?.every(({ status, comparison }) => status === 'not-examined' && !comparison),
|
||||
).toBe(true);
|
||||
expect(
|
||||
skipped?.some(
|
||||
({ date, placement }) => date === '2024-05-07' && placement?.balanceId === 'rtt',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
candidates?.some(
|
||||
({ date, placement, comparison }) =>
|
||||
date === '2024-05-07' && placement?.balanceId === 'rtt' && comparison,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['maximum', 'unsafe-total'] as const)(
|
||||
'keeps distinct funding evaluations when equivalence cannot be proved (%s)',
|
||||
(control) => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2024-01-01',
|
||||
horizon: { start: '2024-01-02', end: '2024-01-03' },
|
||||
balances: [
|
||||
{
|
||||
id: 'first',
|
||||
type: 'cp',
|
||||
quantity: control === 'maximum' ? 1 : Number.MAX_SAFE_INTEGER,
|
||||
expiryDate: '2024-01-02',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
type: 'rtt',
|
||||
quantity: control === 'maximum' ? 2 : 1,
|
||||
expiryDate: '2024-01-02',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
presets[0],
|
||||
...(control === 'maximum'
|
||||
? [
|
||||
{
|
||||
id: 'largest-balance',
|
||||
type: 'optimization',
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
aggregation: 'maximum',
|
||||
},
|
||||
const outcome = calculatePlanningInternal({
|
||||
calculationDate: '2024-01-01',
|
||||
horizon: { start: '2024-01-02', end: '2024-01-03' },
|
||||
balances: [
|
||||
{
|
||||
id: 'first',
|
||||
type: 'cp',
|
||||
quantity: control === 'maximum' ? 1 : Number.MAX_SAFE_INTEGER,
|
||||
expiryDate: '2024-01-02',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
type: 'rtt',
|
||||
quantity: control === 'maximum' ? 2 : 1,
|
||||
expiryDate: '2024-01-02',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
presets[0],
|
||||
...(control === 'maximum'
|
||||
? [
|
||||
{
|
||||
id: 'largest-balance',
|
||||
type: 'optimization',
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
aggregation: 'maximum',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ balanceId: control === 'maximum' ? 'second' : 'first' }],
|
||||
});
|
||||
const candidates = artifacts.trace?.phases
|
||||
.find(({ name }) => name === `optimization:${presets[0].id}`)
|
||||
?.decisions.flatMap(({ candidates }) => candidates);
|
||||
expect(
|
||||
candidates?.some(({ reason }) => reason?.code === 'equivalent-date-funding'),
|
||||
).toBe(false);
|
||||
for (const balanceId of ['first', 'second'])
|
||||
expect(
|
||||
candidates?.some(
|
||||
({ placement, comparison }) =>
|
||||
placement?.balanceId === balanceId && comparison,
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('finishes a renamed first quantity rule at zero and still evaluates later priorities and final ties', () => {
|
||||
const loss = { ...presets[0], id: 'personal-quantity', name: 'Personal quantity' };
|
||||
const run = (rules: PlanningRequest['rules']) => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{ ...composedRequest, rules },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
return { outcome, trace: artifacts.trace };
|
||||
const outcome = calculatePlanningInternal({ ...composedRequest, rules });
|
||||
return { outcome };
|
||||
};
|
||||
const window = {
|
||||
id: 'already-satisfied',
|
||||
@@ -239,40 +169,8 @@ describe('integrated optimization and earned leave', () => {
|
||||
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
const stopped = first.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.filter(({ reason }) => reason?.code === 'quantity-prefix-lower-bound');
|
||||
expect(stopped?.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
stopped?.every(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' && reason?.ruleIds?.includes(loss.id),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
first.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' &&
|
||||
reason?.code === 'satisfied-window-prefix-bound' &&
|
||||
reason.ruleIds?.includes(window.id),
|
||||
),
|
||||
).toBe(true);
|
||||
const expiry = first.trace?.phases.find(({ name }) => name === 'expiry');
|
||||
expect(expiry?.completed).toBe(true);
|
||||
expect(
|
||||
expiry?.decisions.some(({ candidates }) =>
|
||||
candidates.some(({ comparison }) => comparison),
|
||||
),
|
||||
).toBe(true);
|
||||
const reordered = run([presets[1], loss, ...presets.slice(2)]);
|
||||
expect(reordered.outcome.kind).toBe('Plan');
|
||||
expect(
|
||||
reordered.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(({ reason }) => reason?.code === 'quantity-prefix-lower-bound'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['sum', 'count', 'maximum'] as const)(
|
||||
@@ -283,12 +181,10 @@ describe('integrated optimization and earned leave', () => {
|
||||
id: 'personal-holiday',
|
||||
configuration: { ...OPTIMIZATION_PRESETS.holidayWeekValue, aggregation },
|
||||
};
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{ ...composedRequest, rules: [presets[0], holiday, ...presets.slice(2)] },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const outcome = calculatePlanningInternal({
|
||||
...composedRequest,
|
||||
rules: [presets[0], holiday, ...presets.slice(2)],
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [
|
||||
@@ -298,85 +194,43 @@ describe('integrated optimization and earned leave', () => {
|
||||
],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
expect(
|
||||
artifacts.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' &&
|
||||
reason?.code === 'satisfied-holiday-prefix-bound' &&
|
||||
reason.ruleIds?.includes(holiday.id),
|
||||
),
|
||||
).toBe(true);
|
||||
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
|
||||
expect(final?.completed).toBe(true);
|
||||
expect(
|
||||
final?.decisions.some(({ candidates }) =>
|
||||
candidates.some(({ comparison }) => comparison),
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('commits a strict holiday bundle before deferring old generic alternatives and preserves higher rules', () => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2026-11-01',
|
||||
horizon: { start: '2026-11-02', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2026-11-30' }],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
id: 'mandatory',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
{
|
||||
id: 'earlier-window',
|
||||
type: 'time_window',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const outcome = calculatePlanningInternal({
|
||||
calculationDate: '2026-11-01',
|
||||
horizon: { start: '2026-11-02', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2026-11-30' }],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
id: 'mandatory',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
{
|
||||
id: 'earlier-window',
|
||||
type: 'time_window',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2026-11-02' }, { date: '2026-11-12' }, { date: '2026-11-13' }],
|
||||
preferences: [{ ruleId: 'earlier-window', satisfied: 1 }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 3 },
|
||||
});
|
||||
const holiday = artifacts.trace?.phases.find(
|
||||
({ name }) => name === `optimization:${presets[1].id}`,
|
||||
);
|
||||
const decisions = holiday?.decisions.flatMap(({ candidates }) => candidates);
|
||||
expect(
|
||||
decisions?.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' && reason?.code === 'strict-prefix-bundle-progress',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(decisions?.some(({ status, comparison }) => status === 'chosen' && comparison)).toBe(
|
||||
true,
|
||||
);
|
||||
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
|
||||
expect(final?.completed).toBe(true);
|
||||
expect(
|
||||
final?.decisions.some(({ candidates }) =>
|
||||
candidates.some(({ comparison }) => comparison),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
|
||||
@@ -254,17 +254,6 @@ describe('explicit active planning objectives', () => {
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
const trace = await calculation.trace;
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions)
|
||||
.flatMap(({ candidates }) => candidates)
|
||||
.some(
|
||||
({ sourceDate, dates }) =>
|
||||
sourceDate === '2024-01-02' &&
|
||||
dates?.join(',') === '2024-01-02,2024-01-03,2024-01-04',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('satisfies a lower IF_THEN pattern by reassigning its complete typed bundle', async () => {
|
||||
@@ -376,17 +365,6 @@ describe('explicit active planning objectives', () => {
|
||||
expect(
|
||||
outcome.placements.filter(({ ruleIds }) => ruleIds.includes(rule.id)),
|
||||
).toHaveLength(2);
|
||||
const trace = await calculation.trace;
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions)
|
||||
.flatMap(({ candidates }) => candidates)
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' &&
|
||||
reason?.code === 'window-obligation-preview-sampling',
|
||||
),
|
||||
).toBe(true);
|
||||
const reversed = await createPlanningCalculation({
|
||||
...request,
|
||||
rules: [...obligations, personal, loss],
|
||||
@@ -492,15 +470,6 @@ describe('explicit active planning objectives', () => {
|
||||
expect(explanation.placements[0].objectiveVector.objectives[1].label).toBe(
|
||||
'Prevent losses',
|
||||
);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map((phase) => phase.name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:loss',
|
||||
'preference:personal',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (comparison.kind !== 'Counterfactual') throw new Error('Expected comparison');
|
||||
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual(ids);
|
||||
|
||||
@@ -241,12 +241,6 @@ describe('saved rule migration', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
expect(await calculation.explanation).toMatchObject({
|
||||
kind: 'PlanExplanation',
|
||||
placements: [],
|
||||
@@ -277,12 +271,5 @@ describe('saved rule migration', () => {
|
||||
metrics: { unplacedAtRisk: 0 },
|
||||
placements: [{ balanceId: 'cp' }],
|
||||
});
|
||||
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
...request.rules.map(({ id }) => `optimization:${id}`),
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -39,14 +39,10 @@ export interface AppTranslations {
|
||||
durationEstimate: string;
|
||||
durationUnits: { seconds: string; minutes: string };
|
||||
remainingDaysWarning: string;
|
||||
remainingWhy: string;
|
||||
remainingValidity: string;
|
||||
remainingHorizon: string;
|
||||
remainingEarnedFrom: string;
|
||||
remainingHistoryHelp: string;
|
||||
remainingNoEvaluations: string;
|
||||
remainingSuggestions: Record<string, string>;
|
||||
remainingCandidateReasons: Record<string, string>;
|
||||
deleteEntry: string;
|
||||
chooseDate: string;
|
||||
availableDate: string;
|
||||
@@ -177,7 +173,6 @@ export interface AppTranslations {
|
||||
includedEntitlement: string;
|
||||
comparison: {
|
||||
title: string;
|
||||
candidate: string;
|
||||
preview: string;
|
||||
nearby: string;
|
||||
noAlternatives: string;
|
||||
@@ -188,7 +183,6 @@ export interface AppTranslations {
|
||||
available: string;
|
||||
unavailable: string;
|
||||
backToPlan: string;
|
||||
backToTrace: string;
|
||||
};
|
||||
details: {
|
||||
intro: string;
|
||||
@@ -197,10 +191,6 @@ export interface AppTranslations {
|
||||
expires: string;
|
||||
criteria: string;
|
||||
criteriaHelp: string;
|
||||
traceImpactTitle: string;
|
||||
traceImpactHelp: string;
|
||||
traceStageTitle: string;
|
||||
traceStageHelp: string;
|
||||
firstDifference: string;
|
||||
higher: string;
|
||||
lower: string;
|
||||
@@ -233,9 +223,6 @@ export interface AppTranslations {
|
||||
alternative: string;
|
||||
sameDate: string;
|
||||
moveHelp: string;
|
||||
traceHelp: string;
|
||||
beforeDecision: string;
|
||||
withCandidate: string;
|
||||
comparisonUnavailable: string;
|
||||
objectives: Record<string, { label: string; help: string }>;
|
||||
preferenceHelp: Record<string, string | undefined>;
|
||||
@@ -245,11 +232,9 @@ export interface AppTranslations {
|
||||
title: string;
|
||||
calendarHelp: string;
|
||||
explanationHelp: string;
|
||||
traceHelp: string;
|
||||
technicalDetails: string;
|
||||
compareDates: string;
|
||||
stopComparing: string;
|
||||
replayNotice: string;
|
||||
daysPlaced: string;
|
||||
daysGained: string;
|
||||
longestWorkSequence: string;
|
||||
@@ -267,7 +252,6 @@ export interface AppTranslations {
|
||||
placementNavigation: string;
|
||||
placement: string;
|
||||
placements: string;
|
||||
decision: string;
|
||||
of: string;
|
||||
first: string;
|
||||
previous: string;
|
||||
@@ -280,13 +264,8 @@ export interface AppTranslations {
|
||||
counterfactualReady: string;
|
||||
counterfactualError: string;
|
||||
retryCounterfactual: string;
|
||||
historicalEligibilityLoading: string;
|
||||
historicalEligibilityReady: string;
|
||||
historicalEligibilityError: string;
|
||||
retryHistoricalEligibility: string;
|
||||
eligible: string;
|
||||
ineligible: string;
|
||||
historical: string;
|
||||
reason: string;
|
||||
explanationError: string;
|
||||
conflictDetails: string;
|
||||
@@ -300,53 +279,6 @@ export interface AppTranslations {
|
||||
currentPlacement: string;
|
||||
explainedPeriod: string;
|
||||
outsideHorizon: string;
|
||||
traceTitle: string;
|
||||
traceLoading: string;
|
||||
traceReady: string;
|
||||
traceUnavailable: string;
|
||||
phases: string;
|
||||
firstPhase: string;
|
||||
previousPhase: string;
|
||||
nextPhase: string;
|
||||
lastPhase: string;
|
||||
decisions: string;
|
||||
firstDecision: string;
|
||||
previousDecision: string;
|
||||
nextDecision: string;
|
||||
lastDecision: string;
|
||||
replay: string;
|
||||
before: string;
|
||||
after: string;
|
||||
finalPlan: string;
|
||||
dates: string;
|
||||
candidates: string;
|
||||
previousCandidates: string;
|
||||
nextCandidates: string;
|
||||
page: string;
|
||||
compareAlternatives: string;
|
||||
deltas: string;
|
||||
activations: string;
|
||||
noDecisions: string;
|
||||
traceStatuses: {
|
||||
chosen: string;
|
||||
'evaluated-rejected': string;
|
||||
pruned: string;
|
||||
'not-examined': string;
|
||||
ineligible: string;
|
||||
};
|
||||
tracePhaseStatuses: {
|
||||
completed: string;
|
||||
interrupted: string;
|
||||
'not-run': string;
|
||||
};
|
||||
traceChanges: {
|
||||
added: string;
|
||||
'moved-from': string;
|
||||
'moved-to': string;
|
||||
removed: string;
|
||||
shared: string;
|
||||
unchanged: string;
|
||||
};
|
||||
};
|
||||
benchmark: {
|
||||
title: string;
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
isWorkingPlanningDate,
|
||||
parseDeclaredDays,
|
||||
} from './declared-non-working-days';
|
||||
import type {
|
||||
LeaveType,
|
||||
PlanningOutcome,
|
||||
PlanningSnapshot,
|
||||
PlanningTrace,
|
||||
} from './leave-planner.service';
|
||||
import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -169,8 +164,6 @@ export class UserInputService {
|
||||
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
||||
readonly planningOutcomeSignal = signal<PlanningOutcome | null>(null);
|
||||
readonly planningSnapshotSignal = signal<PlanningSnapshot | null>(null);
|
||||
readonly planningTraceSignal = signal<PlanningTrace | null>(null);
|
||||
readonly traceResolvedSignal = signal(false);
|
||||
readonly displayedPlanSignal = computed(() => {
|
||||
const outcome = this.planningOutcomeSignal();
|
||||
return outcome?.kind === 'Plan'
|
||||
|
||||
+1
-75
@@ -34,25 +34,15 @@
|
||||
"daySuffix": "Tag",
|
||||
"daysSuffix": "Tage",
|
||||
"expires": "abl.",
|
||||
"remainingWhy": "Warum?",
|
||||
"remainingValidity": "Nutzbar vom {available} bis zum {expiry}.",
|
||||
"remainingHorizon": "Berechneter Plan vom {start} bis zum {end}.",
|
||||
"remainingEarnedFrom": "Durch die Regel „{rule}“ erworbener Urlaub.",
|
||||
"remainingHistoryHelp": "Diese Daten wurden während der Berechnung tatsächlich geprüft. Die Werte vergleichen jeden Versuch mit seinem Ausgangszustand; der endgültige Plan kann abweichen.",
|
||||
"remainingNoEvaluations": "Für dieses Urlaubskonto wurde kein zuordenbarer Versuch aufgezeichnet. Die Ablaufkategorie allein erklärt nicht, warum es ungenutzt bleibt.",
|
||||
"remainingSuggestions": {
|
||||
"available-after-horizon": "Sie können den Planungszeitraum über das Verfügbarkeitsdatum hinaus verlängern und neu berechnen.",
|
||||
"no-working-date": "Prüfen Sie den Planungszeitraum und die Gültigkeitsdaten dieses Urlaubskontos.",
|
||||
"all-dates-occupied": "Sie können den Zeitraum bei entsprechender Gültigkeit verlängern oder Ihre bereits gebuchten Tage und Regeln anpassen und neu berechnen.",
|
||||
"preserved": "Keine Aktion nötig. Um die Nutzung anzufordern, fügen Sie eine Regel innerhalb der Gültigkeit hinzu und berechnen Sie neu.",
|
||||
"calculation-incomplete": "Starten Sie die Berechnung erneut, wenn Sie die Planung abschließen möchten."
|
||||
},
|
||||
"remainingCandidateReasons": {
|
||||
"quality-no-improvement": "Diese Möglichkeit wurde in dieser Berechnungsphase nicht ausgewählt.",
|
||||
"quality-protected-objective": "Diese Möglichkeit würde eine zu erhaltende Priorität verschlechtern.",
|
||||
"quality-outperformed": "Die Berechnung fand später eine besser bewertete Möglichkeit.",
|
||||
"earned-entitlement-order": "Diese Möglichkeit hält die Verfügbarkeitsdaten des erworbenen Urlaubs nicht ein.",
|
||||
"unknown": "Der genaue Grund für diesen Versuch ist nicht verfügbar."
|
||||
}
|
||||
},
|
||||
"dayTypes": {
|
||||
@@ -218,7 +208,6 @@
|
||||
"placementNavigation": "Navigation der Platzierungserklärungen",
|
||||
"placement": "Urlaubstag",
|
||||
"placements": "Platzierungen",
|
||||
"decision": "Entscheidung",
|
||||
"of": "von",
|
||||
"first": "Erste Platzierung",
|
||||
"previous": "Vorherige Platzierung",
|
||||
@@ -231,13 +220,8 @@
|
||||
"counterfactualReady": "Alternative Termine sind bereit.",
|
||||
"counterfactualError": "Alternative Termine konnten nicht berechnet werden.",
|
||||
"retryCounterfactual": "Datumsanalyse erneut versuchen",
|
||||
"historicalEligibilityLoading": "Prüfe, an welchen Tagen dieses Urlaubsguthaben verfügbar war …",
|
||||
"historicalEligibilityReady": "Die historische Datumseignung ist bereit.",
|
||||
"historicalEligibilityError": "Die historische Datumseignung konnte nicht geprüft werden.",
|
||||
"retryHistoricalEligibility": "Historische Datumseignung erneut prüfen",
|
||||
"eligible": "Geeignet",
|
||||
"ineligible": "Nicht geeignet",
|
||||
"historical": "Status der ursprünglichen Suche",
|
||||
"reason": "Grund",
|
||||
"explanationError": "Die Erklärung konnte nicht erstellt werden.",
|
||||
"conflictDetails": "Warum kein Plan die Verpflichtungen erfüllt",
|
||||
@@ -251,60 +235,11 @@
|
||||
"currentPlacement": "Aktuell erklärte Platzierung",
|
||||
"explainedPeriod": "Erklärter Freizeitzeitraum",
|
||||
"outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts",
|
||||
"traceTitle": "Erweitert: ursprüngliche Entscheidungsspur",
|
||||
"traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…",
|
||||
"traceReady": "Der Plan ist fertig und seine Entscheidungsspur wurde geladen.",
|
||||
"traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.",
|
||||
"phases": "Spurphasen",
|
||||
"firstPhase": "Erste Phase",
|
||||
"previousPhase": "Vorherige Phase",
|
||||
"nextPhase": "Nächste Phase",
|
||||
"lastPhase": "Letzte Phase",
|
||||
"decisions": "Atomare Entscheidungen",
|
||||
"firstDecision": "Erste Entscheidung",
|
||||
"previousDecision": "Vorherige Entscheidung",
|
||||
"nextDecision": "Nächste Entscheidung",
|
||||
"lastDecision": "Letzte Entscheidung",
|
||||
"replay": "Entscheidungszustand",
|
||||
"before": "Vorher",
|
||||
"after": "Nachher",
|
||||
"finalPlan": "Endgültiger Plan",
|
||||
"dates": "Daten",
|
||||
"candidates": "Kandidaten",
|
||||
"previousCandidates": "Vorherige Kandidatenseite",
|
||||
"nextCandidates": "Nächste Kandidatenseite",
|
||||
"page": "Seite",
|
||||
"compareAlternatives": "Diesen Kandidaten im gesamten Horizont vergleichen",
|
||||
"deltas": "Zustandsänderungen",
|
||||
"activations": "erworbene Aktivierungen",
|
||||
"noDecisions": "Diese Phase enthält keine atomare Entscheidung.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Ausgewählt",
|
||||
"evaluated-rejected": "Bewertet und abgelehnt",
|
||||
"pruned": "Verworfen",
|
||||
"not-examined": "Nicht untersucht",
|
||||
"ineligible": "Nicht geeignet"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Abgeschlossen",
|
||||
"interrupted": "Unterbrochen",
|
||||
"not-run": "Nicht ausgeführt"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Durch diese Entscheidung hinzugefügt",
|
||||
"moved-from": "Von diesem Datum verschoben",
|
||||
"moved-to": "Auf dieses Datum verschoben",
|
||||
"removed": "Durch diese Entscheidung entfernt",
|
||||
"shared": "Durch diese Entscheidung geteilt",
|
||||
"unchanged": "Durch diese Entscheidung unverändert"
|
||||
},
|
||||
"calendarHelp": "Wählen Sie einen Urlaubstag, um seinen Beitrag zum Plan zu sehen.",
|
||||
"explanationHelp": "Sehen Sie Ihre Auszeit und den Beitrag jedes Tages.",
|
||||
"traceHelp": "Erkunden Sie die Berechnung, Entscheidung für Entscheidung.",
|
||||
"technicalDetails": "Berechnungsdetails",
|
||||
"compareDates": "Andere Termine vergleichen",
|
||||
"stopComparing": "Datumsvergleich schließen",
|
||||
"replayNotice": "Sie sehen einen Berechnungsschritt.",
|
||||
"explanation": {
|
||||
"breakTitle": "Ihre Auszeit",
|
||||
"leaveDays": "eingesetzte Urlaubstage",
|
||||
@@ -328,9 +263,6 @@
|
||||
"alternative": "Mit diesem Datum",
|
||||
"sameDate": "Dies ist das geplante Datum. Wählen Sie oben ein anderes Datum, um den Unterschied zu sehen.",
|
||||
"moveHelp": "Verschieben Sie probeweise diesen Tag. Alle anderen Urlaubstermine bleiben gleich.",
|
||||
"traceHelp": "Vergleichen Sie diesen Kandidaten mit dem Zustand vor seiner ursprünglichen Entscheidung.",
|
||||
"beforeDecision": "Vor dieser Entscheidung",
|
||||
"withCandidate": "Mit diesem Kandidaten",
|
||||
"comparisonUnavailable": "Der Vergleich für das ursprüngliche Datum ist nicht verfügbar. Daher lässt sich die Änderung nicht anzeigen.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
@@ -407,10 +339,6 @@
|
||||
"expires": "Verfällt am",
|
||||
"criteria": "Die Prioritäten des Planers",
|
||||
"criteriaHelp": "Von oben nach unten lesen: Der erste Unterschied hat Vorrang. Wählen Sie ein Kriterium, um seine Berechnung zu sehen.",
|
||||
"traceImpactTitle": "Auswirkung dieses Zweigs auf den Gesamtplan",
|
||||
"traceImpactHelp": "Diese Werte vergleichen den vollständigen Zustand vor und nach diesem Zweig. Sie zeigen seine Auswirkungen, aber nicht, warum der Planer ihn gewählt hat.",
|
||||
"traceStageTitle": "Tatsächlicher Vergleich in dieser Planungsstufe",
|
||||
"traceStageHelp": "Diese Rohwerte wurden bei dieser Entscheidung verglichen; der erste Unterschied erklärt den Status des Zweigs. Die Tabelle zum Gesamtplan unten zeigt die Auswirkung dieser Wahl.",
|
||||
"firstDifference": "Erster Unterschied",
|
||||
"higher": "Höher ist besser",
|
||||
"lower": "Niedriger ist besser",
|
||||
@@ -431,7 +359,6 @@
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Daten vergleichen",
|
||||
"candidate": "Datum des Kandidaten",
|
||||
"preview": "Nur eine Vorschau. Ihr Plan bleibt unverändert.",
|
||||
"nearby": "Verfügbare Termine in der Nähe",
|
||||
"noAlternatives": "Dieser Urlaubstag kann auf kein anderes Datum gelegt werden. Sie können trotzdem ein Datum prüfen, um den Grund zu sehen.",
|
||||
@@ -441,8 +368,7 @@
|
||||
"changes": "Was sich ändern würde",
|
||||
"available": "Verfügbares Datum",
|
||||
"unavailable": "Dieses Datum ist nicht möglich",
|
||||
"backToPlan": "Zurück zu meinem Plan",
|
||||
"backToTrace": "Zurück zur Berechnung"
|
||||
"backToPlan": "Zurück zu meinem Plan"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
|
||||
+1
-75
@@ -34,25 +34,15 @@
|
||||
"daySuffix": "day",
|
||||
"daysSuffix": "days",
|
||||
"expires": "exp.",
|
||||
"remainingWhy": "See why",
|
||||
"remainingValidity": "Usable from {available} through {expiry}.",
|
||||
"remainingHorizon": "Calculated plan from {start} through {end}.",
|
||||
"remainingEarnedFrom": "Leave earned through the rule “{rule}”.",
|
||||
"remainingHistoryHelp": "These dates were actually examined during the calculation. The values compare each attempt with its starting state; the final plan may differ.",
|
||||
"remainingNoEvaluations": "No attempt attributable to this balance was recorded. Its expiry category alone does not explain why it remains unused.",
|
||||
"remainingSuggestions": {
|
||||
"available-after-horizon": "You can extend the planning period beyond its availability date, then recalculate.",
|
||||
"no-working-date": "Check the planning period and this balance’s validity dates.",
|
||||
"all-dates-occupied": "You can extend the period if validity allows, or adjust your already booked days and rules, then recalculate.",
|
||||
"preserved": "No action needed. To request its use, add a rule within its validity period, then recalculate.",
|
||||
"calculation-incomplete": "Run the calculation again if you want it to finish planning."
|
||||
},
|
||||
"remainingCandidateReasons": {
|
||||
"quality-no-improvement": "This option was not retained at this stage of the calculation.",
|
||||
"quality-protected-objective": "This option would worsen a priority that must be preserved.",
|
||||
"quality-outperformed": "The calculation later found a better-ranked option.",
|
||||
"earned-entitlement-order": "This option does not respect the availability dates of earned leave.",
|
||||
"unknown": "The precise reason for this attempt is unavailable."
|
||||
}
|
||||
},
|
||||
"dayTypes": {
|
||||
@@ -218,7 +208,6 @@
|
||||
"placementNavigation": "Placement explanation navigation",
|
||||
"placement": "Leave day",
|
||||
"placements": "placements",
|
||||
"decision": "Decision",
|
||||
"of": "of",
|
||||
"first": "First placement",
|
||||
"previous": "Previous placement",
|
||||
@@ -231,13 +220,8 @@
|
||||
"counterfactualReady": "Date alternatives are ready.",
|
||||
"counterfactualError": "Date alternatives could not be calculated.",
|
||||
"retryCounterfactual": "Retry date analysis",
|
||||
"historicalEligibilityLoading": "Checking which dates this leave could use…",
|
||||
"historicalEligibilityReady": "Original date eligibility is ready.",
|
||||
"historicalEligibilityError": "Original date eligibility could not be checked.",
|
||||
"retryHistoricalEligibility": "Retry date eligibility check",
|
||||
"eligible": "Eligible",
|
||||
"ineligible": "Ineligible",
|
||||
"historical": "Original search status",
|
||||
"reason": "Reason",
|
||||
"explanationError": "The explanation could not be produced.",
|
||||
"conflictDetails": "Why no plan can satisfy the obligations",
|
||||
@@ -251,60 +235,11 @@
|
||||
"currentPlacement": "Current explained placement",
|
||||
"explainedPeriod": "Explained time-off period",
|
||||
"outsideHorizon": "Context date outside the planning horizon",
|
||||
"traceTitle": "Advanced: original decision trace",
|
||||
"traceLoading": "The plan is ready; its decision trace is still loading…",
|
||||
"traceReady": "The plan is ready and its decision trace has loaded.",
|
||||
"traceUnavailable": "No decision trace is available for this result.",
|
||||
"phases": "Trace phases",
|
||||
"firstPhase": "First phase",
|
||||
"previousPhase": "Previous phase",
|
||||
"nextPhase": "Next phase",
|
||||
"lastPhase": "Last phase",
|
||||
"decisions": "Atomic decisions",
|
||||
"firstDecision": "First decision",
|
||||
"previousDecision": "Previous decision",
|
||||
"nextDecision": "Next decision",
|
||||
"lastDecision": "Last decision",
|
||||
"replay": "Decision state",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"finalPlan": "Final plan",
|
||||
"dates": "Dates",
|
||||
"candidates": "Candidates",
|
||||
"previousCandidates": "Previous candidate page",
|
||||
"nextCandidates": "Next candidate page",
|
||||
"page": "Page",
|
||||
"compareAlternatives": "Compare this candidate across the horizon",
|
||||
"deltas": "State changes",
|
||||
"activations": "earned activations",
|
||||
"noDecisions": "This phase recorded no atomic decision.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Chosen",
|
||||
"evaluated-rejected": "Evaluated and rejected",
|
||||
"pruned": "Pruned",
|
||||
"not-examined": "Not examined",
|
||||
"ineligible": "Ineligible"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Completed",
|
||||
"interrupted": "Interrupted",
|
||||
"not-run": "Not run"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Added by this decision",
|
||||
"moved-from": "Moved from this date",
|
||||
"moved-to": "Moved to this date",
|
||||
"removed": "Removed by this decision",
|
||||
"shared": "Shared by this decision",
|
||||
"unchanged": "Unchanged by this decision"
|
||||
},
|
||||
"calendarHelp": "Select a leave day to see its contribution to your plan.",
|
||||
"explanationHelp": "See the break you get and what each day changes.",
|
||||
"traceHelp": "Explore the calculation, decision by decision.",
|
||||
"technicalDetails": "Calculation details",
|
||||
"compareDates": "Compare other dates",
|
||||
"stopComparing": "Close date comparison",
|
||||
"replayNotice": "You are viewing a calculation step.",
|
||||
"explanation": {
|
||||
"breakTitle": "Your break",
|
||||
"leaveDays": "leave days used",
|
||||
@@ -328,9 +263,6 @@
|
||||
"alternative": "With this date",
|
||||
"sameDate": "This is the planned date. Choose another date above to see the difference.",
|
||||
"moveHelp": "Try moving this one day. All other leave dates stay the same.",
|
||||
"traceHelp": "Test this candidate against the state before its original decision.",
|
||||
"beforeDecision": "Before this decision",
|
||||
"withCandidate": "With this candidate",
|
||||
"comparisonUnavailable": "The comparison for the original date is unavailable, so the change cannot be shown.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
@@ -407,10 +339,6 @@
|
||||
"expires": "Expires on",
|
||||
"criteria": "The planner’s priorities",
|
||||
"criteriaHelp": "Read from top to bottom: the first difference takes priority. Select a criterion to see how it is calculated.",
|
||||
"traceImpactTitle": "Full-plan effect of this branch",
|
||||
"traceImpactHelp": "These values compare the complete state before and after this branch. They show its impact; they do not explain why the planner chose it.",
|
||||
"traceStageTitle": "Actual stage comparison",
|
||||
"traceStageHelp": "These raw values were compared at this decision; the first difference explains the candidate’s status. The full-plan table below shows the effect after this choice.",
|
||||
"firstDifference": "First difference",
|
||||
"higher": "Higher is better",
|
||||
"lower": "Lower is better",
|
||||
@@ -431,7 +359,6 @@
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Compare dates",
|
||||
"candidate": "Candidate date",
|
||||
"preview": "Preview only. Your plan stays unchanged.",
|
||||
"nearby": "Nearby available dates",
|
||||
"noAlternatives": "No other date can use this leave day. You can still check a date to see why.",
|
||||
@@ -441,8 +368,7 @@
|
||||
"changes": "What would change",
|
||||
"available": "Available date",
|
||||
"unavailable": "This date can’t be used",
|
||||
"backToPlan": "Back to my plan",
|
||||
"backToTrace": "Back to the calculation"
|
||||
"backToPlan": "Back to my plan"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
|
||||
+1
-75
@@ -34,25 +34,15 @@
|
||||
"daySuffix": "día",
|
||||
"daysSuffix": "días",
|
||||
"expires": "exp.",
|
||||
"remainingWhy": "Ver por qué",
|
||||
"remainingValidity": "Utilizable del {available} al {expiry}.",
|
||||
"remainingHorizon": "Plan calculado del {start} al {end}.",
|
||||
"remainingEarnedFrom": "Permiso obtenido gracias a la regla «{rule}».",
|
||||
"remainingHistoryHelp": "Estas fechas se examinaron realmente durante el cálculo. Los valores comparan cada intento con su estado inicial; el plan final puede ser diferente.",
|
||||
"remainingNoEvaluations": "No se registró ningún intento atribuible a este saldo. Su categoría de caducidad por sí sola no explica por qué sigue sin utilizarse.",
|
||||
"remainingSuggestions": {
|
||||
"available-after-horizon": "Puedes ampliar el período planificado más allá de su fecha de disponibilidad y volver a calcular.",
|
||||
"no-working-date": "Comprueba el período planificado y las fechas de validez de este saldo.",
|
||||
"all-dates-occupied": "Puedes ampliar el período si la validez lo permite, o ajustar los días ya reservados y tus reglas, y volver a calcular.",
|
||||
"preserved": "No es necesario hacer nada. Para solicitar su uso, añade una regla dentro de su período de validez y vuelve a calcular.",
|
||||
"calculation-incomplete": "Repite el cálculo si quieres que termine la planificación."
|
||||
},
|
||||
"remainingCandidateReasons": {
|
||||
"quality-no-improvement": "Esta opción no se seleccionó en esta fase del cálculo.",
|
||||
"quality-protected-objective": "Esta opción empeoraría una prioridad que debe conservarse.",
|
||||
"quality-outperformed": "El cálculo encontró después una opción mejor clasificada.",
|
||||
"earned-entitlement-order": "Esta opción no respeta las fechas de disponibilidad de los permisos obtenidos.",
|
||||
"unknown": "El motivo preciso de este intento no está disponible."
|
||||
}
|
||||
},
|
||||
"dayTypes": {
|
||||
@@ -218,7 +208,6 @@
|
||||
"placementNavigation": "Navegación por las explicaciones de las asignaciones",
|
||||
"placement": "Día de permiso",
|
||||
"placements": "asignaciones",
|
||||
"decision": "Decisión",
|
||||
"of": "de",
|
||||
"first": "Primera asignación",
|
||||
"previous": "Asignación anterior",
|
||||
@@ -231,13 +220,8 @@
|
||||
"counterfactualReady": "Las fechas alternativas están listas.",
|
||||
"counterfactualError": "No se pudieron calcular las fechas alternativas.",
|
||||
"retryCounterfactual": "Reintentar el análisis de fechas",
|
||||
"historicalEligibilityLoading": "Comprobando qué fechas podía usar este permiso…",
|
||||
"historicalEligibilityReady": "La elegibilidad histórica de las fechas está lista.",
|
||||
"historicalEligibilityError": "No se pudo comprobar la elegibilidad histórica de las fechas.",
|
||||
"retryHistoricalEligibility": "Reintentar la comprobación de elegibilidad",
|
||||
"eligible": "Válida",
|
||||
"ineligible": "No válida",
|
||||
"historical": "Estado de la búsqueda original",
|
||||
"reason": "Motivo",
|
||||
"explanationError": "No se pudo generar la explicación.",
|
||||
"conflictDetails": "Por qué ningún plan satisface las obligaciones",
|
||||
@@ -251,60 +235,11 @@
|
||||
"currentPlacement": "Asignación explicada actualmente",
|
||||
"explainedPeriod": "Período de descanso explicado",
|
||||
"outsideHorizon": "Fecha de contexto fuera del horizonte de planificación",
|
||||
"traceTitle": "Avanzado: traza de decisiones original",
|
||||
"traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…",
|
||||
"traceReady": "El plan está listo y se ha cargado su traza de decisiones.",
|
||||
"traceUnavailable": "No hay una traza de decisiones disponible para este resultado.",
|
||||
"phases": "Fases de la traza",
|
||||
"firstPhase": "Primera fase",
|
||||
"previousPhase": "Fase anterior",
|
||||
"nextPhase": "Fase siguiente",
|
||||
"lastPhase": "Última fase",
|
||||
"decisions": "Decisiones atómicas",
|
||||
"firstDecision": "Primera decisión",
|
||||
"previousDecision": "Decisión anterior",
|
||||
"nextDecision": "Decisión siguiente",
|
||||
"lastDecision": "Última decisión",
|
||||
"replay": "Estado de la decisión",
|
||||
"before": "Antes",
|
||||
"after": "Después",
|
||||
"finalPlan": "Plan final",
|
||||
"dates": "Fechas",
|
||||
"candidates": "Candidatos",
|
||||
"previousCandidates": "Página de candidatos anterior",
|
||||
"nextCandidates": "Página de candidatos siguiente",
|
||||
"page": "Página",
|
||||
"compareAlternatives": "Comparar este candidato en todo el horizonte",
|
||||
"deltas": "Cambios de estado",
|
||||
"activations": "activaciones obtenidas",
|
||||
"noDecisions": "Esta fase no registró ninguna decisión atómica.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Elegido",
|
||||
"evaluated-rejected": "Evaluado y rechazado",
|
||||
"pruned": "Podado",
|
||||
"not-examined": "No examinado",
|
||||
"ineligible": "No válido"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Completada",
|
||||
"interrupted": "Interrumpida",
|
||||
"not-run": "No ejecutada"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Añadido por esta decisión",
|
||||
"moved-from": "Movido desde esta fecha",
|
||||
"moved-to": "Movido a esta fecha",
|
||||
"removed": "Eliminado por esta decisión",
|
||||
"shared": "Compartido por esta decisión",
|
||||
"unchanged": "Sin cambios por esta decisión"
|
||||
},
|
||||
"calendarHelp": "Selecciona un día de permiso para ver su aportación al plan.",
|
||||
"explanationHelp": "Consulta tu descanso y qué cambia con cada día.",
|
||||
"traceHelp": "Explora el cálculo, decisión a decisión.",
|
||||
"technicalDetails": "Detalles del cálculo",
|
||||
"compareDates": "Comparar otras fechas",
|
||||
"stopComparing": "Cerrar comparación",
|
||||
"replayNotice": "Estás viendo un paso del cálculo.",
|
||||
"explanation": {
|
||||
"breakTitle": "Tu descanso",
|
||||
"leaveDays": "días de permiso usados",
|
||||
@@ -328,9 +263,6 @@
|
||||
"alternative": "Con esta fecha",
|
||||
"sameDate": "Esta es la fecha prevista. Elige otra fecha arriba para ver la diferencia.",
|
||||
"moveHelp": "Prueba a mover este día. Las demás fechas de permiso no cambian.",
|
||||
"traceHelp": "Compara este candidato con el estado anterior a su decisión original.",
|
||||
"beforeDecision": "Antes de esta decisión",
|
||||
"withCandidate": "Con este candidato",
|
||||
"comparisonUnavailable": "La comparación de la fecha original no está disponible, por lo que no se puede mostrar el cambio.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
@@ -407,10 +339,6 @@
|
||||
"expires": "Vence el",
|
||||
"criteria": "Las prioridades del cálculo",
|
||||
"criteriaHelp": "Lee de arriba abajo: la primera diferencia tiene prioridad. Selecciona un criterio para ver cómo se calcula.",
|
||||
"traceImpactTitle": "Efecto de esta rama en el plan completo",
|
||||
"traceImpactHelp": "Estos valores comparan el estado completo antes y después de esta rama. Muestran su efecto, no por qué el planificador la eligió.",
|
||||
"traceStageTitle": "Comparación real en esta etapa",
|
||||
"traceStageHelp": "Estos valores brutos se compararon en esta decisión; la primera diferencia explica el estado de la opción. La tabla del plan completo muestra el efecto de esta elección.",
|
||||
"firstDifference": "Primera diferencia",
|
||||
"higher": "Más es mejor",
|
||||
"lower": "Menos es mejor",
|
||||
@@ -431,7 +359,6 @@
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Comparar fechas",
|
||||
"candidate": "Fecha del candidato",
|
||||
"preview": "Solo una vista previa. Tu plan no cambia.",
|
||||
"nearby": "Fechas cercanas disponibles",
|
||||
"noAlternatives": "No hay otra fecha disponible para este día de permiso. Puedes comprobar una fecha para ver el motivo.",
|
||||
@@ -441,8 +368,7 @@
|
||||
"changes": "Qué cambiaría",
|
||||
"available": "Fecha disponible",
|
||||
"unavailable": "Esta fecha no se puede usar",
|
||||
"backToPlan": "Volver a mi plan",
|
||||
"backToTrace": "Volver al cálculo"
|
||||
"backToPlan": "Volver a mi plan"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
|
||||
+1
-75
@@ -34,25 +34,15 @@
|
||||
"daySuffix": "jour",
|
||||
"daysSuffix": "jours",
|
||||
"expires": "exp.",
|
||||
"remainingWhy": "Voir pourquoi",
|
||||
"remainingValidity": "Utilisable du {available} au {expiry}.",
|
||||
"remainingHorizon": "Planning calculé du {start} au {end}.",
|
||||
"remainingEarnedFrom": "Congés gagnés grâce à la règle « {rule} ».",
|
||||
"remainingHistoryHelp": "Ces dates ont réellement été examinées pendant le calcul. Les valeurs comparent chaque essai à son état de départ ; le planning final peut être différent.",
|
||||
"remainingNoEvaluations": "Aucun essai attribuable à ce solde n’a été enregistré. La catégorie d’expiration ne suffit pas à expliquer pourquoi il reste inutilisé.",
|
||||
"remainingSuggestions": {
|
||||
"available-after-horizon": "Vous pouvez prolonger la période planifiée au-delà de sa date de disponibilité, puis recalculer.",
|
||||
"no-working-date": "Vérifiez la période planifiée et les dates de validité de ce solde.",
|
||||
"all-dates-occupied": "Vous pouvez élargir la période si la validité le permet, ou ajuster vos jours déjà posés et vos règles, puis recalculer.",
|
||||
"preserved": "Aucune action nécessaire. Pour demander son utilisation, ajoutez une règle sur une période où il est utilisable, puis recalculez.",
|
||||
"calculation-incomplete": "Relancez le calcul si vous souhaitez qu’il termine la planification."
|
||||
},
|
||||
"remainingCandidateReasons": {
|
||||
"quality-no-improvement": "Cette possibilité n’a pas été retenue à cette étape du calcul.",
|
||||
"quality-protected-objective": "Cette possibilité dégraderait une priorité à préserver.",
|
||||
"quality-outperformed": "Le calcul a ensuite trouvé une possibilité mieux classée.",
|
||||
"earned-entitlement-order": "Cette possibilité ne respecte pas les dates de disponibilité des congés gagnés.",
|
||||
"unknown": "La raison précise de cet essai n’est pas disponible."
|
||||
}
|
||||
},
|
||||
"dayTypes": {
|
||||
@@ -218,7 +208,6 @@
|
||||
"placementNavigation": "Navigation entre les explications des placements",
|
||||
"placement": "Jour de congé",
|
||||
"placements": "placements",
|
||||
"decision": "Décision",
|
||||
"of": "sur",
|
||||
"first": "Premier placement",
|
||||
"previous": "Placement précédent",
|
||||
@@ -231,13 +220,8 @@
|
||||
"counterfactualReady": "Les dates alternatives sont prêtes.",
|
||||
"counterfactualError": "Les dates alternatives n’ont pas pu être calculées.",
|
||||
"retryCounterfactual": "Relancer l’analyse des dates",
|
||||
"historicalEligibilityLoading": "Vérification des dates utilisables pour ce congé…",
|
||||
"historicalEligibilityReady": "L’éligibilité historique des dates est prête.",
|
||||
"historicalEligibilityError": "L’éligibilité historique des dates n’a pas pu être vérifiée.",
|
||||
"retryHistoricalEligibility": "Revérifier l’éligibilité historique",
|
||||
"eligible": "Éligible",
|
||||
"ineligible": "Inéligible",
|
||||
"historical": "Statut de la recherche originale",
|
||||
"reason": "Raison",
|
||||
"explanationError": "L’explication n’a pas pu être produite.",
|
||||
"conflictDetails": "Pourquoi aucun plan ne satisfait les obligations",
|
||||
@@ -251,60 +235,11 @@
|
||||
"currentPlacement": "Placement expliqué actuellement",
|
||||
"explainedPeriod": "Période de repos expliquée",
|
||||
"outsideHorizon": "Date de contexte hors de l’horizon de planification",
|
||||
"traceTitle": "Avancé : trace des décisions originales",
|
||||
"traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…",
|
||||
"traceReady": "Le plan est prêt et sa trace de décision est chargée.",
|
||||
"traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.",
|
||||
"phases": "Phases de la trace",
|
||||
"firstPhase": "Première phase",
|
||||
"previousPhase": "Phase précédente",
|
||||
"nextPhase": "Phase suivante",
|
||||
"lastPhase": "Dernière phase",
|
||||
"decisions": "Décisions atomiques",
|
||||
"firstDecision": "Première décision",
|
||||
"previousDecision": "Décision précédente",
|
||||
"nextDecision": "Décision suivante",
|
||||
"lastDecision": "Dernière décision",
|
||||
"replay": "État de la décision",
|
||||
"before": "Avant",
|
||||
"after": "Après",
|
||||
"finalPlan": "Plan final",
|
||||
"dates": "Dates",
|
||||
"candidates": "Candidats",
|
||||
"previousCandidates": "Page de candidats précédente",
|
||||
"nextCandidates": "Page de candidats suivante",
|
||||
"page": "Page",
|
||||
"compareAlternatives": "Comparer ce candidat sur tout l’horizon",
|
||||
"deltas": "Changements d’état",
|
||||
"activations": "activations acquises",
|
||||
"noDecisions": "Cette phase n’a enregistré aucune décision atomique.",
|
||||
"traceStatuses": {
|
||||
"chosen": "Choisi",
|
||||
"evaluated-rejected": "Évalué et rejeté",
|
||||
"pruned": "Élagué",
|
||||
"not-examined": "Non examiné",
|
||||
"ineligible": "Inéligible"
|
||||
},
|
||||
"tracePhaseStatuses": {
|
||||
"completed": "Terminée",
|
||||
"interrupted": "Interrompue",
|
||||
"not-run": "Non exécutée"
|
||||
},
|
||||
"traceChanges": {
|
||||
"added": "Ajouté par cette décision",
|
||||
"moved-from": "Déplacé depuis cette date",
|
||||
"moved-to": "Déplacé vers cette date",
|
||||
"removed": "Retiré par cette décision",
|
||||
"shared": "Partagé par cette décision",
|
||||
"unchanged": "Inchangé par cette décision"
|
||||
},
|
||||
"calendarHelp": "Sélectionnez un jour de congé pour voir son apport au planning.",
|
||||
"explanationHelp": "Découvrez votre pause et ce que chaque jour change.",
|
||||
"traceHelp": "Explorez le calcul, décision par décision.",
|
||||
"technicalDetails": "Détails du calcul",
|
||||
"compareDates": "Comparer d’autres dates",
|
||||
"stopComparing": "Fermer la comparaison",
|
||||
"replayNotice": "Vous consultez une étape du calcul.",
|
||||
"explanation": {
|
||||
"breakTitle": "Votre pause",
|
||||
"leaveDays": "jours de congé posés",
|
||||
@@ -328,9 +263,6 @@
|
||||
"alternative": "Avec cette date",
|
||||
"sameDate": "C’est la date prévue. Choisissez une autre date ci-dessus pour voir la différence.",
|
||||
"moveHelp": "Essayez de déplacer ce jour. Les autres dates de congé restent identiques.",
|
||||
"traceHelp": "Testez ce candidat par rapport à l’état précédant sa décision d’origine.",
|
||||
"beforeDecision": "Avant cette décision",
|
||||
"withCandidate": "Avec ce candidat",
|
||||
"comparisonUnavailable": "La comparaison pour la date initiale est indisponible ; la différence ne peut donc pas être affichée.",
|
||||
"objectives": {
|
||||
"obligation-validity": {
|
||||
@@ -407,10 +339,6 @@
|
||||
"expires": "Expire le",
|
||||
"criteria": "Les priorités du calcul",
|
||||
"criteriaHelp": "Lisez de haut en bas : la première différence est prioritaire. Sélectionnez un critère pour comprendre son calcul.",
|
||||
"traceImpactTitle": "Effet de cette branche sur le plan complet",
|
||||
"traceImpactHelp": "Ces valeurs comparent l’état complet avant et après cette branche. Elles montrent son effet, pas pourquoi le calcul l’a choisie.",
|
||||
"traceStageTitle": "Comparaison réelle à cette étape",
|
||||
"traceStageHelp": "Ces valeurs brutes ont été comparées lors de cette décision ; la première différence explique le statut de la possibilité. Le tableau du plan complet ci-dessous montre l’effet de ce choix.",
|
||||
"firstDifference": "Première différence",
|
||||
"higher": "Plus, c’est mieux",
|
||||
"lower": "Moins, c’est mieux",
|
||||
@@ -431,7 +359,6 @@
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Comparer les dates",
|
||||
"candidate": "Date du candidat",
|
||||
"preview": "Simulation uniquement. Votre planning reste inchangé.",
|
||||
"nearby": "Dates proches disponibles",
|
||||
"noAlternatives": "Aucune autre date ne permet de poser ce jour. Vous pouvez vérifier une date pour comprendre pourquoi.",
|
||||
@@ -441,8 +368,7 @@
|
||||
"changes": "Ce qui changerait",
|
||||
"available": "Date disponible",
|
||||
"unavailable": "Cette date ne peut pas être utilisée",
|
||||
"backToPlan": "Revenir à mon planning",
|
||||
"backToTrace": "Revenir au calcul"
|
||||
"backToPlan": "Revenir à mon planning"
|
||||
}
|
||||
},
|
||||
"benchmark": {
|
||||
|
||||
Reference in new issue
Block a user