2026-10-10 14:56:48 +00:00
2026-10-02 15:05:30 +02:00
2025-10-23 00:21:20 +02:00
2025-10-31 01:49:42 +01:00
2026-10-01 05:02:09 +00:00
2026-10-02 15:05:30 +02:00
2025-12-06 22:19:44 +01:00
2025-12-06 22:19:44 +01:00

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 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?
  • 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 + 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: explanation + trace batches" --> Facade
        Facade -- "trace acknowledgements" --> Worker
        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. Trace delivery uses bounded batches with receiver acknowledgements; large calendars and traces can still cause UI pauses and substantial memory use, as recorded in the benchmark report.

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 original trace.
  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 explanation and complete original trace, 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.

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 trace capture, observer callbacks, and post-outcome artifact 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 trace delivery is excluded. The range never stops a calculation. See the duration model and measurement contract.

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 "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 or trace 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 and the advanced trace 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.

The trace explorer navigates original phases and atomic decisions, expands candidate evaluations, and shows before/after calendar states. Historical date statuses remain separate from hypothetical results. Incomplete outcomes explain the retained plan and unfinished phases; conflicts identify the reported obligations and bottleneck. Controls support keyboard navigation, labelled dates, live announcements, and text or icons alongside color. Only the latest trace is retained locally in memory.

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
  • 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 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

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. 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, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, separate trace-delivery latency, total wall time, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, determinism, and trace size/status.

The release protocol, recorded desktop measurements, and hardware limitations are documented in docs/benchmark/README.md. No reference-phone run or multi-year duration ceiling is established; timing measurements 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;
  • 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 required test job runs application tests with coverage, one worker and a 2 GiB Node heap limit. A separate automatic benchmark job uses the same environment outside coverage instrumentation, with the unchanged five-minute timeout and protocol assertions.

At the user's request, the benchmark job has allow_failure: true while #78 is open, so benchmark failure does not block functional delivery. #78 must restore the required benchmark gate after the protocol passes. A green pipeline during this exception does not establish benchmark acceptance.

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:

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

  • 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.
  • ADR 0005 records saved-input restoration and random fallback behavior.
  • ADR 0006 records fixed saved horizons, cancellation, duration estimation, and the removal of the automatic deadline.
S
Description
No description provided
Readme
4.4 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 71%
Python 19.5%
HTML 5.3%
SCSS 2.5%
Shell 0.8%
Other 0.8%