Compare commits

...
Author SHA1 Message Date
renovate Bot 5a8656df11 chore(deps): update docker docker tag to v29.9.0 2026-10-09 05:03:06 +00:00
nessar 209bf05f36 feat(docs): add detailed planning objectives and optimization rules documentation 2026-10-08 23:20:40 +02:00
nessar 0c266a4b9d fix(deps): update angular-cli monorepo to v22.2.2
See merge request nessar/vac-optimizer!185
2026-10-08 19:08:11 +00:00
nessar f786872a18 feat(ui): configure planning horizon alongside leave balances
See merge request nessar/vac-optimizer!183
2026-10-08 19:07:27 +00:00
nessar ad5ef69f5f docs: document GitFlow rules for agents
See merge request nessar/vac-optimizer!186
2026-10-08 08:10:35 +00:00
nessar 749348ec0d docs: document GitFlow rules for agents 2026-10-08 08:56:00 +02:00
nessar 6777cbc282 feat(ui): use Material datepickers for planning horizon 2026-10-08 06:49:06 +00:00
nessar 31d4333783 feat(ui): configure planning horizon alongside leave balances 2026-10-08 06:49:06 +00:00
renovate Bot db561a31e6 fix(deps): update angular-cli monorepo to v22.2.2 2026-10-08 06:49:01 +00:00
nessar 964abd3546 ci: enforce GitFlow and publish branch images
See merge request nessar/vac-optimizer!184
2026-10-08 06:48:18 +00:00
nessar 42f1114709 ci: serialize test workers and cap Node memory 2026-10-08 01:05:41 +02:00
nessar 8fcd53fc32 ci: allow the complete benchmark to finish on shared runners 2026-10-08 00:54:49 +02:00
nessar 6b3113de9a ci: bound the benchmark heap on shared runners 2026-10-08 00:43:21 +02:00
nessar 2a53bb6cb4 ci: enforce GitFlow and publish branch images 2026-10-08 00:09:29 +02:00
31 changed files with 786 additions and 168 deletions

No files matched your search

+65 -8
View File
@@ -1,5 +1,17 @@
image: oven/bun:latest
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
when: never
- if: $CI_PIPELINE_SOURCE == "push"
default:
interruptible: true
stages:
- validate
- test
@@ -10,6 +22,7 @@ stages:
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ''
AST_ENABLE_MR_PIPELINES: 'true'
cache:
key:
@@ -19,11 +32,24 @@ cache:
- .bun/
.node_setup:
rules:
- when: on_success
before_script:
- bun install --frozen-lockfile
# -- Validate Stage --
ci-policy:
image: node:24.21.0
stage: validate
variables:
GIT_DEPTH: '0'
rules:
- when: on_success
script:
- node scripts/ci-policy.test.mjs
- node scripts/ci-policy.mjs
lint:
extends: .node_setup
stage: validate
@@ -48,11 +74,19 @@ test:
image: node:24.21.0
extends: .node_setup
stage: test
variables:
NODE_OPTIONS: --max-old-space-size=2048
before_script:
- npm install --global bun@1.4.2
- bun install --frozen-lockfile
script:
- bun run test --watch=false --coverage
- |
node -e 'console.log("CI memory (bytes)", { available: process.availableMemory(), heapLimit: require("node:v8").getHeapStatistics().heap_size_limit })'
- bun scripts/generate-langs.js
# ponytail: one worker bounds peak RAM; increase concurrency after adding runner memory.
- bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
# Keep the complete benchmark outside coverage instrumentation.
- bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
artifacts:
when: always
paths:
@@ -90,6 +124,8 @@ trivy_scan:
name: aquasec/trivy:latest
entrypoint: ['']
before_script: [] # override global before_script
rules:
- when: on_success
script:
# Trivy will scan the project directory
- trivy fs --format sarif --output trivy-results.sarif .
@@ -100,16 +136,37 @@ trivy_scan:
# -- Docker Stage --
docker-build:
.docker:
stage: docker
image: docker:29.8.2
image: docker:29.9.0
services:
- docker:29.8.2-dind
- docker:29.9.0-dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- printf '%s' "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
cache: []
dependencies: []
docker-build:
extends: .docker
resource_group: image-$CI_COMMIT_REF_SLUG
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
# ponytail: GitLab slugs truncate at 63 bytes; add a branch-name hash if colliding names are needed.
- docker build -t "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker push "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG"
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "detached" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID
docker-release:
extends: .docker
interruptible: false
resource_group: stable-image
script:
- docker pull "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
@@ -0,0 +1,25 @@
# 01 — Unifier l’évaluation et la comparaison des objectifs
**GitLab:** [#70](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/70)
**What to build:** Le planificateur conserve ses résultats actuels pendant une préparation qui rend les six objectifs évaluables à partir de configurations de règles. Le contrat commun permet ensuite aux règles fournies et aux règles personnelles d’utiliser les mêmes mesures, sans dépendre de leur nom ou de leur identité.
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
Préparation du remplacement décrit par l’ADR 0008, justifiée par les comparaisons aujourd’hui dispersées dans le moteur. Ouvrir une branche d’intégration consacrée à cette évolution ; la mise en service de l’ensemble dépend du ticket 06. Préserver les données utilisateur est une décision explicite du cadrage.
**Hypothèses de travail :** Pour clore les détails techniques restés ouverts dans la spécification, partir du catalogue minimal nécessaire aux six préréglages : somme, comptage et maximum, directions minimiser ou maximiser, valeur zéro sur ensemble vide. Le déficit de reprise possède un seuil entier positif, initialement cinq ; la pondération des semaines fériées reste la mesure métier existante. Les autres agrégations et filtres ne sont pas requis par ce découpage.
- [ ] Décrire le contrat composé : éléments observés, conditions applicables, mesure, agrégation et direction. Définir une table finie des combinaisons compatibles et des paramètres acceptés, avec les unités en jours travaillés ou calendaires selon la mesure. Le catalogue permet de composer des configurations, sans se réduire à six identifiants d’objectifs.
- [ ] Exprimer dans ce contrat les six préréglages : somme des soldes à risque non placés à minimiser ; somme des valeurs des semaines fériées complètes à maximiser ; gain de jours non ouvrés à maximiser ; longueur maximale des séquences de travail à minimiser ; déficit des courtes séquences internes à minimiser ; nombre de placements d’appoint à minimiser.
- [ ] Conserver les définitions exactes : pondération des fériés du lundi au vendredi de 1, 2, 3, 2, 1 ; déficit de cinq moins la longueur pour chaque séquence interne non vide de moins de cinq jours ; séquences aux bords de l’horizon incluses dans le maximum et exclues du déficit interne.
- [ ] Définir explicitement zéro pour le comptage, la somme et le maximum sur un ensemble vide, en reprenant les résultats actuels. Valider le domaine et l’unité des paramètres ; une combinaison non prise en charge est identifiable.
- [ ] Une même configuration appliquée au même plan produit la même valeur quels que soient le nom et l’identité de la règle. Vérifier aussi une configuration paramétrée distincte du préréglage, par exemple le déficit à trois jours, sans inventer une nouvelle mesure métier.
- [ ] Regrouper l’évaluation et les comparaisons nécessaires à la sélection des solutions d’obligations, aux déplacements, aux placements d’appoint, aux départages et aux explications. Remplacer les calculs dupliqués concernés ; ne pas ajouter un second moteur ou un adaptateur de compatibilité.
- [ ] Cette préparation conserve l’ordre historique pour ses scénarios de référence : prévention des pertes, préférences, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises, placements d’appoint, expiration puis dates.
- [ ] Les tests existants de placements, soldes, obligations conjointes, congés gagnés et trace de qualité conservent leurs résultats observables pendant ce prefactoring. Les mesures sont également vérifiées sur des plans concrets comportant des jours déclarés et des périodes aux limites de l’horizon.
- [ ] Consigner le contrat minimal et ses cas limites pour les tickets suivants, en distinguant les décisions déjà confirmées des hypothèses techniques retenues. Les tickets suivants partagent ce contrat et cette branche d’intégration.
**Vérification de bout en bout :** Comparer les résultats et les explications des scénarios de référence avant et après la préparation ; vérifier que les six recettes et une variante paramétrée évaluent correctement les mêmes plans.
@@ -0,0 +1,24 @@
# 02 — Piloter la prévention des pertes depuis les règles
**GitLab:** [#71](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/71)
**What to build:** L’utilisateur crée une règle d’optimisation de prévention des pertes, l’enregistre, la déplace parmi ses préférences et constate son effet sur les congés placés et les soldes restants. La désactiver retire réellement sa priorité automatique.
**Blocked by:** 01 — Unifier l’évaluation et la comparaison des objectifs.
**Status:** ready-for-agent
Première tranche fonctionnelle du modèle commun de l’ADR 0008. Continuer la branche d’intégration du ticket 01 ; l’ajout des six préréglages aux données existantes et la mise en service sont traités au ticket 06. Les scénarios de cette tranche soumettent explicitement leurs règles.
- [ ] Proposer la famille Optimisation dans la liste existante et permettre de créer, nommer, éditer, activer, désactiver, supprimer et réordonner une règle utilisant la somme des soldes à risque non placés. Seul le mode préférence est disponible pour cette famille.
- [ ] L’édition, les résumés et les commandes utilisent le contrat du ticket 01, sont traduits et accessibles au clavier. La configuration complète est sauvegardée localement et restaurée, avec son identité, ses paramètres, son activation et sa position.
- [ ] Le calcul résout les obligations conjointement puis utilise l’ordre des préférences actives pour arbitrer les placements. Placer la prévention des pertes sous une préférence personnelle peut laisser davantage de congés à risque inutilisés ; la remonter restaure sa priorité.
- [ ] Remplacer l’exécution implicite de la prévention des pertes par l’exécution de sa configuration explicite. Mettre en place le parcours ordonné partagé des règles ; aucun objectif n’est ajouté implicitement à une requête pour compenser une règle absente ou une configuration non prise en charge.
- [ ] Une règle désactivée donne le même plan que son absence, à autres entrées identiques. Une requête sans règle active n’ajoute aucun congé, même avec des soldes à risque, et conserve les jours déclarés. Les départages par expiration et dates ne déclenchent aucun placement.
- [ ] Les quantités, disponibilités, expirations, jours éligibles et jours déclarés restent des contraintes de validité. Une configuration active incompatible ou mal formée produit une erreur d’entrée rattachable à la règle, sans devenir un conflit d’obligations ni réactiver un défaut.
- [ ] Les chemins direct et worker conservent tous les paramètres de la règle dans l’instantané du calcul. L’explication, la trace et les analyses contrefactuelles suivent le même ordre actif ; une modification ultérieure dans l’éditeur ne change pas le résultat soumis.
- [ ] Adapter les consommateurs concernés, notamment ceux qui supposent qu’une règle comporte toujours un nombre de jours à poser. L’estimation de durée reste finie et les règles existantes Si… alors… et Période restent utilisables.
- [ ] Les tests de comportement couvrent création et rechargement, déplacement au clavier, priorité inversée contre une préférence personnelle, désactivation équivalente à suppression, absence de règles, entrée invalide et passage par le worker. S’appuyer sur les suites existantes de gestion des règles, de planification et d’instantané immuable.
- [ ] Les fonctionnalités communes de cette tranche sont réutilisables par les familles des tickets 03 à 05. Ne pas conserver une exécution historique parallèle comme solution transitoire ; la série est livrée après sa validation commune.
**Vérification de bout en bout :** Avec un solde à risque et une préférence personnelle concurrente, déplacer la prévention des pertes, recalculer et comparer les placements et soldes ; désactiver ensuite toutes les règles et vérifier l’absence de nouveaux congés.
@@ -0,0 +1,23 @@
# 03 — Composer les règles de semaines fériées et de repos
**GitLab:** [#72](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/72)
**What to build:** L’utilisateur crée et règle les préférences « Compléter les semaines avec un jour férié » et « Profiter des week-ends et jours fériés », puis choisit laquelle prime dans le planning et dans ses explications.
**Blocked by:** 02 — Piloter la prévention des pertes depuis les règles.
**Status:** ready-for-agent
Tranche de calendrier de l’ADR 0008, sur la branche d’intégration commune. Elle utilise l’édition, la persistance, le transport et l’ordre actif introduits au ticket 02. Elle peut avancer indépendamment du ticket 04 ; la mise en service reste conditionnée au ticket 06.
- [ ] Permettre de composer, enregistrer et modifier les règles sur les semaines fériées et les périodes de repos avec les conditions, mesures et agrégations compatibles du contrat commun. Les deux préréglages sont des configurations du même modèle que les règles personnelles.
- [ ] Le préréglage des semaines fériées maximise la somme des valeurs des semaines complètes. Compter chaque férié du lundi au vendredi selon sa distance au week-end le plus proche ; additionner les contributions de plusieurs fériés dans une semaine sans compter la semaine deux fois.
- [ ] Le préréglage du gain maximise les dates distinctes de week-end et de jour férié contenues dans les périodes de repos. Les jours de congé posés ne constituent pas eux-mêmes ce gain.
- [ ] Conserver les contributions des jours déclarés et les règles d’adjacence aux limites de l’horizon. Les placements restent dans l’horizon et sur des dates éligibles ; une semaine ordinaire entièrement déclarée ne devient pas une semaine fériée.
- [ ] Les règles s’arbitrent selon leur position parmi toutes les préférences actives. Un scénario où la meilleure semaine fériée concurrence un gain de jours non ouvrés plus élevé démontre les deux ordres possibles.
- [ ] Une préférence de calendrier peut être placée avant ou après Samedi Malin et les autres préférences. Aucun ancien garde-fou ne rétablit automatiquement la priorité d’une semaine fériée ou d’une prévention des pertes désactivée.
- [ ] La recherche peut utiliser des placements d’appoint admissibles lorsque l’objectif actif les justifie et que les préférences supérieures et obligations sont préservées. Leur classification est conservée pour le ticket 05.
- [ ] La configuration éditée et sauvegardée est celle utilisée par le calcul direct, le worker, les explications, la trace et les comparaisons de dates. Les libellés et commandes sont traduits et accessibles.
- [ ] Adapter les scénarios de comportement existants sur les semaines fériées, le gain, les jours déclarés et les placements d’appoint : férié du mercredi, plusieurs fériés, horizon partiel, concurrence entre objectifs et désactivation équivalente à absence. Les requêtes de référence fournissent explicitement leurs règles.
**Vérification de bout en bout :** Créer les deux règles, inverser leur ordre sur un cas concurrent, recalculer et constater la modification du planning et du premier objectif décisif dans l’explication ; recharger et retrouver la configuration.
@@ -0,0 +1,23 @@
# 04 — Composer les règles de séquences de travail
**GitLab:** [#73](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/73)
**What to build:** L’utilisateur compose des préférences sur ses séquences de travail : réduire la plus longue séquence ou limiter le déficit des reprises courtes, avec un seuil configurable. Leur position parmi les autres préférences influence le planning.
**Blocked by:** 02 — Piloter la prévention des pertes depuis les règles.
**Status:** ready-for-agent
Tranche de séquences de travail de l’ADR 0008, sur la branche d’intégration commune. Elle utilise le contrat minimal et les fonctions de gestion du ticket 02 et peut avancer indépendamment du ticket 03. Le seuil initial de cinq jours reproduit le comportement existant.
- [ ] Permettre de créer, éditer, sauvegarder et réordonner une règle sur la longueur maximale des séquences de travail et une règle sur le déficit des séquences internes, à partir du même modèle composable.
- [ ] Le préréglage de la séquence maximale minimise son nombre de jours travaillés, en incluant les séquences situées aux deux bords de l’horizon. Les week-ends et jours fériés ne comptent pas et ne coupent pas une séquence.
- [ ] Le préréglage des reprises courtes minimise la somme des déficits pour les séquences internes non vides : avec le seuil initial de cinq jours, une reprise d’un jour contribue quatre et une reprise de quatre jours contribue un. Les séquences aux bords de l’horizon sont exclues de cette mesure.
- [ ] Le seuil de la mesure de déficit est un entier strictement positif, initialement cinq. Une règle personnelle utilisant un autre seuil, par exemple trois, calcule son propre déficit et peut produire un autre arbitrage ; il ne s’agit pas d’un seuil impératif de validité.
- [ ] Les conditions de sélection, mesures, unités et paramètres compatibles sont contrôlés à l’édition et à l’entrée du calcul. Une collection vide ou des séquences internes vides respectent la valeur zéro définie au ticket 01.
- [ ] Deux règles portant sur la même famille mais ayant des paramètres distincts sont évaluées séparément selon leur position. L’évaluation et les caches éventuels ne confondent pas les configurations sur la seule base du type de mesure.
- [ ] Les placements choisis respectent les obligations, les préférences supérieures et les jours déclarés. Désactiver une règle retire son influence, y compris lors de la recherche de placements d’appoint, sans conserver une protection fixe de la longueur maximale ou du déficit.
- [ ] L’interface, la restauration, le worker, l’explication, la trace et les comparaisons contrefactuelles utilisent la même configuration capturée. Les libellés distinguent jours travaillés, seuil de préférence et unité de la mesure.
- [ ] Les tests prolongent les suites de statistiques et de planification : bords de l’horizon, séquences vides, jours déclarés, seuil initial, autre seuil, deux règles paramétrées, priorité face à une règle personnelle et désactivation. Les vérifications portent sur les placements et valeurs observables, y compris après rechargement.
**Vérification de bout en bout :** Créer une préférence de reprise courte à cinq jours, constater sa mesure et le plan, puis la modifier à trois jours et recalculer un cas discriminant ; retrouver le seuil et son effet après rechargement.
@@ -0,0 +1,22 @@
# 05 — Piloter l’économie des jours d’appoint
**GitLab:** [#74](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/74)
**What to build:** L’utilisateur ajoute une préférence « Économiser les jours d’appoint » et décide, par sa position, jusqu’où les améliorations du planning peuvent consommer des congés qui expirent après l’horizon.
**Blocked by:** 03 — Composer les règles de semaines fériées et de repos ; 04 — Composer les règles de séquences de travail.
**Status:** ready-for-agent
Tranche d’économie des placements d’appoint de l’ADR 0008, sur la branche d’intégration commune. Les tickets 03 et 04 fournissent les objectifs qui peuvent justifier ces placements et contre lesquels cette préférence doit être vérifiée. La migration globale est réservée au ticket 06.
- [ ] Permettre de créer, éditer, activer, désactiver, supprimer, réordonner et sauvegarder une règle qui minimise le nombre de placements d’appoint selon le contrat commun.
- [ ] Compter uniquement les placements d’appoint au sens du domaine : des placements supplémentaires issus de soldes expirant après l’horizon pour améliorer le plan. Les placements explicitement demandés par une règle ne deviennent pas tous des placements d’appoint parce que leur solde expire plus tard.
- [ ] En position supérieure aux objectifs de calendrier ou de séquences de travail, cette préférence peut empêcher des ajouts d’appoint qui amélioreraient seulement ces objectifs inférieurs. En position inférieure, elle économise des placements sans dégrader les objectifs supérieurs ni les obligations.
- [ ] La suppression, le déplacement ou la réaffectation d’un placement conserve une classification et une provenance cohérentes. Les conditions chronologiques des congés gagnés et les soldes disponibles restent valides.
- [ ] Désactiver cette règle retire son critère de comparaison ; aucune étape fixe ne rétablit la minimisation du nombre de placements d’appoint. Sa désactivation ne déclenche pas à elle seule de nouveaux placements : un objectif actif doit justifier un ajout.
- [ ] Les cas à égalité sur les règles actives conservent les départages finaux par expiration puis dates. Un objectif supprimé ne réapparaît pas sous forme de départage caché.
- [ ] La gestion dans l’interface, la persistance et les commandes au clavier traversent jusqu’au calcul réel. Explications, trace et analyses contrefactuelles rendent compte de la règle active et de la contribution des placements concernés.
- [ ] Adapter les tests existants sur la conservation de provenance et l’économie des placements d’appoint. Ajouter des arbitrages vérifiables contre une amélioration de calendrier et contre une amélioration des séquences de travail, avec inversion des priorités et comparaison entre désactivation et absence.
**Vérification de bout en bout :** Comparer un plan qui ajoute des jours d’appoint pour améliorer le repos à un plan où la préférence d’économie est remontée ; montrer les jours consommés, les soldes conservés et l’objectif décisif.
@@ -0,0 +1,26 @@
# 06 — Migrer les règles sauvegardées et valider l’ensemble
**GitLab:** [#75](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/75)
**What to build:** Au prochain chargement, l’utilisateur retrouve ses règles et les six anciens comportements du moteur sous forme de préréglages actifs. Il peut ensuite les modifier ou les supprimer durablement, et l’ensemble est validé avant sa mise en service.
**Blocked by:** 05 — Piloter l’économie des jours d’appoint.
**Status:** ready-for-agent
Livraison intégrée de l’ADR 0008 sur la branche commune aux tickets 01 à 05. La préservation des données enregistrées et la migration unique ont été explicitement approuvées : ne pas réinitialiser les données pour simplifier le changement de contrat.
- [ ] À l’initialisation, fournir les six préréglages actifs avec cet ordre : prévention des pertes, règles personnelles ou règles initiales existantes dans leur ordre, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises et économie des placements d’appoint.
- [ ] Migrer une seule fois les listes enregistrées avant cette évolution, y compris une ancienne liste vide. Préserver intégralement l’identité, le nom, les paramètres, les dates, la force, l’activation et l’ordre relatif des règles existantes.
- [ ] Enregistrer de façon cohérente les règles migrées et le fait que la migration a eu lieu. Ne pas considérer l’opération terminée si les règles n’ont pas été sauvegardées ; gérer les échecs de stockage sans écraser les données utilisateur valides.
- [ ] Les chargements suivants ne dupliquent pas les préréglages, ne rétablissent pas ceux supprimés et ne réactivent pas ceux désactivés. Une liste vidée volontairement après migration reste vide. Les nouveaux utilisateurs reçoivent directement la nouvelle configuration initiale.
- [ ] La restauration et la migration précèdent le premier calcul utilisant ces données. L’utilisateur peut immédiatement éditer les six configurations avec les mêmes commandes que ses règles personnelles et retrouver ses choix après rechargement.
- [ ] Vérifier de bout en bout les six préréglages dans l’ordre initial, leurs réordonnancements et leurs désactivations. Une configuration personnelle différente d’un préréglage traverse édition, sauvegarde, worker, calcul et explication ; les paramètres ont un effet vérifiable.
- [ ] La liste entièrement désactivée, la liste supprimée après migration et une requête directe sans règle active n’ajoutent aucun congé malgré des soldes à risque. Les jours déclarés, les quantités restantes et les raisons pertinentes restent cohérents.
- [ ] Les obligations conjointes, les conditions des congés gagnés et les contraintes de validité sont respectées pour tout ordre des préférences. Une annulation restitue le meilleur plan valide disponible et sa trace originale selon la configuration soumise.
- [ ] Les explications et analyses contrefactuelles utilisent le même ordre actif que le calcul. Modifier les règles durant le calcul ou pendant la livraison tardive de la trace ne change pas ses résultats. Les anciens objectifs implicites n’interviennent plus dans aucune comparaison ou protection.
- [ ] Les fixtures et scénarios de benchmark qui dépendaient d’une optimisation implicite demandent explicitement les préréglages nécessaires. Les tests d’absence de règles n’utilisent aucun défaut implicite pour retrouver l’ancien résultat.
- [ ] Exécuter les suites concernées de règles, interface, moteur, worker réel, explication, annulation et estimation de durée, ainsi que les contrôles habituels de compilation et de qualité. Vérifier les scénarios de benchmark existants, dont les horizons longs, pour le déterminisme, la réactivité et les durées, sans introduire de délai d’arrêt automatique.
- [ ] La vérification commune des tickets 01 à 06 est la condition de mise en service. Documenter les choix finaux du contrat composable et les limites mesurées ; ne laisser ni double moteur, ni chemin historique implicite, ni adaptateur provisoire ajouté pour rendre les étapes indépendamment livrables.
**Vérification de bout en bout :** Partir d’une ancienne sauvegarde contenant des règles personnalisées et désactivées, charger la nouvelle version, vérifier le premier calcul, modifier ou supprimer des préréglages, recharger et constater la conservation exacte des choix.
+11
View File
@@ -11,3 +11,14 @@ Use the default canonical labels: `needs-triage`, `needs-info`, `ready-for-agent
### Domain docs
This is a single-context repo using root `CONTEXT.md` and `docs/adr/`. See `docs/agents/domain.md`.
## GitFlow
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their MRs to `develop`.
- Rebase working branches onto the latest `origin/develop` before opening or merging an MR. If `develop` advances, rebase and rerun the MR pipeline before merging.
- Integrate through MRs with successful pipelines and Maintainer-only merges. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden.
- Use Conventional Commits for new commits and MR titles.
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` MRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both MRs are merged.
- Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch.
Before preparing a release or hotfix, creating a tag, or changing image publication, read [GitFlow and releases](README.md#gitflow-and-releases) for package versions, explicit stable tags, and image promotion.
+16 -4
View File
@@ -83,17 +83,29 @@ The greatest number of working days in any work sequence within the planning hor
**Short work sequence**:
A work sequence of fewer than five working days between two planned time-off periods.
**Short work-sequence deficit**:
The total number of working days missing for all short work sequences to reach five working days each; empty sequences contribute zero.
**Week with one workday**:
An undesirable Monday-to-Sunday calendar week with exactly one working day remaining after excluding leave placements and declared non-working days. All five Monday-to-Friday dates must fall within the planning horizon.
**Planning rule**:
A user instruction that requests leave placements around a calendar opportunity or inside a date window. Its force is either an obligation or a preference.
A named instruction, supplied by the application or defined by the user, that guides leave placement. Only enabled rules influence the plan, and each rule's force is either an obligation or a preference.
**Optimization rule**:
A preference rule that minimizes or maximizes a measure over selected planning elements, such as leave balances, time-off periods, or work sequences.
**Optimization rule preset**:
A predefined optimization rule configuration supplied by the application that the user can use as an editable planning rule.
**Loss prevention rule**:
An optimization rule that minimizes unplaced at-risk leave.
**Obligation rule**:
A planning rule that every valid plan must satisfy. Obligation rules are resolved jointly; their order only breaks ties between valid plans.
**Preference rule**:
A planning rule that guides placement but may remain unsatisfied to preserve validity or prevent more at-risk leave from expiring.
A planning rule that guides placement but may remain unsatisfied to preserve validity or satisfy higher-priority preferences.
**IF_THEN rule**:
A planning rule evaluated independently for every French public holiday on its configured weekday. A preference requests a complete pattern around each occurrence when possible; an obligation requires it for every occurrence.
@@ -105,7 +117,7 @@ A planning rule evaluated independently for every French public holiday on its c
A planning rule counting compatible leave placements and declared non-working days in an inclusive date window. A preference requests up to its configured quantity; an obligation requires exactly that quantity.
**Rule priority**:
The user-defined lexicographic order of preference rules. Each earlier preference is satisfied as far as possible before a later preference is considered.
The user-defined lexicographic order shared by all active preference rules, including optimization rules. Each earlier preference is satisfied as far as possible before a later preference is considered.
**Rule satisfaction**:
The relationship in which a plan fulfils a planning rule. One compatible leave placement or declared non-working day may contribute to the satisfaction of multiple rules.
@@ -164,7 +176,7 @@ A hypothetical comparison that keeps the calculation snapshot and all unrelated
A calculation stopped by user cancellation or another interruption before it completed the planning objective order. On user cancellation it exposes its best available valid plan as partial, if one exists, and never implies that the obligations conflict.
**Planning objective order**:
The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize public-holiday week value; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. Complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A lower criterion never degrades a higher one.
The lexicographic quality order applied after joint obligation feasibility, with active preference rules, including optimization rules, sharing the user-defined rule priority. A lower criterion never degrades a higher one.
**Earned leave**:
The total quantity of earned leave entitlements generated by all applicable planning rules for a plan, counted before consumption and attributed to their generating rules. Only entitlements becoming available strictly after the calculation's Europe/Paris calendar date qualify, including those becoming available after the planning horizon.
+35 -1
View File
@@ -242,7 +242,41 @@ GitLab CI runs independent validation, test, build, security, and image stages:
- Vitest with coverage artifacts;
- an optimized production build;
- GitLab SAST and Trivy filesystem scanning;
- Docker image publication from the default branch.
- GitFlow, Conventional Commit, and release-version checks;
- Docker image publication for every successful branch push, including branches with an open MR.
Pipelines switch to MR pipelines when a branch has an open MR, avoiding duplicate builds. Docker publication from MR pipelines is limited to detached pipelines within this project. Tag pipelines promote the already-built commit image instead of rebuilding it.
The test job runs Vitest directly with one worker and a 2 GiB Node heap limit. It runs application tests with coverage, then the complete benchmark protocol separately without coverage instrumentation. The benchmark uses a five-minute execution timeout to allow for shared-runner contention; all protocol assertions remain mandatory. Timing measurements are benchmark evidence, not CI performance thresholds.
### GitFlow and releases
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their MR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both MRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
Use Conventional Commits for new commits and MR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. GitLab generates merge messages from the validated MR title. Legacy history is not rewritten.
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-<GitLab ref slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
For example, `feature/calendar` publishes `branch-feature-calendar`; integration publishes `branch-develop`. GitLab normalizes ref slugs to lowercase and truncates them to 63 bytes, so choose branch names with distinct slugs. Only valid release tags can update `latest`.
After the MR into `main` and its full pipeline have succeeded, tag the published main commit:
```bash
git fetch origin
git tag -a v1.6.0 origin/main -m "Release 1.6.0"
git push origin v1.6.0
```
The tag must match the checked-out `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main pipeline before tagging: the release job requires its `sha-<commit>` image. Tags do not increment the version automatically.
GitLab project settings must use `develop` as the default branch, the **Merge commit** method, squashing disabled, and **Pipelines must succeed** with skipped pipelines disallowed. Protect `main` and `develop` with no direct or forced pushes and Maintainer-only merges. Set the merge commit template to `%{title}` followed by the MR reference, preserving Conventional Commit subjects. The CI permits only `release/*` and `hotfix/*` into `main`; working MRs target `develop`. Rebase checks validate the target head at pipeline time: if `develop` advances afterward, rebase and rerun the MR pipeline before merging.
Run the policy integration check locally with `node scripts/ci-policy.test.mjs`.
## Domain and design references
+30 -10
View File
@@ -16,7 +16,7 @@
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.1",
"@angular/ssr": "22.2.2",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
@@ -27,8 +27,8 @@
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.1",
"@angular/cli": "22.2.1",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
@@ -67,17 +67,17 @@
"@angular-devkit/architect": ["@angular-devkit/architect@0.2202.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-WGRAxFj+R9LC8HuK3DnDgxHo/XrgfklNQeNgjq/phTEVq5ZdWxc8kDoPA4uQz4vF+HY2eycAAtOPfMvj2TBEiQ=="],
"@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-frHhFwu2TEfTZX5jfAYL2bztuG5mGggfJhiM9bo5/QtLAi49LtRbJxIktsu6vjJdujxeB2TKD7KqnXwNYgd41Q=="],
"@angular/animations": ["@angular/animations@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1" } }, "sha512-vTxZN6uL/m61lv7ZCvfmQ3VuFb4o/tniGLpc3SpkNHE6MLM8WN0IjfNBRLMYMovd1uUBSPwhcG4Llcmucr9dmw=="],
"@angular/build": ["@angular/build@22.2.1", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.1", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.1", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-VA8EkK5Y6sJbBFyn2x5jzd3KByh29DDpI7n6hucT0sK6YkMJEvsojPUWet9ifwF3OL+ZrGvxYlAHyZGKVlqw0g=="],
"@angular/build": ["@angular/build@22.2.2", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.2", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.2", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-QHJWkZdEWTqqb3+HiSp8lnuS4iQJCa7yTK9gg5TaMIjCoZzP9mD9L417stiVDZGj2J3Y9mrml044nEVvSbW0ig=="],
"@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="],
"@angular/cli": ["@angular/cli@22.2.1", "", { "dependencies": { "@angular-devkit/architect": "0.2202.1", "@angular-devkit/core": "22.2.1", "@angular-devkit/schematics": "22.2.1", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.0.0", "@schematics/angular": "22.2.1", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.5" }, "bin": { "ng": "bin/ng.js" } }, "sha512-tKMP68SZP+A9YCSRg0c+2e/+yWp7qiFrjCuQAomHK7owNaxHnLigAbra+8QA4xmWHoGaQk89NMmUPyA1BsBp8w=="],
"@angular/cli": ["@angular/cli@22.2.2", "", { "dependencies": { "@angular-devkit/architect": "0.2202.2", "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.3.1", "@schematics/angular": "22.2.2", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.5" }, "bin": { "ng": "bin/ng.js" } }, "sha512-AALIN+zai0CiA7HgSCGVPHyZyiw3yFWliCfbaS4ueHDJHVZ8QhVjRUrnfGZc3EJnigcyQ0w2DKeTxjwqpDjKew=="],
"@angular/common": ["@angular/common@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw=="],
@@ -99,7 +99,7 @@
"@angular/router": ["@angular/router@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.1", "@angular/core": "22.2.1", "@angular/platform-browser": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-HJfJ4yA1znKd9YFtbDOJfKmD12x0PhS7LkmYdWlJRu2WkQZRBVrdGKdmbvV5UvrqCRllVWKpzw1TIZJM69rlcw=="],
"@angular/ssr": ["@angular/ssr@22.2.1", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-I228uYQ53Eyc30B+7ZGzw35N36MG9F6E6IUPAlK2dhuxZsCLherbElIa1FPTh5R2qcr+xVJw0Ej6QEUj59+KvA=="],
"@angular/ssr": ["@angular/ssr@22.2.2", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-iMcwT5ZynIj33R0XSq+AtC5Ra6FGKOvV3qU0sjRr+lUaGfL6MjygPovPFsKLi8HzuGOA5dYDKffJfPedsC9Rxg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.1.3", "", { "dependencies": { "@csstools/css-calc": "^3.4.2", "@csstools/css-color-parser": "^4.2.5", "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" } }, "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ=="],
@@ -555,9 +555,9 @@
"@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.3.1", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" } }, "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
@@ -837,7 +837,7 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
"@schematics/angular": ["@schematics/angular@22.2.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "@angular-devkit/schematics": "22.2.1", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-L4Nl8D43znCzXwze9PtbQhG1GKlhWq9ip8Hz+e3LHgG3WpT5Tlr7aVsyHDqb9Qetuy0Mpd4tXE15STtGdA897w=="],
"@schematics/angular": ["@schematics/angular@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-JxpXKpxdmQcRsJbriWJOhB7HeVTzz9CEsPNw6fQ0LOuPFnvtyUIXLxyFbKv/LfoFUz8wFADCtNU5sjowME5Mvg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
@@ -2379,16 +2379,26 @@
"@analogjs/vite-plugin-nitro/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"@angular-devkit/architect/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
"@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/build/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular/build/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/build/oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="],
"@angular/build/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"@angular/cli/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
"@angular/cli/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@angular/compiler-cli/@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="],
"@angular/compiler-cli/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
@@ -2787,6 +2797,8 @@
"@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@schematics/angular/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
"@schematics/angular/oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
@@ -3235,6 +3247,10 @@
"@analogjs/vite-plugin-nitro/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@angular-devkit/architect/@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular-devkit/schematics/@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
"@angular/build/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.150.0", "", { "os": "android", "cpu": "arm" }, "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw=="],
"@angular/build/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.150.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA=="],
@@ -3277,6 +3293,8 @@
"@angular/build/vite/rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="],
"@angular/cli/@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@angular/compiler-cli/@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"@angular/compiler-cli/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -5401,6 +5419,8 @@
"@oxc-parser/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@schematics/angular/@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
"@schematics/angular/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.150.0", "", { "os": "android", "cpu": "arm" }, "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw=="],
"@schematics/angular/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.150.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA=="],
@@ -2,4 +2,6 @@
The ten-second automatic deadline is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff.
The fixed ordering between user preferences and configurable optimization objectives, including the unconditional priority for at-risk loss prevention, is superseded by [ADR 0008](0008-user-controlled-planning-objectives.md); implementation of that decision is pending.
VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
@@ -6,7 +6,7 @@ Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_it
VacOptimizer accepts explicitly selected, fixed planning horizons whose start may be past and whose duration may exceed twelve months, retaining today through the same date twelve months later as the default. Calculations remain in the browser and run to completion without an automatic elapsed-time cutoff, with an indicative duration estimate and an explicit Cancel action; this accepts variable waiting time and resource use to make longer horizons usable without silently changing the user's dates. This supersedes ADR 0003's ten-second automatic planning limit and its retention in both ADR 0004 documents, while preserving the separation between the planning outcome, stable explanation, and original trace.
The settings' end date is the inclusive last planning date. The planner retains its half-open horizon by using the following calendar date as its exclusive end, matching the inclusive date-selection convention of TIME_WINDOW rules.
The planning-period control in Configure your leave uses an inclusive last planning date. The planner retains its half-open horizon by using the following calendar date as its exclusive end, matching the inclusive date-selection convention of TIME_WINDOW rules.
Editing the horizon does not trigger a calculation. The user can adjust dates, balances, and rules before using the existing Calculate action; until a new calculation is requested, the displayed calendar retains its preceding outcome and horizon.
@@ -0,0 +1,23 @@
---
status: accepted
---
# User-Controlled Planning Objectives
VacOptimizer represents configurable plan-wide objectives as named optimization rules in the same user-controlled list as calendar-based planning rules, adding rule types where existing calendar patterns cannot express the objective. All active preference rules share one user-defined lexicographic order, so moving an optimization preference above Samedi Malin may deliberately forgo earned leave to improve that higher-priority objective. Making the displayed order authoritative supersedes the fixed ordering between user preferences and optimization objectives in [ADR 0003](0003-constraint-first-lexicographic-planning.md).
At-risk loss prevention also becomes an optimization rule, enabled in first position by default and freely disableable or reorderable. The planner follows the resulting preference order even when a higher-priority preference leaves more at-risk leave unplaced; this also supersedes ADR 0003's unconditional priority for loss prevention.
Optimization rules use preference mode. Supporting them as obligations would require explicit thresholds and additional conflict semantics, which are outside this change.
The shared model for user-created optimization rules selects planning elements and applicable conditions, a measure and its aggregation, and a direction to minimize or maximize. At minimum, the application supplies six presets expressed in that same model: loss prevention, public-holiday week value, non-working-day gain, longest work-sequence length, the deficit of internal work sequences shorter than five working days, and the number of supporting placements.
These presets retain their existing domain meanings, including the weekday-dependent value of complete public-holiday weeks and the total deficit of short work sequences rather than their count. Supporting placements are additional uses of balances expiring after the planning horizon to improve the plan. User-created rules can compose supported planning elements and measures; introducing a new domain measure requires extending the planner.
The six presets are initially enabled, ordered as loss prevention, existing calendar-based rules, then the other five optimization presets in their former engine order. Existing saved rule lists, including an empty list, receive this addition once while preserving every existing rule's identity, parameters, force, enabled state, and relative order. Subsequent loads restore the user's saved choices without reinserting deleted presets or reactivating disabled ones.
After all active rules compare equally, earlier balance expiry and then earlier placement dates remain deterministic tie-breakers.
Only active rules drive placement decisions and preference comparisons; disabled or deleted optimization rules have no residual influence through fixed optimization stages or protected metrics. With no active rules, the planner adds no leave placements, even when balances are at risk of expiring, and retains declared non-working days. Final tie-breakers cannot independently trigger new placements.
Implementation is pending.
+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.1",
"@angular/ssr": "22.2.2",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
@@ -49,8 +49,8 @@
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.1",
"@angular/cli": "22.2.1",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
+85
View File
@@ -0,0 +1,85 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
const ancestor = (base, commit) =>
spawnSync('git', ['merge-base', '--is-ancestor', base, commit]).status === 0;
const conventional = /^[a-z]+(?:\([^()\r\n]+\))?!?: \S.*$/i;
const requirePolicy = (condition, message) => {
if (!condition) throw new Error(message);
};
try {
const commit = env.CI_COMMIT_SHA;
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
'--no-tags',
'origin',
'+refs/heads/main:refs/remotes/origin/main',
'+refs/heads/develop:refs/remotes/origin/develop',
);
if (env.CI_COMMIT_TAG) {
requirePolicy(
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_TAG),
'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
);
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
requirePolicy(env.CI_COMMIT_TAG === `v${version}`, 'Release tag must match package.json.');
requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else {
const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event';
const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH;
const target = mr
? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
: branch === 'main' || branch?.startsWith('hotfix/')
? 'main'
: 'develop';
requirePolicy(branch, 'A branch name is required.');
requirePolicy(['main', 'develop'].includes(target), 'MRs must target main or develop.');
if (mr) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
'MR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
'Only release/* and hotfix/* may merge into main.',
);
if (target === 'develop' && branch !== 'main' && !/^(release|hotfix)\//.test(branch)) {
requirePolicy(
ancestor('origin/develop', commit),
'Rebase the working branch onto origin/develop before merging.',
);
requirePolicy(
!git('rev-list', '--merges', `origin/develop..${commit}`),
'Working branches must use rebase, not merge commits.',
);
}
}
const before = env.CI_COMMIT_BEFORE_SHA;
const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
for (const line of subjects ? subjects.split('\n') : []) {
requirePolicy(
conventional.test(line.slice(line.indexOf(' ') + 1)),
`Commit must use Conventional Commits: ${line}`,
);
}
}
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const check = (variables, expected = 0) => {
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
CI_PIPELINE_SOURCE: 'push',
CI_COMMIT_SHA: git('rev-parse', 'HEAD'),
CI_COMMIT_BRANCH: 'feature/test',
...variables,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
};
const mr = {
CI_PIPELINE_SOURCE: 'merge_request_event',
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/test',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'develop',
CI_MERGE_REQUEST_TITLE: 'feat(test): add policy checks',
};
try {
git('init', '--initial-branch=main');
git('config', 'user.name', 'CI policy test');
git('config', 'user.email', 'ci-policy@example.invalid');
git('remote', 'add', 'origin', directory);
writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'Legacy history is allowed');
git('branch', 'develop');
git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({});
check(mr);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1);
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_SHA: '0'.repeat(40) }, 1);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
check(mr, 1);
git('rebase', 'develop');
check(mr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1);
check(mr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
git('switch', '-c', 'release/1.6.0');
writeFileSync(join(directory, 'package.json'), '{"version":"1.6.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0' });
git('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
check({ CI_COMMIT_TAG: 'v1.6.0' });
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_TAG: 'v01.6.0' }, 1);
check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1);
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' });
git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1' });
// The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
check(mr, 1);
console.log('CI policy integration checks passed.');
} finally {
rmSync(directory, { recursive: true, force: true });
}
+47
View File
@@ -339,6 +339,53 @@ describe('Application Settings', () => {
expect(await dialog.getAllHarnesses(MatButtonHarness)).toHaveLength(1);
});
it('saves planning dates before balances and recalculates only when Calculate is selected', async () => {
const root = fixture.nativeElement as HTMLElement;
const period = root.querySelector<HTMLElement>('app-user-input .planning-dates')!;
expect(
period.compareDocumentPosition(root.querySelector('.listOfDays')!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(period.querySelector('legend')?.textContent).toBe('Planning period');
expect(period.querySelector('#planning-dates-help')?.textContent).toContain(
'Calculate plan',
);
const start = period.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = period.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
start.value = '29/02/2024';
start.dispatchEvent(new Event('input'));
last.value = '08/03/2024';
last.dispatchEvent(new Event('input'));
await fixture.whenStable();
expect(calculate).toHaveBeenCalledOnce();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2024-03-08',
});
root.querySelector<HTMLButtonElement>('.calculate-button')!.click();
await fixture.whenStable();
expect(calculate).toHaveBeenCalledTimes(2);
expect(calculate.mock.calls[1][0].horizon).toEqual({
start: '2024-02-29',
end: '2024-03-09',
});
const dialog = await openSettings();
expect(await dialog.getContentText()).not.toContain('Planning period');
const [, language] = await dialog.getAllHarnesses(MatSelectHarness);
await selectOption(language, 'Français');
expect(period.querySelector('legend')?.textContent).toBe('Période à planifier');
await dialog.close();
await revisitApp(true);
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
'29/02/2024',
);
expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe(
'08/03/2024',
);
});
it('supports keyboard theme selection and moves focus into the dialog', async () => {
const dialog = await openSettings();
const [theme] = await dialog.getAllHarnesses(MatSelectHarness);
@@ -109,5 +109,5 @@ describe('leave planner benchmark protocol', () => {
} finally {
vi.stubGlobal('window', browserWindow);
}
}, 90_000);
}, 300_000);
});
+55 -4
View File
@@ -7,6 +7,60 @@
>
</header>
<fieldset class="planning-dates">
<legend>{{ tr().settings.planningHorizon }}</legend>
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.planningStart }}</mat-label>
<input
matInput
[matDatepicker]="planningStartPicker"
name="planningStart"
required
[ngModel]="planningDates().start"
(ngModelChange)="setPlanningDate('start', $event)"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'planning-dates-help planning-dates-error'
: 'planning-dates-help'
"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="planningStartPicker"
></mat-datepicker-toggle>
<mat-datepicker #planningStartPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
<input
matInput
[matDatepicker]="lastPlanningDatePicker"
name="lastPlanningDate"
required
[ngModel]="planningDates().lastPlanningDate"
(ngModelChange)="setPlanningDate('lastPlanningDate', $event)"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'planning-dates-help planning-dates-error'
: 'planning-dates-help'
"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="lastPlanningDatePicker"
></mat-datepicker-toggle>
<mat-datepicker #lastPlanningDatePicker></mat-datepicker>
</mat-form-field>
@if (calendarSettings.planningHorizonError(); as error) {
<p id="planning-dates-error" class="validation-error" role="alert">
{{ tr().settings[error] }}
</p>
}
</fieldset>
<div class="listOfDays">
@for (day of daysOff; track day.id) {
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
@@ -80,7 +134,7 @@
[disabled]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'invalid-planning-horizon'
? 'planning-dates-error'
: 'planning-duration-estimate'
"
(click)="triggerComputation()"
@@ -88,9 +142,6 @@
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
{{ tr().userInput.calculate }}
</button>
@if (calendarSettings.planningHorizonError()) {
<p id="invalid-planning-horizon" role="alert">{{ tr().settings.invalidHorizon }}</p>
}
@if (hasRemainingDays()) {
<mat-card class="warning-card" [class.is-informational]="!hasAtRiskDays()">
@@ -31,6 +31,41 @@
}
}
.planning-dates {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-column: 1 / -1;
column-gap: 1rem;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
legend {
font-weight: 600;
}
mat-form-field {
min-width: 0;
width: 100%;
}
p {
grid-column: 1 / -1;
margin: 0.5rem 0 1rem;
color: var(--ui-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.validation-error {
color: var(--ui-error-text);
background: var(--ui-error-bg);
padding: 0.75rem;
border-radius: 0.5rem;
}
}
.listOfDays {
display: flex;
flex-direction: column;
@@ -250,6 +285,9 @@ form {
}
@container user-input (min-width: 40rem) {
.planning-dates {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.listOfDays {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -43,6 +43,7 @@ describe('UserInput', () => {
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
fixture.detectChanges();
await fixture.whenStable();
}
async function revisit(): Promise<void> {
@@ -200,26 +201,103 @@ describe('UserInput', () => {
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-03-01', '2024-02-29');
fixture.detectChanges();
settings.setPlanningDates('2024-02-29', '2024-03-08');
TestBed.inject(TranslationService).setLanguage('fr');
await fixture.whenStable();
const toggles = fixture.nativeElement.querySelectorAll(
'.planning-dates mat-datepicker-toggle button',
);
expect(toggles).toHaveLength(2);
for (const [index, day] of ['27', '9'].entries()) {
toggles[index].click();
await fixture.whenStable();
const calendar = document.querySelector<HTMLElement>('mat-calendar')!;
if (index === 0)
expect(
calendar
.querySelector('.mat-calendar-period-button')
?.textContent?.toLowerCase(),
).toContain('févr');
Array.from(calendar.querySelectorAll<HTMLButtonElement>('.mat-calendar-body-cell'))
.find((cell) => cell.textContent?.trim() === day)!
.click();
await fixture.whenStable();
await expect.poll(() => document.querySelector('mat-calendar')).toBeNull();
}
expect(settings.planningHorizon()).toEqual({ start: '2024-02-27', end: '2024-03-10' });
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-03-01', '2024-03-01');
await fixture.whenStable();
editInput('input[name="lastPlanningDate"]', '29/02/2024');
const root = fixture.nativeElement as HTMLElement;
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
expect(calculate.disabled).toBe(true);
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'planning dates in Settings',
);
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
settings.setPlanningDates('2024-02-29', '2024-02-29');
fixture.detectChanges();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
'reports an invalid planning date without saving it: %s',
async (value) => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
editInput('input[name="planningStart"]', value);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
value,
);
expect(
fixture.nativeElement.querySelector('#planning-dates-error')?.textContent,
).toContain('two valid calendar dates');
expect(settings.planningHorizon()).toBeNull();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2027-02-28',
});
},
);
it('edits inclusive dates with inline validation while preserving the saved planning horizon', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('29/02/2024');
expect(last.value).toBe('28/02/2027');
expect(root.textContent).toContain('Last planning date (inclusive)');
editInput('input[name="lastPlanningDate"]', '28/02/2024');
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
expect(last.getAttribute('aria-invalid')).toBe('true');
expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error');
expect(settings.planningHorizon()).toBeNull();
editInput('input[name="lastPlanningDate"]', '29/02/2024');
expect(root.querySelector('#planning-dates-error')).toBeNull();
expect(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('should initialize with vacation days from service', () => {
const vacationDays = userInputService.vacationNumberSignal();
expect(vacationDays).toBeDefined();
+30 -1
View File
@@ -7,6 +7,11 @@ import {
ChangeDetectionStrategy,
} from '@angular/core';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { DateAdapter } from '@angular/material/core';
import { FormsModule } from '@angular/forms';
import dayjs from 'dayjs';
import { UserInputService } from '../../services/user-input-service';
import { RulesPanel } from '../rules-panel/rules-panel';
import { MatCard, MatCardContent } from '@angular/material/card';
@@ -18,12 +23,16 @@ import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays } from '../../services/planning-date';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@Component({
selector: 'app-user-input',
imports: [
MatInputModule,
MatFormFieldModule,
MatDatepickerModule,
FormsModule,
RulesPanel,
MatCard,
MatCardContent,
@@ -34,6 +43,7 @@ import { addPlanningDays } from '../../services/planning-date';
templateUrl: './user-input.html',
styleUrl: './user-input.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class UserInput {
private userInputService: UserInputService = inject(UserInputService);
@@ -43,6 +53,16 @@ export class UserInput {
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
protected readonly planningDates = computed(() => {
const { start, lastPlanningDate } = this.calendarSettings.planningDates();
return {
start: isPlanningDate(start) ? new Date(`${start}T00:00:00`) : null,
lastPlanningDate: isPlanningDate(lastPlanningDate)
? new Date(`${lastPlanningDate}T00:00:00`)
: null,
};
});
protected readonly durationLabel = computed(() => {
const estimate = this.duration.estimate();
if (!estimate) return '';
@@ -169,6 +189,7 @@ export class UserInput {
);
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
effect(() => {
this.userInputService.planningOutcomeSignal();
this.historyBalanceId.set(null);
@@ -176,6 +197,14 @@ export class UserInput {
});
}
protected setPlanningDate(field: 'start' | 'lastPlanningDate', value: Date | null): void {
const dates = {
...this.calendarSettings.planningDates(),
[field]: value ? dayjs(value).format('YYYY-MM-DD') : '',
};
this.calendarSettings.setPlanningDates(dates.start, dates.lastPlanningDate);
}
private ruleName(id: string): string {
return (
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
@@ -2,53 +2,6 @@
<mat-dialog-content>
<p>{{ tr().settings.description }}</p>
<div class="settings-controls">
<fieldset class="planning-dates">
<legend>{{ tr().settings.planningHorizon }}</legend>
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.planningStart }}</mat-label>
<input
matInput
type="date"
name="planningStart"
required
#planningStart
[value]="calendarSettings.planningDates().start"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
aria-describedby="planning-dates-help planning-dates-error"
(input)="
calendarSettings.setPlanningDates(
planningStart.value,
lastPlanningDate.value
)
"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
<input
matInput
type="date"
name="lastPlanningDate"
required
#lastPlanningDate
[value]="calendarSettings.planningDates().lastPlanningDate"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
aria-describedby="planning-dates-help planning-dates-error"
(input)="
calendarSettings.setPlanningDates(
planningStart.value,
lastPlanningDate.value
)
"
/>
</mat-form-field>
@if (calendarSettings.planningHorizonError(); as error) {
<p id="planning-dates-error" class="validation-error" role="alert">
{{ tr().settings[error] }}
</p>
}
</fieldset>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
<mat-select
@@ -20,26 +20,3 @@ mat-dialog-content > p {
mat-form-field {
width: 100%;
}
.planning-dates {
min-width: 0;
margin: 0 0 1rem;
padding: 0;
border: 0;
legend {
font-weight: 600;
}
p {
margin: 0.5rem 0 1rem;
color: var(--ui-text-secondary);
}
.validation-error {
color: var(--ui-error-text);
background: var(--ui-error-bg);
padding: 0.75rem;
border-radius: 0.5rem;
}
}
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { UserSettings } from './user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { TranslationService } from '../../services/translation-service';
describe('UserSettings', () => {
@@ -16,7 +15,6 @@ describe('UserSettings', () => {
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28');
fixture = TestBed.createComponent(UserSettings);
component = fixture.componentInstance;
fixture.detectChanges();
@@ -24,49 +22,8 @@ describe('UserSettings', () => {
afterEach(() => localStorage.clear());
it.each(['', '2024-02-30', 'not-a-date'])(
'reports an invalid date entry without saving it: %s',
(value) => {
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
start.value = value;
start.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'two valid calendar dates',
);
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2027-02-28',
});
},
);
it('edits inclusive dates with an inline validation indication while preserving the saved horizon', () => {
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('2024-02-29');
expect(last.value).toBe('2027-02-28');
expect(root.textContent).toContain('Last planning date (inclusive)');
last.value = '2024-02-28';
last.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')?.textContent).toContain('on or after');
expect(last.getAttribute('aria-invalid')).toBe('true');
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
last.value = '2024-02-29';
last.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')).toBeNull();
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toEqual({
start: '2024-02-29',
end: '2024-03-01',
});
});
it('should create', () => {
expect(component).toBeTruthy();
expect(fixture.nativeElement.querySelector('input[type="date"]')).toBeNull();
});
});
@@ -9,7 +9,6 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatInputModule } from '@angular/material/input';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { ThemeService } from '../../services/theme-service';
import { TranslationService } from '../../services/translation-service';
@@ -25,7 +24,6 @@ import { TranslationService } from '../../services/translation-service';
MatFormFieldModule,
MatSelectModule,
MatSlideToggle,
MatInputModule,
],
templateUrl: './user-settings.html',
styleUrl: './user-settings.scss',
+2 -2
View File
@@ -17,7 +17,7 @@
},
"userInput": {
"title": "Urlaub konfigurieren",
"description": "Fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.",
"description": "Wählen Sie den Planungszeitraum und fügen Sie jedes Urlaubskonto und seinen Gültigkeitszeitraum hinzu.",
"addVacationDay": "Urlaubskonto hinzufügen",
"calculate": "Plan berechnen",
"durationEstimate": "Ungefähre Wartezeit: {range} {unit}",
@@ -83,7 +83,7 @@
"planningDatesHelp": "Wählen Sie beliebige Daten, auch vergangene. Der Kalender wird aktualisiert, wenn Sie Plan berechnen auswählen.",
"invalidDate": "Geben Sie zwei gültige Kalenderdaten ein.",
"horizonOrder": "Der letzte Planungstag muss am oder nach dem Startdatum liegen.",
"invalidHorizon": "Korrigieren Sie die Planungsdaten in den Einstellungen vor der Berechnung."
"invalidHorizon": "Korrigieren Sie die Planungsdaten vor der Berechnung."
},
"rules": {
"title": "Planungsregeln",
+3 -3
View File
@@ -17,7 +17,7 @@
},
"userInput": {
"title": "Configure your leave",
"description": "Add each leave balance and its validity period.",
"description": "Choose a planning period, then add each leave balance and its validity period.",
"addVacationDay": "Add leave balance",
"calculate": "Calculate plan",
"durationEstimate": "Approximate wait: {range} {unit}",
@@ -77,13 +77,13 @@
"dark": "Dark"
},
"systemAppearance": "Current system appearance",
"planningHorizon": "Planning horizon",
"planningHorizon": "Planning period",
"planningStart": "Start date (inclusive)",
"lastPlanningDate": "Last planning date (inclusive)",
"planningDatesHelp": "Choose any dates, including past dates. Your calendar updates when you select Calculate plan.",
"invalidDate": "Enter two valid calendar dates.",
"horizonOrder": "The last planning date must be on or after the start date.",
"invalidHorizon": "Correct the planning dates in Settings before calculating."
"invalidHorizon": "Correct the planning dates before calculating."
},
"rules": {
"title": "Planning rules",
+3 -3
View File
@@ -17,7 +17,7 @@
},
"userInput": {
"title": "Configura tus vacaciones",
"description": "Añade cada saldo de vacaciones y su periodo de validez.",
"description": "Elige el período de planificación y añade cada saldo de vacaciones y su periodo de validez.",
"addVacationDay": "Añadir saldo de vacaciones",
"calculate": "Calcular plan",
"durationEstimate": "Espera aproximada: {range} {unit}",
@@ -77,13 +77,13 @@
"dark": "Oscuro"
},
"systemAppearance": "Apariencia actual del sistema",
"planningHorizon": "Horizonte de planificación",
"planningHorizon": "Período de planificación",
"planningStart": "Fecha de inicio (incluida)",
"lastPlanningDate": "Última fecha de planificación (incluida)",
"planningDatesHelp": "Elige cualquier fecha, incluso pasada. El calendario se actualiza al seleccionar Calcular plan.",
"invalidDate": "Introduce dos fechas válidas.",
"horizonOrder": "La última fecha de planificación debe ser igual o posterior a la fecha de inicio.",
"invalidHorizon": "Corrige las fechas de planificación en Ajustes antes de calcular."
"invalidHorizon": "Corrige las fechas de planificación antes de calcular."
},
"rules": {
"title": "Reglas de planificación",
+3 -3
View File
@@ -17,7 +17,7 @@
},
"userInput": {
"title": "Configurez vos congés",
"description": "Ajoutez chaque solde de congés et sa période de validité.",
"description": "Choisissez la période à planifier, puis ajoutez chaque solde de congés et sa période de validité.",
"addVacationDay": "Ajouter un solde de congés",
"calculate": "Calculer le planning",
"durationEstimate": "Attente indicative : {range} {unit}",
@@ -77,13 +77,13 @@
"dark": "Sombre"
},
"systemAppearance": "Apparence actuelle du système",
"planningHorizon": "Horizon de planification",
"planningHorizon": "Période à planifier",
"planningStart": "Date de début (incluse)",
"lastPlanningDate": "Dernière date de planification (incluse)",
"planningDatesHelp": "Choisissez vos dates, même passées. Le calendrier est mis à jour lorsque vous cliquez sur Calculer le planning.",
"invalidDate": "Saisissez deux dates calendaires valides.",
"horizonOrder": "La dernière date de planification doit être égale ou postérieure à la date de début.",
"invalidHorizon": "Corrigez les dates de planification dans les paramètres avant de calculer."
"invalidHorizon": "Corrigez les dates de planification avant de calculer."
},
"rules": {
"title": "Règles de planification",