Compare commits

..
1 Commits
Author SHA1 Message Date
renovate Bot 6f5e4f8bc2 fix(deps): update angular-cli monorepo to v22.2.3
CI / policy (push) Successful in 10s
CI / validate (push) Successful in 1m17s
CI / test (push) Successful in 5m5s
CI / benchmark (push) Successful in 7m20s
CI / build (push) Successful in 1m29s
CI / security (push) Successful in 1m47s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m15s
CI / test (pull_request) Successful in 5m0s
CI / benchmark (pull_request) Successful in 7m16s
CI / build (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 1m37s
CI / images (push) Successful in 1m49s
CI / required (push) Successful in 9s
CI / images (pull_request) Successful in 2m3s
CI / required (pull_request) Successful in 1s
2026-10-17 05:03:43 +00:00
21 changed files with 976 additions and 1573 deletions

No files matched your search

+21 -17
View File
@@ -16,7 +16,7 @@
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.2",
"@angular/ssr": "22.2.3",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
@@ -27,8 +27,8 @@
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/build": "22.2.3",
"@angular/cli": "22.2.3",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
@@ -66,19 +66,19 @@
"@analogjs/vitest-angular": ["@analogjs/vitest-angular@2.8.0", "", { "peerDependencies": { "@analogjs/vite-plugin-angular": "*", "@angular-devkit/architect": ">=0.1700.0 < 0.2300.0 || >=0.2200.0 < 0.2300.0", "@angular-devkit/schematics": ">=17.0.0", "vitest": "^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "zone.js": ">=0.14.0" }, "optionalPeers": ["zone.js"] }, "sha512-ZNwjOf8ACEOW8yTC9iAu3uXt8q0GABgNXoo9lBUN0RuV7SOubpcabZewirETP5NqgyhJ5S96Ql1WFeW/jAznqA=="],
"@angular-devkit/architect": ["@angular-devkit/architect@0.2202.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-WGRAxFj+R9LC8HuK3DnDgxHo/XrgfklNQeNgjq/phTEVq5ZdWxc8kDoPA4uQz4vF+HY2eycAAtOPfMvj2TBEiQ=="],
"@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
"@angular-devkit/core": ["@angular-devkit/core@22.2.3", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw=="],
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-frHhFwu2TEfTZX5jfAYL2bztuG5mGggfJhiM9bo5/QtLAi49LtRbJxIktsu6vjJdujxeB2TKD7KqnXwNYgd41Q=="],
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@angular/animations": ["@angular/animations@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1" } }, "sha512-vTxZN6uL/m61lv7ZCvfmQ3VuFb4o/tniGLpc3SpkNHE6MLM8WN0IjfNBRLMYMovd1uUBSPwhcG4Llcmucr9dmw=="],
"@angular/build": ["@angular/build@22.2.2", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.2", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.2", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-QHJWkZdEWTqqb3+HiSp8lnuS4iQJCa7yTK9gg5TaMIjCoZzP9mD9L417stiVDZGj2J3Y9mrml044nEVvSbW0ig=="],
"@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/cli": ["@angular/cli@22.2.2", "", { "dependencies": { "@angular-devkit/architect": "0.2202.2", "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.3.1", "@schematics/angular": "22.2.2", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.5" }, "bin": { "ng": "bin/ng.js" } }, "sha512-AALIN+zai0CiA7HgSCGVPHyZyiw3yFWliCfbaS4ueHDJHVZ8QhVjRUrnfGZc3EJnigcyQ0w2DKeTxjwqpDjKew=="],
"@angular/cli": ["@angular/cli@22.2.3", "", { "dependencies": { "@angular-devkit/architect": "0.2202.3", "@angular-devkit/core": "22.2.3", "@angular-devkit/schematics": "22.2.3", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.3.1", "@schematics/angular": "22.2.3", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.6" }, "bin": { "ng": "bin/ng.js" } }, "sha512-ObTX0DxJ0/PMl4pkUKI+uWPr7UC7Je94KpHP8l1lr3exhundapgr7VvNsozprJb1hMlOMDab6+quTReSYvF85Q=="],
"@angular/common": ["@angular/common@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw=="],
@@ -100,7 +100,7 @@
"@angular/router": ["@angular/router@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.1", "@angular/core": "22.2.1", "@angular/platform-browser": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-HJfJ4yA1znKd9YFtbDOJfKmD12x0PhS7LkmYdWlJRu2WkQZRBVrdGKdmbvV5UvrqCRllVWKpzw1TIZJM69rlcw=="],
"@angular/ssr": ["@angular/ssr@22.2.2", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-iMcwT5ZynIj33R0XSq+AtC5Ra6FGKOvV3qU0sjRr+lUaGfL6MjygPovPFsKLi8HzuGOA5dYDKffJfPedsC9Rxg=="],
"@angular/ssr": ["@angular/ssr@22.2.3", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-u/jeHCvSBMf9H72/rxrzON0UQLXux73pgft2Az4lgyhEgnFq+ttTUVoDSABcUoqZJKfh4pSTiCuFZJCpYB+xJQ=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.1.3", "", { "dependencies": { "@csstools/css-calc": "^3.4.2", "@csstools/css-color-parser": "^4.2.5", "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" } }, "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ=="],
@@ -838,7 +838,7 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
"@schematics/angular": ["@schematics/angular@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-JxpXKpxdmQcRsJbriWJOhB7HeVTzz9CEsPNw6fQ0LOuPFnvtyUIXLxyFbKv/LfoFUz8wFADCtNU5sjowME5Mvg=="],
"@schematics/angular": ["@schematics/angular@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "@angular-devkit/schematics": "22.2.3", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-DY8wv/LtNf3YD/EEoFdER4ihMyw5yywuOQEKasm4d2uBJ5peGIWV3LXJXXqD7b4WVC8xS1eUAjingyWfI9reHw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
@@ -2378,19 +2378,19 @@
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
"zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="],
"@analogjs/vite-plugin-nitro/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"@angular-devkit/architect/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/architect/@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
"@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
"@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/build/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular/build/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-/605FxTg8Kt5fqNuIBVZMfuiNwIf+CTNsiYnHY85/7OTqCNkEwdkXKc3DiFM2KlR/AV2g64GkvHgHqxHzYh34g=="],
"@angular/build/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
@@ -2398,9 +2398,9 @@
"@angular/build/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"@angular/cli/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular/cli/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-/605FxTg8Kt5fqNuIBVZMfuiNwIf+CTNsiYnHY85/7OTqCNkEwdkXKc3DiFM2KlR/AV2g64GkvHgHqxHzYh34g=="],
"@angular/cli/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@angular/cli/@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/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=="],
@@ -2764,6 +2764,10 @@
"@mapbox/node-pre-gyp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"@modelcontextprotocol/core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
"@modelcontextprotocol/server/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
"@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -2800,7 +2804,7 @@
"@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@schematics/angular/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@schematics/angular/@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=="],
"@schematics/angular/oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="],
+3 -2
View File
@@ -73,8 +73,9 @@ range is explicitly indicative, not a confidence interval or a promise.
## What is timed
Every request, manual or initial, captures `performance.now()` inside
`PlanningCalculationService.start`.
Manual requests capture `performance.now()` inside
`UserInputService.requestComputation`, before updating the calculation trigger.
The existing initial calculation falls back to the start of `startCalculation`.
The endpoint is Angular's `afterNextRender` callback after the complete plan has
been projected into the calendar, its months and placements have been rendered,
and the loading overlay has been removed. This measures request scheduling,
+3 -3
View File
@@ -38,7 +38,7 @@
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.2",
"@angular/ssr": "22.2.3",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
@@ -49,8 +49,8 @@
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/build": "22.2.3",
"@angular/cli": "22.2.3",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
@@ -184,10 +184,14 @@ export class CalendarMonth implements OnInit {
getDayTooltip(day: Date): string | null {
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
const tooltips: string[] = this.selectedDates
.dayTypes(day)
.filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type))
.map((type) => this.translationService.getDayTypeLabel(type));
const tooltips: string[] = this.selectedDates.datesSelected
.filter(
(dateSelected) =>
dateSelected.isSelected(day) &&
dateSelected.type &&
(!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)),
)
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
const declaredType = this.getDeclaredType(this.getDateKey(day));
if (declaredType)
tooltips.push(
@@ -9,7 +9,6 @@ import {
DayType,
SelectedDate,
SelectedDates,
calendarProjectionHorizon,
SelectableDayType,
} from '../../services/calendar-service';
import {
@@ -31,7 +30,6 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
@@ -126,39 +124,17 @@ const explanation: PlanningExplanation = {
})),
};
function projectionFor(
value: Parameters<typeof calendarProjectionHorizon>[0] & {
horizon: { start: string; end: string };
},
includeHoliday = false,
) {
const horizon = calendarProjectionHorizon(value)!;
const months: { monthIndex: number; year: number }[] = [];
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const cursor = new Date(`${horizon.start}T00:00:00`);
cursor.setDate(1);
while (cursor <= last) {
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
}
return { months, selections: selectedDatesForPlan(value, includeHoliday) };
}
function selectedDatesForPlan(
value: { placements: readonly PlanOutcome['placements'][number][] },
includeHoliday = false,
): SelectedDates {
const ranges: SelectedDate[] = [];
function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
const selected = new SelectedDates();
if (includeHoliday) {
const holiday = new Date('2024-05-08T00:00:00');
ranges.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
}
for (const placement of value.placements) {
const date = new Date(`${placement.date}T00:00:00`);
ranges.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
}
return new SelectedDates(ranges);
return selected;
}
function placementForSelection(selection: CounterfactualSelection): LeavePlacement {
@@ -232,9 +208,11 @@ function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExpla
return {
calculation,
calendar: {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
createPlanningCalculation: vi.fn(() => calculation),
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
},
};
}
@@ -496,9 +474,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const before = {
balances: structuredClone(input.vacationNumberSignal()),
displayed: structuredClone(
TestBed.inject(PlanningCalculationService).displayedPlan(),
),
remaining: structuredClone(input.remainingVacationDaysSignal()),
declarations: structuredClone(input.declaredDaysSignal()),
rules: structuredClone(rules.rules),
};
@@ -530,11 +506,9 @@ describe('CalendarYear planning outcome boundary', () => {
];
expect(statistics()).toEqual(expectedStatistics);
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances);
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
before.displayed,
);
expect(input.remainingVacationDaysSignal()).toEqual(before.remaining);
expect(input.declaredDaysSignal()).toEqual(before.declarations);
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
@@ -551,7 +525,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(statistics()).toEqual(expectedStatistics);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
},
);
@@ -1073,6 +1047,7 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const input = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
@@ -1081,10 +1056,10 @@ describe('CalendarYear planning outcome boundary', () => {
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
TestBed.inject(PlanningCalculationService).start();
input.requestComputation();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
TestBed.inject(PlanningCalculationService).start();
input.requestComputation();
fixture.detectChanges();
const otherControl = document.createElement('button');
otherControl.textContent = 'Another control';
@@ -1096,7 +1071,7 @@ describe('CalendarYear planning outcome boundary', () => {
oldOutcome.resolve(plan);
await fixture.whenStable();
fixture.detectChanges();
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
expect(input.planningOutcomeSignal()).toBe(replacement);
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
if (replacement.kind === 'Plan') {
expect(opener).not.toBeNull();
@@ -1772,7 +1747,7 @@ describe('CalendarYear planning outcome boundary', () => {
},
]);
const duration = TestBed.inject(PlanningDurationService);
const submitted = TestBed.inject(PlanningCalculationService).request()!;
const submitted = duration.request()!;
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: pending.promise,
@@ -1874,13 +1849,11 @@ describe('CalendarYear planning outcome boundary', () => {
}).compileComponents();
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-01', '2024-12-31');
const input = TestBed.inject(UserInputService);
const duration = TestBed.inject(PlanningDurationService);
calendar.createPlanningCalculation.mockImplementation(() => ({
...calculation,
snapshot: {
...calculation.snapshot,
...TestBed.inject(PlanningCalculationService).request()!,
},
snapshot: { ...calculation.snapshot, ...duration.request()! },
outcome:
calendar.createPlanningCalculation.mock.calls.length === 1
? old.promise
@@ -1890,7 +1863,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
now = 11_000;
settings.setPlanningDates('2024-01-01', '2026-12-31');
TestBed.inject(PlanningCalculationService).start();
input.requestComputation();
now = 12_000; // Rendering/scheduling after the request is part of the wait.
fixture.detectChanges();
const initial = duration.estimate();
@@ -1912,10 +1885,7 @@ describe('CalendarYear planning outcome boundary', () => {
const revisitedOutcome = deferred<PlanningOutcome>();
calendar.createPlanningCalculation.mockReturnValueOnce({
...calculation,
snapshot: {
...calculation.snapshot,
...TestBed.inject(PlanningCalculationService).request()!,
},
snapshot: { ...calculation.snapshot, ...duration.request()! },
outcome: revisitedOutcome.promise,
});
now = 631_000; // Returning to the calendar is a fresh initial calculation.
@@ -1977,7 +1947,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
expect(root.querySelector('.loading-overlay')).toBeNull();
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
TestBed.inject(PlanningCalculationService).start();
TestBed.inject(UserInputService).requestComputation();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -1988,146 +1958,6 @@ describe('CalendarYear planning outcome boundary', () => {
},
);
it('does not calibrate or announce a previous result when the planner is remounted', async () => {
const { calendar } = calendarFor(plan, explanation);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan, explanation))
.mockReturnValueOnce({
...calculationFor(plan, explanation),
outcome: new Promise(() => undefined),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const record = vi.spyOn(TestBed.inject(PlanningDurationService), 'recordCompletion');
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
};
const first = TestBed.createComponent(CalendarYear);
await settle(first);
expect(record).toHaveBeenCalledTimes(1);
first.destroy();
const second = TestBed.createComponent(CalendarYear);
await settle(second);
expect(record).toHaveBeenCalledTimes(1);
expect(second.componentInstance['liveMessage']()).toBe(
second.componentInstance['tr']().loading,
);
});
it('does not announce a previous rejected explanation when the planner is remounted', async () => {
const { calendar } = calendarFor(plan, explanation);
const rejected = {
...calculationFor(plan, explanation),
explanation: Promise.reject(new Error('explanation exploded')),
};
rejected.explanation.catch(() => undefined);
calendar.createPlanningCalculation.mockReturnValueOnce(rejected).mockReturnValueOnce({
...calculationFor(plan, explanation),
outcome: new Promise(() => undefined),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
};
const first = TestBed.createComponent(CalendarYear);
await settle(first);
expect(first.componentInstance['liveMessage']()).toContain('explanation exploded');
first.destroy();
const second = TestBed.createComponent(CalendarYear);
await settle(second);
expect(second.componentInstance['liveMessage']()).toBe(
second.componentInstance['tr']().loading,
);
});
it('announces loading and keeps focus when a calculation starts during a cancellation', async () => {
const first = deferred<PlanningOutcome>();
const { calendar, calculation } = calendarFor(plan, undefined);
calendar.createPlanningCalculation
.mockReturnValueOnce({ ...calculation, outcome: first.promise })
.mockReturnValueOnce({ ...calculation, outcome: new Promise(() => undefined) });
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const live = () => fixture.componentInstance['liveMessage']();
const tr = fixture.componentInstance['tr']();
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.cancel-calculation')!);
fixture.detectChanges();
expect(live()).toBe(tr.planning.cancelling);
const outside = document.createElement('button');
document.body.append(outside);
outside.focus();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
first.resolve(plan);
await fixture.whenStable();
fixture.detectChanges();
expect(document.activeElement).toBe(outside);
expect(live()).toBe(tr.loading);
outside.remove();
});
it.each([
['a delivered explanation error', false],
['a rejected explanation', true],
])('announces only %s when it rejected', async (_, rejected) => {
const reason = { code: 'explanation-error', message: 'Explanation boom' };
const { calendar } = calendarFor(plan, undefined);
calendar.createPlanningCalculation.mockReturnValue({
...calculationFor(plan, undefined),
explanation: rejected
? Promise.reject(new Error(reason.message))
: Promise.resolve(undefined),
explanationError: Promise.resolve(rejected ? undefined : reason),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const service = TestBed.inject(PlanningCalculationService);
expect(service.explanationError()).toMatchObject(reason);
expect(service.explanationRejected()).toBe(rejected);
expect(fixture.componentInstance['liveMessage']().includes(reason.message)).toBe(rejected);
});
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -2221,7 +2051,7 @@ describe('CalendarYear planning outcome boundary', () => {
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
settings.setPlanningDates('2021-05-14', '2021-05-14');
TestBed.inject(PlanningCalculationService).start();
TestBed.inject(UserInputService).requestComputation();
fixture.detectChanges();
await fixture.whenStable();
oldOutcome.resolve(plan);
@@ -2287,7 +2117,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(calculate).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
TestBed.inject(PlanningCalculationService).start();
input.requestComputation();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -2326,9 +2156,6 @@ describe('CalendarYear planning outcome boundary', () => {
['PlanningIncomplete without incumbent', incomplete],
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
const { calendar } = calendarFor(outcome);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan, explanation))
.mockReturnValueOnce(calculationFor(outcome));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2336,27 +2163,20 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([
{
id: 'stale',
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date('2025-01-01'),
},
]);
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
const service = TestBed.inject(PlanningCalculationService);
const settle = async () => {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
};
await settle();
const root = fixture.nativeElement as HTMLElement;
expect(service.displayedPlan()).toBeDefined();
expect(
Array.from(root.querySelectorAll('.plan-summary dd')).map((e) => e.textContent?.trim()),
).toEqual(['2', '4', '6']);
fixture.detectChanges();
await fixture.whenStable();
service.start();
await settle();
expect(service.displayedPlan()).toBeUndefined();
expect(root.querySelector('.plan-summary')).toBeNull();
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
it('keeps and summarizes a returned result', async () => {
@@ -2368,14 +2188,14 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(
TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
).toEqual(plan.remainingBalances);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
const summaryValues = Array.from(
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
).map((element) => element.textContent?.trim());
@@ -2520,7 +2340,9 @@ describe('CalendarYear planning outcome boundary', () => {
placeType: 'cp',
},
];
calendar.project.mockImplementation((value) => projectionFor(value, true));
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2725,7 +2547,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
TestBed.inject(PlanningCalculationService).start();
TestBed.inject(UserInputService).requestComputation();
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -3130,12 +2952,14 @@ describe('CalendarYear planning outcome boundary', () => {
it('turns planner rejection into an incomplete error and clears loading', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
createPlanningCalculation: vi.fn(() => ({
...calculationFor(plan),
outcome: Promise.reject(new Error('planner exploded')),
})),
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -3144,6 +2968,15 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([
{
id: 'stale',
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date('2025-01-01'),
},
]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
@@ -3155,6 +2988,6 @@ describe('CalendarYear planning outcome boundary', () => {
if (outcome?.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('planner-error');
expect(fixture.componentInstance['isLoading']()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
});
+267 -106
View File
@@ -33,17 +33,26 @@ import {
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import { CalendarService, SelectedDates } from '../../services/calendar-service';
import {
CalendarService,
calendarProjectionHorizon,
SelectedDates,
SelectableDayType,
VacDay,
} from '../../services/calendar-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import {
CounterfactualAnalysis,
CounterfactualSelection,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
@@ -80,7 +89,6 @@ export class CalendarYear {
private readonly calendarService = inject(CalendarService);
private readonly settings = inject(CalendarSettingsService);
private readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
private readonly injector = inject(Injector);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
@@ -93,13 +101,13 @@ export class CalendarYear {
private statisticsDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling;
protected readonly isLoading = signal(false);
protected readonly isCancelling = signal(false);
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
protected readonly loadingProgress = this.calculations.progress;
protected readonly planningOutcome = this.calculations.outcome;
protected readonly planningExplanation = this.calculations.explanation;
protected readonly explanationError = this.calculations.explanationError;
protected readonly loadingProgress = signal(0);
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
protected readonly explanationError = signal<PlanningReason | null>(null);
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
@@ -110,7 +118,8 @@ export class CalendarYear {
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly liveMessage = signal('');
protected readonly planningHorizon = this.calculations.displayedHorizon;
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
private readonly declarationDrag = signal<{
@@ -135,21 +144,21 @@ export class CalendarYear {
}
return { dates, type: this.userInput.declaredTypeSignal() };
});
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
protected readonly calendarHorizon = computed(() =>
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
);
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
: (this.calculations.accepted()?.declaredDays ?? []);
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
private wasCancelling = false;
private readonly initialAccepted = this.calculations.accepted();
private readonly initialExplanation = this.calculations.explanation();
private readonly initialExplanationError = this.calculations.explanationError();
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -157,7 +166,7 @@ export class CalendarYear {
return this.translationService.tr;
}
protected readonly displayedPlan = this.calculations.displayedPlan;
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
protected readonly weekendConnectedHolidays = computed(() => {
const isWeekend = (date: string) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
@@ -268,22 +277,20 @@ export class CalendarYear {
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly calendarProjection = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return undefined;
if (this.declarationEntry())
return this.calendarService.project(
{ placements: [], horizon, declaredDays: this.userInput.declaredDaysSignal() },
{ fixedMonths: true },
);
const plan = this.displayedPlan();
return this.calendarService.project(
plan ? { ...plan, horizon: plan.horizon ?? horizon } : { placements: [], horizon },
);
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
return horizon
? this.calendarService.selectedDatesForPlan({
placements: [],
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
})
: new SelectedDates();
}
return this.selectedDates();
});
protected readonly calendarSelectedDates = computed(
() => this.calendarProjection()?.selections ?? new SelectedDates(),
);
protected readonly currentPlacementDate = computed(() => {
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
@@ -293,7 +300,26 @@ export class CalendarYear {
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
);
protected readonly months = computed(() => this.calendarProjection()?.months ?? []);
protected readonly months = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return [];
const plan = this.displayedPlan();
const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
: horizon;
const first = new Date(`${projectionHorizon.start}T00:00:00`);
const last = new Date(`${projectionHorizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
}
return result;
});
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
@@ -316,75 +342,188 @@ export class CalendarYear {
});
});
effect(() => {
if (this.calculations.pendingId() !== null)
untracked(() => this.liveMessage.set(this.tr().loading));
this.userInput.computeTriggerSignal();
untracked(() => this.startCalculation());
});
effect(() => {
const pending = this.calculations.pendingId() !== null;
const cancelling = this.isCancelling();
untracked(() => {
if (this.wasCancelling && !pending) this.resultsTitle()?.nativeElement.focus();
this.wasCancelling = cancelling;
}
private startCalculation(): void {
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
this.userInput.computationRequestedAtMs.set(null);
const request = this.duration.request();
if (!request) return;
const { horizon, rules: currentRules } = request;
this.declarationEntry.set(false);
if (!this.planningHorizon()) this.planningHorizon.set(horizon);
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.isCancelling.set(false);
this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
let calculation: PlanningCalculationContext;
let completed = false;
try {
calculation = this.calendarService.createPlanningCalculation({
...request,
balances: request.balances.map((balance) => ({
...balance,
label: this.translationService.getDayTypeLabel(balance.type),
})),
labels: Object.fromEntries(
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
});
effect(() => {
const accepted = this.calculations.accepted();
if (!accepted || accepted === this.initialAccepted) return;
untracked(() => {
this.closeStatistics();
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.clearCounterfactual();
this.liveMessage.set(this.outcomeLabel(accepted.outcome));
if (accepted.successful && !this.destroyRef.destroyed)
} catch (error) {
this.resetCalculationState(horizon);
this.planningHorizon.set(horizon);
this.failCalculation(error, version);
return;
}
this.runningCalculation = calculation;
void calculation.outcome
.then((outcome) => {
if (version !== this.calculationVersion) return;
this.resetCalculationState(horizon);
this.activeCalculation = calculation;
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
this.planningHorizon.set(horizon);
this.applyOutcome(outcome);
this.loadArtifacts(calculation);
completed = outcome.kind === 'Plan' && !this.isCancelling();
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
this.resetCalculationState(horizon);
this.activeCalculation = calculation;
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
this.planningHorizon.set(horizon);
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [
{
code: 'planner-error',
message:
error instanceof Error
? error.message
: 'The planner failed unexpectedly.',
},
],
});
this.loadArtifacts(calculation);
})
.finally(() => {
if (version !== this.calculationVersion) return;
this.runningCalculation = null;
this.loadingProgress.set(100);
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
this.isCancelling.set(false);
if (completed && !this.destroyRef.destroyed)
afterNextRender(
() => {
if (
this.calculations.accepted() === accepted &&
!this.destroyRef.destroyed &&
accepted.snapshot
)
if (version === this.calculationVersion && !this.destroyRef.destroyed)
this.duration.recordCompletion(
accepted.snapshot,
performance.now() - accepted.requestedAt,
calculation.snapshot,
performance.now() - requestedAt,
);
},
{ injector: this.injector },
);
});
});
effect(() => {
const explanation = this.calculations.explanation();
if (explanation?.kind !== 'PlanExplanation' || explanation === this.initialExplanation)
return;
untracked(() => {
this.selectPlacement(0, false);
const outcome = this.planningOutcome();
this.liveMessage.set(
`${outcome ? this.outcomeLabel(outcome) : ''} ${this.placementAnnouncement()}`.trim(),
);
});
});
effect(() => {
const reason = this.explanationError();
if (
reason &&
reason !== this.initialExplanationError &&
this.calculations.explanationRejected()
)
untracked(() =>
this.liveMessage.set(
`${this.tr().planning.explanationError} ${reason.message}`,
),
);
});
this.calculations.start();
}
protected cancelCalculation(): void {
if (!this.isLoading() || this.isCancelling()) return;
this.calculations.cancel();
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
this.isCancelling.set(true);
this.liveMessage.set(this.tr().planning.cancelling);
this.runningCalculation.cancel();
}
private failCalculation(error: unknown, version: number): void {
if (version !== this.calculationVersion) return;
const reason = {
code: 'planner-error',
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
};
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
this.explanationError.set(reason);
this.loadingProgress.set(100);
this.isLoading.set(false);
}
private resetCalculationState(horizon: { start: string; end: string }): void {
this.closeStatistics();
this.activeCalculation = null;
this.userInput.planningSnapshotSignal.set(null);
this.planningOutcome.set(null);
this.planningExplanation.set(null);
this.explanationError.set(null);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.clearCounterfactual();
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
private loadArtifacts(calculation: PlanningCalculationContext): void {
void calculation.explanation
.then((explanation) => {
if (calculation !== this.activeCalculation) return;
this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation') {
this.selectPlacement(0, false);
this.liveMessage.set(
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
);
}
})
.catch((error: unknown) => {
if (calculation !== this.activeCalculation) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
void calculation.explanationError
.then((reason) => {
if (calculation === this.activeCalculation)
this.explanationError.set(reason ?? null);
})
.catch((error: unknown) => {
if (calculation !== this.activeCalculation) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
}
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
this.liveMessage.set(this.outcomeLabel(outcome));
const plan = this.displayedPlan();
this.resultDeclaredDays.set(
plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
);
if (plan) {
this.planningHorizon.set(plan.horizon);
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates.set(
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
);
this.userInput.remainingVacationDaysSignal.set([]);
}
protected formatStatistic(value: number | null): string {
@@ -690,7 +829,7 @@ export class CalendarYear {
selection: CounterfactualSelection,
openDialog = false,
): Promise<void> {
const calculation = this.calculations.accepted()?.calculation;
const calculation = this.activeCalculation;
if (!calculation) return;
this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion;
@@ -705,10 +844,7 @@ export class CalendarYear {
);
try {
const result = await calculation.analyzeCounterfactual(selection);
if (
version !== this.counterfactualVersion ||
calculation !== this.calculations.accepted()?.calculation
)
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
@@ -719,10 +855,7 @@ export class CalendarYear {
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (
version !== this.counterfactualVersion ||
calculation !== this.calculations.accepted()?.calculation
)
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
const message = error instanceof Error ? error.message : String(error);
this.counterfactualError.set(message);
@@ -787,10 +920,10 @@ export class CalendarYear {
}
protected ruleLabel(ruleId: string): string {
const rule = this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === ruleId);
return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId;
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
}
protected balanceLabel(explanation: PlacementExplanation): string {
@@ -822,9 +955,9 @@ export class CalendarYear {
protected objectiveCopy(id: string) {
if (id.startsWith('optimization:')) {
const ruleId = id.slice('optimization:'.length);
const rule = this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === ruleId);
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
const copy = this.tr().planning.explanation.objectives['optimization'];
return {
...copy,
@@ -842,9 +975,9 @@ export class CalendarYear {
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
? this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === id.slice('preference:'.length))
? this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === id.slice('preference:'.length),
)
: undefined;
return (
copy && {
@@ -929,4 +1062,32 @@ export class CalendarYear {
: this.tr().planning.cancelled;
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
}
private stageProgress(stage: string): 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 this.loadingProgress();
}
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
return plan.remainingBalances.map((balance) => ({
id: balance.id,
type: balance.type as SelectableDayType,
numberOfDays: balance.quantity,
availableDate: new Date(`${balance.availableFrom}T00:00:00`),
expiryDate: new Date(`${balance.expiryDate}T00:00:00`),
classification: balance.classification,
reason: balance.reason,
}));
}
}
@@ -7,19 +7,12 @@ import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { vi, type MockInstance } from 'vitest';
import { vi } from 'vitest';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { CalendarService } from '../../services/calendar-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import {
createPlanningCalculation,
type PlanningCalculationContext,
type PlanningOutcome,
PlanningRequest,
} from '../../services/leave-planner.service';
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
@@ -28,8 +21,6 @@ describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
let userInputService: UserInputService;
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
let calculations: PlanningCalculationService;
beforeEach(async () => {
localStorage.clear();
@@ -54,11 +45,6 @@ describe('UserInput', () => {
fixture = TestBed.createComponent(UserInput);
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
calculations = TestBed.inject(PlanningCalculationService);
// Unless a test supplies a result, a started calculation stays pending.
createCalculation = vi
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
.mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
fixture.detectChanges();
await fixture.whenStable();
}
@@ -105,25 +91,16 @@ describe('UserInput', () => {
async function calculate(request: PlanningRequest): Promise<void> {
const browserWindow = globalThis.window;
let calculation: PlanningCalculationContext;
let calculation: ReturnType<typeof createPlanningCalculation>;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
await show(calculation);
}
async function show(
calculation: PlanningCalculationContext,
outcome?: PlanningOutcome,
): Promise<void> {
const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
createCalculation.mockReturnValueOnce(shown);
calculations.start();
await shown.outcome;
await Promise.resolve();
const outcome = await calculation.outcome;
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
fixture.detectChanges();
}
@@ -155,33 +132,6 @@ describe('UserInput', () => {
expect(card.textContent).toBe(explanation);
});
it('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => {
await calculate({
horizon: { start: '2027-05-01', end: '2027-05-10' },
balances: [
{
id: 'other',
type: 'other',
quantity: 1,
availableFrom: '2027-05-10',
expiryDate: '2028-05-10',
},
],
rules: [],
});
const root = fixture.nativeElement as HTMLElement;
expect(calculations.displayedPlan()).toBeDefined();
expect(root.querySelector('.warning-card')).not.toBeNull();
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningConflict',
reasons: [],
conflictingRuleIds: ['rule'],
});
expect(calculations.displayedPlan()).toBeUndefined();
expect(root.querySelector('.warning-card')).toBeNull();
});
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
@@ -205,18 +155,19 @@ describe('UserInput', () => {
expect(card.textContent).toContain('Aucune action nécessaire');
expect(card.textContent).not.toContain('Disponible seulement');
expect(root.querySelector('.remaining-history')).toBeNull();
const outcome = JSON.stringify(calculations.outcome());
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
const plan = calculations.displayedPlan()!;
await show(calculations.accepted()!.calculation!, {
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
fixture.detectChanges();
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
expect(card.textContent).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
@@ -234,7 +185,7 @@ describe('UserInput', () => {
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
fixture.detectChanges();
expect(estimateText()).not.toBe(initial);
expect(createCalculation).not.toHaveBeenCalled();
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
@@ -267,7 +218,7 @@ describe('UserInput', () => {
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
expect(createCalculation).not.toHaveBeenCalled();
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
@@ -282,11 +233,11 @@ describe('UserInput', () => {
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(createCalculation).not.toHaveBeenCalled();
expect(userInputService.computeTriggerSignal()).toBe(0);
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(createCalculation).toHaveBeenCalledTimes(1);
expect(userInputService.computeTriggerSignal()).toBe(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
@@ -331,7 +282,7 @@ describe('UserInput', () => {
expect(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
expect(createCalculation).not.toHaveBeenCalled();
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('should initialize with vacation days from service', () => {
@@ -567,7 +518,7 @@ describe('UserInput', () => {
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
expect(createCalculation).toHaveBeenCalledTimes(1);
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
+11 -9
View File
@@ -15,7 +15,6 @@ import { VacDay } from '../../services/calendar-service';
import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -46,7 +45,6 @@ export class UserInput {
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
protected readonly planningDates = computed(() => {
@@ -74,21 +72,22 @@ export class UserInput {
}
protected readonly remainingDays = computed(() =>
(this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
(total, balance) => total + balance.quantity,
0,
),
);
protected readonly hasAtRiskDays = computed(() =>
this.calculations
.displayedPlan()
this.userInputService
.displayedPlanSignal()
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
);
protected remainingDaysDetails = computed(() => {
const plan = this.calculations.displayedPlan();
const plan = this.userInputService.displayedPlanSignal();
if (!plan) return [];
const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
const incomplete =
this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
@@ -148,7 +147,10 @@ export class UserInput {
}
private ruleName(id: string): string {
return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
return (
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
?.name ?? id
);
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
@@ -176,6 +178,6 @@ export class UserInput {
public triggerComputation() {
if (this.calendarSettings.planningHorizonError()) return;
this.calculations.start();
this.userInputService.requestComputation();
}
}
+110 -133
View File
@@ -46,18 +46,21 @@ describe('CalendarService', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Europe/Paris';
try {
const { selections: selected } = service.project({
const selected = service.selectedDatesForPlan({
horizon: { start: date, end },
placements: [
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
});
const day = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.isStart(day(periodStart))).toBe(true);
expect(selected.isEnd(day(periodStart))).toBe(false);
expect(selected.isStart(day(date))).toBe(false);
expect(selected.isEnd(day(date))).toBe(true);
expect(selected.isSelected(day(end))).toBe(false);
const period = selected.datesSelected.find(
(item) =>
item.type === null &&
item.range.start?.getTime() ===
new Date(`${periodStart}T00:00:00`).getTime() &&
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
);
expect(period).toBeDefined();
expect(period?.getLengthInDays()).toBe(3);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
@@ -67,7 +70,7 @@ describe('CalendarService', () => {
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
const horizon = { start: '2024-04-02', end: '2024-04-03' };
const { selections: selected } = service.project({
const selected = service.selectedDatesForPlan({
horizon,
placements: [
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
@@ -104,15 +107,21 @@ describe('CalendarService', () => {
],
});
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
const selected = service.project(outcome).selections;
const selected = service.selectedDatesForPlan(outcome);
const date = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
expect(selected.isStart(date('2024-05-31'))).toBe(true);
expect(selected.isEnd(date('2024-06-03'))).toBe(true);
expect(selected.isEnd(date('2024-06-02'))).toBe(false);
expect(
selected.datesSelected
.find(
(selection) =>
selection.type === null &&
selection.range.start?.getTime() === date('2024-05-31').getTime(),
)
?.getLengthInDays(),
).toBe(4);
expect(outcome.placements).toEqual([]);
expect(outcome.horizon).toEqual(horizon);
});
@@ -125,7 +134,7 @@ describe('CalendarService', () => {
];
const before = { horizon, declaredDays, placements: [] };
expect(calendarProjectionHorizon(before)).toEqual(horizon);
expect(service.project(before).selections.isSelected(new Date('2024-08-01'))).toBe(false);
expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false);
const after = {
...before,
placements: [
@@ -138,7 +147,7 @@ describe('CalendarService', () => {
},
],
};
const selected = service.project(after).selections;
const selected = service.selectedDatesForPlan(after);
expect(calendarProjectionHorizon(after)).toEqual({
start: horizon.start,
end: '2024-08-02',
@@ -192,60 +201,52 @@ describe('CalendarService', () => {
});
});
describe('project', () => {
const project = (horizon: { start: string; end: string }) =>
service.project({ placements: [], horizon });
const day = (key: string) => new Date(`${key}T00:00:00`);
describe('getSelectedDatesForYear', () => {
it('should return selected dates for the year', () => {
const selectedDates = service.getSelectedDatesForYear();
expect(selectedDates).toBeDefined();
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
});
it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
const horizon = service.getDefaultPlanningHorizon();
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
DayType.TODAY,
it('should include weekends', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasWeekend = selectedDates._datesSelected.some(
(date) => date.type === DayType.WEEKEND,
);
expect(hasWeekend).toBe(true);
});
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
{ monthIndex: 11, year: 2024 },
{ monthIndex: 0, year: 2025 },
{ monthIndex: 1, year: 2025 },
]);
it('should include holidays', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasHoliday = selectedDates._datesSelected.some(
(date) => date.type === DayType.CLOSED_DAY,
);
expect(hasHoliday).toBe(true);
});
it('keeps fixed months when an edge declaration widens the projection', () => {
const plan = {
placements: [],
horizon: { start: '2024-07-01', end: '2025-01-04' },
declaredDays: [{ date: '2024-07-01', type: 'cp' as const }],
};
const months = (projection: ReturnType<typeof service.project>) =>
projection.months.map(({ monthIndex, year }) => `${year}-${monthIndex}`);
expect(months(service.project(plan))[0]).toBe('2024-5');
const fixed = service.project(plan, { fixedMonths: true });
expect(months(fixed)).toEqual([
'2024-6',
'2024-7',
'2024-8',
'2024-9',
'2024-10',
'2024-11',
'2025-0',
]);
expect(fixed.selections.dayTypes(day('2024-06-30'))).toContain(DayType.WEEKEND);
expect(fixed.selections.isStart(day('2024-07-01'))).toBe(false);
it('should include today', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasToday = selectedDates._datesSelected.some(
(date) => date.type === DayType.TODAY,
);
expect(hasToday).toBe(true);
});
it('includes the final partial month of a half-open one-year horizon', () => {
const { selections } = project({ start: '2024-01-15', end: '2025-01-15' });
expect(selections.dayTypes(day('2025-01-04'))).toContain(DayType.WEEKEND);
const selectedDates = service.getSelectedDatesForHorizon({
start: '2024-01-15',
end: '2025-01-15',
});
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
DayType.WEEKEND,
);
});
it('projects holidays in every covered year and in both partial boundary months', () => {
const { selections } = project({ start: '2023-12-26', end: '2027-01-03' });
const selected = service.getSelectedDatesForHorizon({
start: '2023-12-26',
end: '2027-01-03',
});
for (const date of [
'2023-12-25',
'2024-04-01',
@@ -253,34 +254,20 @@ describe('CalendarService', () => {
'2026-04-06',
'2027-01-01',
]) {
expect(selections.dayTypes(day(date))).toContain(DayType.CLOSED_DAY);
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
DayType.CLOSED_DAY,
);
}
expect(selections.dayTypes(day('2027-01-02'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
});
});
it('does not mutate the submitted plan or its horizon', () => {
const plan = {
horizon: { start: '2024-04-02', end: '2024-04-03' },
placements: [
{
date: '2024-04-02',
balanceId: 'cp',
type: 'cp' as const,
ruleIds: [],
supporting: false,
},
],
declaredDays: [{ date: '2024-04-03', type: 'rtt' as const }],
};
const before = structuredClone(plan);
const frozen = JSON.parse(JSON.stringify(plan), (_, value) =>
typeof value === 'object' && value ? Object.freeze(value) : value,
);
const projection = service.project(frozen);
expect(projection.selections.dayTypes(new Date('2024-04-03T00:00:00'))).toContain(
DayType.RTT,
);
expect(frozen).toEqual(before);
describe('setWeekendSelection', () => {
it('should add weekend for Saturday', () => {
const selectedDates = new SelectedDates();
const saturday = new Date(2024, 0, 6); // A Saturday
service.setWeekendSelection(saturday, selectedDates);
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
});
});
});
@@ -311,6 +298,13 @@ describe('SelectedDate', () => {
expect(classes).toContain(DayType.CP);
});
it('should return tooltip for selected date', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
const tooltip = selectedDate.getTooltip(date);
expect(tooltip).toBeTruthy();
});
it('should calculate length in days', () => {
const startDate = new Date(2024, 0, 1);
const endDate = new Date(2024, 0, 3);
@@ -326,64 +320,47 @@ describe('SelectedDate', () => {
});
describe('SelectedDates', () => {
const range = (type: DayType, start: Date, end = start) =>
new SelectedDate(type, new DateRange<Date>(start, end));
it('selects covered dates and reports their day types', () => {
it('should push selected dates', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
const selectedDates = new SelectedDates([range(DayType.CP, date)]);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates._datesSelected.length).toBe(1);
});
it('should check if date is selected', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates.isSelected(date)).toBe(true);
expect(selectedDates.dayTypes(date)).toEqual([DayType.CP]);
expect(selectedDates.getClasses(date)).toContain(DayType.CP);
});
it('treats adjacent ranges as one period with shared boundaries', () => {
const first = new Date(2024, 0, 1);
const second = new Date(2024, 0, 2);
const third = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.RTT, second),
range(DayType.OTHER, third),
]);
expect(selectedDates.isStart(first)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(false);
expect(selectedDates.isStart(second)).toBe(false);
expect(selectedDates.isEnd(second)).toBe(false);
expect(selectedDates.isEnd(third)).toBe(true);
expect(selectedDates.dayTypes(second)).toEqual([DayType.RTT]);
it('should group adjacent dates', () => {
const selectedDates = new SelectedDates();
const date1 = new Date(2024, 0, 1);
const date2 = new Date(2024, 0, 2);
selectedDates._datesSelected.push(
new SelectedDate(DayType.CP, new DateRange<Date>(date1, date1)),
);
selectedDates._datesSelected.push(
new SelectedDate(DayType.RTT, new DateRange<Date>(date2, date2)),
);
selectedDates.update();
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
});
it('keeps separated ranges as separate periods', () => {
const first = new Date(2024, 0, 1);
const later = new Date(2024, 0, 5);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.CP, later),
]);
expect(selectedDates.isStart(later)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(true);
});
it('merges overlapping ranges and keeps every overlapping day type', () => {
const start = new Date(2024, 0, 1);
const overlap = new Date(2024, 0, 2);
const end = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.WEEKEND, start, overlap),
range(DayType.CLOSED_DAY, overlap, end),
]);
expect(selectedDates.dayTypes(overlap)).toEqual([DayType.WEEKEND, DayType.CLOSED_DAY]);
expect(selectedDates.isStart(start)).toBe(true);
expect(selectedDates.isEnd(end)).toBe(true);
expect(selectedDates.isStart(overlap)).toBe(false);
});
it('does not select today', () => {
it('should return correct classes', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
const selectedDates = new SelectedDates([range(DayType.TODAY, date)]);
expect(selectedDates.isSelected(date)).toBe(false);
expect(selectedDates.dayTypes(date)).toEqual([]);
expect(selectedDates.getClasses(date)).toContain(DayType.TODAY);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const classes = selectedDates.getClasses(date);
expect(classes).toContain(DayType.CP);
});
it('should return tooltip', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const tooltip = selectedDates.getTooltip(date);
expect(tooltip).toBeTruthy();
});
});
+234 -108
View File
@@ -10,7 +10,9 @@ import {
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
PlanningStateSnapshot,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
@@ -52,11 +54,6 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
return horizon;
}
export interface CalendarProjection {
months: { monthIndex: number; year: number }[];
selections: SelectedDates;
}
@Injectable({
providedIn: 'root',
})
@@ -71,82 +68,112 @@ export class CalendarService {
return { start, end: addPlanningYears(start, 1) };
}
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
project(
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
{ fixedMonths = false } = {},
): CalendarProjection {
const horizon = calendarProjectionHorizon(plan)!;
const firstMonth = plan.horizon.start.slice(0, 7);
const lastMonth = addPlanningDays(plan.horizon.end, -1).slice(0, 7);
const first = new Date(`${horizon.start}T00:00:00`);
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
const projectionHorizon = calendarProjectionHorizon(plan);
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) =>
!projectionHorizon ||
(day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
),
]) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(
new Date(`${placement.date}T00:00:00`),
new Date(`${placement.date}T00:00:00`),
),
),
);
}
return selected;
}
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
const _holidays = this.getHolidays();
const selectedDates: SelectedDates = new SelectedDates();
// Logic to populate selectedDates from now until the next year
return selectedDates;
}
getSelectedDatesForYear(): SelectedDates {
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
}
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
const start = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
);
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start);
cursor.setDate(1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
);
cursor.setMonth(cursor.getMonth() + 1);
}
const selectedDates: SelectedDates = new SelectedDates();
// Use DateCacheService to get today's date components
const {
date: todayDate,
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = [];
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
const month = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
if (!fixedMonths || (month >= firstMonth && month <= lastMonth))
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
for (const week of CalendarService.weeksInMonth(
cursor.getMonth(),
cursor.getFullYear(),
)) {
for (const day of week) {
if (!day) continue;
if (day.getDay() === 6) {
const end = new Date(day);
end.setDate(end.getDate() + 1);
ranges.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(day, end)),
);
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInHorizon) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
this.setWeekendSelection(day, selectedDates);
if (holidayTimestamps.has(day.getTime())) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
}
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)),
);
}
}
if (day.getDate() === 1 && day.getDay() === 0) {
const start = new Date(day);
start.setDate(start.getDate() - 1);
ranges.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, day)),
);
}
if (holidays.has(day.getTime()))
ranges.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
}
}
cursor.setMonth(cursor.getMonth() + 1);
}
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) => day.date >= horizon.start && day.date < horizon.end,
),
]) {
const day = new Date(`${placement.date}T00:00:00`);
ranges.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(day, day),
),
selectedDates.update();
return selectedDates;
}
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
if (date.getDay() === 6) {
const end = new Date(date);
end.setDate(end.getDate() + 1);
selectedDates._datesSelected.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
);
}
if (date.getDate() === 1 && date.getDay() === 0) {
const start = new Date(date);
start.setDate(start.getDate() - 1);
selectedDates._datesSelected.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
);
}
return { months, selections: new SelectedDates(ranges) };
}
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
@@ -228,6 +255,8 @@ export type VacDay = {
/** Legacy balances omit this value; the planner then uses the horizon start. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
@@ -256,6 +285,16 @@ const dayTypeForLeaveType: Record<LeaveType, DayType> = {
other: DayType.OTHER,
};
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
[DayType.RTT]: 'RTT',
[DayType.CP]: 'Congé Payé',
[DayType.OTHER]: 'Autre',
[DayType.TODAY]: "Aujourd'hui",
[SelectableDayType.ANY]: "N'importe",
};
export const monthNames: string[] = [
'Janvier',
'Février',
@@ -315,6 +354,15 @@ export class SelectedDate implements SelectedDateInterface {
return returnType;
}
getTooltip(date: Date): string | null | undefined {
if (this.isSelected(date)) {
if (this.type) {
return tooltipTypeMapping[this.type];
}
}
return null;
}
getLengthInDays(): number {
if (this.range.start && this.range.end) {
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
@@ -324,60 +372,113 @@ export class SelectedDate implements SelectedDateInterface {
}
export class SelectedDates implements SelectedDateInterface {
private readonly ranges: readonly SelectedDate[];
// Ranges plus synthetic null-type groups for connected ranges, so connected periods share boundaries.
private readonly grouped: readonly SelectedDate[];
_datesSelected: SelectedDate[] = [];
datesSelected: SelectedDate[] = [];
constructor(ranges: readonly SelectedDate[] = []) {
this.ranges = ranges.slice();
this.grouped = SelectedDates.group(this.ranges);
private minDate: Date | null = null;
private maxDate: Date | null = null;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
}
private static group(ranges: readonly SelectedDate[]): SelectedDate[] {
const result = ranges.slice();
if (ranges.length <= 1) return result;
const sorted = [...ranges].sort(
(a, b) => (a.range.start?.getTime() ?? 0) - (b.range.start?.getTime() ?? 0),
);
const groups: SelectedDate[][] = [];
let current: SelectedDate[] = [sorted[0]];
let maxEnd = sorted[0].range.end?.getTime() ?? 0;
for (const next of sorted.slice(1)) {
const nextDay = new Date(maxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if ((next.range.start?.getTime() ?? 0) > nextDay.getTime()) {
if (current.length > 1) groups.push(current);
current = [];
}
current.push(next);
maxEnd = Math.max(maxEnd, next.range.end?.getTime() ?? 0);
push(selectedDate: SelectedDate) {
this._datesSelected.push(selectedDate);
this.datesSelected = this._datesSelected.slice();
this.updateMinMaxDates();
this.grouping();
}
private updateMinMaxDates(): void {
if (this._datesSelected.length === 0) {
this.minDate = null;
this.maxDate = null;
return;
}
if (current.length > 1) groups.push(current);
this.minDate = this._datesSelected.reduce((min, date) => {
if (!date.range.start) return min;
if (!min) return date.range.start;
return date.range.start < min ? date.range.start : min;
}, this._datesSelected[0].range.start);
this.maxDate = this._datesSelected.reduce((max, date) => {
if (!date.range.end) return max;
if (!max) return date.range.end;
return date.range.end > max ? date.range.end : max;
}, this._datesSelected[0].range.end);
}
// make a "mother group" with type = null when two or more groups are adjacent or overlapping; keep the children groups
grouping(): void {
if (this._datesSelected.length <= 1) {
return;
}
// Sort dates by start date
const sorted = [...this._datesSelected].sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
const bStart = b.range.start?.getTime() ?? 0;
return aStart - bStart;
});
// Group adjacent or overlapping dates
const groups: SelectedDate[][] = [];
let currentGroup: SelectedDate[] = [sorted[0]];
let currentGroupMaxEnd = sorted[0].range.end?.getTime() ?? 0;
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const currStart = current.range.start?.getTime() ?? 0;
const currEnd = current.range.end?.getTime() ?? 0;
// Check if current date is adjacent or overlapping with the maximum end of the current group
const nextDay = new Date(currentGroupMaxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
currentGroup = [current];
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
}
}
// Add the last group if it has more than one element
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
// Create mother groups and add to result
const result: SelectedDate[] = [...this._datesSelected];
for (const group of groups) {
const start = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const end = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
const groupStart = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const groupEnd = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
result.push(
new SelectedDate(null, new DateRange<Date>(new Date(start), new Date(end))),
new SelectedDate(
null,
new DateRange<Date>(new Date(groupStart), new Date(groupEnd)),
),
);
}
return result;
}
/** Day types covering the date, in insertion order (today is never a selection). */
dayTypes(date: Date): readonly DayType[] {
return this.ranges
.filter((selected) => selected.type && selected.isSelected(date))
.map((selected) => selected.type!);
this.datesSelected = result;
}
isSelected(date: Date): boolean {
return this.grouped.some((selectedDate) => selectedDate.isSelected(date));
return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date));
}
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
let starts = 0;
let ends = 0;
for (const selected of this.grouped) {
for (const selected of this.datesSelected) {
if (selected.range.start && date.getTime() === selected.range.start.getTime()) {
starts++;
}
@@ -400,9 +501,27 @@ export class SelectedDates implements SelectedDateInterface {
return ends >= starts;
}
// True when the date lies strictly inside at least one selected range.
/**
* Check whether a given date is selected and lies strictly inside a selected range
* (i.e., it is part of at least one selected range but is not the start or end of that range).
*
* This method returns true only if:
* - `this.isSelected(dateTest)` is true (the date is considered selected), and
* - there exists at least one element in `this.datesSelected` for which
* `dateSelected.isStart(dateTest)` and `dateSelected.isEnd(dateTest)` are both false.
*
* Notes:
* - The exact equality/comparison semantics are delegated to `isSelected`, `isStart`
* and `isEnd` implementations on the respective objects.
* - `dateTest` is expected to be a valid Date. Behavior for null/undefined/invalid Date
* values is not defined here.
*
* @private
* @param dateTest - The date to test.
* @returns True if the date is an interior day of at least one selected range; otherwise false.
*/
private existInARangeButNotABoundary(dateTest: Date): boolean {
return this.grouped.some(
return this.datesSelected.some(
(dateSelected) =>
dateSelected.isSelected(dateTest) &&
!dateSelected.isStart(dateTest) &&
@@ -411,6 +530,13 @@ export class SelectedDates implements SelectedDateInterface {
}
getClasses(date: Date): string {
return this.grouped.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
return this.datesSelected.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
getTooltip(date: Date): string | null | undefined {
return this.datesSelected
.map((dateSelected) => dateSelected.getTooltip(date))
.filter((tooltip) => tooltip)
.join(' + ');
}
}
@@ -1,172 +0,0 @@
import { vi } from 'vitest';
import {
createPlanningCalculation,
type LeavePlacement,
type PlanningCalculationContext,
} from './leave-planner.service';
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
const plan = {
kind: 'Plan',
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
completedStages: [],
};
const state = { marker: 'state' };
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
class FakeWorker {
static all: FakeWorker[] = [];
static constructThrows = false;
static postThrows = false;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
terminate = vi.fn();
postMessage = vi.fn(() => {
if (this.throwOnPost) throw new Error('post failed');
});
private throwOnPost = FakeWorker.postThrows;
constructor() {
if (FakeWorker.constructThrows) throw new Error('no worker');
FakeWorker.all.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
interface Started {
worker?: FakeWorker;
succeeded: Promise<boolean>;
deliver: () => void;
late: () => void;
}
type Flags = { construct?: boolean; post?: boolean };
const arm = (flags: Flags) => {
FakeWorker.constructThrows = !!flags.construct;
FakeWorker.postThrows = !!flags.post;
};
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
plan: async (flags) => {
arm(flags);
const progress = vi.fn();
const calculation = createPlanningCalculation({ ...request, progress });
const worker = FakeWorker.all[0];
return {
worker,
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
deliver: () => {
worker?.emit({ type: 'result', outcome: plan });
worker?.emit({ type: 'artifacts', artifacts: {} });
},
late: () => {
worker?.emit({ type: 'progress', stage: 'late' });
expect(progress).not.toHaveBeenCalled();
},
};
},
counterfactual: async (flags) => {
const calculation = createPlanningCalculation(request);
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
await calculation.timings;
arm(flags);
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
plan.placements[0],
);
const succeeded = analysis.then(
(result) => result.kind !== 'CounterfactualStale',
() => false,
);
await flush();
const worker = FakeWorker.all[1];
return {
worker,
succeeded,
deliver: () =>
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
'explain-checkpoint': async (flags) => {
const calculation = createPlanningCalculation(request);
const first = FakeWorker.all[0];
first.emit({ type: 'checkpoint', plan, state });
arm(flags);
first.emit({ type: 'error', message: 'planner crashed' });
const worker = FakeWorker.all[1];
return {
worker,
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
deliver: () =>
worker?.emit({
type: 'explanation',
explanation: { kind: 'IncompleteExplanation' },
}),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
};
describe('worker job lifetime', () => {
beforeEach(() => {
FakeWorker.all = [];
arm({});
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', FakeWorker);
});
afterEach(() => {
arm({});
vi.unstubAllGlobals();
});
describe.each(Object.keys(jobs))('%s job', (name) => {
const start = jobs[name];
const released = (worker: FakeWorker) => {
expect(worker.terminate).toHaveBeenCalledOnce();
expect(worker.onmessage).toBeNull();
expect(worker.onerror).toBeNull();
};
it('releases the worker once on success and leaves no handler for late messages', async () => {
const job = await start({});
job.deliver();
expect(await job.succeeded).toBe(true);
released(job.worker!);
job.late();
job.deliver();
released(job.worker!);
});
it('fails without a worker when construction throws', async () => {
const job = await start({ construct: true });
expect(job.worker).toBeUndefined();
expect(await job.succeeded).toBe(false);
});
it('releases the worker once when postMessage throws', async () => {
const job = await start({ post: true });
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once on a runtime error', async () => {
const job = await start({});
job.worker!.onerror!();
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once when a newer calculation supersedes it', async () => {
const job = await start({});
createPlanningCalculation(request);
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
released(job.worker!);
});
});
});
+129 -114
View File
@@ -17,7 +17,6 @@ import type {
StaleCounterfactualAnalysis,
} from './leave-planner.core';
import { planningDateInParis } from './planning-date';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
export type {
IfThenPlanningRule,
@@ -279,6 +278,20 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
return undefined;
}
type PlannerWorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'error'; code?: string; message: string };
interface PlanningRun {
outcome: Promise<PlanningOutcome>;
explanation: Promise<PlanningExplanation | undefined>;
@@ -307,47 +320,6 @@ function interruptedOutcome(
const latestWorkerCancels = new Set<() => void>();
let latestCounterfactualCancel: (() => void) | undefined;
interface WorkerJob {
post(message: WorkerRequest, onFailure: (error: unknown) => void): void;
release(): void;
}
// Single owner of a worker's lifetime: creation, handlers, cancellation registration and an idempotent release.
function startWorkerJob(
supersede: () => void,
onMessage: (message: WorkerResponse) => void,
onError: () => void,
): WorkerJob {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
let released = false;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (event.data) onMessage(event.data);
};
worker.onerror = () => {
onError();
};
latestWorkerCancels.add(supersede);
return {
post(message, onFailure) {
try {
worker.postMessage(message);
} catch (error) {
onFailure(error);
}
},
release() {
if (released) return;
released = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
latestWorkerCancels.delete(supersede);
},
};
}
function cancelLatestWorkers(): void {
for (const cancel of [...latestWorkerCancels]) cancel();
latestWorkerCancels.clear();
@@ -377,11 +349,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
});
let cancel: (() => void) | undefined;
try {
const job = startWorkerJob(
() => supersede(),
(message) => handleMessage(message),
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
);
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
let outcomeSettled = false;
let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined;
@@ -394,7 +364,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
let receivedFinalState: PlanningStateSnapshot | undefined;
let livenessWatchdog: ReturnType<typeof setTimeout>;
const terminate = () => {
job.release();
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
clearTimeout(livenessWatchdog);
};
const finishOutcome = (result: PlanningOutcome) => {
@@ -462,6 +434,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
resolveArtifacts(complete);
resolveTimings(complete.timings ?? {});
terminate();
latestWorkerCancels.delete(supersede);
};
const supersede = () => {
if (artifactsSettled) return;
@@ -483,6 +456,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
finalState: receivedFinalState ?? checkpointState,
});
};
latestWorkerCancels.add(supersede);
const fail = (code: string, message: string) => {
const reason = { code, message };
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
@@ -534,11 +508,15 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
cancel = () => {
if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true;
job.post({ type: 'cancel' }, () =>
fail('worker-error', 'The cancellation request could not reach the worker.'),
);
try {
worker.postMessage({ type: 'cancel' });
} catch {
fail('worker-error', 'The cancellation request could not reach the worker.');
}
};
const handleMessage = (message: WorkerResponse) => {
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
if (message.type === 'heartbeat') {
scheduleLivenessWatchdog();
return;
@@ -574,9 +552,13 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
else fail('worker-error', 'Planning worker returned an unexpected response.');
};
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
);
worker.onerror = () =>
fail('worker-error', 'Planning worker failed before returning explanation data.');
try {
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
} catch {
const result = interruptedOutcome(
undefined,
@@ -695,43 +677,62 @@ function counterfactualInWorker(
): Promise<CounterfactualAnalysis> {
return new Promise((resolve, reject) => {
latestCounterfactualCancel?.();
let job: WorkerJob;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
job.release();
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
callback();
};
let settled = false;
const cancel = () =>
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
let worker: Worker;
try {
job = startWorkerJob(
cancel,
(message) => {
if (message.type === 'counterfactual-result' && message.analysis)
finish(() => resolve(message.analysis));
else if (message.type === 'error')
finish(() =>
reject(
new Error(
message.message ??
`Counterfactual analysis failed (${message.code ?? 'worker-error'}).`,
),
),
);
},
() => finish(() => reject(new Error('Counterfactual worker failed.'))),
);
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch (error) {
reject(error);
return;
}
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
if (cancel) latestWorkerCancels.delete(cancel);
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
callback();
};
const cancel = () =>
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
latestCounterfactualCancel = cancel;
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
finish(() => reject(error)),
);
latestWorkerCancels.add(cancel);
worker.onmessage = (
event: MessageEvent<{
type: string;
analysis?: CounterfactualAnalysis;
code?: string;
message?: string;
}>,
) => {
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
finish(() => resolve(event.data.analysis!));
else if (event.data?.type === 'error')
finish(() =>
reject(
new Error(
event.data.message ??
`Counterfactual analysis failed (${event.data.code ?? 'worker-error'}).`,
),
),
);
};
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
try {
worker.postMessage({
type: 'counterfactual',
request: snapshot,
plan,
placement,
state,
});
} catch (error) {
finish(() => reject(error));
}
});
}
@@ -742,39 +743,53 @@ function explainCheckpointInWorker(
interruption: PlanningReason,
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
return new Promise((resolve, reject) => {
let job: WorkerJob;
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
job.release();
callback();
};
let worker: Worker;
try {
job = startWorkerJob(
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
(response) => {
if (response.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response.type === 'error')
finish(() => reject(new Error(response.message)));
},
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
);
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch (error) {
reject(error);
return;
}
job.post(
{ type: 'explain-checkpoint', request: snapshot, plan, state, interruption },
(error) => finish(() => reject(error)),
);
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
if (cancel) latestWorkerCancels.delete(cancel);
callback();
};
const cancel = () =>
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
latestWorkerCancels.add(cancel);
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const response = event.data;
if (response?.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
};
worker.onerror = () =>
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
try {
worker.postMessage({
type: 'explain-checkpoint',
request: snapshot,
plan,
state,
interruption,
});
} catch (error) {
finish(() => reject(error));
}
});
}
@@ -1,60 +0,0 @@
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningArtifacts } from './leave-planner.core';
// Message contract shared by the planning coordinator and its worker; types only.
export type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
export type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
export type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
+58 -2
View File
@@ -1,7 +1,15 @@
/// <reference lib="webworker" />
import type { PlanningOutcome } from './leave-planner.service';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import {
analyzeCounterfactualInternal,
calculatePlanningAsyncInternal,
@@ -9,6 +17,54 @@ import {
type PlanningArtifacts,
} from './leave-planner.core';
type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
const worker = self as DedicatedWorkerGlobalScope;
let activeCalculation: { cancelled: boolean } | undefined;
@@ -1,301 +0,0 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarService } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import type {
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
} from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { UserInputService } from './user-input-service';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function planFor(start: string, end: string): PlanOutcome {
return {
kind: 'Plan',
horizon: { start, end },
declaredDays: [],
placements: [],
timeOffPeriods: [],
preferences: [],
remainingBalances: [],
completedStages: [],
} as unknown as PlanOutcome;
}
const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
function fakeCalculation(id: string) {
const outcome = deferred<PlanningOutcome>();
const explanation = deferred<PlanningExplanation | undefined>();
const explanationError = deferred<PlanningReason | undefined>();
const cancel = vi.fn();
const context = {
id,
cancel,
snapshot: { rules: [], labels: {}, declaredDays: [] },
outcome: outcome.promise,
explanation: explanation.promise,
explanationError: explanationError.promise,
analyzeCounterfactual: vi.fn(),
} as unknown as PlanningCalculationContext;
return { context, outcome, explanation, explanationError, cancel };
}
type Fake = ReturnType<typeof fakeCalculation>;
const flush = () => new Promise<void>((resolve) => setTimeout(resolve));
describe('PlanningCalculationService', () => {
let create: ReturnType<typeof vi.fn>;
let service: PlanningCalculationService;
function queue(...fakes: Fake[]): void {
for (const fake of fakes) create.mockReturnValueOnce(fake.context);
}
beforeEach(() => {
localStorage.clear();
create = vi.fn();
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: { createPlanningCalculation: create } },
],
});
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
service = TestBed.inject(PlanningCalculationService);
});
afterEach(() => {
TestBed.resetTestingModule();
localStorage.clear();
});
it('does not calculate until started and ignores input edits afterwards', async () => {
const first = fakeCalculation('first');
queue(first);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
expect(create).not.toHaveBeenCalled();
expect(service.accepted()).toBeNull();
service.start();
expect(create).toHaveBeenCalledTimes(1);
expect(service.isCalculating()).toBe(true);
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
const accepted = service.accepted()!;
expect(accepted.successful).toBe(true);
expect(service.isCalculating()).toBe(false);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
await flush();
expect(create).toHaveBeenCalledTimes(1);
expect(service.accepted()).toBe(accepted);
expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
});
it('hands the submission translated labels and leaves declaration entry', () => {
queue(fakeCalculation('first'));
const input = TestBed.inject(UserInputService);
input.declarationEntrySignal.set(true);
service.start();
expect(input.declarationEntrySignal()).toBe(false);
const submission = create.mock.calls[0][0];
expect(submission.horizon).toEqual(service.request()!.horizon);
expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
expect(submission.labels).toBeDefined();
});
it('reports progress of the latest calculation only', () => {
queue(fakeCalculation('old'), fakeCalculation('latest'));
service.start();
const oldProgress = create.mock.calls[0][0].progress;
service.start();
const latestProgress = create.mock.calls[1][0].progress;
oldProgress('obligations');
expect(service.progress()).toBe(0);
latestProgress('at-risk');
expect(service.progress()).toBe(35);
latestProgress('unknown');
expect(service.progress()).toBe(35);
});
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
const first = fakeCalculation('first');
const stale = fakeCalculation('stale');
const latest = fakeCalculation('latest');
queue(first, stale, latest);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
const previous = service.accepted()!;
service.start();
service.start();
expect(service.accepted()).toBe(previous);
expect(service.displayedPlan()).toBe(previous.outcome);
stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
await flush();
expect(service.accepted()).toBe(previous);
expect(service.isCalculating()).toBe(true);
latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
await flush();
expect(service.accepted()!.calculation).toBe(latest.context);
expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
expect(service.isCalculating()).toBe(false);
});
it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
const first = fakeCalculation('first');
const second = fakeCalculation('second');
queue(first, second);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanation()).toBeNull();
service.start();
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
first.explanation.resolve(explained);
await flush();
expect(service.explanation()).toBeNull();
second.explanation.resolve(undefined);
second.explanationError.resolve(undefined);
await flush();
expect(service.outcome()?.kind).toBe('Plan');
});
it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
const first = fakeCalculation('first');
const second = fakeCalculation('second');
queue(first, second);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
service.start();
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
first.explanationError.resolve({ code: 'old', message: 'old' });
await flush();
expect(service.explanationError()).toBeNull();
});
it('keeps the outcome when the explanation fails', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
first.explanation.reject(new Error('no trace'));
first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanationError()?.message).toBeTruthy();
expect(service.explanation()).toBeNull();
});
it('keeps the outcome when the explanation error promise rejects', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
first.explanationError.reject(new Error('lost'));
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
});
it('turns an outcome rejection into an accepted planner error', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.reject(new Error('worker died'));
await flush();
const accepted = service.accepted()!;
expect(accepted.successful).toBe(false);
expect(accepted.outcome).toEqual({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'planner-error', message: 'worker died' }],
});
expect(service.isCalculating()).toBe(false);
});
it('turns a construction failure into an accepted planner error with the requested horizon', () => {
create.mockImplementation(() => {
throw new Error('cannot start');
});
service.start();
const reason = { code: 'planner-error', message: 'cannot start' };
expect(service.outcome()).toEqual({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [reason],
});
expect(service.explanationError()).toEqual(reason);
expect(service.accepted()!.calculation).toBeNull();
expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
expect(service.isCalculating()).toBe(false);
});
it('cancels cooperatively once, and not when nothing is running', async () => {
const first = fakeCalculation('first');
queue(first);
service.cancel();
expect(first.cancel).not.toHaveBeenCalled();
service.start();
service.cancel();
service.cancel();
expect(first.cancel).toHaveBeenCalledTimes(1);
expect(service.isCancelling()).toBe(true);
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
expect(service.isCancelling()).toBe(false);
expect(service.accepted()!.successful).toBe(false);
});
it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
service.cancel();
first.outcome.resolve({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
await flush();
expect(service.outcome()?.kind).toBe('PlanningIncomplete');
expect(service.displayedPlan()).toBeUndefined();
expect(service.accepted()!.successful).toBe(false);
});
it('shows the incumbent of a cancelled calculation', async () => {
const first = fakeCalculation('first');
queue(first);
const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
service.start();
service.cancel();
first.outcome.resolve({
kind: 'PlanningIncomplete',
incumbent,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
await flush();
expect(service.displayedPlan()).toBe(incumbent);
expect(service.accepted()!.successful).toBe(false);
});
});
@@ -1,233 +0,0 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarService } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import type {
DeclaredNonWorkingDay,
PlanningCalculationContext,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
} from './leave-planner.service';
import { RuleService } from './rule-service';
import { TranslationService } from './translation-service';
import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string };
interface PendingCalculation {
id: number;
horizon: Horizon;
progress: number;
cancelling: boolean;
}
export interface AcceptedCalculation {
/** Null when the planner could not even be created. */
calculation: PlanningCalculationContext | null;
outcome: PlanningOutcome;
snapshot: PlanningSnapshot | null;
horizon: Horizon;
declaredDays: readonly DeclaredNonWorkingDay[];
requestedAt: number;
successful: boolean;
}
function stageProgress(stage: string, current: number): number {
if (stage === 'complete') return 100;
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
if (stage === 'short-sequence-deficit') return 79;
if (stage === 'supporting-placements') return 86;
if (stage === 'expiry') return 93;
if (stage === 'dates') return 98;
return current;
}
function plannerError(error: unknown): PlanningReason {
return {
code: 'planner-error',
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
};
}
function explanationFailure(error: unknown): PlanningReason {
return {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
}
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
@Injectable({ providedIn: 'root' })
export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calendar = inject(CalendarService);
private readonly translation = inject(TranslationService);
private version = 0;
private running: PlanningCalculationContext | null = null;
private readonly pendingState = signal<PendingCalculation | null>(null);
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
private readonly explanationState = signal<PlanningExplanation | null>(null);
private readonly explanationErrorState = signal<PlanningReason | null>(null);
private readonly explanationRejectedState = signal(false);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
balances: this.userInput.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: this.rules.rules,
};
});
readonly accepted = this.acceptedState.asReadonly();
readonly explanation = this.explanationState.asReadonly();
readonly explanationError = this.explanationErrorState.asReadonly();
readonly explanationRejected = this.explanationRejectedState.asReadonly();
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
readonly isCalculating = computed(() => this.pendingState() !== null);
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => {
const outcome = this.outcome();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
readonly displayedHorizon = computed(
() => this.acceptedState()?.horizon ?? this.pendingState()?.horizon ?? null,
);
start(): void {
const request = this.request();
if (!request) return;
const requestedAt = performance.now();
const version = ++this.version;
this.userInput.declarationEntrySignal.set(false);
this.pendingState.set({
id: version,
horizon: request.horizon,
progress: 0,
cancelling: false,
});
let calculation: PlanningCalculationContext;
try {
calculation = this.calendar.createPlanningCalculation({
...request,
balances: request.balances.map((balance) => ({
...balance,
label: this.translation.getDayTypeLabel(balance.type),
})),
labels: Object.fromEntries(
request.rules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.version)
this.pendingState.update(
(pending) =>
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
);
},
});
} catch (error) {
const reason = plannerError(error);
this.running = null;
this.accept(null, this.incomplete(reason), request.horizon, [], requestedAt);
this.explanationErrorState.set(reason);
return;
}
this.running = calculation;
const accept = (outcome: PlanningOutcome) => {
if (version !== this.version) return false;
this.running = null;
this.accept(calculation, outcome, request.horizon, undefined, requestedAt);
return true;
};
void calculation.outcome
.then((outcome) => accept(outcome))
.catch((error: unknown) => accept(this.incomplete(plannerError(error))))
.then((accepted) => {
if (accepted) this.loadArtifacts(calculation);
});
}
cancel(): void {
const pending = this.pendingState();
if (!pending || pending.cancelling || !this.running) return;
this.pendingState.set({ ...pending, cancelling: true });
this.running.cancel();
}
private incomplete(reason: PlanningReason): PlanningOutcome {
return { kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] };
}
private accept(
calculation: PlanningCalculationContext | null,
outcome: PlanningOutcome,
requestHorizon: Horizon,
declaredDays: readonly DeclaredNonWorkingDay[] | undefined,
requestedAt: number,
): void {
const plan = outcome.kind === 'Plan' ? outcome : undefined;
const shown =
plan ?? (outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined);
const cancelling = this.pendingState()?.cancelling ?? false;
this.explanationState.set(null);
this.explanationErrorState.set(null);
this.explanationRejectedState.set(false);
this.pendingState.set(null);
this.acceptedState.set({
calculation,
outcome,
snapshot: calculation?.snapshot ?? null,
horizon: shown?.horizon ?? requestHorizon,
declaredDays:
shown?.declaredDays ?? declaredDays ?? calculation?.snapshot.declaredDays ?? [],
requestedAt,
successful: plan !== undefined && !cancelling,
});
}
private loadArtifacts(calculation: PlanningCalculationContext): void {
const current = () => this.acceptedState()?.calculation === calculation;
const fail = (error: unknown) => {
if (!current()) return;
this.explanationRejectedState.set(true);
this.explanationErrorState.set(explanationFailure(error));
};
calculation.explanation
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
.catch(fail);
calculation.explanationError
.then((reason) => current() && this.explanationErrorState.set(reason ?? null))
.catch(fail);
}
}
@@ -5,7 +5,6 @@ import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('Planning duration estimate', () => {
@@ -32,8 +31,7 @@ describe('Planning duration estimate', () => {
it('offers a coarse seconds range on the first launch without calculating', () => {
const duration = TestBed.inject(PlanningDurationService);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0);
expect(TestBed.inject(RuleService).rules).toEqual([]);
});
@@ -49,7 +47,7 @@ describe('Planning duration estimate', () => {
});
const duration = TestBed.inject(PlanningDurationService);
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000);
duration.recordCompletion(duration.request()!, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
@@ -83,20 +81,19 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(moreLeave);
settings.setPlanningDates('2026-12-31', '2024-01-01');
expect(duration.estimate()).toBeNull();
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
expect(input.computeTriggerSignal()).toBe(0);
});
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = TestBed.inject(PlanningCalculationService).request()!;
const submitted = duration.request()!;
duration.recordCompletion(submitted, 120_000);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
});
it('calibrates against the submitted snapshot even when the form has since changed', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = TestBed.inject(PlanningCalculationService).request()!;
const submitted = duration.request()!;
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
duration.recordCompletion(submitted, 20_000);
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
@@ -117,16 +114,14 @@ describe('Planning duration estimate', () => {
placeType: SelectableDayType.CP,
});
const duration = TestBed.inject(PlanningDurationService);
const calculation = createPlanningCalculation(
TestBed.inject(PlanningCalculationService).request()!,
);
const calculation = createPlanningCalculation(duration.request()!);
duration.recordCompletion(calculation.snapshot, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = TestBed.inject(PlanningCalculationService).request()!;
const submitted = duration.request()!;
for (const elapsed of [20_000, 25_000, 900_000])
duration.recordCompletion(submitted, elapsed);
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
@@ -141,7 +136,7 @@ describe('Planning duration estimate', () => {
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
const duration = TestBed.inject(PlanningDurationService);
const initial = duration.estimate();
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
duration.recordCompletion(duration.request()!, elapsed);
expect(duration.estimate()).toEqual(initial);
});
});
+26 -3
View File
@@ -1,7 +1,9 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarSettingsService } from './calendar-settings-service';
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { RuleService } from './rule-service';
import { UserInputService } from './user-input-service';
export interface PlanningDurationRange {
minimum: number;
@@ -52,11 +54,32 @@ function initialDurationMs(request: PlanningRequest): number {
@Injectable({ providedIn: 'root' })
export class PlanningDurationService {
private readonly calculations = inject(PlanningCalculationService);
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calibration = signal<readonly number[]>([]);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
balances: this.userInput.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: this.rules.rules,
};
});
readonly estimate = computed<PlanningDurationRange | null>(() => {
const request = this.calculations.request();
const request = this.request();
if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
+2 -2
View File
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { CalendarSettingsService } from './calendar-settings-service';
import { PlanningCalculationService } from './planning-calculation.service';
import { PlanningDurationService } from './planning-duration.service';
import { UserInputService } from './user-input-service';
import { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
@@ -260,7 +260,7 @@ describe('saved rule migration', () => {
expiryDate: new Date('2024-01-05'),
},
]);
const request = TestBed.inject(PlanningCalculationService).request()!;
const request = TestBed.inject(PlanningDurationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
@@ -46,6 +46,7 @@ describe('UserInputService', () => {
{ date: '2024-01-02', type: 'other' },
]);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
@@ -118,6 +119,7 @@ describe('UserInputService', () => {
]);
expect(save).toHaveBeenCalledTimes(3);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
});
it('restores an empty list when saved declarations are unreadable', () => {
+21 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, signal } from '@angular/core';
import { computed, Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
@@ -6,7 +6,7 @@ import {
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
import type { LeaveType } from './leave-planner.service';
import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
@Injectable({
providedIn: 'root',
@@ -161,6 +161,20 @@ export class UserInputService {
readonly vacationNumberSignal = signal<VacDay[]>(
this.getSavedValues() ?? this.getInitialValues(),
);
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly planningOutcomeSignal = signal<PlanningOutcome | null>(null);
readonly planningSnapshotSignal = signal<PlanningSnapshot | null>(null);
readonly displayedPlanSignal = computed(() => {
const outcome = this.planningOutcomeSignal();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
readonly computeTriggerSignal = signal(0);
readonly computationRequestedAtMs = signal<number | null>(null);
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
try {
@@ -180,4 +194,9 @@ export class UserInputService {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
public requestComputation(): void {
this.computationRequestedAtMs.set(performance.now());
this.computeTriggerSignal.update((count) => count + 1);
}
}