Compare commits

..
7 Commits
Author SHA1 Message Date
nessarandClaude Opus 5.5 bc5ee50bd5 fix(planning): plan TIME_WINDOW rules on the picked calendar dates
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 46s
CI / build (pull_request) Successful in 41s
CI / benchmark (pull_request) Successful in 1m16s
CI / security (pull_request) Successful in 56s
CI / test (pull_request) Successful in 1m55s
CI / images (pull_request) Successful in 53s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 26s
CI / build (push) Successful in 30s
CI / benchmark (push) Successful in 1m17s
CI / security (push) Successful in 55s
CI / test (push) Successful in 1m49s
CI / images (push) Successful in 1m13s
CI / required (push) Successful in 1s
Rule windows reached the planner as local-midnight Date objects, and the
planner read their Europe/Paris date, so east of Paris every window
started and ended one day early. Format them from local components as
YYYY-MM-DD before planning, as balances already are.

Closes #311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:34:33 +02:00
nessarandClaude Opus 5.5 42134284e7 chore(deps): remove deprecated @angular/animations
CI / policy (pull_request) Successful in 14s
CI / validate (pull_request) Successful in 33s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 53s
CI / benchmark (pull_request) Successful in 1m48s
CI / test (pull_request) Successful in 1m53s
CI / images (pull_request) Successful in 51s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 27s
CI / build (push) Successful in 32s
CI / benchmark (push) Successful in 1m22s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m1s
CI / images (push) Successful in 1m12s
CI / required (push) Successful in 6s
Angular deprecated the package in favour of `animate.enter` and
`animate.leave`. Nothing in the app or in Angular Material imports it,
and `@angular/platform-browser` only lists it as an optional peer, so it
is dropped instead of migrated. Clears the Renovate dashboard warning in #56.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:09:18 +02:00
nessarandClaude Opus 5.5 f5d592ea5e chore(deps): automerge minor, patch, digest and pin Renovate updates
CI / policy (pull_request) Successful in 13s
CI / validate (pull_request) Successful in 33s
CI / build (pull_request) Successful in 31s
CI / benchmark (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 58s
CI / test (pull_request) Successful in 1m54s
CI / images (pull_request) Successful in 1m13s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 27s
CI / build (push) Successful in 36s
CI / benchmark (push) Successful in 1m23s
CI / security (push) Successful in 59s
CI / test (push) Successful in 1m57s
CI / images (push) Successful in 52s
CI / required (push) Successful in 1s
Renovate merges these updates with a merge commit once the required
check passes, three days after their release. Major updates stay manual.
The develop merge allowlist admits the renovate bot for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:45:16 +02:00
renovate Bot 9e5c93e733 chore(deps): update dependency @types/jsdom to v30
CI / policy (pull_request) Successful in 15s
CI / validate (pull_request) Successful in 24s
CI / build (pull_request) Successful in 38s
CI / benchmark (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 2m3s
CI / security (pull_request) Successful in 1m8s
CI / images (pull_request) Successful in 51s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 11s
CI / validate (push) Successful in 28s
CI / build (push) Successful in 40s
CI / benchmark (push) Successful in 1m16s
CI / security (push) Successful in 1m2s
CI / test (push) Successful in 1m51s
CI / images (push) Successful in 51s
CI / required (push) Successful in 1s
2026-10-19 14:28:54 +02:00
nessarandClaude Opus 5.5 581c637029 ci: run push workflows only on develop, main and release tags
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 28s
CI / build (pull_request) Successful in 25s
CI / benchmark (pull_request) Successful in 1m30s
CI / security (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 2m31s
CI / images (pull_request) Successful in 1m21s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 11s
CI / validate (push) Successful in 28s
CI / build (push) Successful in 22s
CI / security (push) Successful in 1m6s
CI / benchmark (push) Successful in 1m47s
CI / test (push) Successful in 2m7s
CI / images (push) Successful in 1m17s
CI / required (push) Successful in 1s
Every commit on a branch with an open PR ran the whole pipeline twice, once
for the push and once for the pull_request event, in separate concurrency
groups. Filter the push trigger to develop, main and v* tags so other
branches run CI and publish their images only through their PR. Tags are
listed explicitly because a branch filter alone stops tag pushes.

Update the trigger, benchmark and image prose in the README and the Gitea
Actions guide to match.

Refs #309

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:17:04 +02:00
nessarandClaude Opus 5.5 ff6c4d8122 fix(calendar): mark today and past days from the Europe/Paris date
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 31s
CI / build (pull_request) Successful in 29s
CI / benchmark (pull_request) Successful in 1m34s
CI / security (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 2m9s
CI / images (pull_request) Successful in 1m10s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 36s
CI / build (push) Successful in 34s
CI / benchmark (push) Successful in 1m39s
CI / security (push) Successful in 1m8s
CI / test (push) Successful in 2m20s
CI / images (push) Successful in 1m1s
CI / required (push) Successful in 1s
The today marker and past styling used the device's local date while the
default planning horizon starts on the Paris date, so outside Paris time
near midnight the horizon's first day was not marked as today (CI in UTC
failed nightly). project() now takes an injectable now, derives today once
with planningDateInParis and returns it; calendar-month styles a day as
past when its date key is before that today.

Delete DateCacheService, monthsInAYearFromNow() and the default year of
getHolidays(), whose only remaining callers were their own specs.

Refs #301

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:52 +02:00
nessarandClaude Opus 5.5 d089c4670b docs(context): define today as the Europe/Paris calendar date
The today marker and past styling follow the same Paris date that
starts the default planning horizon (#301).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:52 +02:00
16 changed files with 124 additions and 241 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ This is a single-context repo using root `CONTEXT.md` and `docs/adr/`. See `docs
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their PRs to `develop`. - `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their PRs to `develop`.
- Rebase working branches onto the latest `origin/develop` before opening or merging a PR. If `develop` advances, rebase and rerun the PR checks before merging. - Rebase working branches onto the latest `origin/develop` before opening or merging a PR. If `develop` advances, rebase and rerun the PR checks before merging.
- Integrate through PRs with successful required checks and merges restricted to designated maintainers through Gitea's merge allowlist. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden. - Integrate through PRs with successful required checks and merges restricted to designated maintainers through Gitea's merge allowlist. The `develop` allowlist also admits the `renovate` bot, which automerges minor, patch, digest, and pin updates once `required` passes; major updates stay manual. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden.
- Use Conventional Commits for new commits and PR titles. - Use Conventional Commits for new commits and PR titles.
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` PRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both PRs are merged. - Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` PRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both PRs are merged.
- Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch. - Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch.
+4
View File
@@ -24,6 +24,10 @@ The unspent portion of a leave balance that expires after the planning horizon.
The category of leave in a balance or on a declared non-working day: CP (paid leave), RTT (working-time reduction leave), or Other (a user-managed catch-all, including any other reason for not working). The category of leave in a balance or on a declared non-working day: CP (paid leave), RTT (working-time reduction leave), or Other (a user-managed catch-all, including any other reason for not working).
_Avoid_: ANY as a leave type; `ANY` is only a rule selector. _Avoid_: ANY as a leave type; `ANY` is only a rule selector.
**Today**:
The current Europe/Paris calendar date, whatever the device's time zone. The calendar marks it as today and shows earlier dates as past.
_Avoid_: local date
**Planning horizon**: **Planning horizon**:
The user-selected half-open date interval within which leave may be placed; its start may precede today's Europe/Paris calendar date and its duration may exceed twelve months. It defaults to today through the same date twelve months later, and explicitly selected boundaries remain unchanged as time passes, with past dates still eligible for leave placements. The user-selected half-open date interval within which leave may be placed; its start may precede today's Europe/Paris calendar date and its duration may exceed twelve months. It defaults to today through the same date twelve months later, and explicitly selected boundaries remain unchanged as time passes, with past dates still eligible for leave placements.
+1 -1
View File
@@ -286,7 +286,7 @@ git push origin v1.6.0
The tag must match the tagged commit's `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main checks and image publication before tagging: promotion requires its `sha-<commit>` image. Tags do not increment the version automatically. The tag must match the tagged commit's `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main checks and image publication before tagging: promotion requires its `sha-<commit>` image. Tags do not increment the version automatically.
The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. After merge-message approval and a successful reviewed PR run, replace the migration gate with the reported pull-request context for the `required` job, as described in the activation guide. These are required settings; the migration does not establish that every setting has already been applied. The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. The `develop` allowlist also admits the `renovate` bot: [renovate.json](renovate.json) automerges minor, patch, digest, and pin updates three days after their release once the `required` check passes, and leaves major updates for manual review. After merge-message approval and a successful reviewed PR run, replace the migration gate with the reported pull-request context for the `required` job, as described in the activation guide. These are required settings; the migration does not establish that every setting has already been applied.
Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging. Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
+3 -16
View File
@@ -5,7 +5,6 @@
"": { "": {
"name": "vac-optimizer", "name": "vac-optimizer",
"dependencies": { "dependencies": {
"@angular/animations": "22.2.3",
"@angular/cdk": "^22.0.0", "@angular/cdk": "^22.0.0",
"@angular/common": "22.2.3", "@angular/common": "22.2.3",
"@angular/compiler": "22.2.3", "@angular/compiler": "22.2.3",
@@ -27,7 +26,7 @@
"@angular/compiler-cli": "22.2.3", "@angular/compiler-cli": "22.2.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/bun": "1.4.3", "@types/bun": "1.4.3",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^30.0.0",
"@types/node": "^26.0.0", "@types/node": "^26.0.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -54,8 +53,6 @@
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ=="], "@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ=="],
"@angular/animations": ["@angular/animations@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.3" } }, "sha512-sslKnTrKwHwtTvT/pWj7UoYq0cfuX3PijZM7v0JtCajQkK1Nj0Ljob0mebxXBXUVBrOixfCnfBS0EG4JNEznFA=="],
"@angular/build": ["@angular/build@22.2.3", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.3", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.3", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-EtNEuPBUCyIuzdXrQ/nINvgFzW8bUI5AkAZtJiSn7nsFOlvaygaKN0kENNTO3zRl3T9B4GDog+Ks5Nec3FBkhg=="], "@angular/build": ["@angular/build@22.2.3", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.3", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.3", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-EtNEuPBUCyIuzdXrQ/nINvgFzW8bUI5AkAZtJiSn7nsFOlvaygaKN0kENNTO3zRl3T9B4GDog+Ks5Nec3FBkhg=="],
"@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="], "@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="],
@@ -442,7 +439,7 @@
"@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="], "@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="],
"@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/jsdom": ["@types/jsdom@30.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^8.9.0" } }, "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ=="],
"@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
@@ -810,7 +807,7 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"parse5-html-rewriting-stream": ["parse5-html-rewriting-stream@8.0.1", "", { "dependencies": { "entities": "^8.0.0", "parse5": "^8.0.0", "parse5-sax-parser": "^8.0.0" } }, "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw=="], "parse5-html-rewriting-stream": ["parse5-html-rewriting-stream@8.0.1", "", { "dependencies": { "entities": "^8.0.0", "parse5": "^8.0.0", "parse5-sax-parser": "^8.0.0" } }, "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw=="],
@@ -1016,8 +1013,6 @@
"@angular/build/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], "@angular/build/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"@angular/cdk/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"@angular/compiler-cli/@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="], "@angular/compiler-cli/@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="],
"@angular/forms/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "@angular/forms/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
@@ -1074,16 +1069,8 @@
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"jsdom/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"parse5-html-rewriting-stream/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"parse5-sax-parser/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="], "rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
"sass-embedded/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "sass-embedded/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
+1 -2
View File
@@ -30,7 +30,6 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "22.2.3",
"@angular/cdk": "^22.0.0", "@angular/cdk": "^22.0.0",
"@angular/common": "22.2.3", "@angular/common": "22.2.3",
"@angular/compiler": "22.2.3", "@angular/compiler": "22.2.3",
@@ -52,7 +51,7 @@
"@angular/compiler-cli": "22.2.3", "@angular/compiler-cli": "22.2.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/bun": "1.4.3", "@types/bun": "1.4.3",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^30.0.0",
"@types/node": "^26.0.0", "@types/node": "^26.0.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
+14 -1
View File
@@ -4,5 +4,18 @@
"config:recommended" "config:recommended"
], ],
"prConcurrentLimit": 3, "prConcurrentLimit": 3,
"automerge": false "automerge": false,
"automergeStrategy": "merge-commit",
"minimumReleaseAge": "3 days",
"packageRules": [
{
"matchUpdateTypes": [
"minor",
"patch",
"digest",
"pin"
],
"automerge": true
}
]
} }
@@ -34,7 +34,7 @@
<button <button
type="button" type="button"
[attr.data-date]="dateKey" [attr.data-date]="dateKey"
[class.past]="day < getNow()" [class.past]="dateKey < today"
[class.start]="selectedDates.isStart(day)" [class.start]="selectedDates.isStart(day)"
[class.end]="selectedDates.isEnd(day)" [class.end]="selectedDates.isEnd(day)"
[class.selected]="selectedDates.isSelected(day)" [class.selected]="selectedDates.isSelected(day)"
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core'; import { provideZonelessChangeDetection } from '@angular/core';
import { SelectedDates } from '../../services/calendar-service';
import { CalendarMonth } from './calendar-month'; import { CalendarMonth } from './calendar-month';
describe('CalendarMonth', () => { describe('CalendarMonth', () => {
@@ -20,4 +21,20 @@ describe('CalendarMonth', () => {
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('styles only the dates before the given today as past', () => {
const monthFixture = TestBed.createComponent(CalendarMonth);
monthFixture.componentRef.setInput('monthIndex', 9);
monthFixture.componentRef.setInput('year', 2026);
monthFixture.componentRef.setInput('selectedDates', new SelectedDates());
monthFixture.componentRef.setInput('today', '2026-10-18');
monthFixture.detectChanges();
const button = (date: string) =>
(monthFixture.nativeElement as HTMLElement).querySelector(
`button[data-date="${date}"]`,
)!;
expect(button('2026-10-17').classList).toContain('past');
expect(button('2026-10-18').classList).not.toContain('past');
expect(button('2026-10-19').classList).not.toContain('past');
});
}); });
@@ -10,7 +10,6 @@ import {
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service'; import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { DateCacheService } from '../../services/date-cache.service';
import { TranslationService } from '../../services/translation-service'; import { TranslationService } from '../../services/translation-service';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days'; import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import type { LeaveType } from '../../services/leave-planner.service'; import type { LeaveType } from '../../services/leave-planner.service';
@@ -29,7 +28,6 @@ export interface DeclarationPreview {
}) })
export class CalendarMonth implements OnInit { export class CalendarMonth implements OnInit {
protected calendarSettingsService = inject(CalendarSettingsService); protected calendarSettingsService = inject(CalendarSettingsService);
private dateCache = inject(DateCacheService);
protected translationService = inject(TranslationService); protected translationService = inject(TranslationService);
@Input({ required: true }) @Input({ required: true })
@@ -42,11 +40,6 @@ export class CalendarMonth implements OnInit {
return this.translationService.tr; return this.translationService.tr;
} }
// Use DateCacheService instead of creating Date objects
getNow(): Date {
return this.dateCache.getNow();
}
currentMonth: Date = new Date(); currentMonth: Date = new Date();
weeksInAMonth: Month = []; weeksInAMonth: Month = [];
@@ -56,6 +49,9 @@ export class CalendarMonth implements OnInit {
@Input({ required: true }) @Input({ required: true })
selectedDates!: SelectedDates; selectedDates!: SelectedDates;
@Input({ required: true })
today!: string;
@Input() @Input()
horizonStart?: string; horizonStart?: string;
@@ -608,6 +608,7 @@
[monthIndex]="month.monthIndex" [monthIndex]="month.monthIndex"
[year]="month.year" [year]="month.year"
[selectedDates]="calendarSelectedDates()" [selectedDates]="calendarSelectedDates()"
[today]="calendarProjection()?.today ?? ''"
[horizonStart]="calendarHorizon()?.start" [horizonStart]="calendarHorizon()?.start"
[horizonEnd]="calendarHorizon()?.end" [horizonEnd]="calendarHorizon()?.end"
[declarationEntry]="declarationEntry()" [declarationEntry]="declarationEntry()"
+31 -12
View File
@@ -150,14 +150,14 @@ describe('CalendarService', () => {
describe('getHolidays', () => { describe('getHolidays', () => {
it('should return an array of holidays', () => { it('should return an array of holidays', () => {
const holidays = service.getHolidays(); const holidays = service.getHolidays(2026);
expect(Array.isArray(holidays)).toBe(true); expect(Array.isArray(holidays)).toBe(true);
expect(holidays.length).toBeGreaterThan(0); expect(holidays.length).toBeGreaterThan(0);
}); });
it('should cache holidays for the same year', () => { it('should cache holidays for the same year', () => {
const holidays1 = service.getHolidays(); const holidays1 = service.getHolidays(2026);
const holidays2 = service.getHolidays(); const holidays2 = service.getHolidays(2026);
expect(holidays1).toEqual(holidays2); expect(holidays1).toEqual(holidays2);
}); });
}); });
@@ -179,11 +179,6 @@ describe('CalendarService', () => {
expect(months.length).toBe(12); expect(months.length).toBe(12);
}); });
it('should calculate months from now', () => {
const months = service.monthsInAYearFromNow();
expect(months.length).toBe(12);
});
it('should return week number for a date', () => { it('should return week number for a date', () => {
const date = new Date(2024, 0, 1); const date = new Date(2024, 0, 1);
const weekNumber = CalendarService.getWeekNumber(date); const weekNumber = CalendarService.getWeekNumber(date);
@@ -193,20 +188,44 @@ describe('CalendarService', () => {
}); });
describe('project', () => { describe('project', () => {
const project = (horizon: { start: string; end: string }) => const project = (
service.project({ placements: [], horizon }); horizon: { start: string; end: string },
options?: Parameters<typeof service.project>[1],
) => service.project({ placements: [], horizon }, options);
const day = (key: string) => new Date(`${key}T00:00:00`); const day = (key: string) => new Date(`${key}T00:00:00`);
it('projects weekends, holidays and today for the default planning horizon', () => { it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' }); const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND); expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY); expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
const horizon = service.getDefaultPlanningHorizon(); const now = new Date();
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain( const horizon = service.getDefaultPlanningHorizon(now);
expect(project(horizon, { now }).selections.getClasses(day(horizon.start))).toContain(
DayType.TODAY, DayType.TODAY,
); );
}); });
it('marks the Europe/Paris date as today when the device is in UTC', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'UTC';
try {
const now = new Date('2026-10-17T22:16:00Z');
const horizon = service.getDefaultPlanningHorizon(now);
expect(horizon.start).toBe('2026-10-18');
const projection = project(horizon, { now });
expect(projection.today).toBe('2026-10-18');
expect(projection.selections.getClasses(day('2026-10-18'))).toContain(
DayType.TODAY,
);
expect(projection.selections.getClasses(day('2026-10-17'))).not.toContain(
DayType.TODAY,
);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
});
it('lists the covered months, including partial boundary months and year changes', () => { it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([ expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 }, { monthIndex: 10, year: 2024 },
+8 -29
View File
@@ -1,10 +1,9 @@
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { DateRange } from '@angular/material/datepicker'; import { DateRange } from '@angular/material/datepicker';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days'; import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear'; import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek'; import isoWeek from 'dayjs/plugin/isoWeek';
import { DateCacheService } from './date-cache.service';
import { import {
createPlanningCalculation as createPlanningCalculationOperation, createPlanningCalculation as createPlanningCalculationOperation,
planLeave as planLeaveOperation, planLeave as planLeaveOperation,
@@ -55,14 +54,13 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
export interface CalendarProjection { export interface CalendarProjection {
months: { monthIndex: number; year: number }[]; months: { monthIndex: number; year: number }[];
selections: SelectedDates; selections: SelectedDates;
today: string;
} }
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class CalendarService { export class CalendarService {
private dateCache = inject(DateCacheService);
readonly planLeave = planLeaveOperation; readonly planLeave = planLeaveOperation;
readonly createPlanningCalculation = createPlanningCalculationOperation; readonly createPlanningCalculation = createPlanningCalculationOperation;
@@ -74,7 +72,7 @@ export class CalendarService {
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */ /** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
project( project(
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] }, plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
{ fixedMonths = false } = {}, { fixedMonths = false, now = new Date() } = {},
): CalendarProjection { ): CalendarProjection {
const horizon = calendarProjectionHorizon(plan)!; const horizon = calendarProjectionHorizon(plan)!;
const firstMonth = plan.horizon.start.slice(0, 7); const firstMonth = plan.horizon.start.slice(0, 7);
@@ -85,11 +83,8 @@ export class CalendarService {
const holidays = new Set( const holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()), this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
); );
const { const today = planningDateInParis(now);
date: todayDate, const todayTime = new Date(`${today}T00:00:00`).getTime();
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
const months: CalendarProjection['months'] = []; const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = []; const ranges: SelectedDate[] = [];
const cursor = new Date(first); const cursor = new Date(first);
@@ -122,11 +117,7 @@ export class CalendarService {
ranges.push( ranges.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)), new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
); );
if ( if (day.getTime() === todayTime)
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day))); ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
} }
} }
@@ -146,10 +137,10 @@ export class CalendarService {
), ),
); );
} }
return { months, selections: new SelectedDates(ranges) }; return { months, selections: new SelectedDates(ranges), today };
} }
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] { getHolidays(startYear: number, lastYear = startYear + 1): Date[] {
const dates: Date[] = []; const dates: Date[] = [];
for (let year = startYear; year <= lastYear; year++) { for (let year = startYear; year <= lastYear; year++) {
for (const date of frenchPublicHolidaysForYear(year)) for (const date of frenchPublicHolidaysForYear(year))
@@ -204,18 +195,6 @@ export class CalendarService {
return months; return months;
} }
monthsInAYearFromNow(): Year {
const months: Year = [];
const currentMonthIndex = this.dateCache.getCurrentMonth();
const currentYear = this.dateCache.getCurrentYear();
for (let i = 0; i < 12; i++) {
const monthIndex = (currentMonthIndex + i) % 12;
const year = currentYear + Math.floor((currentMonthIndex + i) / 12);
months.push(CalendarService.weeksInMonth(monthIndex, year));
}
return months;
}
static getWeekNumber(date: Date): number { static getWeekNumber(date: Date): number {
return dayjs(date).isoWeek(); return dayjs(date).isoWeek();
} }
@@ -1,63 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { DateCacheService } from './date-cache.service';
describe('DateCacheService', () => {
let service: DateCacheService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(DateCacheService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should cache today date', () => {
const today1 = service.getToday();
const today2 = service.getToday();
expect(today1).toBe(today2); // Same reference
});
it('should cache today components', () => {
const components1 = service.getTodayComponents();
const components2 = service.getTodayComponents();
expect(components1).toBe(components2); // Same reference
});
it('should cache current month', () => {
const month1 = service.getCurrentMonth();
const month2 = service.getCurrentMonth();
expect(month1).toBe(month2);
});
it('should cache current year', () => {
const year1 = service.getCurrentYear();
const year2 = service.getCurrentYear();
expect(year1).toBe(year2);
});
it('should clear cache', () => {
const year1 = service.getCurrentYear();
service.clearCache();
const year2 = service.getCurrentYear();
expect(year1).toBe(year2); // Values should be the same even after clearing
});
it('should return today with time normalized to 00:00:00.000', () => {
const today = service.getToday();
expect(today.getHours()).toBe(0);
expect(today.getMinutes()).toBe(0);
expect(today.getSeconds()).toBe(0);
expect(today.getMilliseconds()).toBe(0);
});
it('should return getNow() same as getToday()', () => {
const now = service.getNow();
const today = service.getToday();
expect(now).toBe(today);
});
});
-105
View File
@@ -1,105 +0,0 @@
import { Injectable } from '@angular/core';
/**
* Service to cache and provide commonly used date values.
* This reduces redundant Date object creation across the application.
*/
@Injectable({
providedIn: 'root',
})
export class DateCacheService {
private _today: Date | null = null;
private _todayComponents: { date: number; month: number; year: number } | null = null;
private _currentMonth: number | null = null;
private _currentYear: number | null = null;
/**
* Get today's date (time set to 00:00:00.000)
*/
getToday(): Date {
if (!this._today || this.isNewDay(this._today)) {
this._today = new Date();
this._today.setHours(0, 0, 0, 0);
this._todayComponents = null; // Invalidate components cache
}
return this._today;
}
/**
* Get today's date components (date, month, year) for efficient comparison
*/
getTodayComponents(): { date: number; month: number; year: number } {
if (!this._todayComponents || (this._today && this.isNewDay(this._today))) {
const today = this.getToday();
this._todayComponents = {
date: today.getDate(),
month: today.getMonth(),
year: today.getFullYear(),
};
}
return this._todayComponents;
}
/**
* Get current month (0-11)
*/
getCurrentMonth(): number {
if (this._currentMonth === null || this.isNewMonth()) {
this._currentMonth = new Date().getMonth();
}
return this._currentMonth;
}
/**
* Get current year
*/
getCurrentYear(): number {
if (this._currentYear === null || this.isNewYear()) {
this._currentYear = new Date().getFullYear();
}
return this._currentYear;
}
/**
* Get current date with time set to 00:00:00.000 (alias for getToday)
*/
getNow(): Date {
return this.getToday();
}
/**
* Check if a cached date is from a previous day
*/
private isNewDay(cachedDate: Date): boolean {
const now = new Date();
return (
cachedDate.getDate() !== now.getDate() ||
cachedDate.getMonth() !== now.getMonth() ||
cachedDate.getFullYear() !== now.getFullYear()
);
}
/**
* Check if we're in a new month
*/
private isNewMonth(): boolean {
return this._currentMonth !== null && this._currentMonth !== new Date().getMonth();
}
/**
* Check if we're in a new year
*/
private isNewYear(): boolean {
return this._currentYear !== null && this._currentYear !== new Date().getFullYear();
}
/**
* Clear all caches (useful for testing)
*/
clearCache(): void {
this._today = null;
this._todayComponents = null;
this._currentMonth = null;
this._currentYear = null;
}
}
@@ -1,7 +1,7 @@
import { provideZonelessChangeDetection } from '@angular/core'; import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { vi } from '../../testing/vitest-compat'; import { vi } from '../../testing/vitest-compat';
import { CalendarService } from './calendar-service'; import { CalendarService, SelectableDayType } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service'; import { CalendarSettingsService } from './calendar-settings-service';
import type { import type {
PlanOutcome, PlanOutcome,
@@ -11,6 +11,7 @@ import type {
PlanningReason, PlanningReason,
} from './leave-planner.service'; } from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service'; import { PlanningCalculationService } from './planning-calculation.service';
import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service'; import { UserInputService } from './user-input-service';
function deferred<T>() { function deferred<T>() {
@@ -119,6 +120,33 @@ describe('PlanningCalculationService', () => {
expect(submission.labels).toBeDefined(); expect(submission.labels).toBeDefined();
}); });
it('hands TIME_WINDOW rules the picked calendar dates east of Paris', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Pacific/Auckland';
try {
TestBed.inject(RuleService).addRule({
id: 'christmas',
name: 'Christmas',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'obligation',
startDate: new Date(2026, 11, 20),
endDate: new Date(2026, 11, 31),
placeDays: 3,
placeType: SelectableDayType.CP,
});
queue(fakeCalculation('first'));
service.start();
const submitted = create.mock.calls[0][0].rules.find(
(rule: { id: string }) => rule.id === 'christmas',
);
expect(submitted).toMatchObject({ startDate: '2026-12-20', endDate: '2026-12-31' });
} finally {
// bun test defaults to UTC without setting TZ; deleting it would fall back to the host zone.
process.env['TZ'] = previousTimeZone ?? 'UTC';
}
});
it('reports the stage and submission of the latest calculation only, until Cancel', () => { it('reports the stage and submission of the latest calculation only, until Cancel', () => {
queue(fakeCalculation('old'), fakeCalculation('latest')); queue(fakeCalculation('old'), fakeCalculation('latest'));
service.start(); service.start();
@@ -11,7 +11,7 @@ import type {
PlanningRequest, PlanningRequest,
PlanningSnapshot, PlanningSnapshot,
} from './leave-planner.service'; } from './leave-planner.service';
import { RuleService } from './rule-service'; import { RuleService, RuleType } from './rule-service';
import { TranslationService } from './translation-service'; import { TranslationService } from './translation-service';
import { UserInputService } from './user-input-service'; import { UserInputService } from './user-input-service';
@@ -85,7 +85,15 @@ export class PlanningCalculationService {
: undefined, : undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'), expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})), })),
rules: this.rules.rules, rules: this.rules.rules.map((rule) =>
rule.type === RuleType.TIME_WINDOW
? {
...rule,
startDate: dayjs(rule.startDate).format('YYYY-MM-DD'),
endDate: dayjs(rule.endDate).format('YYYY-MM-DD'),
}
: rule,
),
}; };
}); });