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.
The planner answers four distinct 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?
- Which alternatives were considered, and what would another date change?
Architecture
flowchart LR
Host["Express production host<br/>static and client assets"] -. serves .-> UI
subgraph Browser
UI["Angular standalone components"]
State["Signals and localStorage-backed settings"]
Facade["Planning calculation context<br/>immutable snapshot + async artifacts"]
Worker["Leave-planner Web Worker"]
Core["Synchronous planner core"]
Holidays["date-holidays<br/>French public calendar"]
UI <--> State
UI --> Facade
Facade --> Worker
Worker --> Core
Core --> Holidays
Worker -- "progress, checkpoints, outcome, then artifacts" --> 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 in a dedicated worker so the calendar remains responsive. The synchronous core is emitted as a separate worker/lazy chunk; the facade imports it only for validation or execution outside the normal browser-worker path.
Main execution path
CalendarYeartakes a snapshot of leave balances and ordered rules.CalendarServicesupplies the Europe/Paris planning horizon and opens one immutable planning calculation context.leave-planner.service.tscopies the request into a canonical transport shape and starts the worker.leave-planner.worker.tsruns the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and original trace.CalendarYearignores stale calculations, renders the selected dates, and reports remaining balances or errors.
Planning work has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is PlanningIncomplete with that incumbent; it is not reported as a constraint conflict. Explanation and trace delivery happen after the outcome and do not consume that planning budget.
Planning model
A request contains:
- a half-open planning horizon of at most 12 months;
- CP, RTT, or Other leave balances with quantities, availability dates, and expiry dates;
- ordered
TIME_WINDOWandIF_THENrules; - an optional 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 |
The deadline or worker 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.
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
stateDiagram-v2
direction LR
state "Request snapshot" as Snapshot
state "Validate and normalize" as Validation
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 --> Obligations: valid input
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
Obligations --> PlanningIncomplete: deadline
AtRisk --> PlanningIncomplete: deadline
Preferences --> PlanningIncomplete: deadline
Quality --> PlanningIncomplete: deadline
InvalidInput --> [*]
PlanningConflict --> [*]
PlanningIncomplete --> [*]
Plan --> [*]
PlanningIncomplete is reachable from every active calculation state. Once obligations are feasible, each completed stage publishes a valid checkpoint; an interruption returns the latest checkpoint as the incumbent. PlanningConflict is produced only when the obligations are valid but jointly infeasible.
- validate and normalize the request;
- find joint obligation assignments;
- minimize unplaced at-risk leave;
- satisfy each preference in user order, including shared placements and earned-entitlement chains;
- maximize completed public-holiday week value (Wednesday holidays outrank Tuesday/Thursday, which outrank Monday/Friday);
- maximize adjacent weekend and public-holiday gain;
- minimize the longest work sequence;
- minimize deficits caused by internal work sequences shorter than five days;
- minimize supporting placements;
- prefer earlier-expiring balances;
- 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.
Technical stack
- Angular 21 standalone components with zoneless change detection
- Angular Material and CDK
- Signals for UI state and local storage for persisted settings and rules
- TypeScript 6
- Web Workers for planner isolation
date-holidaysfor 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
- GitLab CI 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 synchronous planner into its main entry point.
Repository layout
src/app/
├── benchmark/ # Lazy five-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
│ └── 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. Dependency versions are locked in bun.lock.
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, and a loaded year. It reports preparation time, planning latency, separate trace-delivery latency, total wall time, outcome, last completed stage, placement count, determinism, and trace size/status.
The release protocol and reference-phone thresholds are documented in docs/benchmark/README.md. Phone timings are release evidence, not CI assertions.
Docker
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
GitLab CI runs independent validation, test, build, security, and image stages:
- ESLint, Prettier, and TypeScript checks;
- Vitest with coverage artifacts;
- an optimized production build;
- GitLab SAST and Trivy filesystem scanning;
- Docker image publication from the default branch.
Domain and design references
- CONTEXT.md defines the project vocabulary and planner semantics.
- ADR 0001 records the ordered heuristic decision.
- ADR 0002 records the client-side scope.
- ADR 0003 records the constraint-first planner architecture.
- ADR 0004 separates the outcome, stable explanation, original trace, and hypothetical analysis.