CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
292 lines
23 KiB
Markdown
292 lines
23 KiB
Markdown
# VacOptimizer
|
|
|
|
VacOptimizer is a personal leave planner for turning dated leave balances into a deterministic plan around weekends and French public holidays. It handles mandatory rules, ordered preferences, earned leave, expiring balances, and interrupted calculations without relying on a backend solver.
|
|
|
|
Source code, issues, and pull requests are hosted on [Gitea](https://gitea.nessar.fr/nessar/vac-optimizer). The [issue-tracker guide](docs/agents/issue-tracker.md) documents `tea-cli` access and tracker conventions.
|
|
|
|
The planner answers these questions:
|
|
|
|
- Is the request valid?
|
|
- Can all mandatory rules be satisfied together?
|
|
- What is the best plan found by the ordered heuristic?
|
|
- If calculation stops early, what valid incumbent is available?
|
|
- Why did each displayed placement survive the objective order?
|
|
- What would another date change for a displayed placement?
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
Host["Express production host<br/>static and client assets"] -. serves .-> UI
|
|
|
|
subgraph Browser
|
|
UI["Angular standalone components"]
|
|
State["Signals + localStorage<br/>settings, balances, rules"]
|
|
Duration["Indicative duration<br/>session-local calibration"]
|
|
Facade["Planning calculation context<br/>immutable snapshot + async artifacts"]
|
|
Worker["Leave-planner Web Worker"]
|
|
Core["Resumable planner core"]
|
|
Holidays["date-holidays<br/>French public calendar"]
|
|
|
|
UI <--> State
|
|
State -- "pending inputs" --> Duration
|
|
Duration -- "estimated wait" --> UI
|
|
UI -- "rendered-calendar timing" --> Duration
|
|
UI -- "Calculate / Cancel" --> Facade
|
|
Facade -- "request / cancellation" --> Worker
|
|
Worker --> Core
|
|
Core --> Holidays
|
|
Worker -- "heartbeats, progress, checkpoints, outcome" --> Facade
|
|
Worker -- "after outcome: stable explanation + final state" --> Facade
|
|
Facade --> UI
|
|
end
|
|
```
|
|
|
|
Leave calculation stays in the browser. The production Express process serves the generated Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. The original decision trace and historical analysis were removed in issue #76; immutable checkpoints and final state remain available for cancellation recovery and displayed-placement alternatives.
|
|
|
|
### Main execution path
|
|
|
|
1. `PlanningDurationService` reads the pending horizon, balances, and ordered rules to estimate the wait without running the planner.
|
|
2. On startup or an explicit Calculate action, `CalendarYear` submits those inputs through `CalendarService`, which opens one immutable planning calculation context.
|
|
3. `leave-planner.service.ts` copies the request into a canonical transport shape and starts the worker.
|
|
4. `leave-planner.worker.ts` runs the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and final state.
|
|
5. `CalendarYear` ignores stale calculations, renders the selected dates, reports remaining balances or errors, and records successful request-to-rendered-calendar timing for duration refinement.
|
|
|
|
Calculations run until all objectives complete or the user cancels; there is no automatic elapsed-time cutoff. During preparation and optimization, Cancel interrupts the worker cooperatively and preserves its best jointly valid candidate as a cancelled, incomplete calculation. A cancellation before a valid candidate reports no plan. The final calendar is delivered before the stable explanation, whose later arrival or failure cannot change the retained plan. Worker heartbeats distinguish responsive long phases from a worker that stops responding. See [the cancellation mechanism and checks](docs/planner-cancellation.md).
|
|
|
|
Technical callers may explicitly supply `deadlineMs`, a finite non-negative planning-work budget of any size. Omission means no deadline; the calendar and benchmark never supply one. This opt-in budget starts after calendar preparation and excludes observer callbacks and post-outcome explanation work. Expiry returns `PlanningIncomplete` with the latest valid checkpoint, if any, rather than a conflict. It is independent of worker liveness monitoring.
|
|
|
|
## Planning model
|
|
|
|
A request contains:
|
|
|
|
- a user-selected half-open planning horizon, including past dates and multiple years;
|
|
- CP, RTT, or Other leave balances with quantities, availability dates, and expiry dates;
|
|
- ordered `TIME_WINDOW` and `IF_THEN` rules;
|
|
- an optional explicit technical deadline and progress callback.
|
|
|
|
Rules are either:
|
|
|
|
- **obligations**, which must all be feasible in the same plan; or
|
|
- **preferences**, optimized lexicographically in user order.
|
|
|
|
`TIME_WINDOW` places a quantity inside an inclusive date window. `IF_THEN` creates before, after, around, or automatic patterns for each matching French public holiday. A completed `IF_THEN` occurrence may earn a new dated leave entitlement after its final time-off period ends.
|
|
|
|
The outcome is one of:
|
|
|
|
| Outcome | Meaning |
|
|
| -------------------- | ------------------------------------------------------------------------------------------- |
|
|
| `InvalidInput` | The request is malformed. |
|
|
| `PlanningConflict` | Valid obligations cannot all be satisfied together. |
|
|
| `Plan` | All stages completed with placements, periods, metrics, provenance, and remaining balances. |
|
|
| `PlanningIncomplete` | Cancellation, the deadline, or worker failure stopped calculation; a valid incumbent may be present. |
|
|
|
|
All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/Paris domain. The planner treats weekdays excluding French national public holidays as working days.
|
|
|
|
Settings save a fixed start date and an inclusive last planning date. The planner's exclusive end is the following calendar date, so matching settings dates cover one day. Without saved dates, the default is today in Europe/Paris through the same date twelve months later, exclusive (February 29 uses February 28 in the following year). Invalid edits prevent calculation and preserve the last valid saved dates. Changes to dates, balances, and rules apply to the displayed calendar on the next explicit Calculate action.
|
|
|
|
An indicative range beside Calculate follows the current dates, balances, and rules. Completed plans refine it locally for the current browser session using request-to-rendered-calendar time; later explanation delivery is excluded. The range never stops a calculation. See [the duration model and measurement contract](docs/planning-duration.md).
|
|
|
|
## Optimization pipeline
|
|
|
|
The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization:
|
|
|
|
### Planner state machine
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
direction LR
|
|
|
|
state "Request snapshot" as Snapshot
|
|
state "Validate and normalize" as Validation
|
|
state "Prepare calendar across the horizon" as Preparation
|
|
state "Solve obligations jointly" as Obligations
|
|
state "Place at-risk balances" as AtRisk
|
|
state "Resolve ordered preferences and earned chains" as Preferences
|
|
state "Run lexicographic quality passes<br/>(complete holiday weeks, then non-working-day gain)" as Quality
|
|
|
|
[*] --> Snapshot
|
|
Snapshot --> Validation: canonical request
|
|
|
|
Validation --> InvalidInput: malformed input
|
|
Validation --> Preparation: valid input
|
|
Preparation --> Obligations: calendar ready
|
|
|
|
Obligations --> PlanningConflict: no joint solution
|
|
Obligations --> AtRisk: feasible solution
|
|
|
|
AtRisk --> Preferences: at-risk leave placed
|
|
Preferences --> Quality: ordered preferences resolved
|
|
Quality --> Plan: all quality passes complete
|
|
|
|
Snapshot --> PlanningIncomplete: worker unavailable
|
|
Validation --> PlanningIncomplete: interrupted
|
|
Preparation --> PlanningIncomplete: Cancel or worker failure
|
|
Obligations --> PlanningIncomplete: Cancel or interruption
|
|
AtRisk --> PlanningIncomplete: Cancel or interruption
|
|
Preferences --> PlanningIncomplete: Cancel or interruption
|
|
Quality --> PlanningIncomplete: Cancel or interruption
|
|
|
|
InvalidInput --> [*]
|
|
PlanningConflict --> [*]
|
|
PlanningIncomplete --> [*]
|
|
Plan --> [*]
|
|
```
|
|
|
|
`PlanningIncomplete` is reachable from every active calculation state. Cancellation retains the best available jointly valid candidate, even before a stage completes; completed stages also publish checkpoints for other interruption paths. An explicit technical deadline applies only after calendar preparation, while worker failure can interrupt any active phase. `PlanningConflict` is produced only when the obligations are valid but jointly infeasible. Outcome delivery does not wait for explanation completion.
|
|
|
|
1. validate and normalize the request;
|
|
2. find joint obligation assignments;
|
|
3. minimize unplaced at-risk leave;
|
|
4. satisfy each preference in user order, including shared placements and earned-entitlement chains;
|
|
5. maximize completed public-holiday week value (Wednesday holidays outrank Tuesday/Thursday, which outrank Monday/Friday);
|
|
6. maximize adjacent weekend and public-holiday gain;
|
|
7. minimize the longest work sequence;
|
|
8. minimize deficits caused by internal work sequences shorter than five days;
|
|
9. minimize supporting placements;
|
|
10. prefer earlier-expiring balances;
|
|
11. prefer earlier placement dates.
|
|
|
|
Every placement records its source balance and all rules it satisfies. Remaining balances are classified as unavoidable expiry, at risk, or deferred.
|
|
|
|
The calendar keeps plan details collapsed initially. Plan details navigate final placements chronologically, showing provenance, contributions, the first decisive objective, and the full ordered comparison including obligation validity. **Compare dates** opens an on-demand hypothetical comparison across the submitted horizon, preserving the selected balance, type, and provenance without changing the plan.
|
|
|
|
Incomplete outcomes explain the retained plan and unfinished phases; conflicts identify the reported obligations and bottleneck. Remaining balances retain their classification, validity, earned-entitlement source and concrete displayed-plan reasons, without historical attempt lists. Controls support keyboard navigation, labelled dates, live announcements, and text or icons alongside color.
|
|
|
|
## Technical stack
|
|
|
|
- Angular 22 standalone components with zoneless change detection
|
|
- Angular Material and CDK
|
|
- Signals for UI state and local storage for settings, edited leave balances, and rules
|
|
- TypeScript 6
|
|
- Web Workers for planner isolation
|
|
- `date-holidays` for the French public calendar
|
|
- Bun for dependency management and project scripts
|
|
- Vitest through the Angular test builder
|
|
- ESLint and Prettier
|
|
- Angular server build hosted by Express
|
|
- Gitea repository hosting and a multi-stage Docker image
|
|
|
|
The `/benchmark` route and planner core are lazy-loaded, so the normal application does not execute the benchmark or bundle the planner core into its main entry point.
|
|
|
|
The header opens one Settings dialog for planning dates, System / Light / Dark theme selection, English / French / German / Spanish language selection, and ISO week numbers. Changes apply and save on selection; closing the dialog retains them. System follows device appearance changes.
|
|
|
|
Saved balances, including an empty list, are restored before the initial calculation. When no readable saved list exists, CP, RTT, and Other start with random quantities from 10 through 20. These defaults remain unsaved until the first balance edit; subsequent edits save the entire list with identities, order, quantities, types, and calendar dates. Invalid saved balance data is cleared without removing unrelated settings or rules.
|
|
|
|
## Repository layout
|
|
|
|
```text
|
|
src/app/
|
|
├── benchmark/ # Lazy seven-scenario performance harness
|
|
├── components/ # Calendar, inputs, rules, and settings UI
|
|
├── services/
|
|
│ ├── leave-planner.service.ts # Canonical async planning boundary
|
|
│ ├── leave-planner.worker.ts # Browser worker adapter
|
|
│ ├── leave-planner.core.ts # Framework-free planning algorithm
|
|
│ ├── calendar-service.ts # Calendar projection and UI integration
|
|
│ ├── calendar-settings-service.ts # Persisted horizon and week-number settings
|
|
│ ├── planning-duration.service.ts # Indicative wait and session-local refinement
|
|
│ ├── user-input-service.ts # Saved balances and explicit calculation requests
|
|
│ └── planning-date.ts # Shared Europe/Paris date helpers
|
|
└── shared/ # Small shared UI utilities
|
|
|
|
src/i18n/ # English, French, German, and Spanish text
|
|
docs/adr/ # Architecture decisions
|
|
docs/benchmark/ # Reference-phone benchmark protocol
|
|
CONTEXT.md # Canonical domain language and rules
|
|
```
|
|
|
|
## Local development
|
|
|
|
Prerequisite: [Bun](https://bun.sh/). Dependency versions are locked in `bun.lock`.
|
|
|
|
```bash
|
|
bun install --frozen-lockfile
|
|
bun start
|
|
```
|
|
|
|
Open <http://localhost:4200>. The development server reloads when source files change.
|
|
|
|
### Useful commands
|
|
|
|
| Command | Purpose |
|
|
| ----------------------------------------- | ------------------------------------------------------------ |
|
|
| `bun start` | Generate the i18n registry and start the development server. |
|
|
| `bun run test --watch=false` | Run the unit and integration suite once. |
|
|
| `bun run lint` | Run ESLint. |
|
|
| `bun run format:check` | Check Prettier formatting. |
|
|
| `bunx tsc --noEmit -p tsconfig.app.json` | Type-check application code. |
|
|
| `bunx tsc --noEmit -p tsconfig.spec.json` | Type-check tests. |
|
|
| `bun run build` | Create the optimized browser and server bundles in `dist/`. |
|
|
| `bun run serve:ssr:vac-optimizer` | Serve a completed production build on port 4000. |
|
|
|
|
## Performance benchmark
|
|
|
|
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
|
|
|
|
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or multi-year duration ceiling is established; timing measurements are release evidence, not CI assertions.
|
|
|
|
## Docker
|
|
|
|
```bash
|
|
docker build -t vac-optimizer .
|
|
docker run --rm -p 4000:4000 vac-optimizer
|
|
```
|
|
|
|
The image builds the Angular application with Bun and Node, then runs the generated Express server with Bun. Set `PORT` to override the default port `4000`.
|
|
|
|
## Continuous integration
|
|
|
|
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
|
|
|
|
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, application/test type checks, Vitest coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
|
|
|
Functional tests use one worker and a 2 GiB Node heap. The separate benchmark job runs without coverage and keeps its five-minute protocol timeout. At the user's request, it has `continue-on-error: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. #78 must restore the benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
|
|
|
|
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
|
|
|
|
Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
|
|
|
|
### GitFlow and releases
|
|
|
|
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their PR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
|
|
|
|
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both PRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
|
|
|
|
Use Conventional Commits for new commits and PR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. The merge commit subject must preserve the validated PR title. Gitea supports a [Git-based merge-message template](https://docs.gitea.com/usage/issues-prs/merge-message-templates/) at `.gitea/default_merge_message/MERGE_TEMPLATE.md`, using `${PullRequestTitle}` and `${PullRequestReference}`. The committed template and a checked example expansion are documented in the [activation guide](docs/gitea-actions.md); legacy history is not rewritten.
|
|
|
|
The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-optimizer`:
|
|
|
|
| Trigger | Published image tags |
|
|
| --- | --- |
|
|
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
|
|
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
|
|
|
|
For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion begin after the owner provisions publisher credentials and validates publication permissions.
|
|
|
|
After owner activation, the PR into `main` has merged, and its full checks and SHA image publication have succeeded, tag the published main commit:
|
|
|
|
```bash
|
|
git fetch origin
|
|
git tag -a v1.6.0 origin/main -m "Release 1.6.0"
|
|
git push origin v1.6.0
|
|
```
|
|
|
|
The tag must match the tagged commit's `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main checks and image publication before tagging: promotion requires its `sha-<commit>` image. Tags do not increment the version automatically.
|
|
|
|
The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. After merge-message approval and a successful reviewed PR run, replace the migration gate with the reported pull-request context for the `required` job, as described in the activation guide. These are required settings; the migration does not establish that every setting has already been applied.
|
|
|
|
Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
|
|
|
|
Run `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
|
|
|
|
## Domain and design references
|
|
|
|
- [CONTEXT.md](CONTEXT.md) defines the project vocabulary and planner semantics.
|
|
- [ADR 0001](docs/adr/0001-ordered-heuristic-optimization.md) records the ordered heuristic decision.
|
|
- [ADR 0002](docs/adr/0002-personal-client-side-planner-for-now.md) records the client-side scope.
|
|
- [ADR 0003](docs/adr/0003-constraint-first-lexicographic-planning.md) records the constraint-first planner architecture.
|
|
- [ADR 0004](docs/adr/0004-stable-explanation-original-trace-counterfactual.md) separates the outcome, stable explanation and displayed-placement hypothetical analysis.
|
|
- [ADR 0005](docs/adr/0005-saved-planning-inputs-before-random-defaults.md) records saved-input restoration and random fallback behavior.
|
|
- [ADR 0006](docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md) records fixed saved horizons, cancellation, duration estimation, and the removal of the automatic deadline.
|