Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc5ee50bd5 | ||
|
|
42134284e7 | ||
|
|
f5d592ea5e | ||
|
|
9e5c93e733 | ||
|
|
581c637029 | ||
|
|
ff6c4d8122 | ||
|
|
d089c4670b | ||
|
|
c6af6d45ab | ||
|
|
58be749949 | ||
|
|
35a63d24c4 | ||
|
|
8414c02e10 | ||
|
|
abfbca1d45 | ||
|
|
b37101435a | ||
|
|
7fc3457b63 |
No files matched your search
@@ -2,6 +2,8 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [develop, main]
|
||||||
|
tags: ['v*']
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened, synchronize, edited]
|
types: [opened, reopened, synchronize, edited]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
@@ -46,3 +46,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Auto-generated i18n files
|
# Auto-generated i18n files
|
||||||
/src/app/i18n-registry.ts
|
/src/app/i18n-registry.ts
|
||||||
|
|
||||||
|
# Claude Code local files (.claude is a symlink to .agents/)
|
||||||
|
.agents/settings.local.json
|
||||||
|
.agents/worktrees/
|
||||||
@@ -16,7 +16,7 @@ This is a single-context repo using root `CONTEXT.md` and `docs/adr/`. See `docs
|
|||||||
|
|
||||||
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their PRs to `develop`.
|
- `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.
|
- 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.
|
- Integrate through PRs with successful required checks and merges restricted to designated maintainers through Gitea's merge allowlist. The `develop` allowlist also admits the `renovate` bot, which automerges minor, patch, digest, and pin updates once `required` passes; major updates stay manual. 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.
|
- 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.
|
- 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.
|
- Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch.
|
||||||
|
|||||||
+12
@@ -24,6 +24,10 @@ The unspent portion of a leave balance that expires after the planning horizon.
|
|||||||
The category of leave in a balance or on a declared non-working day: CP (paid leave), RTT (working-time reduction leave), or Other (a user-managed catch-all, including any other reason for not working).
|
The category of leave in a balance or on a declared non-working day: CP (paid leave), RTT (working-time reduction leave), or Other (a user-managed catch-all, including any other reason for not working).
|
||||||
_Avoid_: ANY as a leave type; `ANY` is only a rule selector.
|
_Avoid_: ANY as a leave type; `ANY` is only a rule selector.
|
||||||
|
|
||||||
|
**Today**:
|
||||||
|
The current Europe/Paris calendar date, whatever the device's time zone. The calendar marks it as today and shows earlier dates as past.
|
||||||
|
_Avoid_: local date
|
||||||
|
|
||||||
**Planning horizon**:
|
**Planning horizon**:
|
||||||
The user-selected half-open date interval within which leave may be placed; its start may precede today's Europe/Paris calendar date and its duration may exceed twelve months. It defaults to today through the same date twelve months later, and explicitly selected boundaries remain unchanged as time passes, with past dates still eligible for leave placements.
|
The user-selected half-open date interval within which leave may be placed; its start may precede today's Europe/Paris calendar date and its duration may exceed twelve months. It defaults to today through the same date twelve months later, and explicitly selected boundaries remain unchanged as time passes, with past dates still eligible for leave placements.
|
||||||
|
|
||||||
@@ -157,6 +161,14 @@ A deterministic explanation derived from the final displayed plan. It identifies
|
|||||||
**Incomplete calculation**:
|
**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.
|
A calculation stopped by user cancellation or another interruption before it completed the planning objective order. On user cancellation it exposes its best available valid plan as partial, if one exists, and never implies that the obligations conflict.
|
||||||
|
|
||||||
|
**Planning stage**:
|
||||||
|
One reported step of a calculation: preparation, validation, obligations, one stage per active preference rule in rule order, expiry, then dates. Stages describe what the calculation is doing, not how far it has progressed.
|
||||||
|
_Avoid_: phase, quality stage
|
||||||
|
|
||||||
|
**Indicative wait**:
|
||||||
|
The estimated duration of a calculation, shown as a range before Calculate and as an approximate remaining time during it. It is a rough guide refined by the device's own completed calculations, never a deadline.
|
||||||
|
_Avoid_: ETA, timeout, deadline
|
||||||
|
|
||||||
**Planning objective order**:
|
**Planning objective order**:
|
||||||
The lexicographic quality order applied after joint obligation feasibility, with active preference rules, including optimization rules, sharing the user-defined rule priority. A lower criterion never degrades a higher one.
|
The lexicographic quality order applied after joint obligation feasibility, with active preference rules, including optimization rules, sharing the user-defined rule priority. A lower criterion never degrades a higher one.
|
||||||
|
|
||||||
|
|||||||
@@ -249,13 +249,13 @@ The server answers `GET` and `HEAD` only (other methods get 405). It serves file
|
|||||||
|
|
||||||
[`.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.
|
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
|
||||||
|
|
||||||
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
The workflow runs on pushes to `develop` and `main`, `v*` tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Other branches run CI only through their PR, so a branch without a PR gets no automatic CI or image. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
||||||
|
|
||||||
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
|
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
|
||||||
|
|
||||||
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 240-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restored this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). After [#302](docs/benchmark/issue-302.md) the local protocol takes about 41 seconds. Benchmark success is required for image publication and the aggregate `required` job. The lowered hang guard and tightened multipliers still need confirmation on the CI runners.
|
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 240-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restored this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). After [#302](docs/benchmark/issue-302.md) the local protocol takes about 41 seconds. Benchmark success is required for image publication and the aggregate `required` job. The lowered hang guard and tightened multipliers still need confirmation on the CI runners.
|
||||||
|
|
||||||
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.
|
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 to `develop`, `main` and release tags, and same-repository PRs. Registry credentials are confined to the publishing step.
|
||||||
|
|
||||||
Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
|
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.
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-
|
|||||||
|
|
||||||
| Trigger | Published image tags |
|
| Trigger | Published image tags |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
|
| Successful push to `develop` or `main`, or same-repository PR | `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 |
|
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -286,7 +286,7 @@ git push origin v1.6.0
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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. The `develop` allowlist also admits the `renovate` bot: [renovate.json](renovate.json) automerges minor, patch, digest, and pin updates three days after their release once the `required` check passes, and leaves major updates for manual review. 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "vac-optimizer",
|
"name": "vac-optimizer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "22.2.3",
|
|
||||||
"@angular/cdk": "^22.0.0",
|
"@angular/cdk": "^22.0.0",
|
||||||
"@angular/common": "22.2.3",
|
"@angular/common": "22.2.3",
|
||||||
"@angular/compiler": "22.2.3",
|
"@angular/compiler": "22.2.3",
|
||||||
@@ -27,7 +26,7 @@
|
|||||||
"@angular/compiler-cli": "22.2.3",
|
"@angular/compiler-cli": "22.2.3",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "1.4.3",
|
"@types/bun": "1.4.3",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^30.0.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -54,8 +53,6 @@
|
|||||||
|
|
||||||
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ=="],
|
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ=="],
|
||||||
|
|
||||||
"@angular/animations": ["@angular/animations@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.3" } }, "sha512-sslKnTrKwHwtTvT/pWj7UoYq0cfuX3PijZM7v0JtCajQkK1Nj0Ljob0mebxXBXUVBrOixfCnfBS0EG4JNEznFA=="],
|
|
||||||
|
|
||||||
"@angular/build": ["@angular/build@22.2.3", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.3", "@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.3", "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-EtNEuPBUCyIuzdXrQ/nINvgFzW8bUI5AkAZtJiSn7nsFOlvaygaKN0kENNTO3zRl3T9B4GDog+Ks5Nec3FBkhg=="],
|
"@angular/build": ["@angular/build@22.2.3", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.3", "@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.3", "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-EtNEuPBUCyIuzdXrQ/nINvgFzW8bUI5AkAZtJiSn7nsFOlvaygaKN0kENNTO3zRl3T9B4GDog+Ks5Nec3FBkhg=="],
|
||||||
|
|
||||||
"@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="],
|
"@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="],
|
||||||
@@ -442,7 +439,7 @@
|
|||||||
|
|
||||||
"@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="],
|
"@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="],
|
||||||
|
|
||||||
"@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="],
|
"@types/jsdom": ["@types/jsdom@30.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^8.9.0" } }, "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ=="],
|
||||||
|
|
||||||
"@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
|
"@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
|
||||||
|
|
||||||
@@ -810,7 +807,7 @@
|
|||||||
|
|
||||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||||
|
|
||||||
"parse5-html-rewriting-stream": ["parse5-html-rewriting-stream@8.0.1", "", { "dependencies": { "entities": "^8.0.0", "parse5": "^8.0.0", "parse5-sax-parser": "^8.0.0" } }, "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw=="],
|
"parse5-html-rewriting-stream": ["parse5-html-rewriting-stream@8.0.1", "", { "dependencies": { "entities": "^8.0.0", "parse5": "^8.0.0", "parse5-sax-parser": "^8.0.0" } }, "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw=="],
|
||||||
|
|
||||||
@@ -1016,8 +1013,6 @@
|
|||||||
|
|
||||||
"@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/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/cdk/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
|
||||||
|
|
||||||
"@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/@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/forms/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
"@angular/forms/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||||
@@ -1074,16 +1069,8 @@
|
|||||||
|
|
||||||
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
"jsdom/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
|
||||||
|
|
||||||
"magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
"magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
|
||||||
|
|
||||||
"parse5-html-rewriting-stream/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
|
||||||
|
|
||||||
"parse5-sax-parser/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
|
||||||
|
|
||||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
|
"rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
|
||||||
|
|
||||||
"sass-embedded/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
"sass-embedded/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ Before the protocol, in the same process, the spec times the calibration engine
|
|||||||
a871f8f) on the `two years` scenario, best of three, and prints the result. This
|
a871f8f) on the `two years` scenario, best of three, and prints the result. This
|
||||||
bundle never changes, so ratios stay comparable with the recorded history; the
|
bundle never changes, so ratios stay comparable with the recorded history; the
|
||||||
differential check's frozen bundle can be rebuilt independently.
|
differential check's frozen bundle can be rebuilt independently.
|
||||||
|
The spec repeats the calibration after the protocol and uses the slower of the two
|
||||||
|
values, so load that starts during the protocol slows the yardstick too; the second
|
||||||
|
value can only loosen the budgets. When it does, the log prints it and the summary
|
||||||
|
table again with the budgets actually applied.
|
||||||
Each scenario's budget is its multiplier times that calibration; the mean
|
Each scenario's budget is its multiplier times that calibration; the mean
|
||||||
`planningMs` of its two measured repetitions must not exceed it. A scenario
|
`planningMs` of its two measured repetitions must not exceed it. A scenario
|
||||||
without a multiplier fails too. #286 tightened the multipliers once #302 met
|
without a multiplier fails too. #286 tightened the multipliers once #302 met
|
||||||
|
|||||||
+34
-29
@@ -11,33 +11,38 @@ message.
|
|||||||
|
|
||||||
## Jobs and triggers
|
## Jobs and triggers
|
||||||
|
|
||||||
Every branch and tag push runs CI. Pull requests run on opening, reopening,
|
Pushes to `develop` and `main` and `v*` tag pushes run CI. Other branches
|
||||||
synchronization, and title/body edits. Manual dispatch runs validation without
|
(feature, renovate, release, hotfix) run it only through their pull request; a
|
||||||
publishing images. There is no schedule or deployment job in the preserved
|
branch without a PR gets no automatic CI or image, so open a draft PR to get
|
||||||
configuration; the Compose files remain manual deployment options.
|
one. 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 |
|
| Job | Behavior |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
|
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
|
||||||
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
|
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
|
||||||
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
|
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
|
||||||
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 240-second timeout is only a hang guard. |
|
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It times the frozen engine on `two years` (best of three) before and after the protocol, and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 240-second timeout is only a hang guard. |
|
||||||
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
|
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
|
||||||
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
|
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
|
||||||
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
|
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
|
||||||
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
|
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
|
||||||
|
|
||||||
The benchmark runs on both push and pull_request events: the push run gates image
|
The benchmark gates image publication (`images.needs` includes `benchmark`) and
|
||||||
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
|
the `required` aggregate. The relative gate absorbs a slower runner and load
|
||||||
The relative gate absorbs a slower runner and load that lasts through the whole
|
that lasts through the whole job, because calibration runs in the same job. It
|
||||||
job, because calibration runs in the same job. It does not absorb load that
|
calibrates before and after the protocol and judges against the slower value,
|
||||||
starts or stops during the protocol, such as an overlapping push and
|
so it also absorbs load that starts during the protocol, such as another run
|
||||||
pull_request run of the same commit; the 240-second hang guard still bounds
|
sharing the runner. It does not absorb load that ends before the second
|
||||||
those runs.
|
calibration; the 240-second hang guard still bounds those runs.
|
||||||
|
|
||||||
Push and PR runs are separate so PR title and rebase checks run on the PR event.
|
Because branch pushes outside `develop` and `main` do not trigger CI, a commit
|
||||||
New runs cancel older runs for the same event/ref. Image jobs serialize per
|
on a PR branch runs only on PR events, which carry the PR title and rebase
|
||||||
branch slug; stable promotions share one group and are not cancelled in progress.
|
checks. 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
|
## Runners and permissions
|
||||||
|
|
||||||
@@ -111,10 +116,10 @@ only one place fails CI instead of drifting.
|
|||||||
## Images and releases
|
## Images and releases
|
||||||
|
|
||||||
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
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
|
at `gitea.nessar.fr/nessar/vac-optimizer`. Pushes to `develop` and `main` and
|
||||||
publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs retain the previous
|
same-repository PRs publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs
|
||||||
lowercase, non-alphanumeric-to-hyphen, 63-byte normalization; choose distinct
|
retain the previous lowercase, non-alphanumeric-to-hyphen, 63-byte
|
||||||
branch slugs.
|
normalization; choose distinct branch slugs.
|
||||||
|
|
||||||
Only stable `vX.Y.Z` tags matching the tagged commit's `package.json` version
|
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
|
and reachable from `main` may promote images. Promotion first pulls the existing
|
||||||
@@ -147,7 +152,7 @@ secrets. Complete the remaining activation checks in this order:
|
|||||||
`git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
|
`git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
|
||||||
2. Publish the reviewed feature branch and open its PR to register the workflow.
|
2. Publish the reviewed feature branch and open its PR to register the workflow.
|
||||||
Once registered, manual dispatch verifies validation and artifacts without
|
Once registered, manual dispatch verifies validation and artifacts without
|
||||||
publishing images. Push and PR validation can also run now; their image and
|
publishing images. PR validation can also run now; its image and
|
||||||
aggregate checks remain blocked until publisher secrets are provisioned.
|
aggregate checks remain blocked until publisher secrets are provisioned.
|
||||||
3. Verify runner isolation and Docker service support, then provision the two
|
3. Verify runner isolation and Docker service support, then provision the two
|
||||||
publisher secrets. Review the package publisher's permissions and registry
|
publisher secrets. Review the package publisher's permissions and registry
|
||||||
|
|||||||
@@ -82,6 +82,28 @@ duration rounding to whole milliseconds prevents floating-point noise at unit
|
|||||||
boundaries. Very fast runs can finish before the displayed lower bound. The
|
boundaries. Very fast runs can finish before the displayed lower bound. The
|
||||||
range is explicitly indicative, not a confidence interval or a promise.
|
range is explicitly indicative, not a confidence interval or a promise.
|
||||||
|
|
||||||
|
## During a calculation
|
||||||
|
|
||||||
|
The loading overlay's percentage follows the indicative wait, not the planning
|
||||||
|
stages completed. At Calculate, `PlanningDurationService` freezes the expected
|
||||||
|
duration `e` for the submitted snapshot, including the local refinement. On a
|
||||||
|
250 ms tick, it measures elapsed time from the same timestamp the calibration
|
||||||
|
uses. Progress rises linearly to 95% at `e`, holds there and shows 100% only
|
||||||
|
when the planner reports completion. Per-stage weights are not used, because
|
||||||
|
the costly stage depends on the configuration. Form edits and repeated stages
|
||||||
|
cannot move it backwards. On Cancel, the tick stops and the percentage freezes.
|
||||||
|
|
||||||
|
Below the status, one line names the current planning stage. For an
|
||||||
|
optimization or preference stage, it shows the rule's name. When `e` is at least
|
||||||
|
3 seconds, a second line shows "About N s left". This is in whole seconds under
|
||||||
|
10 seconds, 5-second steps under a minute and whole minutes after that. From
|
||||||
|
`e` to the top of the range shown before Calculate, the percentage holds at 95%
|
||||||
|
and that line is hidden. Because `e` sits near the middle of recent waits, about
|
||||||
|
half of all runs pass it, so it is no reason to warn the user. Past the top of
|
||||||
|
the range, for any `e`, the line reads "Taking longer than expected" and the
|
||||||
|
live status announces it once. Both lines are visible text, not live regions,
|
||||||
|
and are hidden after Cancel.
|
||||||
|
|
||||||
## What is timed
|
## What is timed
|
||||||
|
|
||||||
Every request, manual or initial, captures `performance.now()` inside
|
Every request, manual or initial, captures `performance.now()` inside
|
||||||
|
|||||||
+1
-2
@@ -30,7 +30,6 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "22.2.3",
|
|
||||||
"@angular/cdk": "^22.0.0",
|
"@angular/cdk": "^22.0.0",
|
||||||
"@angular/common": "22.2.3",
|
"@angular/common": "22.2.3",
|
||||||
"@angular/compiler": "22.2.3",
|
"@angular/compiler": "22.2.3",
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
"@angular/compiler-cli": "22.2.3",
|
"@angular/compiler-cli": "22.2.3",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "1.4.3",
|
"@types/bun": "1.4.3",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^30.0.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
|||||||
+14
-1
@@ -4,5 +4,18 @@
|
|||||||
"config:recommended"
|
"config:recommended"
|
||||||
],
|
],
|
||||||
"prConcurrentLimit": 3,
|
"prConcurrentLimit": 3,
|
||||||
"automerge": false
|
"automerge": false,
|
||||||
|
"automergeStrategy": "merge-commit",
|
||||||
|
"minimumReleaseAge": "3 days",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch",
|
||||||
|
"digest",
|
||||||
|
"pin"
|
||||||
|
],
|
||||||
|
"automerge": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-
|
|||||||
import { createPlanningCalculation } from '../services/leave-planner.service';
|
import { createPlanningCalculation } from '../services/leave-planner.service';
|
||||||
import {
|
import {
|
||||||
BENCHMARK_BUDGET_MULTIPLIERS,
|
BENCHMARK_BUDGET_MULTIPLIERS,
|
||||||
|
type BenchmarkMeasurement,
|
||||||
benchmarkReport,
|
benchmarkReport,
|
||||||
benchmarkScenarios,
|
benchmarkScenarios,
|
||||||
benchmarkSummaryHeader,
|
benchmarkSummaryHeader,
|
||||||
@@ -71,23 +72,43 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
}
|
}
|
||||||
// Calibration engine (frozen at a871f8f) on 'two years', best of three: the runner-speed yardstick.
|
// Calibration engine (frozen at a871f8f) on 'two years', best of three: the runner-speed yardstick.
|
||||||
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
|
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
|
||||||
let calibrationMs = Infinity;
|
const calibrate = async () => {
|
||||||
for (let run = 0; run < 3; run++) {
|
let best = Infinity;
|
||||||
const request = structuredClone(calibrationRequest);
|
for (let run = 0; run < 3; run++) {
|
||||||
const start = performance.now();
|
const request = structuredClone(calibrationRequest);
|
||||||
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
|
const start = performance.now();
|
||||||
calibrationMs = Math.min(calibrationMs, performance.now() - start);
|
await calculatePlanningAsyncInternal({
|
||||||
}
|
...request,
|
||||||
|
calculationDate: '2024-01-01',
|
||||||
|
});
|
||||||
|
best = Math.min(best, performance.now() - start);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
const calibrationBefore = await calibrate();
|
||||||
// Direct stdout writes show each row as soon as its scenario finishes.
|
// Direct stdout writes show each row as soon as its scenario finishes.
|
||||||
const print = (text: string) => process.stdout.write(`${text}\n`);
|
const print = (text: string) => process.stdout.write(`${text}\n`);
|
||||||
print(
|
print(
|
||||||
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
|
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationBefore.toFixed(1)} ms` +
|
||||||
'\nBudget = limit x calibration; ratio = mean / calibration.\n',
|
'\nBudget = limit x calibration; ratio = mean / calibration.\n',
|
||||||
);
|
);
|
||||||
print(benchmarkSummaryHeader());
|
print(benchmarkSummaryHeader());
|
||||||
const measurements = await runLeavePlannerBenchmark(2, (rows) =>
|
const scenarioRows: BenchmarkMeasurement[][] = [];
|
||||||
print(benchmarkSummaryRow(rows, calibrationMs)),
|
const measurements = await runLeavePlannerBenchmark(2, (rows) => {
|
||||||
);
|
scenarioRows.push(rows);
|
||||||
|
print(benchmarkSummaryRow(rows, calibrationBefore));
|
||||||
|
});
|
||||||
|
// Load that starts after the first calibration slows the scenarios but not the yardstick;
|
||||||
|
// calibrating again and judging against the slower value absorbs it. Never tighter than before.
|
||||||
|
const calibrationAfter = await calibrate();
|
||||||
|
const calibrationMs = Math.max(calibrationBefore, calibrationAfter);
|
||||||
|
if (calibrationMs !== calibrationBefore) {
|
||||||
|
print(
|
||||||
|
`\nCalibration after the protocol = ${calibrationAfter.toFixed(1)} ms; budgets use the slower: ${calibrationMs.toFixed(1)} ms\n`,
|
||||||
|
);
|
||||||
|
print(benchmarkSummaryHeader());
|
||||||
|
for (const rows of scenarioRows) print(benchmarkSummaryRow(rows, calibrationMs));
|
||||||
|
}
|
||||||
for (const [name, placements] of [
|
for (const [name, placements] of [
|
||||||
['two years', 60],
|
['two years', 60],
|
||||||
['three years', 90],
|
['three years', 90],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.data-date]="dateKey"
|
[attr.data-date]="dateKey"
|
||||||
[class.past]="day < getNow()"
|
[class.past]="dateKey < today"
|
||||||
[class.start]="selectedDates.isStart(day)"
|
[class.start]="selectedDates.isStart(day)"
|
||||||
[class.end]="selectedDates.isEnd(day)"
|
[class.end]="selectedDates.isEnd(day)"
|
||||||
[class.selected]="selectedDates.isSelected(day)"
|
[class.selected]="selectedDates.isSelected(day)"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { SelectedDates } from '../../services/calendar-service';
|
||||||
import { CalendarMonth } from './calendar-month';
|
import { CalendarMonth } from './calendar-month';
|
||||||
|
|
||||||
describe('CalendarMonth', () => {
|
describe('CalendarMonth', () => {
|
||||||
@@ -20,4 +21,20 @@ describe('CalendarMonth', () => {
|
|||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('styles only the dates before the given today as past', () => {
|
||||||
|
const monthFixture = TestBed.createComponent(CalendarMonth);
|
||||||
|
monthFixture.componentRef.setInput('monthIndex', 9);
|
||||||
|
monthFixture.componentRef.setInput('year', 2026);
|
||||||
|
monthFixture.componentRef.setInput('selectedDates', new SelectedDates());
|
||||||
|
monthFixture.componentRef.setInput('today', '2026-10-18');
|
||||||
|
monthFixture.detectChanges();
|
||||||
|
const button = (date: string) =>
|
||||||
|
(monthFixture.nativeElement as HTMLElement).querySelector(
|
||||||
|
`button[data-date="${date}"]`,
|
||||||
|
)!;
|
||||||
|
expect(button('2026-10-17').classList).toContain('past');
|
||||||
|
expect(button('2026-10-18').classList).not.toContain('past');
|
||||||
|
expect(button('2026-10-19').classList).not.toContain('past');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
|
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { DateCacheService } from '../../services/date-cache.service';
|
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
||||||
import type { LeaveType } from '../../services/leave-planner.service';
|
import type { LeaveType } from '../../services/leave-planner.service';
|
||||||
@@ -29,7 +28,6 @@ export interface DeclarationPreview {
|
|||||||
})
|
})
|
||||||
export class CalendarMonth implements OnInit {
|
export class CalendarMonth implements OnInit {
|
||||||
protected calendarSettingsService = inject(CalendarSettingsService);
|
protected calendarSettingsService = inject(CalendarSettingsService);
|
||||||
private dateCache = inject(DateCacheService);
|
|
||||||
protected translationService = inject(TranslationService);
|
protected translationService = inject(TranslationService);
|
||||||
|
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
@@ -42,11 +40,6 @@ export class CalendarMonth implements OnInit {
|
|||||||
return this.translationService.tr;
|
return this.translationService.tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DateCacheService instead of creating Date objects
|
|
||||||
getNow(): Date {
|
|
||||||
return this.dateCache.getNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
currentMonth: Date = new Date();
|
currentMonth: Date = new Date();
|
||||||
|
|
||||||
weeksInAMonth: Month = [];
|
weeksInAMonth: Month = [];
|
||||||
@@ -56,6 +49,9 @@ export class CalendarMonth implements OnInit {
|
|||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
selectedDates!: SelectedDates;
|
selectedDates!: SelectedDates;
|
||||||
|
|
||||||
|
@Input({ required: true })
|
||||||
|
today!: string;
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
horizonStart?: string;
|
horizonStart?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1935,6 +1935,186 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function waitingCalendar() {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const clock = { now: 1_000 };
|
||||||
|
vi.spyOn(performance, 'now').mockImplementation(() => clock.now);
|
||||||
|
const { calendar, calculation } = calendarFor(plan);
|
||||||
|
calendar.createPlanningCalculation.mockReturnValue({
|
||||||
|
...calculation,
|
||||||
|
outcome: new Promise<PlanningOutcome>(() => undefined),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
|
TestBed.inject(RuleService).addRule({
|
||||||
|
id: 'spring',
|
||||||
|
name: 'Spring break',
|
||||||
|
type: RuleType.TIME_WINDOW,
|
||||||
|
enabled: true,
|
||||||
|
force: 'preference',
|
||||||
|
startDate: new Date('2024-04-01T00:00:00'),
|
||||||
|
endDate: new Date('2024-04-30T00:00:00'),
|
||||||
|
placeDays: 5,
|
||||||
|
placeType: SelectableDayType.CP,
|
||||||
|
});
|
||||||
|
TestBed.inject(PlanningDurationService).recordCompletion(
|
||||||
|
TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const [request] = calendar.createPlanningCalculation.mock.calls[0] as unknown as [
|
||||||
|
{ progress: (stage: string) => void },
|
||||||
|
];
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const text = (selector: string) => root.querySelector(selector)?.textContent?.trim();
|
||||||
|
const at = (elapsedMs: number) => {
|
||||||
|
clock.now = 1_000 + elapsedMs;
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
return { fixture, root, text, at, progress: request.progress };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('names the planning stage and remaining wait as visible text in every supported language', async () => {
|
||||||
|
try {
|
||||||
|
const { fixture, root, text, at, progress } = await waitingCalendar();
|
||||||
|
at(0);
|
||||||
|
expect(text('.spinner-text')).toBe('0%');
|
||||||
|
expect(text('.calculation-stage')).toBe('Preparing the calendar');
|
||||||
|
expect(text('.calculation-remaining')).toBe('About 20 s left');
|
||||||
|
expect(
|
||||||
|
root.querySelector('.calculation-detail')!.closest('[role], [aria-live]'),
|
||||||
|
).toBeNull();
|
||||||
|
for (const [stage, expected] of [
|
||||||
|
['validation', 'Checking your inputs'],
|
||||||
|
['obligations', 'Satisfying obligations'],
|
||||||
|
['optimization:default-holiday-week-value', 'Applying “Semaines fériées”'],
|
||||||
|
['preference:spring', 'Applying “Spring break”'],
|
||||||
|
['expiry', 'Using balances that expire first'],
|
||||||
|
['dates', 'Settling the final dates'],
|
||||||
|
]) {
|
||||||
|
progress(stage);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(text('.calculation-stage')).toBe(expected);
|
||||||
|
}
|
||||||
|
const stages = [
|
||||||
|
'preparation',
|
||||||
|
'validation',
|
||||||
|
'obligations',
|
||||||
|
'preference:spring',
|
||||||
|
'expiry',
|
||||||
|
'dates',
|
||||||
|
];
|
||||||
|
const localized = [
|
||||||
|
[
|
||||||
|
'fr',
|
||||||
|
[
|
||||||
|
'Préparation du calendrier',
|
||||||
|
'Vérification de vos saisies',
|
||||||
|
'Respect des obligations',
|
||||||
|
'Application de « Spring break »',
|
||||||
|
'Utilisation des soldes qui expirent en premier',
|
||||||
|
'Fixation des dates définitives',
|
||||||
|
],
|
||||||
|
'Encore environ 20 s',
|
||||||
|
'Plus long que prévu',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'es',
|
||||||
|
[
|
||||||
|
'Preparando el calendario',
|
||||||
|
'Comprobando tus datos',
|
||||||
|
'Cumpliendo las obligaciones',
|
||||||
|
'Aplicando «Spring break»',
|
||||||
|
'Usando primero los saldos que vencen antes',
|
||||||
|
'Fijando las fechas definitivas',
|
||||||
|
],
|
||||||
|
'Quedan unos 20 s',
|
||||||
|
'Está tardando más de lo previsto',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'de',
|
||||||
|
[
|
||||||
|
'Kalender wird vorbereitet',
|
||||||
|
'Eingaben werden geprüft',
|
||||||
|
'Verpflichtungen werden erfüllt',
|
||||||
|
'„Spring break“ wird angewendet',
|
||||||
|
'Zuerst verfallende Guthaben werden verwendet',
|
||||||
|
'Endgültige Daten werden festgelegt',
|
||||||
|
],
|
||||||
|
'Noch etwa 20 s',
|
||||||
|
'Dauert länger als erwartet',
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
for (const [language, texts, remaining] of localized) {
|
||||||
|
TestBed.inject(TranslationService).language.set(language);
|
||||||
|
stages.forEach((stage, index) => {
|
||||||
|
progress(stage);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(text('.calculation-stage')).toBe(texts[index]);
|
||||||
|
});
|
||||||
|
expect(text('.calculation-remaining')).toBe(remaining);
|
||||||
|
}
|
||||||
|
at(60_000);
|
||||||
|
for (const [language, , , overrun] of localized) {
|
||||||
|
TestBed.inject(TranslationService).language.set(language);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(text('.calculation-remaining')).toBe(overrun);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces an overrun once past the displayed range while the overlay status keeps loading', async () => {
|
||||||
|
try {
|
||||||
|
const { fixture, root, text, at } = await waitingCalendar();
|
||||||
|
at(19_000);
|
||||||
|
expect(text('.calculation-remaining')).toBe('About 1 s left');
|
||||||
|
at(20_000);
|
||||||
|
expect(text('.spinner-text')).toBe('95%');
|
||||||
|
expect(root.querySelector('.calculation-remaining')).toBeNull();
|
||||||
|
expect(text('.sr-only[role="status"]')).toBe('Calculating...');
|
||||||
|
at(60_000);
|
||||||
|
expect(text('.calculation-remaining')).toBe('Taking longer than expected');
|
||||||
|
expect(text('.loading-overlay [role="status"]')).toBe('Calculating...');
|
||||||
|
expect(text('.sr-only[role="status"]')).toBe('Taking longer than expected');
|
||||||
|
fixture.componentInstance['liveMessage'].set('');
|
||||||
|
at(120_000);
|
||||||
|
expect(text('.spinner-text')).toBe('95%');
|
||||||
|
expect(text('.sr-only[role="status"]')).toBe('');
|
||||||
|
expect(root.querySelector('.cancel-calculation')).not.toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes the percentage and hides the stage and remaining wait after Cancel', async () => {
|
||||||
|
try {
|
||||||
|
const { root, text, at, progress } = await waitingCalendar();
|
||||||
|
at(10_000);
|
||||||
|
expect(text('.spinner-text')).toBe('47%');
|
||||||
|
root.querySelector<HTMLButtonElement>('.cancel-calculation')!.click();
|
||||||
|
progress('complete');
|
||||||
|
at(30_000);
|
||||||
|
expect(text('.spinner-text')).toBe('47%');
|
||||||
|
expect(root.querySelector('.calculation-detail')).toBeNull();
|
||||||
|
expect(text('.loading-overlay [role="status"]')).toBe('Cancelling…');
|
||||||
|
expect(root.querySelector<HTMLButtonElement>('.cancel-calculation')!.disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each([false, true])(
|
it.each([false, true])(
|
||||||
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||||||
async (withIncumbent) => {
|
async (withIncumbent) => {
|
||||||
|
|||||||
@@ -10,11 +10,21 @@
|
|||||||
<mat-spinner
|
<mat-spinner
|
||||||
[diameter]="72"
|
[diameter]="72"
|
||||||
[mode]="'determinate'"
|
[mode]="'determinate'"
|
||||||
[value]="loadingProgress()"
|
[value]="wait()?.percent ?? 0"
|
||||||
></mat-spinner>
|
></mat-spinner>
|
||||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
<div class="spinner-text">{{ wait()?.percent ?? 0 }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
|
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
|
||||||
|
@if (!isCancelling()) {
|
||||||
|
<div class="calculation-detail">
|
||||||
|
@if (calculationStage()) {
|
||||||
|
<p class="calculation-stage">{{ calculationStage() }}</p>
|
||||||
|
}
|
||||||
|
@if (remainingWait()) {
|
||||||
|
<p class="calculation-remaining">{{ remainingWait() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -598,6 +608,7 @@
|
|||||||
[monthIndex]="month.monthIndex"
|
[monthIndex]="month.monthIndex"
|
||||||
[year]="month.year"
|
[year]="month.year"
|
||||||
[selectedDates]="calendarSelectedDates()"
|
[selectedDates]="calendarSelectedDates()"
|
||||||
|
[today]="calendarProjection()?.today ?? ''"
|
||||||
[horizonStart]="calendarHorizon()?.start"
|
[horizonStart]="calendarHorizon()?.start"
|
||||||
[horizonEnd]="calendarHorizon()?.end"
|
[horizonEnd]="calendarHorizon()?.end"
|
||||||
[declarationEntry]="declarationEntry()"
|
[declarationEntry]="declarationEntry()"
|
||||||
|
|||||||
@@ -510,6 +510,15 @@ output {
|
|||||||
.cancel-calculation {
|
.cancel-calculation {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
.calculation-detail {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
.spinner-container {
|
.spinner-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,26 @@ export class CalendarYear {
|
|||||||
protected readonly isLoading = this.calculations.isCalculating;
|
protected readonly isLoading = this.calculations.isCalculating;
|
||||||
protected readonly isCancelling = this.calculations.isCancelling;
|
protected readonly isCancelling = this.calculations.isCancelling;
|
||||||
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
||||||
protected readonly loadingProgress = this.calculations.progress;
|
protected readonly wait = this.duration.wait;
|
||||||
|
private readonly overrun = computed(() => this.wait()?.overrun ?? false);
|
||||||
|
protected readonly calculationStage = computed(() => {
|
||||||
|
const pending = this.calculations.pending();
|
||||||
|
if (!pending) return '';
|
||||||
|
const progress = this.tr().planning.progress;
|
||||||
|
const ruleId = /^(?:optimization|preference):(.*)$/.exec(pending.stage)?.[1];
|
||||||
|
if (ruleId === undefined) return progress.stages[pending.stage] ?? '';
|
||||||
|
const rule = pending.request.rules.find(({ id }) => id === ruleId);
|
||||||
|
return progress.rule.replace('{rule}', rule?.name ?? ruleId);
|
||||||
|
});
|
||||||
|
protected readonly remainingWait = computed(() => {
|
||||||
|
const wait = this.wait();
|
||||||
|
const progress = this.tr().planning.progress;
|
||||||
|
if (wait?.overrun) return progress.overrun;
|
||||||
|
if (!wait?.remaining) return '';
|
||||||
|
return progress.remaining
|
||||||
|
.replace('{value}', String(wait.remaining.value))
|
||||||
|
.replace('{unit}', this.tr().userInput.durationUnits[wait.remaining.unit]);
|
||||||
|
});
|
||||||
protected readonly planningOutcome = this.calculations.outcome;
|
protected readonly planningOutcome = this.calculations.outcome;
|
||||||
protected readonly planningExplanation = this.calculations.explanation;
|
protected readonly planningExplanation = this.calculations.explanation;
|
||||||
protected readonly explanationError = this.calculations.explanationError;
|
protected readonly explanationError = this.calculations.explanationError;
|
||||||
@@ -319,6 +338,10 @@ export class CalendarYear {
|
|||||||
if (this.calculations.pendingId() !== null)
|
if (this.calculations.pendingId() !== null)
|
||||||
untracked(() => this.liveMessage.set(this.tr().loading));
|
untracked(() => this.liveMessage.set(this.tr().loading));
|
||||||
});
|
});
|
||||||
|
effect(() => {
|
||||||
|
if (this.overrun())
|
||||||
|
untracked(() => this.liveMessage.set(this.tr().planning.progress.overrun));
|
||||||
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const pending = this.calculations.pendingId() !== null;
|
const pending = this.calculations.pendingId() !== null;
|
||||||
const cancelling = this.isCancelling();
|
const cancelling = this.isCancelling();
|
||||||
|
|||||||
@@ -150,14 +150,14 @@ describe('CalendarService', () => {
|
|||||||
|
|
||||||
describe('getHolidays', () => {
|
describe('getHolidays', () => {
|
||||||
it('should return an array of holidays', () => {
|
it('should return an array of holidays', () => {
|
||||||
const holidays = service.getHolidays();
|
const holidays = service.getHolidays(2026);
|
||||||
expect(Array.isArray(holidays)).toBe(true);
|
expect(Array.isArray(holidays)).toBe(true);
|
||||||
expect(holidays.length).toBeGreaterThan(0);
|
expect(holidays.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should cache holidays for the same year', () => {
|
it('should cache holidays for the same year', () => {
|
||||||
const holidays1 = service.getHolidays();
|
const holidays1 = service.getHolidays(2026);
|
||||||
const holidays2 = service.getHolidays();
|
const holidays2 = service.getHolidays(2026);
|
||||||
expect(holidays1).toEqual(holidays2);
|
expect(holidays1).toEqual(holidays2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -179,11 +179,6 @@ describe('CalendarService', () => {
|
|||||||
expect(months.length).toBe(12);
|
expect(months.length).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should calculate months from now', () => {
|
|
||||||
const months = service.monthsInAYearFromNow();
|
|
||||||
expect(months.length).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return week number for a date', () => {
|
it('should return week number for a date', () => {
|
||||||
const date = new Date(2024, 0, 1);
|
const date = new Date(2024, 0, 1);
|
||||||
const weekNumber = CalendarService.getWeekNumber(date);
|
const weekNumber = CalendarService.getWeekNumber(date);
|
||||||
@@ -193,20 +188,44 @@ describe('CalendarService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('project', () => {
|
describe('project', () => {
|
||||||
const project = (horizon: { start: string; end: string }) =>
|
const project = (
|
||||||
service.project({ placements: [], horizon });
|
horizon: { start: string; end: string },
|
||||||
|
options?: Parameters<typeof service.project>[1],
|
||||||
|
) => service.project({ placements: [], horizon }, options);
|
||||||
const day = (key: string) => new Date(`${key}T00:00:00`);
|
const day = (key: string) => new Date(`${key}T00:00:00`);
|
||||||
|
|
||||||
it('projects weekends, holidays and today for the default planning horizon', () => {
|
it('projects weekends, holidays and today for the default planning horizon', () => {
|
||||||
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
|
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
|
||||||
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
|
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
|
||||||
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
|
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
|
||||||
const horizon = service.getDefaultPlanningHorizon();
|
const now = new Date();
|
||||||
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
|
const horizon = service.getDefaultPlanningHorizon(now);
|
||||||
|
expect(project(horizon, { now }).selections.getClasses(day(horizon.start))).toContain(
|
||||||
DayType.TODAY,
|
DayType.TODAY,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks the Europe/Paris date as today when the device is in UTC', () => {
|
||||||
|
const previousTimeZone = process.env['TZ'];
|
||||||
|
process.env['TZ'] = 'UTC';
|
||||||
|
try {
|
||||||
|
const now = new Date('2026-10-17T22:16:00Z');
|
||||||
|
const horizon = service.getDefaultPlanningHorizon(now);
|
||||||
|
expect(horizon.start).toBe('2026-10-18');
|
||||||
|
const projection = project(horizon, { now });
|
||||||
|
expect(projection.today).toBe('2026-10-18');
|
||||||
|
expect(projection.selections.getClasses(day('2026-10-18'))).toContain(
|
||||||
|
DayType.TODAY,
|
||||||
|
);
|
||||||
|
expect(projection.selections.getClasses(day('2026-10-17'))).not.toContain(
|
||||||
|
DayType.TODAY,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousTimeZone === undefined) delete process.env['TZ'];
|
||||||
|
else process.env['TZ'] = previousTimeZone;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('lists the covered months, including partial boundary months and year changes', () => {
|
it('lists the covered months, including partial boundary months and year changes', () => {
|
||||||
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
|
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
|
||||||
{ monthIndex: 10, year: 2024 },
|
{ monthIndex: 10, year: 2024 },
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { DateRange } from '@angular/material/datepicker';
|
import { DateRange } from '@angular/material/datepicker';
|
||||||
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
|
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||||
import { DateCacheService } from './date-cache.service';
|
|
||||||
import {
|
import {
|
||||||
createPlanningCalculation as createPlanningCalculationOperation,
|
createPlanningCalculation as createPlanningCalculationOperation,
|
||||||
planLeave as planLeaveOperation,
|
planLeave as planLeaveOperation,
|
||||||
@@ -55,14 +54,13 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
|
|||||||
export interface CalendarProjection {
|
export interface CalendarProjection {
|
||||||
months: { monthIndex: number; year: number }[];
|
months: { monthIndex: number; year: number }[];
|
||||||
selections: SelectedDates;
|
selections: SelectedDates;
|
||||||
|
today: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class CalendarService {
|
export class CalendarService {
|
||||||
private dateCache = inject(DateCacheService);
|
|
||||||
|
|
||||||
readonly planLeave = planLeaveOperation;
|
readonly planLeave = planLeaveOperation;
|
||||||
readonly createPlanningCalculation = createPlanningCalculationOperation;
|
readonly createPlanningCalculation = createPlanningCalculationOperation;
|
||||||
|
|
||||||
@@ -74,7 +72,7 @@ export class CalendarService {
|
|||||||
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
|
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
|
||||||
project(
|
project(
|
||||||
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
|
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
|
||||||
{ fixedMonths = false } = {},
|
{ fixedMonths = false, now = new Date() } = {},
|
||||||
): CalendarProjection {
|
): CalendarProjection {
|
||||||
const horizon = calendarProjectionHorizon(plan)!;
|
const horizon = calendarProjectionHorizon(plan)!;
|
||||||
const firstMonth = plan.horizon.start.slice(0, 7);
|
const firstMonth = plan.horizon.start.slice(0, 7);
|
||||||
@@ -85,11 +83,8 @@ export class CalendarService {
|
|||||||
const holidays = new Set(
|
const holidays = new Set(
|
||||||
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
|
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
|
||||||
);
|
);
|
||||||
const {
|
const today = planningDateInParis(now);
|
||||||
date: todayDate,
|
const todayTime = new Date(`${today}T00:00:00`).getTime();
|
||||||
month: todayMonth,
|
|
||||||
year: todayYear,
|
|
||||||
} = this.dateCache.getTodayComponents();
|
|
||||||
const months: CalendarProjection['months'] = [];
|
const months: CalendarProjection['months'] = [];
|
||||||
const ranges: SelectedDate[] = [];
|
const ranges: SelectedDate[] = [];
|
||||||
const cursor = new Date(first);
|
const cursor = new Date(first);
|
||||||
@@ -122,11 +117,7 @@ export class CalendarService {
|
|||||||
ranges.push(
|
ranges.push(
|
||||||
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
|
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
|
||||||
);
|
);
|
||||||
if (
|
if (day.getTime() === todayTime)
|
||||||
day.getDate() === todayDate &&
|
|
||||||
day.getMonth() === todayMonth &&
|
|
||||||
day.getFullYear() === todayYear
|
|
||||||
)
|
|
||||||
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
|
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,10 +137,10 @@ export class CalendarService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { months, selections: new SelectedDates(ranges) };
|
return { months, selections: new SelectedDates(ranges), today };
|
||||||
}
|
}
|
||||||
|
|
||||||
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
|
getHolidays(startYear: number, lastYear = startYear + 1): Date[] {
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
for (let year = startYear; year <= lastYear; year++) {
|
for (let year = startYear; year <= lastYear; year++) {
|
||||||
for (const date of frenchPublicHolidaysForYear(year))
|
for (const date of frenchPublicHolidaysForYear(year))
|
||||||
@@ -204,18 +195,6 @@ export class CalendarService {
|
|||||||
return months;
|
return months;
|
||||||
}
|
}
|
||||||
|
|
||||||
monthsInAYearFromNow(): Year {
|
|
||||||
const months: Year = [];
|
|
||||||
const currentMonthIndex = this.dateCache.getCurrentMonth();
|
|
||||||
const currentYear = this.dateCache.getCurrentYear();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const monthIndex = (currentMonthIndex + i) % 12;
|
|
||||||
const year = currentYear + Math.floor((currentMonthIndex + i) / 12);
|
|
||||||
months.push(CalendarService.weeksInMonth(monthIndex, year));
|
|
||||||
}
|
|
||||||
return months;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getWeekNumber(date: Date): number {
|
static getWeekNumber(date: Date): number {
|
||||||
return dayjs(date).isoWeek();
|
return dayjs(date).isoWeek();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
|
||||||
import { DateCacheService } from './date-cache.service';
|
|
||||||
|
|
||||||
describe('DateCacheService', () => {
|
|
||||||
let service: DateCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [provideZonelessChangeDetection()],
|
|
||||||
});
|
|
||||||
service = TestBed.inject(DateCacheService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cache today date', () => {
|
|
||||||
const today1 = service.getToday();
|
|
||||||
const today2 = service.getToday();
|
|
||||||
expect(today1).toBe(today2); // Same reference
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cache today components', () => {
|
|
||||||
const components1 = service.getTodayComponents();
|
|
||||||
const components2 = service.getTodayComponents();
|
|
||||||
expect(components1).toBe(components2); // Same reference
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cache current month', () => {
|
|
||||||
const month1 = service.getCurrentMonth();
|
|
||||||
const month2 = service.getCurrentMonth();
|
|
||||||
expect(month1).toBe(month2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cache current year', () => {
|
|
||||||
const year1 = service.getCurrentYear();
|
|
||||||
const year2 = service.getCurrentYear();
|
|
||||||
expect(year1).toBe(year2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear cache', () => {
|
|
||||||
const year1 = service.getCurrentYear();
|
|
||||||
service.clearCache();
|
|
||||||
const year2 = service.getCurrentYear();
|
|
||||||
expect(year1).toBe(year2); // Values should be the same even after clearing
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return today with time normalized to 00:00:00.000', () => {
|
|
||||||
const today = service.getToday();
|
|
||||||
expect(today.getHours()).toBe(0);
|
|
||||||
expect(today.getMinutes()).toBe(0);
|
|
||||||
expect(today.getSeconds()).toBe(0);
|
|
||||||
expect(today.getMilliseconds()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return getNow() same as getToday()', () => {
|
|
||||||
const now = service.getNow();
|
|
||||||
const today = service.getToday();
|
|
||||||
expect(now).toBe(today);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service to cache and provide commonly used date values.
|
|
||||||
* This reduces redundant Date object creation across the application.
|
|
||||||
*/
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class DateCacheService {
|
|
||||||
private _today: Date | null = null;
|
|
||||||
private _todayComponents: { date: number; month: number; year: number } | null = null;
|
|
||||||
private _currentMonth: number | null = null;
|
|
||||||
private _currentYear: number | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get today's date (time set to 00:00:00.000)
|
|
||||||
*/
|
|
||||||
getToday(): Date {
|
|
||||||
if (!this._today || this.isNewDay(this._today)) {
|
|
||||||
this._today = new Date();
|
|
||||||
this._today.setHours(0, 0, 0, 0);
|
|
||||||
this._todayComponents = null; // Invalidate components cache
|
|
||||||
}
|
|
||||||
return this._today;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get today's date components (date, month, year) for efficient comparison
|
|
||||||
*/
|
|
||||||
getTodayComponents(): { date: number; month: number; year: number } {
|
|
||||||
if (!this._todayComponents || (this._today && this.isNewDay(this._today))) {
|
|
||||||
const today = this.getToday();
|
|
||||||
this._todayComponents = {
|
|
||||||
date: today.getDate(),
|
|
||||||
month: today.getMonth(),
|
|
||||||
year: today.getFullYear(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return this._todayComponents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current month (0-11)
|
|
||||||
*/
|
|
||||||
getCurrentMonth(): number {
|
|
||||||
if (this._currentMonth === null || this.isNewMonth()) {
|
|
||||||
this._currentMonth = new Date().getMonth();
|
|
||||||
}
|
|
||||||
return this._currentMonth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current year
|
|
||||||
*/
|
|
||||||
getCurrentYear(): number {
|
|
||||||
if (this._currentYear === null || this.isNewYear()) {
|
|
||||||
this._currentYear = new Date().getFullYear();
|
|
||||||
}
|
|
||||||
return this._currentYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current date with time set to 00:00:00.000 (alias for getToday)
|
|
||||||
*/
|
|
||||||
getNow(): Date {
|
|
||||||
return this.getToday();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a cached date is from a previous day
|
|
||||||
*/
|
|
||||||
private isNewDay(cachedDate: Date): boolean {
|
|
||||||
const now = new Date();
|
|
||||||
return (
|
|
||||||
cachedDate.getDate() !== now.getDate() ||
|
|
||||||
cachedDate.getMonth() !== now.getMonth() ||
|
|
||||||
cachedDate.getFullYear() !== now.getFullYear()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're in a new month
|
|
||||||
*/
|
|
||||||
private isNewMonth(): boolean {
|
|
||||||
return this._currentMonth !== null && this._currentMonth !== new Date().getMonth();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're in a new year
|
|
||||||
*/
|
|
||||||
private isNewYear(): boolean {
|
|
||||||
return this._currentYear !== null && this._currentYear !== new Date().getFullYear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all caches (useful for testing)
|
|
||||||
*/
|
|
||||||
clearCache(): void {
|
|
||||||
this._today = null;
|
|
||||||
this._todayComponents = null;
|
|
||||||
this._currentMonth = null;
|
|
||||||
this._currentYear = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from '../../testing/vitest-compat';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { CalendarService } from './calendar-service';
|
import { CalendarService, SelectableDayType } from './calendar-service';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import type {
|
import type {
|
||||||
PlanOutcome,
|
PlanOutcome,
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
PlanningReason,
|
PlanningReason,
|
||||||
} from './leave-planner.service';
|
} from './leave-planner.service';
|
||||||
import { PlanningCalculationService } from './planning-calculation.service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
|
import { RuleService, RuleType } from './rule-service';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
@@ -119,18 +120,50 @@ describe('PlanningCalculationService', () => {
|
|||||||
expect(submission.labels).toBeDefined();
|
expect(submission.labels).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports progress of the latest calculation only', () => {
|
it('hands TIME_WINDOW rules the picked calendar dates east of Paris', () => {
|
||||||
|
const previousTimeZone = process.env['TZ'];
|
||||||
|
process.env['TZ'] = 'Pacific/Auckland';
|
||||||
|
try {
|
||||||
|
TestBed.inject(RuleService).addRule({
|
||||||
|
id: 'christmas',
|
||||||
|
name: 'Christmas',
|
||||||
|
type: RuleType.TIME_WINDOW,
|
||||||
|
enabled: true,
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: new Date(2026, 11, 20),
|
||||||
|
endDate: new Date(2026, 11, 31),
|
||||||
|
placeDays: 3,
|
||||||
|
placeType: SelectableDayType.CP,
|
||||||
|
});
|
||||||
|
queue(fakeCalculation('first'));
|
||||||
|
service.start();
|
||||||
|
const submitted = create.mock.calls[0][0].rules.find(
|
||||||
|
(rule: { id: string }) => rule.id === 'christmas',
|
||||||
|
);
|
||||||
|
expect(submitted).toMatchObject({ startDate: '2026-12-20', endDate: '2026-12-31' });
|
||||||
|
} finally {
|
||||||
|
// bun test defaults to UTC without setting TZ; deleting it would fall back to the host zone.
|
||||||
|
process.env['TZ'] = previousTimeZone ?? 'UTC';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the stage and submission of the latest calculation only, until Cancel', () => {
|
||||||
queue(fakeCalculation('old'), fakeCalculation('latest'));
|
queue(fakeCalculation('old'), fakeCalculation('latest'));
|
||||||
service.start();
|
service.start();
|
||||||
const oldProgress = create.mock.calls[0][0].progress;
|
const oldProgress = create.mock.calls[0][0].progress;
|
||||||
service.start();
|
service.start();
|
||||||
const latestProgress = create.mock.calls[1][0].progress;
|
const latestProgress = create.mock.calls[1][0].progress;
|
||||||
|
const submitted = service.request();
|
||||||
|
expect(service.pending()).toMatchObject({ stage: 'preparation', request: submitted });
|
||||||
oldProgress('obligations');
|
oldProgress('obligations');
|
||||||
expect(service.progress()).toBe(0);
|
expect(service.pending()!.stage).toBe('preparation');
|
||||||
latestProgress('at-risk');
|
latestProgress('preference:spring');
|
||||||
expect(service.progress()).toBe(35);
|
expect(service.pending()!.stage).toBe('preference:spring');
|
||||||
latestProgress('unknown');
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
|
||||||
expect(service.progress()).toBe(35);
|
expect(service.pending()!.request).toBe(submitted);
|
||||||
|
service.cancel();
|
||||||
|
latestProgress('complete');
|
||||||
|
expect(service.pending()).toMatchObject({ stage: 'preference:spring', cancelling: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
|
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
|
||||||
|
|||||||
@@ -8,18 +8,23 @@ import type {
|
|||||||
PlanningExplanation,
|
PlanningExplanation,
|
||||||
PlanningOutcome,
|
PlanningOutcome,
|
||||||
PlanningReason,
|
PlanningReason,
|
||||||
|
PlanningRequest,
|
||||||
PlanningSnapshot,
|
PlanningSnapshot,
|
||||||
} from './leave-planner.service';
|
} from './leave-planner.service';
|
||||||
import { RuleService } from './rule-service';
|
import { RuleService, RuleType } from './rule-service';
|
||||||
import { TranslationService } from './translation-service';
|
import { TranslationService } from './translation-service';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
|
|
||||||
type Horizon = { start: string; end: string };
|
type Horizon = { start: string; end: string };
|
||||||
|
|
||||||
interface PendingCalculation {
|
export interface PendingCalculation {
|
||||||
id: number;
|
id: number;
|
||||||
horizon: Horizon;
|
horizon: Horizon;
|
||||||
progress: number;
|
/** The configuration submitted at Calculate, unaffected by later form edits. */
|
||||||
|
request: PlanningRequest;
|
||||||
|
requestedAt: number;
|
||||||
|
/** The latest planning stage reported by the planner, or 'complete'. */
|
||||||
|
stage: string;
|
||||||
cancelling: boolean;
|
cancelling: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,22 +39,6 @@ export interface AcceptedCalculation {
|
|||||||
successful: boolean;
|
successful: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageProgress(stage: string, current: number): number {
|
|
||||||
if (stage === 'complete') return 100;
|
|
||||||
if (stage === 'validation') return 5;
|
|
||||||
if (stage === 'obligations') return 20;
|
|
||||||
if (stage === 'at-risk') return 35;
|
|
||||||
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
|
|
||||||
if (stage === 'holiday-week-value') return 58;
|
|
||||||
if (stage === 'non-working-day-gain') return 65;
|
|
||||||
if (stage === 'longest-work-sequence') return 72;
|
|
||||||
if (stage === 'short-sequence-deficit') return 79;
|
|
||||||
if (stage === 'supporting-placements') return 86;
|
|
||||||
if (stage === 'expiry') return 93;
|
|
||||||
if (stage === 'dates') return 98;
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function plannerError(error: unknown): PlanningReason {
|
function plannerError(error: unknown): PlanningReason {
|
||||||
return {
|
return {
|
||||||
code: 'planner-error',
|
code: 'planner-error',
|
||||||
@@ -64,7 +53,7 @@ function explanationFailure(error: unknown): PlanningReason {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
|
/** Single owner of planning calculations: submission, planning stages, cancellation and the accepted result. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningCalculationService {
|
export class PlanningCalculationService {
|
||||||
private readonly settings = inject(CalendarSettingsService);
|
private readonly settings = inject(CalendarSettingsService);
|
||||||
@@ -96,7 +85,15 @@ export class PlanningCalculationService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||||
})),
|
})),
|
||||||
rules: this.rules.rules,
|
rules: this.rules.rules.map((rule) =>
|
||||||
|
rule.type === RuleType.TIME_WINDOW
|
||||||
|
? {
|
||||||
|
...rule,
|
||||||
|
startDate: dayjs(rule.startDate).format('YYYY-MM-DD'),
|
||||||
|
endDate: dayjs(rule.endDate).format('YYYY-MM-DD'),
|
||||||
|
}
|
||||||
|
: rule,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +104,7 @@ export class PlanningCalculationService {
|
|||||||
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
|
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
|
||||||
readonly isCalculating = computed(() => this.pendingState() !== null);
|
readonly isCalculating = computed(() => this.pendingState() !== null);
|
||||||
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
|
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
|
||||||
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
|
readonly pending = this.pendingState.asReadonly();
|
||||||
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
|
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
|
||||||
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
|
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
|
||||||
readonly displayedPlan = computed(() => {
|
readonly displayedPlan = computed(() => {
|
||||||
@@ -131,7 +128,9 @@ export class PlanningCalculationService {
|
|||||||
this.pendingState.set({
|
this.pendingState.set({
|
||||||
id: version,
|
id: version,
|
||||||
horizon: request.horizon,
|
horizon: request.horizon,
|
||||||
progress: 0,
|
request,
|
||||||
|
requestedAt,
|
||||||
|
stage: 'preparation',
|
||||||
cancelling: false,
|
cancelling: false,
|
||||||
});
|
});
|
||||||
let calculation: PlanningCalculationContext;
|
let calculation: PlanningCalculationContext;
|
||||||
@@ -147,12 +146,8 @@ export class PlanningCalculationService {
|
|||||||
),
|
),
|
||||||
progress: (stage) => {
|
progress: (stage) => {
|
||||||
if (version === this.version)
|
if (version === this.version)
|
||||||
this.pendingState.update(
|
this.pendingState.update((pending) =>
|
||||||
(pending) =>
|
pending && !pending.cancelling ? { ...pending, stage } : pending,
|
||||||
pending && {
|
|
||||||
...pending,
|
|
||||||
progress: stageProgress(stage, pending.progress),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import { SelectableDayType } from './calendar-service';
|
import { CalendarService, SelectableDayType } from './calendar-service';
|
||||||
import { RuleService, RuleType } from './rule-service';
|
import { RuleService, RuleType } from './rule-service';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation, type PlanningOutcome } from './leave-planner.service';
|
||||||
import { PlanningDurationService } from './planning-duration.service';
|
import { PlanningDurationService } from './planning-duration.service';
|
||||||
import { PlanningCalculationService } from './planning-calculation.service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
@@ -145,3 +146,157 @@ describe('Planning duration estimate', () => {
|
|||||||
expect(duration.estimate()).toEqual(initial);
|
expect(duration.estimate()).toEqual(initial);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Indicative wait progress', () => {
|
||||||
|
let now: number;
|
||||||
|
let startedAt: number;
|
||||||
|
let progress: (stage: string) => void;
|
||||||
|
let resolve: (outcome: PlanningOutcome) => void;
|
||||||
|
let reject: (error: Error) => void;
|
||||||
|
let duration: PlanningDurationService;
|
||||||
|
let calculations: PlanningCalculationService;
|
||||||
|
|
||||||
|
/** Starts a calculation whose expected wait is exactly `expectedMs`. */
|
||||||
|
function start(expectedMs: number): void {
|
||||||
|
duration['calibration'].set([]);
|
||||||
|
duration.recordCompletion(calculations.request()!, expectedMs);
|
||||||
|
startedAt = now;
|
||||||
|
calculations.start();
|
||||||
|
TestBed.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(elapsedMs: number) {
|
||||||
|
now = startedAt + elapsedMs;
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
return duration.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
now = 1_000;
|
||||||
|
vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CalendarService,
|
||||||
|
useValue: {
|
||||||
|
createPlanningCalculation: (request: { progress: typeof progress }) => {
|
||||||
|
progress = request.progress;
|
||||||
|
return {
|
||||||
|
cancel: () => undefined,
|
||||||
|
snapshot: { rules: [], labels: {}, declaredDays: [] },
|
||||||
|
outcome: new Promise<PlanningOutcome>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
}),
|
||||||
|
explanation: new Promise(() => undefined),
|
||||||
|
explanationError: new Promise(() => undefined),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
|
duration = TestBed.inject(PlanningDurationService);
|
||||||
|
calculations = TestBed.inject(PlanningCalculationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('climbs linearly to 95% at the expected wait, holds, then shows 100% on completion', () => {
|
||||||
|
start(20_000);
|
||||||
|
expect(at(0)).toEqual({
|
||||||
|
percent: 0,
|
||||||
|
remaining: { value: 20, unit: 'seconds' },
|
||||||
|
overrun: false,
|
||||||
|
});
|
||||||
|
expect(at(10_000)).toMatchObject({ percent: 47, overrun: false });
|
||||||
|
expect(at(19_999)).toMatchObject({ percent: 94, overrun: false });
|
||||||
|
expect(at(20_000)).toEqual({ percent: 95, remaining: null, overrun: false });
|
||||||
|
expect(at(600_000)).toEqual({ percent: 95, remaining: null, overrun: true });
|
||||||
|
progress('complete');
|
||||||
|
expect(duration.wait()).toEqual({ percent: 100, remaining: null, overrun: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the remaining time only for expected waits of at least 3 seconds', () => {
|
||||||
|
start(2_999);
|
||||||
|
expect(at(1_000)).toEqual({ percent: 31, remaining: null, overrun: false });
|
||||||
|
expect(at(2_999)).toEqual({ percent: 95, remaining: null, overrun: false });
|
||||||
|
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[2_999, { minimum: 1, maximum: 9, unit: 'seconds' }, 9_000],
|
||||||
|
[20_000, { minimum: 10, maximum: 60, unit: 'seconds' }, 60_000],
|
||||||
|
[300_000, { minimum: 2, maximum: 15, unit: 'minutes' }, 900_000],
|
||||||
|
])(
|
||||||
|
'warns of an overrun of a %i ms expected wait only past the top of the range shown before Calculate',
|
||||||
|
(expectedMs, shown, topMs) => {
|
||||||
|
start(expectedMs);
|
||||||
|
expect(duration.estimate()).toEqual(shown);
|
||||||
|
expect(at(expectedMs)).toEqual({ percent: 95, remaining: null, overrun: false });
|
||||||
|
expect(at(topMs - 1)!.overrun).toBe(false);
|
||||||
|
expect(at(topMs)!.overrun).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rounds the remaining time to seconds, then 5-second steps, then minutes', () => {
|
||||||
|
start(3_000);
|
||||||
|
expect(at(0)!.remaining).toEqual({ value: 3, unit: 'seconds' });
|
||||||
|
expect(at(2_100)!.remaining).toEqual({ value: 1, unit: 'seconds' });
|
||||||
|
start(300_000);
|
||||||
|
const left = (seconds: number) => at(300_000 - seconds * 1_000)!.remaining;
|
||||||
|
expect(left(9.2)).toEqual({ value: 10, unit: 'seconds' });
|
||||||
|
expect(left(12.4)).toEqual({ value: 10, unit: 'seconds' });
|
||||||
|
expect(left(12.6)).toEqual({ value: 15, unit: 'seconds' });
|
||||||
|
expect(left(57.4)).toEqual({ value: 55, unit: 'seconds' });
|
||||||
|
expect(left(57.6)).toEqual({ value: 1, unit: 'minutes' });
|
||||||
|
expect(left(149)).toEqual({ value: 2, unit: 'minutes' });
|
||||||
|
expect(left(300)).toEqual({ value: 5, unit: 'minutes' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the expected wait frozen when the form or calibration change mid-calculation', () => {
|
||||||
|
start(20_000);
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2028-12-31');
|
||||||
|
duration.recordCompletion(calculations.request()!, 900_000);
|
||||||
|
expect(at(10_000)).toMatchObject({ percent: 47, remaining: { value: 10 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never moves backwards when the planner repeats stages', () => {
|
||||||
|
start(20_000);
|
||||||
|
const percents = ['obligations', 'preference:a', 'dates', 'preference:a', 'dates'].map(
|
||||||
|
(stage, index) => {
|
||||||
|
progress(stage);
|
||||||
|
return at(index * 5_000)!.percent;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(percents).toEqual([...percents].sort((a, b) => a - b));
|
||||||
|
expect(percents.at(-1)).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes on Cancel and never shows 100% after cancellation, incomplete outcomes or errors', async () => {
|
||||||
|
start(20_000);
|
||||||
|
at(10_000);
|
||||||
|
calculations.cancel();
|
||||||
|
TestBed.tick();
|
||||||
|
progress('complete');
|
||||||
|
expect(at(30_000)).toMatchObject({ percent: 47 });
|
||||||
|
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(duration.wait()).toBeNull();
|
||||||
|
start(20_000);
|
||||||
|
at(25_000);
|
||||||
|
reject(new Error('Worker failed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(duration.wait()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
import { computed, effect, inject, Injectable, signal } from '@angular/core';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
||||||
import { PlanningCalculationService } from './planning-calculation.service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
@@ -9,6 +9,37 @@ export interface PlanningDurationRange {
|
|||||||
unit: 'seconds' | 'minutes';
|
unit: 'seconds' | 'minutes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNIT_MS = { seconds: 1_000, minutes: 60_000 } as const;
|
||||||
|
|
||||||
|
function durationRange(duration: number): PlanningDurationRange {
|
||||||
|
const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
|
||||||
|
const minimum = Math.max(1, Math.floor(duration / 2 / UNIT_MS[unit]));
|
||||||
|
return {
|
||||||
|
minimum,
|
||||||
|
maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / UNIT_MS[unit])),
|
||||||
|
unit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress through the indicative wait of the pending calculation. */
|
||||||
|
export interface PlanningWait {
|
||||||
|
percent: number;
|
||||||
|
/** Approximate remaining time; null when the expected wait is short, elapsed or complete. */
|
||||||
|
remaining: { value: number; unit: PlanningDurationRange['unit'] } | null;
|
||||||
|
/** True past the top of the range shown before Calculate. */
|
||||||
|
overrun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingTime(ms: number): NonNullable<PlanningWait['remaining']> {
|
||||||
|
const seconds = ms / 1_000;
|
||||||
|
// Rounded up under 10 s so the line never reads "0 s" while time remains.
|
||||||
|
if (seconds < 10) return { value: Math.ceil(seconds), unit: 'seconds' };
|
||||||
|
const nearestFive = Math.round(seconds / 5) * 5;
|
||||||
|
return nearestFive < 60
|
||||||
|
? { value: nearestFive, unit: 'seconds' }
|
||||||
|
: { value: Math.round(seconds / 60), unit: 'minutes' };
|
||||||
|
}
|
||||||
|
|
||||||
function dateTime(value: PlanningDate): number {
|
function dateTime(value: PlanningDate): number {
|
||||||
const key =
|
const key =
|
||||||
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
|
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
|
||||||
@@ -54,29 +85,65 @@ function initialDurationMs(request: PlanningRequest): number {
|
|||||||
export class PlanningDurationService {
|
export class PlanningDurationService {
|
||||||
private readonly calculations = inject(PlanningCalculationService);
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
private readonly calibration = signal<readonly number[]>([]);
|
private readonly calibration = signal<readonly number[]>([]);
|
||||||
|
private readonly clock = signal(0);
|
||||||
|
// Equal while the calculation id is unchanged, so e is captured once at Calculate.
|
||||||
|
private readonly expected = computed(
|
||||||
|
() => {
|
||||||
|
const pending = this.calculations.pending();
|
||||||
|
if (!pending) return null;
|
||||||
|
const ms = Math.max(1, this.expectedMs(pending.request));
|
||||||
|
const { maximum, unit } = durationRange(ms);
|
||||||
|
return {
|
||||||
|
id: pending.id,
|
||||||
|
requestedAt: pending.requestedAt,
|
||||||
|
ms,
|
||||||
|
overrunMs: maximum * UNIT_MS[unit],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ equal: (a, b) => a?.id === b?.id },
|
||||||
|
);
|
||||||
|
|
||||||
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
||||||
const request = this.calculations.request();
|
const request = this.calculations.request();
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
const factors = [...this.calibration()].sort((a, b) => a - b);
|
return durationRange(this.expectedMs(request));
|
||||||
const middle = Math.floor(factors.length / 2);
|
});
|
||||||
const factor = factors.length
|
|
||||||
? (factors[middle] + factors[Math.floor((factors.length - 1) / 2)]) / 2
|
readonly wait = computed<PlanningWait | null>(() => {
|
||||||
: 1;
|
const expected = this.expected();
|
||||||
const duration = Math.round(initialDurationMs(request) * factor);
|
if (!expected) return null;
|
||||||
const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
|
if (this.calculations.pending()?.stage === 'complete')
|
||||||
const divisor = unit === 'minutes' ? 60_000 : 1_000;
|
return { percent: 100, remaining: null, overrun: false };
|
||||||
const minimum = Math.max(1, Math.floor(duration / 2 / divisor));
|
const elapsed = Math.max(0, this.clock() - expected.requestedAt);
|
||||||
|
const left = expected.ms - elapsed;
|
||||||
return {
|
return {
|
||||||
minimum,
|
percent: Math.floor(95 * Math.min(1, elapsed / expected.ms)),
|
||||||
maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / divisor)),
|
remaining: expected.ms >= 3_000 && left > 0 ? remainingTime(left) : null,
|
||||||
unit,
|
overrun: elapsed >= expected.overrunMs,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Reading e here captures it at Calculate; stopping the clock on Cancel freezes the percentage.
|
||||||
|
effect((onCleanup) => {
|
||||||
|
if (!this.expected() || this.calculations.isCancelling()) return;
|
||||||
|
const timer = setInterval(() => this.clock.set(performance.now()), 250);
|
||||||
|
onCleanup(() => clearInterval(timer));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
recordCompletion(request: PlanningRequest, elapsedMs: number): void {
|
recordCompletion(request: PlanningRequest, elapsedMs: number): void {
|
||||||
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return;
|
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return;
|
||||||
const factor = elapsedMs / initialDurationMs(request);
|
const factor = elapsedMs / initialDurationMs(request);
|
||||||
this.calibration.update((factors) => [...factors.slice(-7), factor]);
|
this.calibration.update((factors) => [...factors.slice(-7), factor]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private expectedMs(request: PlanningRequest): number {
|
||||||
|
const factors = [...this.calibration()].sort((a, b) => a - b);
|
||||||
|
const middle = Math.floor(factors.length / 2);
|
||||||
|
const factor = factors.length
|
||||||
|
? (factors[middle] + factors[Math.floor((factors.length - 1) / 2)]) / 2
|
||||||
|
: 1;
|
||||||
|
return Math.round(initialDurationMs(request) * factor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -243,6 +243,12 @@ export interface AppTranslations {
|
|||||||
incomplete: string;
|
incomplete: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
cancelling: string;
|
cancelling: string;
|
||||||
|
progress: {
|
||||||
|
stages: Record<string, string>;
|
||||||
|
rule: string;
|
||||||
|
remaining: string;
|
||||||
|
overrun: string;
|
||||||
|
};
|
||||||
cancelled: string;
|
cancelled: string;
|
||||||
cancelledPartial: string;
|
cancelledPartial: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
|||||||
@@ -200,6 +200,18 @@
|
|||||||
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
|
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"cancelling": "Berechnung wird abgebrochen…",
|
"cancelling": "Berechnung wird abgebrochen…",
|
||||||
|
"progress": {
|
||||||
|
"stages": {
|
||||||
|
"preparation": "Kalender wird vorbereitet",
|
||||||
|
"validation": "Eingaben werden geprüft",
|
||||||
|
"obligations": "Verpflichtungen werden erfüllt",
|
||||||
|
"expiry": "Zuerst verfallende Guthaben werden verwendet",
|
||||||
|
"dates": "Endgültige Daten werden festgelegt"
|
||||||
|
},
|
||||||
|
"rule": "„{rule}“ wird angewendet",
|
||||||
|
"remaining": "Noch etwa {value} {unit}",
|
||||||
|
"overrun": "Dauert länger als erwartet"
|
||||||
|
},
|
||||||
"cancelled": "Berechnung abgebrochen",
|
"cancelled": "Berechnung abgebrochen",
|
||||||
"cancelledPartial": "Berechnung abgebrochen — Teilplan",
|
"cancelledPartial": "Berechnung abgebrochen — Teilplan",
|
||||||
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
|
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
|
||||||
|
|||||||
@@ -200,6 +200,18 @@
|
|||||||
"incomplete": "Calculation stopped before all quality stages completed.",
|
"incomplete": "Calculation stopped before all quality stages completed.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"cancelling": "Cancelling…",
|
"cancelling": "Cancelling…",
|
||||||
|
"progress": {
|
||||||
|
"stages": {
|
||||||
|
"preparation": "Preparing the calendar",
|
||||||
|
"validation": "Checking your inputs",
|
||||||
|
"obligations": "Satisfying obligations",
|
||||||
|
"expiry": "Using balances that expire first",
|
||||||
|
"dates": "Settling the final dates"
|
||||||
|
},
|
||||||
|
"rule": "Applying “{rule}”",
|
||||||
|
"remaining": "About {value} {unit} left",
|
||||||
|
"overrun": "Taking longer than expected"
|
||||||
|
},
|
||||||
"cancelled": "Calculation cancelled",
|
"cancelled": "Calculation cancelled",
|
||||||
"cancelledPartial": "Calculation cancelled — partial plan",
|
"cancelledPartial": "Calculation cancelled — partial plan",
|
||||||
"error": "The planner encountered an unexpected error.",
|
"error": "The planner encountered an unexpected error.",
|
||||||
|
|||||||
@@ -200,6 +200,18 @@
|
|||||||
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
|
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"cancelling": "Cancelando…",
|
"cancelling": "Cancelando…",
|
||||||
|
"progress": {
|
||||||
|
"stages": {
|
||||||
|
"preparation": "Preparando el calendario",
|
||||||
|
"validation": "Comprobando tus datos",
|
||||||
|
"obligations": "Cumpliendo las obligaciones",
|
||||||
|
"expiry": "Usando primero los saldos que vencen antes",
|
||||||
|
"dates": "Fijando las fechas definitivas"
|
||||||
|
},
|
||||||
|
"rule": "Aplicando «{rule}»",
|
||||||
|
"remaining": "Quedan unos {value} {unit}",
|
||||||
|
"overrun": "Está tardando más de lo previsto"
|
||||||
|
},
|
||||||
"cancelled": "Cálculo cancelado",
|
"cancelled": "Cálculo cancelado",
|
||||||
"cancelledPartial": "Cálculo cancelado — plan parcial",
|
"cancelledPartial": "Cálculo cancelado — plan parcial",
|
||||||
"error": "El planificador encontró un error inesperado.",
|
"error": "El planificador encontró un error inesperado.",
|
||||||
|
|||||||
@@ -200,6 +200,18 @@
|
|||||||
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
|
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"cancelling": "Annulation en cours…",
|
"cancelling": "Annulation en cours…",
|
||||||
|
"progress": {
|
||||||
|
"stages": {
|
||||||
|
"preparation": "Préparation du calendrier",
|
||||||
|
"validation": "Vérification de vos saisies",
|
||||||
|
"obligations": "Respect des obligations",
|
||||||
|
"expiry": "Utilisation des soldes qui expirent en premier",
|
||||||
|
"dates": "Fixation des dates définitives"
|
||||||
|
},
|
||||||
|
"rule": "Application de « {rule} »",
|
||||||
|
"remaining": "Encore environ {value} {unit}",
|
||||||
|
"overrun": "Plus long que prévu"
|
||||||
|
},
|
||||||
"cancelled": "Calcul annulé",
|
"cancelled": "Calcul annulé",
|
||||||
"cancelledPartial": "Calcul annulé — plan partiel",
|
"cancelledPartial": "Calcul annulé — plan partiel",
|
||||||
"error": "Le planificateur a rencontré une erreur inattendue.",
|
"error": "Le planificateur a rencontré une erreur inattendue.",
|
||||||
|
|||||||
Reference in new issue
Block a user