diff --git a/AGENTS.md b/AGENTS.md index 376ec47..279558b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,14 +9,14 @@ Repository guidance for coding agents and maintainers working in contracts. - `WIREFRAME.md` can include shipped and planned UI, but every section must clearly distinguish which is which. -- `js-src/` and `css-src/` are the editable sources. `static/sf/` contains the +- `ts-src/` and `css-src/` are the editable sources. `static/sf/` contains the generated bundled assets served to consumers. ## Current Release - Crate version: `0.6.5`. -- Versioned asset outputs are emitted as `static/sf/sf..css` and - `static/sf/sf..js`. +- Versioned asset outputs are emitted as `static/sf/sf..css`, + `static/sf/sf..js`, and `static/sf/sf..mjs`. ## Solver Lifecycle Contract @@ -72,3 +72,37 @@ Repository guidance for coding agents and maintainers working in - Prefer `make lint-frontend` for focused JavaScript linting, `make test-frontend` or `make test-browser` for focused frontend validation, and `make test-quick` or `make test` before release work. + +## Downstream Contract Gates + +Run downstream gates when a change touches the shipped global API, backend +contracts, solver lifecycle behavior, dense timeline behavior, generated +bundles, or the crate package surface used by application repos. + +- On the `refactor/migrate-to-typescript` integration branch, frontend tests may + exercise the ES module bundle, but must keep parity coverage for the freshly + rebuilt generated `static/sf/sf.js` global bundle. Keep this branch reliable + for downstream consumers, and avoid merging partial migration states directly + to `main`. + +- `solverforge-cli` is the scaffold/template gate. Its scalar and list + templates call `SF.createBackend({ baseUrl: '' })`, pass that adapter into + `SF.createSolver()`, mount `SF.rail.createTimeline()`, and depend on the + `solverforge-ui` crate assets. For local PR validation from the sibling CLI + repo, use: + `SF_USE_LOCAL_PATCHES=1 SF_ECOSYSTEM_ROOT=/srv/lab/dev/solverforge cargo test --test scaffold_test -- --nocapture`. +- `solverforge-usecases` is the app-runtime gate. `uc-deliveries`, `uc-fsr`, + and `uc-lessons` use the omitted-type HTTP backend shape + `SF.createBackend({ baseUrl: '' })`; `uc-hospital` uses the explicit Axum + shape `SF.createBackend({ type: 'axum', baseUrl: '' })` and exercises solver + controller and dense timeline behavior. +- To force the hospital frontend tests to load this local checkout instead of + the published crate, run them with a temporary Cargo wrapper that appends + `--config 'paths=["/srv/lab/dev/solverforge/solverforge-ui"]'` to + `cargo metadata`, then execute + `node --test tests/frontend/*.test.js` from + `/srv/lab/dev/solverforge/solverforge-usecases/uc-hospital`. +- If the checkout root differs from `/srv/lab/dev/solverforge`, adjust the + paths in the commands above. The gate is the same: downstream consumers must + resolve the local `solverforge-ui` crate and exercise their shipped + `SF.createBackend()`, `SF.createSolver()`, and timeline integrations. diff --git a/Cargo.toml b/Cargo.toml index 3413dd9..c2a1bbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["web-programming", "gui"] exclude = [ "screenshots/", "css-src/", - "js-src/", + "ts-src/", "WIREFRAME.md", "scripts/", ".versionrc.json", diff --git a/Makefile b/Makefile index de343b2..be32d1a 100644 --- a/Makefile +++ b/Makefile @@ -24,13 +24,14 @@ SEMVER_RE := '^[0-9]+\.[0-9]+\.[0-9]+$$' # ============== Asset Sources ============== CSS_SRC := $(sort $(wildcard css-src/*.css)) -JS_SRC := $(sort $(wildcard js-src/*.js)) +TS_SRC := $(sort $(shell find ts-src -type f \( -name '*.ts' -o -name '*.d.ts' \))) VERSIONED_CSS := static/sf/sf.$(VERSION).css VERSIONED_JS := static/sf/sf.$(VERSION).js +VERSIONED_MJS := static/sf/sf.$(VERSION).mjs # ============== Phony Targets ============== .PHONY: banner help assets build build-release test test-quick test-doc test-unit test-frontend test-browser test-one \ - lint lint-frontend fmt fmt-check clippy ci-local pre-release version package-verify browser-setup \ + lint lint-frontend fmt fmt-check clippy ci-local pre-release version package-verify browser-setup screenshots-update \ bump-version bump-patch bump-minor bump-major bump-dry release-tag demo-serve \ publish-dry publish clean watch @@ -44,7 +45,7 @@ banner: # ============== Asset Targets ============== -assets: static/sf/sf.css static/sf/sf.js $(VERSIONED_CSS) $(VERSIONED_JS) +assets: static/sf/sf.css static/sf/sf.js static/sf/sf.mjs $(VERSIONED_CSS) $(VERSIONED_JS) $(VERSIONED_MJS) static/sf/sf.css $(VERSIONED_CSS): $(CSS_SRC) @printf "$(PROGRESS) CSS sf.css ($(words $(CSS_SRC)) files)\n" @@ -52,12 +53,17 @@ static/sf/sf.css $(VERSIONED_CSS): $(CSS_SRC) @cp static/sf/sf.css $(VERSIONED_CSS) @printf "$(GREEN)$(CHECK) CSS bundled$(RESET)\n" -static/sf/sf.js $(VERSIONED_JS): $(JS_SRC) - @printf "$(PROGRESS) JS sf.js ($(words $(JS_SRC)) files)\n" - @cat $(JS_SRC) > static/sf/sf.js +static/sf/sf.js $(VERSIONED_JS): $(TS_SRC) + @printf "$(PROGRESS) JS sf.js (TypeScript bundle)\n" + @npm run build:iife @cp static/sf/sf.js $(VERSIONED_JS) @printf "$(GREEN)$(CHECK) JS bundled$(RESET)\n" +static/sf/sf.mjs $(VERSIONED_MJS): $(TS_SRC) + @printf "$(PROGRESS) JS sf.mjs (ES Module bundle)\n" + @npm run build:esm + @cp static/sf/sf.mjs $(VERSIONED_MJS) + @printf "$(GREEN)$(CHECK) ESM bundled$(RESET)\n" # ============== Build Targets ============== build: banner assets @@ -86,7 +92,7 @@ test: banner @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n" @printf "$(ARROW) $(BOLD)Running all tests...$(RESET)\n" @cargo test && \ - node --test tests/*.test.js && \ + $(MAKE) test-frontend --no-print-directory && \ node tests/demo-browser-check.js && \ printf "\n$(GREEN)$(CHECK) All tests passed$(RESET)\n\n" || \ (printf "\n$(RED)$(CROSS) Tests failed$(RESET)\n\n" && exit 1) @@ -103,10 +109,7 @@ test-quick: banner @cargo test --lib --quiet && \ printf "$(GREEN)$(CHECK) Unit tests passed$(RESET)\n\n" || \ (printf "$(RED)$(CROSS) Unit tests failed$(RESET)\n\n" && exit 1) - @printf "$(PROGRESS) Running frontend tests...\n" - @node --test tests/*.test.js && \ - printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n\n" || \ - (printf "$(RED)$(CROSS) Frontend tests failed$(RESET)\n\n" && exit 1) + @$(MAKE) test-frontend --no-print-directory @printf "$(PROGRESS) Running browser demo smoke tests...\n" @node tests/demo-browser-check.js && \ printf "$(GREEN)$(CHECK) Browser smoke tests passed$(RESET)\n\n" || \ @@ -124,7 +127,7 @@ test-unit: printf "$(GREEN)$(CHECK) Unit tests passed$(RESET)\n" || \ (printf "$(RED)$(CROSS) Unit tests failed$(RESET)\n" && exit 1) -test-frontend: +test-frontend: assets @printf "$(PROGRESS) Running frontend tests...\n" @node --test tests/*.test.js && \ printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" || \ @@ -136,11 +139,18 @@ test-browser: printf "$(GREEN)$(CHECK) Browser smoke tests passed$(RESET)\n" || \ (printf "$(RED)$(CROSS) Browser smoke tests failed$(RESET)\n" && exit 1) +screenshots-update: + @printf "$(PROGRESS) Refreshing tracked browser screenshot baselines...\n" + @node tests/demo-browser-check.js --update-screenshots && \ + printf "$(GREEN)$(CHECK) Screenshot baselines refreshed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Screenshot baseline refresh failed$(RESET)\n" && exit 1) + lint-frontend: - @printf "$(PROGRESS) Running frontend lint...\n" + @printf "$(PROGRESS) Running frontend lint and typecheck...\n" @npm run lint:frontend --silent && \ - printf "$(GREEN)$(CHECK) Frontend lint passed$(RESET)\n" || \ - (printf "$(RED)$(CROSS) Frontend lint failed$(RESET)\n" && exit 1) + npm run typecheck:frontend --silent && \ + printf "$(GREEN)$(CHECK) Frontend lint and typecheck passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Frontend lint and typecheck failed$(RESET)\n" && exit 1) browser-setup: @printf "$(PROGRESS) Installing browser test dependencies...\n" @@ -189,14 +199,14 @@ ci-local: banner @cargo build --quiet && printf "$(GREEN)$(CHECK) Build passed$(RESET)\n" @printf "$(PROGRESS) Step 4/9: Clippy...\n" @$(MAKE) clippy --no-print-directory - @printf "$(PROGRESS) Step 5/9: Frontend lint...\n" + @printf "$(PROGRESS) Step 5/9: Frontend lint and typecheck...\n" @$(MAKE) lint-frontend --no-print-directory @printf "$(PROGRESS) Step 6/9: Doctests...\n" @cargo test --doc --quiet && printf "$(GREEN)$(CHECK) Doctests passed$(RESET)\n" @printf "$(PROGRESS) Step 7/9: Unit tests...\n" @cargo test --lib --quiet && printf "$(GREEN)$(CHECK) Unit tests passed$(RESET)\n" @printf "$(PROGRESS) Step 8/9: Frontend tests...\n" - @node --test tests/*.test.js && printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" + @$(MAKE) test-frontend --no-print-directory @printf "$(PROGRESS) Step 9/9: Browser smoke tests...\n" @node tests/demo-browser-check.js && printf "$(GREEN)$(CHECK) Browser smoke tests passed$(RESET)\n" @printf "\n$(GREEN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n" @@ -226,7 +236,7 @@ bump-version: banner fi; \ printf "$(ARROW) Syncing version surfaces: v$$CURRENT_VERSION -> v$(VERSION)\n"; \ python3 scripts/sync-version.py "$$CURRENT_VERSION" "$(VERSION)"; \ - rm -f "static/sf/sf.$$CURRENT_VERSION.css" "static/sf/sf.$$CURRENT_VERSION.js"; \ + rm -f "static/sf/sf.$$CURRENT_VERSION.css" "static/sf/sf.$$CURRENT_VERSION.js" "static/sf/sf.$$CURRENT_VERSION.mjs"; \ $(MAKE) assets --no-print-directory; \ printf "$(GREEN)$(CHECK) Version updated to v$(VERSION)$(RESET)\n"; \ printf "$(GRAY)Changelog unchanged. Run 'make release-tag' separately when ready.$(RESET)\n" @@ -311,7 +321,7 @@ publish: banner clean: @printf "$(ARROW) Cleaning build artifacts...\n" @cargo clean - @rm -f static/sf/sf.css static/sf/sf.js static/sf/sf.*.css static/sf/sf.*.js + @rm -f static/sf/sf.css static/sf/sf.js static/sf/sf.mjs static/sf/sf.*.css static/sf/sf.*.js static/sf/sf.*.mjs @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n" # ============== Development ============== @@ -345,10 +355,11 @@ help: banner @/bin/echo -e " $(GREEN)make test-frontend$(RESET) - Run frontend Node tests" @/bin/echo -e " $(GREEN)make test-browser$(RESET) - Run browser demo smoke tests" @/bin/echo -e " $(GREEN)make test-one TEST=name$(RESET) - Run specific test with output" + @/bin/echo -e " $(GREEN)make screenshots-update$(RESET) - Refresh tracked browser screenshot baselines" @/bin/echo -e "" @/bin/echo -e "$(CYAN)$(BOLD)Lint & Format:$(RESET)" - @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check + clippy + frontend lint" - @/bin/echo -e " $(GREEN)make lint-frontend$(RESET) - Run ESLint on js-src/, tests/, and scripts/" + @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check + clippy + frontend lint and typecheck" + @/bin/echo -e " $(GREEN)make lint-frontend$(RESET) - Run ESLint on ts-src/, tests/, and scripts/" @/bin/echo -e " $(GREEN)make fmt$(RESET) - Format code" @/bin/echo -e " $(GREEN)make fmt-check$(RESET) - Check formatting" @/bin/echo -e " $(GREEN)make clippy$(RESET) - Run clippy lints" diff --git a/README.md b/README.md index 65b3f32..167aa03 100644 --- a/README.md +++ b/README.md @@ -15,27 +15,43 @@ let app = api::router(state) .fallback_service(ServeDir::new("static")); ``` +The library ships both classic (`sf.js`) and ES module (`sf.mjs`) bundles. + ```html + ``` +For module consumers, import the ES module bundle directly: + +```html + +``` + +The ES module bundle does not create `window.SF`; use the classic `sf.js` +bundle when you want the global `SF` API. + That's it. Every asset is compiled into the binary via `include_dir!`. ## Shipped vs Planned This repository keeps both shipped UI code and design exploration in the same tree. -- Shipped features are the ones implemented in `js-src/`, or exposed as documented optional modules under `static/sf/modules/`, and described in the API reference below. +- Shipped features are the ones implemented in `ts-src/`, or exposed as documented optional modules under `static/sf/modules/`, and described in the API reference below. - Planned or exploratory ideas may appear in CSS or wireframes before the public API is finished. Those should not be treated as supported integration surface until they are wired into a shipped asset and described in the README API reference. - When adding new surface area, update the JavaScript API, README, and runnable examples in the same change so the public contract stays explicit. For production caching, versioned bundle filenames are also emitted as -`/sf/sf..css` and `/sf/sf..js`. Those versioned -files are served with immutable caching, while the stable `sf.css` and `sf.js` -paths remain available for compatibility. +`/sf/sf..css`, `/sf/sf..js`, and `/sf/sf..mjs`. +Those versioned files are served with immutable caching, while the stable +`sf.css`, `sf.js`, and `sf.mjs` paths remain available for compatibility. ## Screenshots @@ -60,8 +76,13 @@ frontend tests for backend adapters, focused solver lifecycle suites, and core component rendering. Use `make test` for the full suite, `make test-quick` for Rust doctests, Rust unit tests, frontend Node coverage, and browser smoke tests, or `make test-frontend` when you only want the JavaScript suite. -Use `make lint-frontend` for ESLint on `js-src/`, `tests/`, and `scripts/`, or -`make lint` to run the Rust and JavaScript lint surfaces together. +Frontend test targets rebuild the generated `static/sf/sf.js` and +`static/sf/sf.mjs` bundles before running the Node coverage. During the +TypeScript migration branch, tests cover the generated module surface and keep +explicit parity checks for the shipped global bundle. Use `make lint-frontend` +for ESLint on `ts-src/`, `tests`, and +`scripts/` plus development-only TypeScript checking, or `make lint` to run the +Rust and JavaScript lint surfaces together. ## Quick Start @@ -173,7 +194,7 @@ Default content is always text-rendered. Use these fields only with trusted HTML | Factory | Returns | Description | |---------|---------|-------------| -| `SF.createBackend(config)` | Backend adapter | HTTP or Tauri IPC transport | +| `SF.createBackend(config)` | Backend adapter | Built-in HTTP or Tauri IPC transport, including adapter convenience methods | | `SF.createSolver(config)` | `{start, pause, resume, cancel, delete, getSnapshot, analyzeSnapshot, isRunning, getJobId, getLifecycleState, getSnapshotRevision}` | Shared job lifecycle orchestration around typed runtime events, exact paused snapshots, retained analysis, and terminal cleanup | Startup streams may begin with either a scored `progress` event or a scored @@ -571,7 +592,16 @@ Expects standard SolverForge REST endpoints: - `GET /demo-data/{name}` — load demo dataset Backend contract expectations: +- `SF.createBackend()` returns the fuller built-in adapter shape for HTTP and + Tauri transports, including convenience methods such as `getJob()`, + `getJobStatus()`, `getDemoData()`, and `listDemoData()`. +- Custom backends passed directly to `SF.createSolver()` use the narrower + lifecycle contract below; they do not need to implement built-in adapter + convenience methods. - Custom backends passed to `SF.createSolver()` must implement `createJob()`, `streamJobEvents()`, `getSnapshot()`, `analyzeSnapshot()`, `pauseJob()`, `resumeJob()`, `cancelJob()`, and `deleteJob()`. +- `SF.createBackend()` treats `type: 'tauri'` as the Tauri adapter and every + other type, including omitted type, `null`, `axum`, `fetch`, and `rails`, as + the HTTP adapter. - `createJob()` must resolve to a plain job id (non-empty string or finite number), or an object containing a scalar `id`, `jobId`, or `job_id`. Numeric `0` is a valid id and is normalized to `"0"`. - Non-scalar `createJob()` ids such as arrays, nested objects, booleans, `NaN`, infinities, and empty strings are rejected before the solver attaches streams or performs snapshot/analysis calls. - Built-in HTTP and Tauri adapters also accept `{ data: { id } }` response wrappers and normalize the id through the same scalar-only path. @@ -677,7 +707,7 @@ SF.map.decodePolyline('_p~iF~ps|U...'); // Google polyline algorithm solverforge-ui/ ├── Cargo.toml # 2 deps: axum + include_dir ├── src/lib.rs # routes() + asset serving -├── Makefile # bundles css-src/ + js-src/ into sf.css + sf.js +├── Makefile # bundles css-src/ + ts-src/ into sf.css + sf.js + sf.mjs ├── .github/workflows/ # CI, release, and publish automation ├── css-src/ # 20 CSS source files (numbered for concat order) │ ├── 00-tokens.css # design system variables @@ -700,27 +730,37 @@ solverforge-ui/ │ ├── 17-gantt-layout.css # split layout, grid table, view controls │ ├── 18-gantt-bars.css # Frappe bar overrides, pinned/highlighted bars │ └── 19-rail-timeline.css # canonical scheduling timeline -├── js-src/ # 17 JS source files -│ ├── 00-core.js # SF namespace, escHtml, el() -│ ├── 01-score.js # score parsing -│ ├── 02-colors.js # Tango palette + project colors -│ ├── 03-buttons.js # createButton() -│ ├── 04-header.js # createHeader() -│ ├── 05-statusbar.js # createStatusBar() -│ ├── 06-modal.js # createModal() -│ ├── 07-tabs.js # createTabs(), showTab() -│ ├── 08-table.js # createTable() -│ ├── 09-toast.js # showToast(), showError() -│ ├── 10-backend.js # createBackend() — axum/tauri/fetch -│ ├── 11-solver.js # createSolver() — SSE state machine -│ ├── 12-api-guide.js # createApiGuide() -│ ├── 13-rail.js # low-level rail header, cards, blocks, changeovers -│ ├── 13a-rail-timeline.js # canonical scheduling timeline -│ ├── 14-gantt.js # Frappe Gantt wrapper (split pane, grid, chart) -│ └── 15-footer.js # createFooter() +├── ts-src/ # TypeScript source files +│ ├── index.ts # Entry point, SF namespace exports +│ ├── global.d.ts # TypeScript type declarations +│ ├── core/ +│ │ └── index.ts # SF namespace, version, escHtml, el(), assert... +│ ├── utils/ +│ │ ├── score.ts # score parsing +│ │ └── colors.ts # Tango palette + project colors +│ ├── components/ +│ │ ├── buttons.ts # createButton() +│ │ ├── header.ts # createHeader() +│ │ ├── statusbar.ts # createStatusBar() +│ │ ├── modal.ts # createModal() +│ │ ├── tabs.ts # createTabs(), showTab() +│ │ ├── table.ts # createTable() +│ │ ├── toast.ts # showToast(), showError() +│ │ ├── api-guide.ts # createApiGuide() +│ │ └── footer.ts # createFooter() +│ ├── rail/ +│ │ ├── index.ts # low-level rail header, cards, blocks, changeovers +│ │ ├── card.ts # rail card factory +│ │ └── timeline.ts # canonical scheduling timeline +│ ├── gantt/ +│ │ └── gantt.ts # Frappe Gantt wrapper (split pane, grid, chart) +│ └── solver/ +│ ├── backend.ts # createBackend() — axum/tauri/fetch +│ └── solver.ts # createSolver() — SSE state machine └── static/sf/ # Embedded assets (include_dir!) ├── sf.css # concatenated from css-src/ - ├── sf.js # concatenated from js-src/ + ├── sf.js # bundled from ts-src/ + ├── sf.mjs # bundled from ts-src/ ├── img/ # SVG logo asset (ouroboros) ├── fonts/ # Space Grotesk + JetBrains Mono WOFF2 ├── modules/ # optional: sf-map.js/css @@ -753,7 +793,7 @@ ln -s vendor/solverforge-ui/static/sf public/sf ```bash # Edit source files vim css-src/06-buttons.css -vim js-src/03-buttons.js +vim ts-src/components/buttons.ts # Rebuild concatenated files make assets @@ -784,11 +824,12 @@ If you are cutting a release locally, make sure Node.js with `npx` is available Use `make package-verify` to inspect the exact crate contents that would be published. -The verification step checks that required bundled assets and crate metadata are present, and that development-only sources such as `css-src/`, `js-src/`, `scripts/`, and screenshots are not shipped in the published crate. +The verification step checks that required bundled assets and crate metadata are present, and that development-only sources such as `css-src/`, `ts-src/`, `scripts/`, and screenshots are not shipped in the published crate. Bundling writes both stable compatibility assets (`static/sf/sf.css`, -`static/sf/sf.js`) and versioned assets (`static/sf/sf..css`, -`static/sf/sf..js`). +`static/sf/sf.js`, `static/sf/sf.mjs`) and versioned assets +(`static/sf/sf..css`, `static/sf/sf..js`, +`static/sf/sf..mjs`). ## Demo Fixtures @@ -799,7 +840,8 @@ Runnable demo fixtures live in `demos/`. - `demos/timeline-dense.html` is the repeatable 28-day, 100-lane, 1500-item dense validation fixture for one scrollable body viewport. - `demos/rail.html` focuses on the low-level rail primitives: resource cards, blocks, gauges, and changeovers. - `make demo-serve` serves the repository at `http://localhost:8000/demos/` for local validation. -- `make test-browser` runs browser-level smoke tests against the shipped demo fixtures and refreshes the timeline acceptance screenshots in `screenshots/`. +- `make test-browser` runs browser-level smoke tests against the shipped demo fixtures and writes transient screenshots to `target/browser-smoke/screenshots/`. +- `make screenshots-update` explicitly refreshes the tracked timeline acceptance screenshots in `screenshots/`. - Run `make browser-setup` once on a machine to install the Playwright test dependency and Chromium. ## Acknowledgments diff --git a/WIREFRAME.md b/WIREFRAME.md index dff1031..c58ad2b 100644 --- a/WIREFRAME.md +++ b/WIREFRAME.md @@ -5,7 +5,7 @@ DOM structure, CSS classes, and how the JS factory wires them together. Sections in this document follow a simple staging rule: -- Shipped: backed by the current JavaScript API in `js-src/` and safe to document as supported behavior. +- Shipped: backed by the current JavaScript API in `ts-src/` and safe to document as supported behavior. - Planned: useful design or styling direction, but not part of the supported public API yet. --- @@ -450,6 +450,38 @@ Shipped detailed/viewport rules: --- +## Downstream Validation Gates (Shipped Integration) + +These gates are part of the shipped integration contract when a change touches +the global `SF` API, generated bundle, backend adapter shapes, solver lifecycle, +or dense timeline behavior. + +On the `refactor/migrate-to-typescript` branch, frontend tests intentionally +exercise the rebuilt generated `static/sf/sf.js` bundle until stable +TypeScript/source imports exist. This keeps downstream behavior tied to the +artifact consumers actually load while the migration remains off `main`. + +| Downstream project | Contract exercised | Local gate | +|---|---|---| +| `solverforge-cli` | Generated scalar/list apps use `SF.createBackend({ baseUrl: '' })`, `SF.createSolver()`, and `SF.rail.createTimeline()` from the crate bundle. | From `/srv/lab/dev/solverforge/solverforge-cli`: `SF_USE_LOCAL_PATCHES=1 SF_ECOSYSTEM_ROOT=/srv/lab/dev/solverforge cargo test --test scaffold_test -- --nocapture` | +| `solverforge-usecases/uc-hospital` | Explicit Axum backend config, retained solver controller behavior, terminal cleanup, dense hospital timeline rendering, and `/sf/sf.js` resolved through Cargo. | From `uc-hospital`, run `node --test tests/frontend/*.test.js` with `cargo metadata` forced through `--config 'paths=["/srv/lab/dev/solverforge/solverforge-ui"]'`. | +| `solverforge-usecases/uc-deliveries`, `uc-fsr`, `uc-lessons` | Omitted-type HTTP backend config and stock bundle integration. | Covered by this repo's omitted-type backend contract tests plus the usecase syntax/build gates when those apps are in scope. | + +One local-runtime form for the hospital gate: + +```bash +cd /srv/lab/dev/solverforge/solverforge-usecases/uc-hospital +tmpdir=$(mktemp -d) +cat > "$tmpdir/cargo" <<'SH' +#!/bin/sh +exec /usr/bin/cargo "$@" --config 'paths=["/srv/lab/dev/solverforge/solverforge-ui"]' +SH +chmod +x "$tmpdir/cargo" +PATH="$tmpdir:$PATH" node --test tests/frontend/*.test.js +``` + +--- + ## 15. Gantt Chart (Frappe Gantt) ``` diff --git a/demos/README.md b/demos/README.md index 30d7f86..705da1f 100644 --- a/demos/README.md +++ b/demos/README.md @@ -33,6 +33,13 @@ make test-browser ``` The automated check serves the repository locally, opens the runnable demo fixtures in Chromium, fails on page or script errors, and verifies that the primary shipped UI surfaces mount successfully. +It writes transient screenshots to `target/browser-smoke/screenshots/` by default so ordinary validation does not modify tracked baselines. + +Refresh the tracked timeline screenshot baselines only when accepting an intentional visual change: + +```bash +make screenshots-update +``` ## Coverage diff --git a/demos/full-surface-esm.html b/demos/full-surface-esm.html new file mode 100644 index 0000000..64f6c54 --- /dev/null +++ b/demos/full-surface-esm.html @@ -0,0 +1,476 @@ + + + + + + + solverforge-ui full surface demo esm + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demos/index.html b/demos/index.html index 91ea8ff..52552a5 100644 --- a/demos/index.html +++ b/demos/index.html @@ -1,5 +1,6 @@ + @@ -8,15 +9,49 @@ +
@@ -25,26 +60,37 @@

solverforge-ui demo fixtures

Full Surface

-

Header, status bar, tabs, table, modal, toast, API guide, the canonical scheduling timeline, footer, and Gantt in one runnable fixture.

+

Header, status bar, tabs, table, modal, toast, API guide, the canonical scheduling timeline, footer, and + Gantt in one runnable fixture.

Open full-surface demo
+
+

Full Surface ESM

+

Header, status bar, tabs, table, modal, toast, API guide, the canonical scheduling timeline, footer, and + Gantt in one runnable fixture.

+ Open full-surface ESM demo +

Scheduling Timeline

-

A focused 28-day hospital-like scheduling example built with SF.rail.createTimeline(), including additive overview summaries and inline expansion.

+

A focused 28-day hospital-like scheduling example built with SF.rail.createTimeline(), + including additive overview summaries and inline expansion.

Open timeline demo

Dense Timeline

-

The repeatable 28-day, 100-lane, 1500-item validation fixture used for dense schedule acceptance and screenshot capture.

+

The repeatable 28-day, 100-lane, 1500-item validation fixture used for dense schedule acceptance and + screenshot capture.

Open dense timeline demo

Rail Primitives

-

A smaller advanced fixture for validating low-level resource cards, gauges, changeovers, and positioned blocks.

+

A smaller advanced fixture for validating low-level resource cards, gauges, changeovers, and positioned + blocks.

Open rail demo
- + + \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index ae20f3e..ea9e9af 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,15 +1,34 @@ -const globals = require('globals'); +import globals from 'globals'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; -const correctnessRules = { +const baseRules = { 'no-dupe-keys': 'error', 'no-redeclare': 'error', 'no-unreachable': 'error', - 'no-undef': 'error', - 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], 'valid-typeof': 'error', }; -module.exports = [ +const tsRules = { + ...baseRules, + ...ts.configs.recommended.rules, + + // Disable JS version (important) + 'no-unused-vars': 'off', + + // Use TS-aware version + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrors: 'none', + }, + ], +}; + +export default [ { ignores: [ 'node_modules/**', @@ -17,11 +36,20 @@ module.exports = [ 'target/**', ], }, + + // ========================= + // TypeScript files + // ========================= { - files: ['js-src/**/*.js'], + files: ['ts-src/**/*.ts'], languageOptions: { ecmaVersion: 2021, - sourceType: 'script', + sourceType: 'module', + parser: tsParser, + parserOptions: { + ecmaVersion: 2021, + sourceType: 'module', + }, globals: { SF: 'readonly', Split: 'readonly', @@ -29,25 +57,31 @@ module.exports = [ ...globals.browser, }, }, - rules: correctnessRules, - }, - { - files: ['js-src/00-core.js'], - rules: { - 'no-redeclare': 'off', - 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none', varsIgnorePattern: '^SF$' }], + plugins: { + '@typescript-eslint': ts, }, + rules: tsRules, }, + + // ========================= + // JS tests & scripts (Node) + // ========================= { files: ['tests/**/*.js', 'scripts/**/*.js'], languageOptions: { ecmaVersion: 2021, - sourceType: 'commonjs', + sourceType: 'module', globals: { - ...globals.browser, ...globals.node, + ...globals.browser, }, }, - rules: correctnessRules, + rules: { + ...baseRules, + + // Re-enable JS-native checks in Node files + 'no-undef': 'error', + 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + }, }, ]; diff --git a/js-src/00-core.js b/js-src/00-core.js deleted file mode 100644 index 1a5369a..0000000 --- a/js-src/00-core.js +++ /dev/null @@ -1,85 +0,0 @@ -/* ============================================================================ - SolverForge UI — Core - ============================================================================ */ - -const SF = (function () { - 'use strict'; - - const sf = { version: '0.6.5' }; - var uidCounter = 0; - - /* ── Utilities ── */ - - sf.escHtml = function (str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }; - - sf.assert = function (cond, message) { - if (!cond) throw new Error('[SolverForge] ' + message); - }; - - sf.normalizeCreateJobId = function (raw) { - var value = raw; - if (value && typeof value === 'object') { - if (value.id != null) value = value.id; - else if (value.jobId != null) value = value.jobId; - else if (value.job_id != null) value = value.job_id; - else if (value.data && typeof value.data === 'object' && value.data.id != null) value = value.data.id; - else return ''; - } - - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim(); - return ''; - }; - - sf.el = function (tag, attrs) { - var children = Array.prototype.slice.call(arguments, 2); - var el = document.createElement(tag); - if (attrs) { - Object.keys(attrs).forEach(function (key) { - if (key === 'className') el.className = attrs[key]; - else if (key === 'style' && typeof attrs[key] === 'object') { - Object.assign(el.style, attrs[key]); - } - else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); - else if (key === 'dataset') Object.assign(el.dataset, attrs[key]); - else if (key === 'html') el.textContent = attrs[key]; - else if (key === 'unsafeHtml') el.innerHTML = attrs[key]; - else el.setAttribute(key, attrs[key]); - }); - } - children.forEach(function (child) { - if (child == null) return; - if (typeof child === 'string') el.appendChild(document.createTextNode(child)); - else if (child instanceof Node) el.appendChild(child); - }); - return el; - }; - - sf.uid = function (prefix) { - uidCounter += 1; - return (prefix || 'sf') + '-' + uidCounter; - }; - - sf.bindActivation = function (el, onActivate) { - if (!el || typeof onActivate !== 'function') return; - - function handleActivate(e) { - if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; - if (e.type === 'keydown') e.preventDefault(); - onActivate(e); - } - - el.addEventListener('click', handleActivate); - el.addEventListener('keydown', handleActivate); - }; - - if (typeof window !== 'undefined') window.SF = sf; - return sf; -})(); diff --git a/js-src/01-score.js b/js-src/01-score.js deleted file mode 100644 index d0e3684..0000000 --- a/js-src/01-score.js +++ /dev/null @@ -1,42 +0,0 @@ -/* ============================================================================ - SolverForge UI — Score Parsing - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.score = {}; - - sf.score.parseHard = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)hard/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.parseSoft = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)soft/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.parseMedium = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)medium/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.getComponents = function (scoreStr) { - return { - hard: sf.score.parseHard(scoreStr), - medium: sf.score.parseMedium(scoreStr), - soft: sf.score.parseSoft(scoreStr), - }; - }; - - sf.score.colorClass = function (scoreStr) { - var hard = sf.score.parseHard(scoreStr); - var soft = sf.score.parseSoft(scoreStr); - return hard < 0 ? 'score-red' : soft < 0 ? 'score-yellow' : 'score-green'; - }; - -})(SF); diff --git a/js-src/02-colors.js b/js-src/02-colors.js deleted file mode 100644 index 7dd83aa..0000000 --- a/js-src/02-colors.js +++ /dev/null @@ -1,71 +0,0 @@ -/* ============================================================================ - SolverForge UI — Color Factory - Tango palette + project color assignment. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8]; - var SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B]; - - var colorMap = {}; - var nextColorCount = 0; - - function buildPercentageColor(floor, ceil, pct) { - var red = (floor & 0xFF0000) + Math.floor(pct * ((ceil & 0xFF0000) - (floor & 0xFF0000))) & 0xFF0000; - var green = (floor & 0x00FF00) + Math.floor(pct * ((ceil & 0x00FF00) - (floor & 0x00FF00))) & 0x00FF00; - var blue = (floor & 0x0000FF) + Math.floor(pct * ((ceil & 0x0000FF) - (floor & 0x0000FF))) & 0x0000FF; - return red | green | blue; - } - - function nextColor() { - var colorIndex = nextColorCount % SEQUENCE_1.length; - var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); - var color; - if (shadeIndex === 0) { - color = SEQUENCE_1[colorIndex]; - } else if (shadeIndex === 1) { - color = SEQUENCE_2[colorIndex]; - } else { - shadeIndex -= 3; - var base = Math.floor((shadeIndex / 2) + 1); - var divisor = 2; - while (base >= divisor) divisor *= 2; - base = (base * 2) - divisor + 1; - color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); - } - nextColorCount++; - return '#' + color.toString(16).padStart(6, '0'); - } - - sf.colors = {}; - - sf.colors.pick = function (key) { - if (colorMap[key] !== undefined) return colorMap[key]; - var c = nextColor(); - colorMap[key] = c; - return c; - }; - - sf.colors.reset = function () { - colorMap = {}; - nextColorCount = 0; - }; - - var PROJECT_COLORS = [ - { main: '#10b981', dark: '#047857', light: 'rgba(16,185,129,0.15)' }, - { main: '#3b82f6', dark: '#1d4ed8', light: 'rgba(59,130,246,0.15)' }, - { main: '#8b5cf6', dark: '#6d28d9', light: 'rgba(139,92,246,0.15)' }, - { main: '#f59e0b', dark: '#b45309', light: 'rgba(245,158,11,0.15)' }, - { main: '#ec4899', dark: '#be185d', light: 'rgba(236,72,153,0.15)' }, - { main: '#06b6d4', dark: '#0e7490', light: 'rgba(6,182,212,0.15)' }, - { main: '#f43f5e', dark: '#be123c', light: 'rgba(244,63,94,0.15)' }, - { main: '#84cc16', dark: '#4d7c0f', light: 'rgba(132,204,22,0.15)' }, - ]; - - sf.colors.project = function (index) { - return PROJECT_COLORS[index % PROJECT_COLORS.length]; - }; - -})(SF); diff --git a/js-src/04-header.js b/js-src/04-header.js deleted file mode 100644 index 7bce4c9..0000000 --- a/js-src/04-header.js +++ /dev/null @@ -1,169 +0,0 @@ -/* ============================================================================ - SolverForge UI — Header Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createHeader = function (config) { - sf.assert(config, 'createHeader(config) requires a configuration object'); - - var header = sf.el('header', { className: 'sf-header' }); - var controls = { - actions: null, - spinner: null, - solveBtn: null, - pauseBtn: null, - resumeBtn: null, - cancelBtn: null, - analyzeBtn: null, - nav: null, - }; - - // Logo - if (config.logo) { - var logo = sf.el('img', { - className: 'sf-header-logo', - src: config.logo, - alt: 'Logo', - }); - header.appendChild(logo); - } - - // Brand text - var brand = sf.el('div', { className: 'sf-header-brand' }); - if (config.title) { - brand.appendChild(sf.el('div', { className: 'sf-header-title' }, config.title)); - } - if (config.subtitle) { - brand.appendChild(sf.el('div', { className: 'sf-header-subtitle' }, config.subtitle)); - } - header.appendChild(brand); - - // Nav tabs - if (config.tabs && config.tabs.length > 0) { - sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); - var nav = sf.el('nav', { className: 'sf-header-nav' }); - controls.nav = nav; - config.tabs.forEach(function (tab) { - sf.assert(tab && tab.id, 'createHeader tab entries require an id'); - sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); - var btn = sf.el('button', { - className: 'sf-nav-btn' + (tab.active ? ' active' : ''), - role: 'tab', - 'aria-selected': !!tab.active, - tabIndex: 0, - dataset: { tab: tab.id }, - onKeyDown: function (e) { - if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; - var buttons = nav.querySelectorAll('.sf-nav-btn'); - var list = Array.prototype.slice.call(buttons); - var nextIndex = e.key === 'ArrowRight' - ? (list.indexOf(btn) + 1) % list.length - : (list.length + list.indexOf(btn) - 1) % list.length; - var next = list[nextIndex]; - if (next && next.focus) next.focus(); - }, - onClick: function () { - nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); - btn.classList.add('active'); - nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { - b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); - }); - if (config.onTabChange) config.onTabChange(tab.id); - }, - }); - if (tab.icon) { - btn.appendChild(sf.el('i', { className: 'fa-solid ' + tab.icon })); - } - btn.appendChild(document.createTextNode(tab.label)); - nav.appendChild(btn); - }); - header.appendChild(nav); - } - - // Action buttons - if (config.actions) { - sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); - sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); - sf.assert(!config.actions.onPause || typeof config.actions.onPause === 'function', 'createHeader(config.actions.onPause) must be a function'); - sf.assert(!config.actions.onResume || typeof config.actions.onResume === 'function', 'createHeader(config.actions.onResume) must be a function'); - sf.assert(!config.actions.onCancel || typeof config.actions.onCancel === 'function', 'createHeader(config.actions.onCancel) must be a function'); - sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); - sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); - - var actions = sf.el('div', { className: 'sf-header-actions' }); - controls.actions = actions; - - // Spinner - var spinner = sf.el('div', { className: 'sf-solving-spinner' }); - controls.spinner = spinner; - actions.appendChild(spinner); - - if (config.actions.onSolve) { - var solveBtn = sf.createButton({ - text: 'Solve', - variant: 'success', - icon: 'fa-play', - onClick: config.actions.onSolve, - }); - controls.solveBtn = solveBtn; - actions.appendChild(solveBtn); - } - - if (config.actions.onPause) { - var pauseBtn = sf.createButton({ - text: 'Pause', - variant: 'default', - icon: 'fa-pause', - onClick: config.actions.onPause, - }); - pauseBtn.style.display = 'none'; - controls.pauseBtn = pauseBtn; - actions.appendChild(pauseBtn); - } - - if (config.actions.onResume) { - var resumeBtn = sf.createButton({ - text: 'Resume', - variant: 'primary', - icon: 'fa-play', - onClick: config.actions.onResume, - }); - resumeBtn.style.display = 'none'; - controls.resumeBtn = resumeBtn; - actions.appendChild(resumeBtn); - } - - if (config.actions.onCancel) { - var cancelBtn = sf.createButton({ - text: 'Stop', - variant: 'danger', - icon: 'fa-stop', - onClick: config.actions.onCancel, - }); - cancelBtn.style.display = 'none'; - controls.cancelBtn = cancelBtn; - actions.appendChild(cancelBtn); - } - - if (config.actions.onAnalyze) { - var analyzeBtn = sf.createButton({ - variant: 'ghost', - icon: 'fa-chart-bar', - circle: true, - tooltip: 'Score Analysis', - onClick: config.actions.onAnalyze, - }); - controls.analyzeBtn = analyzeBtn; - actions.appendChild(analyzeBtn); - } - - header.appendChild(actions); - } - - header.sfControls = controls; - return header; - }; - -})(SF); diff --git a/js-src/05-statusbar.js b/js-src/05-statusbar.js deleted file mode 100644 index 34da6f0..0000000 --- a/js-src/05-statusbar.js +++ /dev/null @@ -1,230 +0,0 @@ -/* ============================================================================ - SolverForge UI — Status Bar Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createStatusBar = function (config) { - var bar = sf.el('div', { className: 'sf-statusbar' }); - var lastScore = null; - var controls = null; - - // Score display - var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); - bar.appendChild(scoreEl); - - // Separator - bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); - - // Constraint dots container - var dotsContainer = sf.el('div', { className: 'sf-statusbar-constraints' }); - bar.appendChild(dotsContainer); - - // Separator + moves display - var movesSep = sf.el('span', { className: 'sf-statusbar-sep' }, '|'); - movesSep.style.display = 'none'; - bar.appendChild(movesSep); - - var movesEl = sf.el('span'); - movesEl.style.display = 'none'; - bar.appendChild(movesEl); - - // Separator + status text - bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); - var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); - bar.appendChild(statusEl); - - // Build initial constraint dots - if (config && config.constraints) { - buildDots(dotsContainer, config.constraints, config.onConstraintClick); - } - - var api = { el: bar }; - - api.bindHeader = function (header) { - controls = header && header.sfControls ? header.sfControls : null; - return api; - }; - - api.updateScore = function (scoreStr) { - if (scoreStr && scoreStr !== lastScore) { - scoreEl.textContent = scoreStr; - var colorClass = sf.score.colorClass(scoreStr); - scoreEl.classList.remove('improved', 'score-green', 'score-red', 'score-yellow'); - scoreEl.classList.add(colorClass); - void scoreEl.offsetWidth; - scoreEl.classList.add('improved'); - lastScore = scoreStr; - } else if (!scoreStr) { - scoreEl.textContent = '\u2014'; - scoreEl.classList.remove('score-green', 'score-red', 'score-yellow', 'improved'); - lastScore = null; - } - }; - - api.setLifecycleState = function (state) { - var normalized = normalizeLifecycleState(state); - var solveBtn = controls && controls.solveBtn; - var pauseBtn = controls && controls.pauseBtn; - var resumeBtn = controls && controls.resumeBtn; - var cancelBtn = controls && controls.cancelBtn; - var spinner = controls && controls.spinner; - - if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? '' : 'none'; - if (pauseBtn) { - pauseBtn.style.display = shouldShowPause(normalized) ? '' : 'none'; - pauseBtn.disabled = normalized === 'PAUSE_REQUESTED'; - } - if (resumeBtn) { - resumeBtn.style.display = normalized === 'PAUSED' ? '' : 'none'; - resumeBtn.disabled = false; - } - if (cancelBtn) { - cancelBtn.style.display = shouldShowCancel(normalized) ? '' : 'none'; - cancelBtn.disabled = false; - } - if (spinner) spinner.classList.toggle('active', shouldSpin(normalized)); - - statusEl.textContent = lifecycleLabel(normalized); - statusEl.style.color = isActiveLifecycle(normalized) - ? 'var(--sf-emerald-600)' - : normalized === 'FAILED' - ? 'var(--sf-red-600)' - : normalized === 'CANCELLED' - ? 'var(--sf-amber-700)' - : 'var(--sf-gray-500)'; - }; - - api.setSolving = function (solving) { - api.setLifecycleState(solving ? 'SOLVING' : 'IDLE'); - }; - - api.updateMoves = function (mps) { - if (mps != null && mps > 0) { - movesEl.textContent = mps.toLocaleString() + ' moves/s'; - movesEl.style.display = ''; - movesSep.style.display = ''; - } else { - movesEl.style.display = 'none'; - movesSep.style.display = 'none'; - } - }; - - api.updateConstraintDots = function (constraints) { - buildDots(dotsContainer, constraints, config && config.onConstraintClick); - }; - - api.colorDotsByScore = function (scoreStr) { - var hard = sf.score.parseHard(scoreStr); - var soft = sf.score.parseSoft(scoreStr); - dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot) { - var isHard = dot.dataset.type === 'hard'; - dot.classList.toggle('violated', isHard && hard < 0); - dot.classList.toggle('violated-soft', !isHard && soft < 0); - }); - }; - - api.colorDotsFromAnalysis = function (constraints) { - if (!constraints || constraints.length === 0) return; - buildDots(dotsContainer, constraints, config && config.onConstraintClick); - dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot, i) { - var c = constraints[i]; - if (!dot) return; - var isHard = c.type === 'hard'; - var scoreVal = isHard ? sf.score.parseHard(c.score) : sf.score.parseSoft(c.score); - var violated = scoreVal < 0; - dot.classList.toggle('violated', isHard && violated); - dot.classList.toggle('violated-soft', !isHard && violated); - }); - }; - - if (config && config.header) { - api.bindHeader(config.header); - } - - api.setLifecycleState('IDLE'); - - return api; - }; - - function buildDots(container, constraints, onClick) { - container.innerHTML = ''; - if (!constraints) return; - constraints.forEach(function (c, i) { - var dot = sf.el('div', { - className: 'sf-constraint-dot', - id: 'sf-cdot-' + i, - title: c.name || ('Constraint ' + i), - role: onClick ? 'button' : null, - tabIndex: onClick ? '0' : null, - 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, - dataset: { type: c.type || 'hard', index: String(i) }, - }); - if (onClick) { - dot.style.cursor = 'pointer'; - sf.bindActivation(dot, function () { onClick(i); }); - } - container.appendChild(dot); - }); - } - - function normalizeLifecycleState(value) { - if (typeof value !== 'string' || !value.trim()) return 'IDLE'; - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toUpperCase(); - } - - function shouldShowSolve(state) { - return state === 'IDLE' - || state === 'COMPLETED' - || state === 'CANCELLED' - || state === 'FAILED' - || state === 'TERMINATED_BY_CONFIG'; - } - - function shouldShowPause(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED'; - } - - function shouldShowCancel(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'PAUSED' - || state === 'RESUMING' - || state === 'CANCELLING'; - } - - function shouldSpin(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'RESUMING' - || state === 'CANCELLING'; - } - - function isActiveLifecycle(state) { - return shouldSpin(state); - } - - function lifecycleLabel(state) { - if (state === 'STARTING') return 'Starting...'; - if (state === 'SOLVING') return 'Solving...'; - if (state === 'PAUSE_REQUESTED') return 'Pause requested...'; - if (state === 'PAUSED') return 'Paused'; - if (state === 'RESUMING') return 'Resuming...'; - if (state === 'CANCELLING') return 'Cancelling...'; - if (state === 'COMPLETED') return 'Completed'; - if (state === 'CANCELLED') return 'Cancelled'; - if (state === 'FAILED') return 'Failed'; - if (state === 'TERMINATED_BY_CONFIG') return 'Completed'; - return 'Ready'; - } - -})(SF); diff --git a/js-src/06-modal.js b/js-src/06-modal.js deleted file mode 100644 index 496e1ba..0000000 --- a/js-src/06-modal.js +++ /dev/null @@ -1,108 +0,0 @@ -/* ============================================================================ - SolverForge UI — Modal Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createModal = function (config) { - sf.assert(config, 'createModal(config) requires a configuration object'); - sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); - - var overlay = sf.el('div', { className: 'sf-modal-overlay' }); - var dialogId = sf.uid('sf-modal'); - var dialog = sf.el('div', { - className: 'sf-modal', - id: dialogId, - role: 'dialog', - 'aria-modal': 'true', - 'aria-labelledby': dialogId + '-title', - }); - var body = sf.el('div', { className: 'sf-modal-body' }); - - // Header - var header = sf.el('div', { className: 'sf-modal-header' }); - var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); - header.appendChild(titleEl); - - var closeBtn = sf.el('button', { - className: 'sf-modal-close', - 'aria-label': 'Close modal', - onClick: function () { api.close(); }, - }, '×'); - header.appendChild(closeBtn); - - dialog.appendChild(header); - - // Body - setBodyContent(body, config.body, config.unsafeBody); - dialog.appendChild(body); - - // Footer - if (config.footer) { - var footer = sf.el('div', { className: 'sf-modal-footer' }); - config.footer.forEach(function (child) { - footer.appendChild(child); - }); - dialog.appendChild(footer); - } - - overlay.appendChild(dialog); - - var previousFocus = null; - - // Close on backdrop click - overlay.addEventListener('click', function (e) { - if (e.target === overlay) api.close(); - }); - - // Close on Escape - function onKeyDown(e) { - if (e.key === 'Escape') api.close(); - } - - var api = { el: overlay, body: body }; - - api.open = function () { - previousFocus = document.activeElement; - document.body.appendChild(overlay); - if (closeBtn.focus) closeBtn.focus(); - overlay.classList.add('open'); - document.addEventListener('keydown', onKeyDown); - }; - - api.close = function () { - overlay.classList.remove('open'); - document.removeEventListener('keydown', onKeyDown); - if (overlay.parentNode) overlay.parentNode.removeChild(overlay); - if (previousFocus && previousFocus.focus) previousFocus.focus(); - if (config.onClose) config.onClose(); - }; - - api.setBody = function (content) { - setBodyContent(body, content); - }; - - if (config.width) { - dialog.style.maxWidth = config.width; - } - - return api; - }; - - function setBodyContent(target, content, explicitUnsafeHtml) { - target.textContent = ''; - if (explicitUnsafeHtml != null) { - target.innerHTML = explicitUnsafeHtml; - } else if (typeof content === 'string') { - target.textContent = content; - } else if (content && content.unsafeBody) { - target.innerHTML = content.unsafeBody; - } else if (content && content.unsafeHtml) { - target.innerHTML = content.unsafeHtml; - } else if (content instanceof Node) { - target.appendChild(content); - } - } - -})(SF); diff --git a/js-src/09-toast.js b/js-src/09-toast.js deleted file mode 100644 index e79f722..0000000 --- a/js-src/09-toast.js +++ /dev/null @@ -1,69 +0,0 @@ -/* ============================================================================ - SolverForge UI — Toast Notifications - jQuery-free replacement for showError/showSimpleError. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var container = null; - - function ensureContainer() { - if (container && document.body.contains(container)) return; - container = sf.el('div', { className: 'sf-toast-container' }); - document.body.appendChild(container); - } - - sf.showToast = function (config) { - sf.assert(config, 'showToast(config) requires a configuration object'); - - ensureContainer(); - - var variant = config.variant || 'danger'; - var toast = sf.el('div', { - className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', - role: 'status', - 'aria-live': 'polite', - }); - - var msg = sf.el('div', { className: 'sf-toast-message' }); - if (config.title) { - msg.appendChild(sf.el('div', { className: 'sf-toast-title' }, config.title)); - } - if (config.message) { - msg.appendChild(sf.el('div', null, config.message)); - } - if (config.detail) { - var pre = sf.el('pre', { style: { margin: '4px 0 0', fontSize: '11px', whiteSpace: 'pre-wrap' } }); - pre.appendChild(sf.el('code', null, config.detail)); - msg.appendChild(pre); - } - toast.appendChild(msg); - - var closeBtn = sf.el('button', { - className: 'sf-toast-close', - 'aria-label': 'Dismiss toast', - onClick: function () { dismiss(); }, - }, '×'); - toast.appendChild(closeBtn); - - container.appendChild(toast); - - var delay = config.delay || 10000; - var timer = setTimeout(dismiss, delay); - - function dismiss() { - clearTimeout(timer); - toast.classList.remove('sf-toast-enter'); - toast.classList.add('sf-toast-exit'); - setTimeout(function () { - if (toast.parentNode) toast.parentNode.removeChild(toast); - }, 200); - } - }; - - sf.showError = function (title, detail) { - sf.showToast({ title: 'Error', message: title, detail: detail, variant: 'danger', delay: 30000 }); - }; - -})(SF); diff --git a/js-src/10-backend.js b/js-src/10-backend.js deleted file mode 100644 index 2b2a3fb..0000000 --- a/js-src/10-backend.js +++ /dev/null @@ -1,197 +0,0 @@ -/* ============================================================================ - SolverForge UI — Backend Adapters - Pluggable transport: Axum, Tauri IPC, generic fetch. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createBackend = function (config) { - config = config || {}; - var type = config.type || 'axum'; - if (type === 'tauri') return createTauriBackend(config); - return createHttpBackend(config); - }; - - function resolveJobId(raw) { - return sf.normalizeCreateJobId(raw); - } - - function resolveEventJobId(payload) { - if (!payload || typeof payload !== 'object') return ''; - if (payload.jobId != null) return String(payload.jobId).trim(); - if (payload.job_id != null) return String(payload.job_id).trim(); - if (payload.id != null) return String(payload.id).trim(); - if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); - if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); - return ''; - } - - function withSnapshotRevision(path, snapshotRevision) { - if (snapshotRevision == null || snapshotRevision === '') return path; - return path + '?snapshot_revision=' + encodeURIComponent(String(snapshotRevision)); - } - - /* ── HTTP backend (Axum, Rails, anything) ── */ - - function createHttpBackend(config) { - var baseUrl = config.baseUrl || ''; - var jobsPath = config.jobsPath || '/jobs'; - var demoDataPath = config.demoDataPath || '/demo-data'; - var extraHeaders = config.headers || {}; - - function headers(extra) { - var h = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders, extra || {}); - return h; - } - - function createRequestError(method, path, res) { - var err = new Error(res.status + ' ' + res.statusText); - err.status = res.status; - err.statusText = res.statusText; - err.method = method; - err.path = path; - err.url = baseUrl + path; - return err; - } - - function request(method, path, body) { - var opts = { method: method, headers: headers() }; - if (body !== undefined) opts.body = JSON.stringify(body); - return fetch(baseUrl + path, opts).then(function (res) { - if (!res.ok) throw createRequestError(method, path, res); - var ct = res.headers.get('content-type') || ''; - return ct.indexOf('json') !== -1 ? res.json() : res.text(); - }); - } - - return { - createJob: function (data) { - return request('POST', jobsPath, data).then(resolveJobId); - }, - getJob: function (id) { - return request('GET', jobsPath + '/' + id); - }, - getJobStatus: function (id) { - return request('GET', jobsPath + '/' + id + '/status'); - }, - getSnapshot: function (id, snapshotRevision) { - return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/snapshot', snapshotRevision)); - }, - analyzeSnapshot: function (id, snapshotRevision) { - return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/analysis', snapshotRevision)); - }, - pauseJob: function (id) { - return request('POST', jobsPath + '/' + id + '/pause'); - }, - resumeJob: function (id) { - return request('POST', jobsPath + '/' + id + '/resume'); - }, - cancelJob: function (id) { - return request('POST', jobsPath + '/' + id + '/cancel'); - }, - deleteJob: function (id) { - return request('DELETE', jobsPath + '/' + id); - }, - getDemoData: function (name) { - return request('GET', demoDataPath + '/' + (name || 'STANDARD')); - }, - listDemoData: function () { - return request('GET', demoDataPath); - }, - streamJobEvents: function (id, onMessage, onError) { - var url = baseUrl + jobsPath + '/' + id + '/events'; - var es = new EventSource(url); - var closed = false; - es.onmessage = function (e) { - try { onMessage(JSON.parse(e.data)); } catch (_) {} - }; - es.onerror = function () { - if (closed || !onError) return; - if (typeof EventSource !== 'undefined' && es.readyState === EventSource.CLOSED) { - onError(createSseClosedError(url)); - } - }; - return function close() { - closed = true; - es.onmessage = null; - es.onerror = null; - es.close(); - }; - }, - }; - } - - /* ── Tauri IPC backend ── */ - - function createTauriBackend(config) { - sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); - sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); - sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); - - var invoke = config.invoke; - var listen = config.listen; - var commands = config.commands || {}; - var eventName = config.eventName || 'solver-update'; - - return { - createJob: function (data) { - return invoke(commands.createJob || 'create_job', { request: data }).then(resolveJobId); - }, - getJob: function (id) { - return invoke(commands.getJob || 'get_job', { id: id }); - }, - getJobStatus: function (id) { - return invoke(commands.getJobStatus || 'get_job_status', { id: id }); - }, - getSnapshot: function (id, snapshotRevision) { - var payload = { id: id }; - if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision; - return invoke(commands.getSnapshot || 'get_snapshot', payload); - }, - analyzeSnapshot: function (id, snapshotRevision) { - var payload = { id: id }; - if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision; - return invoke(commands.analyzeSnapshot || 'analyze_snapshot', payload); - }, - pauseJob: function (id) { - return invoke(commands.pauseJob || 'pause_job', { id: id }); - }, - resumeJob: function (id) { - return invoke(commands.resumeJob || 'resume_job', { id: id }); - }, - cancelJob: function (id) { - return invoke(commands.cancelJob || 'cancel_job', { id: id }); - }, - deleteJob: function (id) { - return invoke(commands.deleteJob || 'delete_job', { id: id }); - }, - getDemoData: function (name) { - return invoke(commands.demoData || 'demo_seed', { name: name }); - }, - listDemoData: function () { - return Promise.resolve([]); - }, - streamJobEvents: function (id, onMessage) { - var targetId = String(id); - var unlisten = null; - listen(eventName, function (event) { - var payload = event && event.payload ? event.payload : {}; - var payloadId = resolveEventJobId(payload); - if (payloadId && payloadId !== targetId) return; - onMessage(payload); - }).then(function (fn) { unlisten = fn; }); - return function close() { if (unlisten) unlisten(); }; - }, - }; - } - - function createSseClosedError(url) { - var err = new Error('Event stream closed for ' + url); - err.code = 'SSE_CLOSED'; - err.transport = 'sse'; - err.url = url; - return err; - } - -})(SF); diff --git a/js-src/11-solver.js b/js-src/11-solver.js deleted file mode 100644 index c95fc3f..0000000 --- a/js-src/11-solver.js +++ /dev/null @@ -1,874 +0,0 @@ -/* ============================================================================ - SolverForge UI — Solver Lifecycle - Shared job orchestration for start, pause, resume, cancel, and snapshots. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createSolver = function (config) { - sf.assert(config, 'createSolver(config) requires a configuration object'); - sf.assert(config.backend, 'createSolver(config.backend) is required'); - sf.assert(hasFunction(config.backend, 'createJob'), 'createSolver(config.backend.createJob) must be a function'); - sf.assert(hasFunction(config.backend, 'getSnapshot'), 'createSolver(config.backend.getSnapshot) must be a function'); - sf.assert(hasFunction(config.backend, 'analyzeSnapshot'), 'createSolver(config.backend.analyzeSnapshot) must be a function'); - sf.assert(hasFunction(config.backend, 'pauseJob'), 'createSolver(config.backend.pauseJob) must be a function'); - sf.assert(hasFunction(config.backend, 'resumeJob'), 'createSolver(config.backend.resumeJob) must be a function'); - sf.assert(hasFunction(config.backend, 'cancelJob'), 'createSolver(config.backend.cancelJob) must be a function'); - sf.assert(hasFunction(config.backend, 'deleteJob'), 'createSolver(config.backend.deleteJob) must be a function'); - sf.assert(hasFunction(config.backend, 'streamJobEvents'), 'createSolver(config.backend.streamJobEvents) must be a function'); - sf.assert(!config.onProgress || typeof config.onProgress === 'function', 'createSolver(config.onProgress) must be a function'); - sf.assert(!config.onSolution || typeof config.onSolution === 'function', 'createSolver(config.onSolution) must be a function'); - sf.assert(!config.onPauseRequested || typeof config.onPauseRequested === 'function', 'createSolver(config.onPauseRequested) must be a function'); - sf.assert(!config.onPaused || typeof config.onPaused === 'function', 'createSolver(config.onPaused) must be a function'); - sf.assert(!config.onResumed || typeof config.onResumed === 'function', 'createSolver(config.onResumed) must be a function'); - sf.assert(!config.onCancelled || typeof config.onCancelled === 'function', 'createSolver(config.onCancelled) must be a function'); - sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); - sf.assert(!config.onFailure || typeof config.onFailure === 'function', 'createSolver(config.onFailure) must be a function'); - sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); - sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); - - var backend = config.backend; - var statusBar = config.statusBar; - var closeStream = null; - var activeJobId = null; - var retainedJobId = null; - var lifecycleState = 'IDLE'; - var phase = 'idle'; - var runToken = 0; - var lastSnapshotRevision = null; - var lastMeta = null; - var lastNotifiedError = null; - var queuedAction = null; - var pendingPause = null; - var pendingResume = null; - var pendingCancel = null; - var terminalSync = null; - - var api = {}; - - api.start = function (data) { - if (retainedJobId) { - return Promise.reject(new Error('Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first')); - } - if (phase !== 'idle') return Promise.resolve(); - - resetForStart(); - phase = 'starting'; - runToken += 1; - applyLifecycleState('STARTING'); - updateMoves(null); - - var token = runToken; - return backend.createJob(data).then(function (id) { - if (token !== runToken) return; - var jobId = ensureJobId(id); - - activeJobId = jobId; - retainedJobId = jobId; - phase = 'solving'; - applyLifecycleState('SOLVING'); - - attachStream(token, jobId); - - if (queuedAction === 'pause') { - queuedAction = null; - requestPause(token, jobId); - } else if (queuedAction === 'cancel') { - queuedAction = null; - requestCancel(token, jobId); - } - }).catch(function (err) { - if (token !== runToken) return; - if (retainedJobId) { - failTransport(err); - } else { - failStartup(err); - } - throw err; - }); - }; - - api.pause = function () { - if (pendingPause) return pendingPause.promise; - if (phase === 'starting' && !activeJobId) { - queuedAction = 'pause'; - pendingPause = createDeferred(); - return pendingPause.promise; - } - var jobId = currentJobId(); - if (phase !== 'solving' || !jobId) return Promise.resolve(); - - pendingPause = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'pause')) return pendingPause.promise; - requestPause(runToken, jobId); - return pendingPause.promise; - }; - - api.resume = function () { - if (pendingResume) return pendingResume.promise; - var jobId = currentJobId(); - if (phase !== 'paused' || !jobId) return Promise.resolve(); - - pendingResume = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'resume')) return pendingResume.promise; - requestResume(runToken, jobId); - return pendingResume.promise; - }; - - api.cancel = function () { - if (pendingCancel) return pendingCancel.promise; - if (phase === 'starting' && !activeJobId) { - queuedAction = 'cancel'; - pendingCancel = createDeferred(); - return pendingCancel.promise; - } - var jobId = currentJobId(); - if (phase === 'cancelling' && jobId) { - pendingCancel = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'cancel')) return pendingCancel.promise; - return pendingCancel.promise; - } - if (!jobId || !isCancelablePhase()) return Promise.resolve(); - - pendingCancel = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'cancel')) return pendingCancel.promise; - requestCancel(runToken, jobId); - return pendingCancel.promise; - }; - - api.delete = function () { - if (!retainedJobId) return Promise.resolve(); - if (!isTerminalLifecycle(lifecycleState)) { - return Promise.reject(new Error('Cannot delete a retained job before it reaches a terminal lifecycle state')); - } - - var jobId = retainedJobId; - return ensureTerminalSyncBeforeDelete(jobId).then(function () { - if (retainedJobId !== jobId) return; - return backend.deleteJob(jobId); - }).then(function () { - if (retainedJobId !== jobId) return; - resetAfterDelete(); - }).catch(function (err) { - notifyError(err); - throw err; - }); - }; - - api.getSnapshot = function (snapshotRevision) { - var jobId = currentJobId(); - if (!jobId) return Promise.reject(new Error('No retained job is available')); - var revision = resolveRequestedSnapshotRevision(snapshotRevision); - return backend.getSnapshot(jobId, revision).then(function (payload) { - return normalizeSnapshot(payload, lastMeta); - }); - }; - - api.analyzeSnapshot = function (snapshotRevision) { - var jobId = currentJobId(); - if (!jobId) return Promise.reject(new Error('No retained job is available')); - var revision = resolveRequestedSnapshotRevision(snapshotRevision); - return backend.analyzeSnapshot(jobId, revision).then(function (payload) { - return normalizeAnalysis(payload, lastMeta); - }); - }; - - api.isRunning = function () { - return phase !== 'idle' && phase !== 'paused'; - }; - - api.getJobId = function () { - return activeJobId != null ? activeJobId : retainedJobId; - }; - - api.getLifecycleState = function () { - return lifecycleState; - }; - - api.getSnapshotRevision = function () { - return lastSnapshotRevision; - }; - - return api; - - function requestPause(token, id) { - phase = 'pause-requested'; - backend.pauseJob(id).catch(function (err) { - if (token !== runToken) return; - phase = 'solving'; - rejectDeferred('pause', err); - notifyError(err); - }); - } - - function attachStream(token, id) { - closeStream = backend.streamJobEvents(id, function (payload) { - if (token !== runToken) return; - handleEvent(token, id, payload); - }, function (err) { - if (token !== runToken) return; - failTransport(err); - }); - } - - function ensureStreamAttached(token, id, pendingName) { - if (closeStream) return true; - try { - attachStream(token, id); - return true; - } catch (err) { - failTransport(err); - rejectDeferred(pendingName, err); - return false; - } - } - - function requestResume(token, id) { - phase = 'resuming'; - backend.resumeJob(id).catch(function (err) { - if (token !== runToken) return; - phase = 'paused'; - rejectDeferred('resume', err); - notifyError(err); - }); - } - - function requestCancel(token, id) { - phase = 'cancelling'; - backend.cancelJob(id).catch(function (err) { - if (token !== runToken) return; - phase = lifecycleState === 'PAUSED' ? 'paused' : 'solving'; - rejectDeferred('cancel', err); - notifyError(err); - }); - } - - function handleEvent(token, expectedId, payload) { - var event = normalizeJobEvent(payload, expectedId); - if (!event) return; - - lastMeta = event.meta; - if (event.meta.snapshotRevision != null) { - lastSnapshotRevision = event.meta.snapshotRevision; - } - retainedJobId = event.meta.jobId; - activeJobId = event.meta.jobId; - - if (event.eventType === 'progress') { - if (!event.meta.currentScore) return; - phase = phaseForLifecycleState(event.meta.lifecycleState); - applyEventMeta(event.meta); - if (config.onProgress) config.onProgress(event.meta); - return; - } - - if (event.eventType === 'best_solution') { - if (!event.solution || !event.meta.currentScore) return; - phase = phaseForLifecycleState(event.meta.lifecycleState); - applyEventMeta(event.meta); - if (config.onSolution) { - config.onSolution(buildLiveSnapshot(event), event.meta); - } - return; - } - - if (event.eventType === 'pause_requested') { - phase = 'pause-requested'; - applyEventMeta(event.meta); - if (config.onPauseRequested) config.onPauseRequested(event.meta); - return; - } - - if (event.eventType === 'paused') { - phase = 'paused'; - applyEventMeta(event.meta); - syncSnapshotBundle(event.meta, true).then(function (bundle) { - if (token !== runToken || hasNewerEvent(event.meta)) return; - applyBundle(bundle); - if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); - resolveDeferred('pause', bundle); - }).catch(function (err) { - if (token !== runToken || hasNewerEvent(event.meta)) return; - rejectDeferred('pause', err); - notifyError(err); - }); - return; - } - - if (event.eventType === 'resumed') { - phase = 'solving'; - applyEventMeta(event.meta); - if (config.onResumed) config.onResumed(event.meta); - resolveDeferred('resume', event.meta); - return; - } - - if (event.eventType === 'completed') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, true); - return; - } - - if (event.eventType === 'cancelled') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, false); - return; - } - - if (event.eventType === 'failed') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, false); - } - } - - function syncSnapshotBundle(meta, requireSnapshot) { - var analysisRequired = !!config.onAnalysis; - var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; - - return backend.getSnapshot(meta.jobId, snapshotRevision).then(function (snapshotPayload) { - var snapshot = normalizeSnapshot(snapshotPayload, meta); - if (!snapshot) throw new Error('Solver backend returned an invalid snapshot payload'); - - var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); - var result = { - meta: mergedMeta, - snapshot: snapshot, - analysis: null, - }; - - if (!analysisRequired) return result; - - return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function (analysisPayload) { - result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); - return result; - }); - }).catch(function (err) { - if (requireSnapshot) throw err; - - var fallback = { meta: meta, snapshot: null, analysis: null }; - if (!analysisRequired || snapshotRevision == null) return fallback; - - return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function (analysisPayload) { - fallback.analysis = normalizeAnalysis(analysisPayload, meta); - return fallback; - }).catch(function () { - return fallback; - }); - }); - } - - function applyBundle(bundle) { - if (!bundle) return; - lastMeta = bundle.meta; - if (bundle.meta && bundle.meta.snapshotRevision != null) { - lastSnapshotRevision = bundle.meta.snapshotRevision; - } - applyEventMeta(bundle.meta, bundle.analysis); - if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); - } - - function finalizeTerminal(meta) { - closeCurrentStream(); - activeJobId = null; - queuedAction = null; - phase = 'idle'; - applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : 'IDLE'); - updateMoves(null); - } - - function failTransport(err) { - var jobId = activeJobId || retainedJobId; - retainedJobId = jobId; - closeCurrentStream(); - activeJobId = null; - phase = phaseForLifecycleState(lifecycleState); - queuedAction = null; - rejectDeferred('pause', err); - rejectDeferred('resume', err); - rejectDeferred('cancel', err); - notifyError(err); - } - - function failStartup(err) { - closeCurrentStream(); - activeJobId = null; - retainedJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - lastNotifiedError = null; - phase = 'idle'; - queuedAction = null; - rejectDeferred('pause', err); - rejectDeferred('resume', err); - rejectDeferred('cancel', err); - applyLifecycleState('IDLE'); - updateMoves(null); - notifyError(err); - } - - function applyEventMeta(meta, analysis) { - applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); - updateScore(readDisplayScore(meta, analysis)); - updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); - if (analysis) { - var constraints = readAnalysisConstraints(analysis); - if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { - statusBar.colorDotsFromAnalysis(constraints); - } - } - } - - function readDisplayScore(meta, analysis) { - if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; - if (analysis && analysis.score != null) return analysis.score; - return null; - } - - function applyLifecycleState(state) { - lifecycleState = state || 'IDLE'; - if (!statusBar) return; - if (typeof statusBar.setLifecycleState === 'function') { - statusBar.setLifecycleState(lifecycleState); - return; - } - if (typeof statusBar.setSolving === 'function') { - statusBar.setSolving(isActiveLifecycle(lifecycleState)); - } - } - - function updateScore(score) { - if (statusBar && typeof statusBar.updateScore === 'function') { - statusBar.updateScore(score); - } - } - - function updateMoves(value) { - if (statusBar && typeof statusBar.updateMoves === 'function') { - statusBar.updateMoves(value); - } - } - - function resetForStart() { - closeCurrentStream(); - activeJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - lastNotifiedError = null; - queuedAction = null; - pendingPause = null; - pendingResume = null; - pendingCancel = null; - terminalSync = null; - } - - function resetAfterDelete() { - closeCurrentStream(); - rejectDeferred('pause', new Error('Solver job was deleted before pause settled')); - rejectDeferred('resume', new Error('Solver job was deleted before resume settled')); - rejectDeferred('cancel', new Error('Solver job was deleted before cancel settled')); - runToken += 1; - activeJobId = null; - retainedJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - queuedAction = null; - pendingPause = null; - pendingResume = null; - pendingCancel = null; - terminalSync = null; - phase = 'idle'; - applyLifecycleState('IDLE'); - updateScore(null); - updateMoves(null); - } - - function closeCurrentStream() { - if (!closeStream) return; - closeStream(); - closeStream = null; - } - - function currentJobId() { - return activeJobId != null ? activeJobId : retainedJobId; - } - - function hasNewerEvent(meta) { - var currentSequence = lastMeta && typeof lastMeta.eventSequence === 'number' ? lastMeta.eventSequence : null; - var candidateSequence = meta && typeof meta.eventSequence === 'number' ? meta.eventSequence : null; - if (currentSequence == null || candidateSequence == null) return false; - return currentSequence > candidateSequence; - } - - function resolveRequestedSnapshotRevision(snapshotRevision) { - if (snapshotRevision != null && snapshotRevision !== '') return snapshotRevision; - return lastSnapshotRevision; - } - - function createTerminalSync(event) { - var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; - terminalSync = { - jobId: event.meta.jobId, - eventType: event.eventType, - meta: event.meta, - status: 'pending', - promise: null, - error: null, - callbackDelivered: existing ? existing.callbackDelivered : false, - }; - return terminalSync; - } - - function runTerminalSync(record, token, event, requireSnapshot) { - record.status = 'pending'; - record.error = null; - record.meta = event.meta; - record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function (bundle) { - if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; - record.status = 'synced'; - record.error = null; - record.meta = bundle.meta; - finalizeTerminal(bundle.meta); - applyBundle(bundle); - deliverTerminalCallback(record, event, bundle); - settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); - return record; - }).catch(function (err) { - if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; - record.status = 'failed'; - record.error = err; - finalizeTerminal(event.meta); - deliverTerminalFailureCallback(record, event); - settlePendingFromTerminal(event.eventType, null, err); - notifyError(err); - return record; - }); - return record.promise; - } - - function ensureTerminalSyncBeforeDelete(jobId) { - var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; - if (!record) return Promise.resolve(); - - return Promise.resolve(record.promise).then(function () { - if (!requiresSuccessfulTerminalSync(record)) return; - if (record.status === 'synced') return; - return retryTerminalSync(record); - }); - } - - function retryTerminalSync(record) { - var retryEvent = { - eventType: record.eventType, - meta: record.meta, - error: null, - }; - return runTerminalSync(record, runToken, retryEvent, true).then(function () { - if (record.status !== 'synced') { - throw record.error || new Error('Terminal snapshot synchronization failed'); - } - }); - } - - function requiresSuccessfulTerminalSync(record) { - return record.eventType === 'completed' - && (record.meta.lifecycleState === 'COMPLETED' || record.meta.lifecycleState === 'TERMINATED_BY_CONFIG'); - } - - function deliverTerminalCallback(record, event, bundle) { - if (record.callbackDelivered) return; - if (event.eventType === 'completed') { - if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); - } else if (event.eventType === 'cancelled') { - if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); - } else if (event.eventType === 'failed') { - if (config.onFailure) config.onFailure(event.error || 'Solver job failed', bundle.meta, bundle.snapshot, bundle.analysis); - } - record.callbackDelivered = true; - } - - function deliverTerminalFailureCallback(record, event) { - if (record.callbackDelivered || event.eventType !== 'failed') return; - if (config.onFailure) config.onFailure(event.error || 'Solver job failed', event.meta, null, null); - record.callbackDelivered = true; - } - - function terminalEventError(event) { - if (event.eventType !== 'failed') return null; - return new Error(event.error || 'Solver job failed'); - } - - function isCancelablePhase() { - return phase === 'solving' || phase === 'pause-requested' || phase === 'paused' || phase === 'resuming'; - } - - function phaseForLifecycleState(state) { - if (state === 'STARTING') return 'starting'; - if (state === 'SOLVING') return 'solving'; - if (state === 'PAUSE_REQUESTED') return 'pause-requested'; - if (state === 'PAUSED') return 'paused'; - if (state === 'RESUMING') return 'resuming'; - if (state === 'CANCELLING') return 'cancelling'; - return 'idle'; - } - - function isTerminalLifecycle(state) { - return state === 'COMPLETED' - || state === 'CANCELLED' - || state === 'FAILED' - || state === 'TERMINATED_BY_CONFIG'; - } - - function settlePendingFromTerminal(eventType, bundle, err) { - if (eventType === 'cancelled') { - resolveDeferred('cancel', bundle); - } else if (pendingCancel) { - if (bundle) pendingCancel.resolve(bundle); - else pendingCancel.reject(err || new Error('Cancel did not settle before the job terminated')); - pendingCancel = null; - } - - rejectDeferred('pause', err || new Error('Job terminated before pause settled')); - rejectDeferred('resume', err || new Error('Job terminated before resume settled')); - } - - function resolveDeferred(name, value) { - var deferred = getDeferred(name); - if (!deferred) return; - deferred.resolve(value); - setDeferred(name, null); - } - - function rejectDeferred(name, err) { - var deferred = getDeferred(name); - if (!deferred) return; - deferred.reject(err); - setDeferred(name, null); - } - - function getDeferred(name) { - if (name === 'pause') return pendingPause; - if (name === 'resume') return pendingResume; - if (name === 'cancel') return pendingCancel; - return null; - } - - function setDeferred(name, value) { - if (name === 'pause') pendingPause = value; - if (name === 'resume') pendingResume = value; - if (name === 'cancel') pendingCancel = value; - } - - function notifyError(err) { - if (err && lastNotifiedError === err) return; - lastNotifiedError = err || null; - if (config.onError) config.onError(err && err.message ? err.message : String(err)); - } - - function ensureJobId(id) { - var jobId = sf.normalizeCreateJobId(id); - if (jobId) return jobId; - throw new Error('Invalid solver backend createJob response'); - } - }; - - function hasFunction(object, key) { - return !!(object && typeof object[key] === 'function'); - } - - function createDeferred() { - var resolve; - var reject; - var promise = new Promise(function (res, rej) { - resolve = res; - reject = rej; - }); - return { promise: promise, resolve: resolve, reject: reject }; - } - - function normalizeJobEvent(payload, expectedId) { - if (!payload || typeof payload !== 'object') return null; - - var eventType = normalizeEventType(readField(payload, ['eventType', 'event_type', 'type'])); - if (!eventType) return null; - - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); - if (jobId == null || jobId === '') jobId = expectedId; - if (jobId == null || jobId === '') return null; - if (String(jobId) !== String(expectedId)) return null; - - var solution = payload.solution || (payload.data && payload.data.solution) || null; - var solutionScore = readField(solution, ['score'], [solution]); - var meta = { - id: String(jobId), - jobId: String(jobId), - eventType: eventType, - eventSequence: readField(payload, ['eventSequence', 'event_sequence'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state', 'solverStatus', 'solver_status'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, - telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), - currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || solutionScore || null, - bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || solutionScore || null, - snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), - }; - - return { - eventType: eventType, - meta: meta, - solution: solution, - error: readField(payload, ['error'], [payload, payload.data]) || null, - }; - } - - function normalizeSnapshot(payload, fallbackMeta) { - if (!payload || typeof payload !== 'object') return null; - - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]); - if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; - var solution = payload.solution || (payload.data && payload.data.solution) || null; - var solutionScore = readField(solution, ['score'], [solution]); - return { - id: jobId != null ? String(jobId) : null, - jobId: jobId != null ? String(jobId) : null, - snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]), - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || null, - currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.data]) || solutionScore || null, - bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.data]) || solutionScore || null, - telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.data]), payload), - solution: solution, - }; - } - - function normalizeAnalysis(payload, fallbackMeta) { - if (!payload || typeof payload !== 'object') return null; - - var analysisBody = payload.analysis || (payload.data && payload.data.analysis) || payload; - var constraints = readAnalysisConstraints(analysisBody); - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]); - if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; - var snapshotRevision = readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]); - if (snapshotRevision == null || snapshotRevision === '') { - snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; - } - return { - jobId: jobId != null ? String(jobId) : null, - snapshotRevision: snapshotRevision != null ? snapshotRevision : null, - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.terminalReason) || null, - analysis: analysisBody, - score: analysisBody && analysisBody.score != null ? analysisBody.score : null, - constraints: constraints, - }; - } - - function buildLiveSnapshot(event) { - return { - id: event.meta.jobId, - jobId: event.meta.jobId, - snapshotRevision: event.meta.snapshotRevision, - lifecycleState: event.meta.lifecycleState, - terminalReason: event.meta.terminalReason, - currentScore: event.meta.currentScore, - bestScore: event.meta.bestScore, - telemetry: event.meta.telemetry, - solution: event.solution, - }; - } - - function mergeMeta(meta, snapshot, eventType) { - if (!snapshot) return meta; - return { - id: meta && meta.id != null ? meta.id : snapshot.id, - jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, - eventType: meta && meta.eventType ? meta.eventType : eventType, - eventSequence: meta ? meta.eventSequence : null, - lifecycleState: (meta && meta.lifecycleState) || snapshot.lifecycleState || normalizeLifecycleState(null, eventType), - terminalReason: (meta && meta.terminalReason) || snapshot.terminalReason || null, - telemetry: snapshot.telemetry || (meta && meta.telemetry) || null, - currentScore: snapshot.currentScore || (meta && meta.currentScore) || null, - bestScore: snapshot.bestScore || (meta && meta.bestScore) || null, - snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : (meta && meta.snapshotRevision), - }; - } - - function readField(payload, names, sources) { - var fields = Array.isArray(names) ? names : [names]; - var roots = sources || [payload]; - for (var i = 0; i < roots.length; i++) { - var source = roots[i]; - if (!source || typeof source !== 'object') continue; - for (var j = 0; j < fields.length; j++) { - if (source[fields[j]] != null) return source[fields[j]]; - } - } - return null; - } - - function normalizeEventType(value) { - if (typeof value !== 'string') return null; - var normalized = value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toLowerCase(); - if (!normalized) return null; - if (normalized === 'finished') return 'completed'; - return normalized; - } - - function normalizeLifecycleState(value, eventType) { - if (typeof value === 'string' && value.trim()) { - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toUpperCase(); - } - - if (eventType === 'progress' || eventType === 'best_solution' || eventType === 'resumed') return 'SOLVING'; - if (eventType === 'pause_requested') return 'PAUSE_REQUESTED'; - if (eventType === 'paused') return 'PAUSED'; - if (eventType === 'completed') return 'COMPLETED'; - if (eventType === 'cancelled') return 'CANCELLED'; - if (eventType === 'failed') return 'FAILED'; - return 'IDLE'; - } - - function normalizeTelemetry(rawTelemetry, payload) { - if (rawTelemetry && typeof rawTelemetry === 'object') return rawTelemetry; - - var telemetry = {}; - var movesPerSecond = readField(payload, ['movesPerSecond', 'moves_per_second']); - var stepCount = readField(payload, ['stepCount', 'step_count']); - if (movesPerSecond != null) telemetry.movesPerSecond = movesPerSecond; - if (stepCount != null) telemetry.stepCount = stepCount; - return Object.keys(telemetry).length ? telemetry : null; - } - - function readMovesPerSecond(telemetry) { - if (!telemetry || typeof telemetry !== 'object') return null; - if (telemetry.movesPerSecond != null) return telemetry.movesPerSecond; - if (telemetry.moves_per_second != null) return telemetry.moves_per_second; - return null; - } - - function readAnalysisConstraints(analysis) { - if (!analysis || typeof analysis !== 'object') return null; - if (Array.isArray(analysis.constraints)) return analysis.constraints; - if (analysis.analysis && Array.isArray(analysis.analysis.constraints)) return analysis.analysis.constraints; - return null; - } - - function isActiveLifecycle(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'RESUMING' - || state === 'CANCELLING'; - } - -})(SF); diff --git a/js-src/13-rail.js b/js-src/13-rail.js deleted file mode 100644 index 523249e..0000000 --- a/js-src/13-rail.js +++ /dev/null @@ -1,319 +0,0 @@ -/* ============================================================================ - SolverForge UI — Timeline Rail - Resource-lane timeline: header + cards with positioned blocks. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.rail = {}; - - sf.rail.createHeader = function (config) { - sf.assert(config, 'createHeader(config) requires a configuration object'); - sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); - - var labelWidth = config.labelWidth || 200; - var columns = config.columns || []; - - var header = sf.el('div', { className: 'sf-timeline-header' }); - header.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - var spacer = sf.el('div', { className: 'sf-timeline-label-spacer' }, config.label || ''); - header.appendChild(spacer); - - var days = sf.el('div', { className: 'sf-timeline-days' }); - days.style.gridTemplateColumns = 'repeat(' + columns.length + ', 1fr)'; - - columns.forEach(function (col) { - var colEl = sf.el('div', { className: 'sf-timeline-day-col' }); - colEl.appendChild(sf.el('span', null, typeof col === 'string' ? col : col.label)); - days.appendChild(colEl); - }); - - header.appendChild(days); - return header; - }; - - sf.rail.createCard = function (config) { - sf.assert(config, 'createCard(config) requires a configuration object'); - - var labelWidth = config.labelWidth || 200; - var card = sf.el('div', { className: 'sf-resource-card' }); - var state = { - unassigned: [], - railConfig: config, - }; - - if (config.id) card.dataset.resourceId = config.id; - - // Header row (identity + gauges) - var resHeader = sf.el('div', { className: 'sf-resource-header' }); - resHeader.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - var identity = sf.el('div', { className: 'sf-resource-identity' }); - if (config.name) { - identity.appendChild(sf.el('div', { className: 'sf-resource-name' }, config.name)); - } - if (config.badges || config.type) { - var meta = sf.el('div', { className: 'sf-resource-meta' }); - if (config.type) { - var badge = sf.el('span', { className: 'sf-resource-type-badge' }, config.type); - if (config.typeStyle) { - badge.style.background = config.typeStyle.bg || ''; - badge.style.color = config.typeStyle.color || ''; - badge.style.border = config.typeStyle.border || ''; - } - meta.appendChild(badge); - } - var badges = Array.isArray(config.badges) - ? config.badges - : config.badges - ? [config.badges] - : []; - if (badges.length) { - badges.forEach(function (entry) { - if (!entry) return; - if (typeof entry === 'string') { - meta.appendChild(sf.el('span', { className: 'sf-resource-type-badge' }, entry)); - return; - } - var extraBadge = sf.el('span', { className: 'sf-resource-type-badge' }, entry.label || ''); - if (entry.style) { - extraBadge.style.background = entry.style.bg || ''; - extraBadge.style.color = entry.style.color || ''; - extraBadge.style.border = entry.style.border || ''; - } - meta.appendChild(extraBadge); - }); - } - identity.appendChild(meta); - } - resHeader.appendChild(identity); - - // Gauges - if (config.gauges && config.gauges.length > 0) { - var gauges = sf.el('div', { className: 'sf-gauges' }); - config.gauges.forEach(function (g) { - var row = sf.el('div', { className: 'sf-gauge-row' }); - row.appendChild(sf.el('span', { className: 'sf-gauge-label' }, g.label)); - var track = sf.el('div', { className: 'sf-gauge-track' }); - var fill = sf.el('div', { - className: 'sf-gauge-fill' + (g.style ? ' sf-gauge-fill--' + g.style : ''), - }); - fill.style.width = Math.min(g.pct || 0, 100) + '%'; - track.appendChild(fill); - row.appendChild(track); - if (g.text) row.appendChild(sf.el('span', { className: 'sf-gauge-value' }, g.text)); - gauges.appendChild(row); - }); - resHeader.appendChild(gauges); - } - - card.appendChild(resHeader); - - // Body (stats + rail) - var body = sf.el('div', { className: 'sf-resource-body' }); - body.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - // Stats panel - var stats = sf.el('div', { className: 'sf-resource-stats' }); - if (config.stats) { - config.stats.forEach(function (s) { - var row = sf.el('div', { className: 'sf-stat-row' }); - row.appendChild(sf.el('span', { className: 'sf-stat-label' }, s.label)); - row.appendChild(sf.el('span', { className: 'sf-stat-value' }, String(s.value))); - stats.appendChild(row); - }); - } - body.appendChild(stats); - - // Rail - var railContainer = sf.el('div', { className: 'sf-rail-container' }); - var rail = sf.el('div', { className: 'sf-rail' }); - if (config.id) rail.id = 'sf-rail-' + config.id; - - // Day grid - var numCols = config.columns || 5; - var dayGrid = sf.el('div', { className: 'sf-day-grid' }); - dayGrid.style.gridTemplateColumns = 'repeat(' + numCols + ', 1fr)'; - for (var i = 0; i < numCols; i++) { - dayGrid.appendChild(sf.el('div', { className: 'sf-day-col' })); - } - rail.appendChild(dayGrid); - - railContainer.appendChild(rail); - body.appendChild(railContainer); - card.appendChild(body); - - // Optional heatmap strip - if (config.heatmap) { - var heatmapCfg = { - horizon: config.heatmap.horizon || 1, - label: config.heatmap.label, - segments: config.heatmap.segments, - labelWidth: labelWidth, - }; - heatmapCfg.railConfig = config; - var heatmap = sf.rail.createHeatmap(heatmapCfg); - if (heatmap) card.appendChild(heatmap); - } - - // Optional unassigned list - var unassignedRail = sf.el('div', { className: 'sf-unassigned-rail' }); - if (config.unassigned) { - state.unassigned = config.unassigned; - renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); - } - if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); - - // API - var cardApi = { el: card, rail: rail }; - - cardApi.addBlock = function (blockConfig) { - return sf.rail.addBlock(rail, blockConfig); - }; - - cardApi.setUnassigned = function (items) { - state.unassigned = Array.isArray(items) ? items : []; - if (state.unassigned.length === 0 && unassignedRail.parentNode) { - unassignedRail.innerHTML = ''; - unassignedRail.parentNode && unassignedRail.parentNode.removeChild(unassignedRail); - return; - } - if (state.unassigned.length > 0) { - renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); - } else { - unassignedRail.innerHTML = ''; - } - if (state.unassigned.length > 0 && !unassignedRail.parentNode) { - card.appendChild(unassignedRail); - } - }; - - cardApi.clearBlocks = function () { - rail.querySelectorAll('.sf-block, .sf-changeover').forEach(function (el) { - el.remove(); - }); - }; - - cardApi.setSolving = function (solving) { - card.classList.toggle('solving', solving); - }; - - return cardApi; - }; - - sf.rail.createHeatmap = function (config) { - if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; - - var heatmap = sf.el('div', { className: 'sf-heatmap' }); - heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + 'px 1fr'; - var label = sf.el('div', { className: 'sf-heatmap-label' }, config.label || ''); - heatmap.appendChild(label); - - var track = sf.el('div', { className: 'sf-heatmap-track' }); - var columns = config.railConfig && config.railConfig.columns || 1; - track.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)'; - heatmap.appendChild(track); - - var horizon = config.horizon || 1; - config.segments.forEach(function (segment) { - if (!segment || segment.end <= segment.start) return; - var band = sf.el('div', { className: 'sf-heatmap-segment' }); - var start = Math.max(0, segment.start); - var width = Math.max(0, segment.end - start); - band.style.left = (start / horizon * 100) + '%'; - band.style.width = Math.max(width / horizon * 100, 0.25) + '%'; - if (segment.color) band.style.background = segment.color; - if (segment.opacity != null) band.style.opacity = segment.opacity; - if (segment.tooltip) band.title = segment.tooltip; - track.appendChild(band); - }); - - return heatmap; - }; - - sf.rail.createUnassignedRail = function (tasks, onTaskClick) { - var rail = sf.el('div', { className: 'sf-unassigned-rail' }); - renderUnassigned(rail, tasks, onTaskClick); - return rail; - }; - - sf.rail.addBlock = function (rail, config) { - sf.assert(rail, 'addBlock(rail) requires a rail element'); - sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); - sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); - - var horizon = config.horizon || 1; - var startPct = (config.start / horizon) * 100; - var widthPct = ((config.end - config.start) / horizon) * 100; - var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; - - var block = sf.el('div', { className: 'sf-block' }); - block.style.left = startPct + '%'; - block.style.width = Math.max(widthPct, minWidthPct) + '%'; - - if (config.color) { - block.style.background = config.color; - block.style.borderLeftColor = config.borderColor || config.color; - } - if (config.className) block.classList.add(config.className); - if (config.late) block.classList.add('late'); - if (config.id) block.dataset.blockId = config.id; - if (config.delay) block.style.animationDelay = config.delay; - - if (config.label) { - block.appendChild(sf.el('div', { className: 'sf-block-label' }, config.label)); - } - if (config.meta) { - block.appendChild(sf.el('div', { className: 'sf-block-meta' }, config.meta)); - } - - if (config.onHover) { - block.addEventListener('mouseenter', function (e) { config.onHover(e, config); }); - } - if (config.onLeave) { - block.addEventListener('mouseleave', function () { config.onLeave(); }); - } - if (config.onClick) { - block.setAttribute('role', 'button'); - block.tabIndex = 0; - sf.bindActivation(block, function (e) { config.onClick(e, config); }); - } - - rail.appendChild(block); - return block; - }; - - sf.rail.addChangeover = function (rail, config) { - sf.assert(rail, 'addChangeover(rail) requires a rail element'); - sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); - sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); - - var horizon = config.horizon || 1; - var startPct = (config.start / horizon) * 100; - var widthPct = ((config.end - config.start) / horizon) * 100; - - var co = sf.el('div', { className: 'sf-changeover' }); - co.style.left = startPct + '%'; - co.style.width = widthPct + '%'; - rail.appendChild(co); - return co; - }; - - function renderUnassigned(unassignedRail, items, onTaskClick) { - unassignedRail.innerHTML = ''; - (items || []).forEach(function (item) { - var label = typeof item === 'string' ? item : item.label || item.id || ''; - if (!label) return; - var pill = sf.el('button', { - className: 'sf-unassigned-pill', - onClick: function () { - if (onTaskClick) onTaskClick(item); - }, - }, label); - unassignedRail.appendChild(pill); - }); - } - -})(SF); diff --git a/js-src/13a-rail-timeline.js b/js-src/13a-rail-timeline.js deleted file mode 100644 index f0a7636..0000000 --- a/js-src/13a-rail-timeline.js +++ /dev/null @@ -1,1745 +0,0 @@ -/* ============================================================================ - SolverForge UI — Rail Timeline - Canonical dense scheduling surface for resource-lane timelines. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var DAY_MINUTES = 24 * 60; - var SIX_HOUR_MINUTES = 6 * 60; - var WEEK_MINUTES = 7 * DAY_MINUTES; - var TRACK_HEIGHT = 34; - var TRACK_GAP = 8; - var TRACK_PADDING = 12; - var OVERVIEW_HEIGHT = 68; - var OVERVIEW_BLOCK_HEIGHT = 34; - var OVERVIEW_GROUP_GAP_MINUTES = 30; - var MIN_LABEL_WIDTH = 180; - var MIN_VISIBLE_TRACK_WIDTH = 320; - var MIN_CONTENT_TRACK_WIDTH = 480; - var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; - - var TONE_MAP = { - emerald: { - id: 'emerald', - background: 'rgba(16, 185, 129, 0.22)', - border: '#059669', - text: '#064e3b', - overlay: 'rgba(16, 185, 129, 0.10)', - }, - blue: { - id: 'blue', - background: 'rgba(59, 130, 246, 0.22)', - border: '#2563eb', - text: '#1e40af', - overlay: 'rgba(59, 130, 246, 0.10)', - }, - amber: { - id: 'amber', - background: 'rgba(245, 158, 11, 0.24)', - border: '#d97706', - text: '#92400e', - overlay: 'rgba(245, 158, 11, 0.10)', - }, - rose: { - id: 'rose', - background: 'rgba(244, 63, 94, 0.22)', - border: '#e11d48', - text: '#9f1239', - overlay: 'rgba(244, 63, 94, 0.10)', - }, - violet: { - id: 'violet', - background: 'rgba(139, 92, 246, 0.22)', - border: '#7c3aed', - text: '#5b21b6', - overlay: 'rgba(139, 92, 246, 0.10)', - }, - cyan: { - id: 'cyan', - background: 'rgba(6, 182, 212, 0.22)', - border: '#0891b2', - text: '#155e75', - overlay: 'rgba(6, 182, 212, 0.10)', - }, - red: { - id: 'red', - background: 'rgba(239, 68, 68, 0.22)', - border: '#dc2626', - text: '#991b1b', - overlay: 'rgba(239, 68, 68, 0.10)', - }, - slate: { - id: 'slate', - background: 'rgba(100, 116, 139, 0.20)', - border: '#475569', - text: '#1e293b', - overlay: 'rgba(100, 116, 139, 0.08)', - }, - }; - - sf.rail = sf.rail || {}; - - sf.rail.createTimeline = function (config) { - sf.assert(config && config.model, 'rail.createTimeline(config.model) requires a normalized model'); - - var labelWidth = config.labelWidth == null - ? 280 - : assertFiniteNumber(config.labelWidth, 'rail.createTimeline(labelWidth)'); - sf.assert(labelWidth > 0, 'rail.createTimeline(labelWidth) must be greater than zero'); - var state = { - cleanup: [], - config: config, - destroyed: false, - expandedClusters: {}, - hasQueuedPostMountSync: false, - instanceId: sf.uid('sf-rail-timeline'), - labelWidth: labelWidth, - model: normalizeModel(config.model), - scrollSync: null, - viewport: null, - layout: null, - }; - - state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); - - var root = sf.el('section', { - className: 'sf-rail-timeline', - dataset: { - labelWidth: String(labelWidth), - }, - }); - root.setAttribute('role', 'region'); - root.setAttribute('aria-label', config.title || 'Scheduling timeline'); - - var toolbar = sf.el('div', { className: 'sf-rail-timeline-toolbar' }); - var toolbarCopy = sf.el('div', { className: 'sf-rail-timeline-toolbar-copy' }); - toolbarCopy.appendChild(sf.el('div', { className: 'sf-rail-timeline-toolbar-title' }, config.title || 'Scheduling timeline')); - toolbarCopy.appendChild(sf.el('div', { className: 'sf-rail-timeline-toolbar-subtitle' }, config.subtitle || 'Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.')); - toolbar.appendChild(toolbarCopy); - - var zoomControls = sf.el('div', { className: 'sf-rail-timeline-zoom-controls' }); - var zoomButtons = []; - normalizeZoomPresets(config.zoomPresets).forEach(function (preset) { - var button = sf.el('button', { - className: 'sf-rail-timeline-zoom-button', - type: 'button', - dataset: { zoom: preset }, - }, preset === 'reset' ? 'Reset' : preset.toUpperCase()); - button.addEventListener('click', function () { - if (preset === 'reset') { - api.setViewport(state.model.axis.initialViewport); - return; - } - api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); - }); - zoomButtons.push(button); - zoomControls.appendChild(button); - }); - if (zoomButtons.length) { - toolbar.appendChild(zoomControls); - } - root.appendChild(toolbar); - - var shell = sf.el('div', { className: 'sf-rail-timeline-shell' }); - var headerViewport = sf.el('div', { className: 'sf-rail-timeline-header-viewport' }); - var bodyViewport = sf.el('div', { className: 'sf-rail-timeline-body-viewport' }); - var headerRow = sf.el('div', { className: 'sf-rail-timeline-header-row' }); - var lanes = sf.el('div', { className: 'sf-rail-timeline-lanes' }); - headerViewport.appendChild(headerRow); - bodyViewport.appendChild(lanes); - shell.appendChild(headerViewport); - shell.appendChild(bodyViewport); - root.appendChild(shell); - - var tooltip = sf.el('div', { className: 'sf-tooltip sf-rail-timeline-tooltip' }); - tooltip.id = sf.uid('sf-rail-timeline-tooltip'); - tooltip.setAttribute('role', 'tooltip'); - tooltip.setAttribute('aria-hidden', 'true'); - root.appendChild(tooltip); - - bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); - bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); - bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); - bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); - bindWindowResize(state, syncLayoutFromViewport); - - function renderStructure() { - renderHeader(); - renderLanes(); - } - - function applyMeasuredLayout() { - state.layout = measureLayout(bodyViewport, state); - applyLayout(root, headerRow, lanes, state.layout); - updateViewportMetadata(root, state); - updateZoomButtons(zoomButtons, state); - } - - function renderHeader() { - headerRow.innerHTML = ''; - - var corner = sf.el('div', { className: 'sf-rail-timeline-label-corner' }, config.label || 'Lane'); - headerRow.appendChild(corner); - - var axis = sf.el('div', { className: 'sf-rail-timeline-axis sf-rail-timeline-axis--header' }); - axis.style.height = '82px'; - renderAxisDecor(axis, state.model.axis, true); - headerRow.appendChild(axis); - } - - function renderLanes() { - lanes.innerHTML = ''; - - state.model.lanes.forEach(function (lane, laneIndex) { - var laneRender = lane.mode === 'overview' - ? buildOverviewRender(lane, state, function () { - rerenderTimeline(); - }) - : buildDetailedRender(lane, lane.items); - - var row = sf.el('div', { - className: 'sf-rail-timeline-row sf-rail-timeline-row--' + lane.mode + (laneRender.expandedClusterId ? ' sf-rail-timeline-row--expanded' : ''), - dataset: { - laneId: lane.id, - mode: lane.mode, - trackCount: String(laneRender.trackCount), - }, - }); - if (laneRender.expandedClusterId) { - row.dataset.expandedClusterId = laneRender.expandedClusterId; - } - row.setAttribute('role', 'group'); - - var label = buildLaneLabel( - lane, - laneRender, - row, - buildScopedId(state.instanceId, 'lane-title-' + laneIndex) - ); - row.appendChild(label); - - var track = sf.el('div', { className: 'sf-rail-timeline-track' }); - track.style.height = laneRender.height + 'px'; - renderAxisDecor(track, state.model.axis, false); - renderOverlays(track, lane.overlays, state.model.axis); - laneRender.blocks.forEach(function (blockConfig) { - appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); - }); - row.appendChild(track); - lanes.appendChild(row); - }); - } - - function rerenderTimeline() { - renderStructure(); - syncLayoutFromViewport(); - } - - function syncLayoutFromViewport() { - applyMeasuredLayout(); - syncScrollToViewport(); - } - - function syncScrollToViewport() { - if (!state.layout) return; - var scrollLeft = viewportToScrollLeft(state, bodyViewport); - state.scrollSync = bodyViewport; - bodyViewport.scrollLeft = scrollLeft; - headerViewport.scrollLeft = scrollLeft; - state.scrollSync = null; - } - - var api = { - destroy: function () { - if (state.destroyed) return; - state.destroyed = true; - state.cleanup.forEach(function (cleanup) { - if (typeof cleanup === 'function') cleanup(); - }); - root.innerHTML = ''; - }, - el: root, - expandCluster: function (laneId, clusterId) { - setExpandedCluster(state, laneId, clusterId); - rerenderTimeline(); - }, - setModel: function (nextModel) { - state.model = normalizeModel(nextModel); - state.viewport = clampViewport(state.model.axis, state.viewport); - pruneExpandedClusters(state); - rerenderTimeline(); - queuePostMountSync(state, syncLayoutFromViewport); - }, - setViewport: function (nextViewport) { - state.viewport = clampViewport( - state.model.axis, - normalizeViewportInput(nextViewport, 'rail.createTimeline().setViewport(viewport)') - ); - syncLayoutFromViewport(); - queuePostMountSync(state, syncLayoutFromViewport); - }, - }; - - renderStructure(); - syncLayoutFromViewport(); - queuePostMountSync(state, syncLayoutFromViewport); - - return api; - }; - - function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { - var tone = blockConfig.tone; - var relativeStart = blockConfig.startMinute - axis.startMinute; - var relativeEnd = blockConfig.endMinute - axis.startMinute; - var horizon = axis.endMinute - axis.startMinute; - var block = sf.rail.addBlock(track, { - start: relativeStart, - end: relativeEnd, - horizon: horizon, - label: blockConfig.label, - meta: blockConfig.metaLabel, - color: tone.background, - borderColor: tone.border, - minWidthPct: 0, - onClick: blockConfig.onClick, - onHover: function (event) { - showTooltip(tooltip, root, blockConfig.tooltip, event); - }, - onLeave: function () { - hideTooltip(tooltip); - }, - }); - - block.classList.add('sf-rail-timeline-item'); - block.classList.add(blockConfig.kindClass); - block.style.left = positionPct(blockConfig.startMinute, axis) + '%'; - block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + '%'; - block.style.top = blockConfig.top + 'px'; - block.style.height = blockConfig.height + 'px'; - block.style.bottom = 'auto'; - block.style.color = tone.text; - block.tabIndex = 0; - block.dataset.itemId = blockConfig.itemId; - block.dataset.laneId = lane.id; - block.dataset.startMinute = String(blockConfig.startMinute); - block.dataset.endMinute = String(blockConfig.endMinute); - if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); - if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; - if (blockConfig.onClick) { - block.setAttribute('role', 'button'); - block.setAttribute('aria-expanded', blockConfig.expanded ? 'true' : 'false'); - } else { - block.setAttribute('role', 'group'); - } - if (blockConfig.ariaLabel) block.setAttribute('aria-label', blockConfig.ariaLabel); - block.setAttribute('aria-describedby', tooltip.id); - if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); - if (blockConfig.detailHint) { - block.appendChild(sf.el('span', { className: 'sf-rail-timeline-detail-hint' }, blockConfig.detailHint)); - } - block.title = blockConfig.tooltip.title; - block.addEventListener('mousemove', function (event) { - showTooltip(tooltip, root, blockConfig.tooltip, event); - }); - block.addEventListener('focus', function () { - showTooltipForElement(tooltip, root, blockConfig.tooltip, block); - }); - block.addEventListener('blur', function () { - hideTooltip(tooltip); - }); - block.addEventListener('keydown', function (event) { - if (event && event.key === 'Escape') hideTooltip(tooltip); - }); - } - - function appendOverviewSummary(block, summary) { - var footer = sf.el('div', { className: 'sf-rail-timeline-summary-footer' }); - if (summary.badges.length > 0) { - var badgeRail = sf.el('div', { className: 'sf-rail-timeline-summary-badges' }); - summary.badges.forEach(function (badge) { - badgeRail.appendChild(sf.el('span', { - className: 'sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--' + badge.kind, - }, badge.text)); - }); - footer.appendChild(badgeRail); - } - if (summary.toneSegments.length > 0) { - var toneBar = sf.el('div', { - className: 'sf-rail-timeline-summary-tonebar', - 'aria-hidden': 'true', - }); - var total = summary.toneSegments.reduce(function (sum, segment) { - return sum + segment.count; - }, 0) || 1; - summary.toneSegments.forEach(function (segment) { - var toneSegment = sf.el('span', { className: 'sf-rail-timeline-summary-tone-segment' }); - toneSegment.style.background = segment.tone.border; - toneSegment.style.width = ((segment.count / total) * 100) + '%'; - toneBar.appendChild(toneSegment); - }); - footer.appendChild(toneBar); - } - if (footer.children.length > 0) block.appendChild(footer); - } - - function bindScrollSync(source, target, state, root, zoomButtons) { - source.addEventListener('scroll', function () { - handleScroll(source, target, state, root, zoomButtons); - }); - target.addEventListener('scroll', function () { - handleScroll(target, source, state, root, zoomButtons); - }); - } - - function bindDragPan(source, target, state, root, zoomButtons) { - var drag = { - active: false, - startClientX: 0, - startScrollLeft: 0, - }; - - source.addEventListener('mousedown', function (event) { - if (event.button != null && event.button !== 0) return; - drag.active = true; - drag.startClientX = event.clientX != null ? event.clientX : 0; - drag.startScrollLeft = source.scrollLeft || 0; - source.classList.add('is-dragging'); - if (event.preventDefault) event.preventDefault(); - }); - - source.addEventListener('mousemove', function (event) { - if (!drag.active) return; - var clientX = event.clientX != null ? event.clientX : drag.startClientX; - var delta = clientX - drag.startClientX; - source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); - handleScroll(source, target, state, root, zoomButtons); - if (event.preventDefault) event.preventDefault(); - }); - - function finishDrag() { - if (!drag.active) return; - drag.active = false; - source.classList.remove('is-dragging'); - } - - source.addEventListener('mouseup', finishDrag); - source.addEventListener('mouseleave', finishDrag); - } - - function handleScroll(source, target, state, root, zoomButtons) { - if (state.destroyed) return; - if (!state.layout) return; - if (state.scrollSync === source) return; - - state.scrollSync = source; - target.scrollLeft = source.scrollLeft; - state.viewport = scrollLeftToViewport(state, source); - updateViewportMetadata(root, state); - updateZoomButtons(zoomButtons, state); - state.scrollSync = null; - } - - function measurePackedHeight(packed) { - return packed.trackCount > 0 - ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP - : OVERVIEW_HEIGHT; - } - - function buildDetailBlockConfig(item, lane, trackIndex, top, options) { - var config = options || {}; - return { - clusterId: config.clusterId || null, - detailHint: config.detailHint || '', - endMinute: item.endMinute, - height: TRACK_HEIGHT, - itemId: item.id, - kindClass: 'sf-rail-timeline-item--detail', - label: item.label, - metaLabel: describeMeta(item.meta), - startMinute: item.startMinute, - top: top, - ariaLabel: buildItemAriaLabel(item, lane), - tooltip: buildItemTooltip(item, lane), - tone: item.tone, - trackIndex: trackIndex, - }; - } - - function buildOverviewBlockConfig(group, height, options) { - var config = options || {}; - return { - clusterId: config.clusterId || null, - endMinute: group.endMinute, - height: OVERVIEW_BLOCK_HEIGHT, - itemId: config.itemId, - kindClass: config.kindClass, - label: group.summary.primaryLabel, - metaLabel: group.summary.secondaryLabel, - onClick: config.onClick || null, - startMinute: group.startMinute, - summary: buildOverviewBlockSummary(group, !!config.expanded), - top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), - ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), - expanded: !!config.expanded, - tooltip: config.tooltip, - tone: group.tone, - }; - } - - function buildDetailedRender(lane, items) { - var packed = packItems(items); - var height = measurePackedHeight(packed); - - var blocks = packed.items.map(function (entry) { - return buildDetailBlockConfig( - entry.item, - lane, - entry.trackIndex, - TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) - ); - }); - - return { - blocks: blocks, - height: height, - trackCount: packed.trackCount || 1, - }; - } - - function buildOverviewRender(lane, state, rerender) { - var groups = groupOverviewItems(lane); - var expandedClusterId = state.expandedClusters[lane.id] || null; - var expandedGroup = null; - var packedExpanded = null; - var expandedDetailsTop = 0; - - groups.forEach(function (group) { - if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { - expandedGroup = group; - } - }); - - if (expandedGroup) { - packedExpanded = packItems(expandedGroup.detailItems); - expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; - } - - var height = packedExpanded - ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) - : OVERVIEW_HEIGHT; - - var blocks = []; - groups.forEach(function (group) { - if (group.isCluster) { - var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); - blocks.push(buildOverviewBlockConfig(group, height, { - clusterId: group.clusterKey, - itemId: group.renderId, - kindClass: 'sf-rail-timeline-item--cluster', - expanded: isExpanded, - onClick: function () { - setExpandedCluster( - state, - lane.id, - state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey - ); - if (state.config && state.config.onClusterToggle) { - state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); - } - if (typeof rerender === 'function') rerender(); - }, - top: isExpanded ? TRACK_PADDING : null, - tooltip: buildClusterTooltip(group, lane), - })); - if (isExpanded) { - packedExpanded.items.forEach(function (entry) { - blocks.push(buildDetailBlockConfig( - entry.item, - lane, - entry.trackIndex, - expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), - { - clusterId: group.clusterKey, - detailHint: 'Expanded', - } - )); - }); - } - return; - } - - blocks.push(buildOverviewBlockConfig(group, height, { - itemId: group.items[0].id, - kindClass: 'sf-rail-timeline-item--overview', - tooltip: buildOverviewTooltip(group, lane), - })); - }); - - return { - blocks: blocks, - expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, - height: height, - trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1, - }; - } - - function buildLaneLabel(lane, laneRender, row, headingId) { - var label = sf.el('div', { - className: 'sf-rail-timeline-lane-label', - dataset: { laneId: lane.id }, - }); - label.style.minHeight = laneRender.height + 'px'; - - var heading = sf.el('div', { className: 'sf-rail-timeline-lane-heading' }); - var title = sf.el('div', { className: 'sf-rail-timeline-lane-title' }, lane.label); - title.id = headingId; - heading.appendChild(title); - if (lane.mode) { - heading.appendChild(sf.el('div', { className: 'sf-rail-timeline-lane-mode' }, lane.mode)); - } - label.appendChild(heading); - if (row) row.setAttribute('aria-labelledby', title.id); - - if (lane.badges.length > 0) { - var badges = sf.el('div', { className: 'sf-rail-timeline-lane-badges' }); - lane.badges.forEach(function (badge) { - var badgeEl = sf.el('span', { className: 'sf-rail-timeline-lane-badge' }, badge.label); - if (badge.style) { - badgeEl.style.background = badge.style.bg || ''; - badgeEl.style.border = badge.style.border || ''; - badgeEl.style.color = badge.style.color || ''; - } - badges.appendChild(badgeEl); - }); - label.appendChild(badges); - } - - if (lane.stats.length > 0) { - var stats = sf.el('div', { className: 'sf-rail-timeline-lane-stats' }); - lane.stats.forEach(function (stat) { - var statRow = sf.el('div', { className: 'sf-rail-timeline-lane-stat' }); - statRow.appendChild(sf.el('span', { className: 'sf-rail-timeline-lane-stat-label' }, stat.label)); - statRow.appendChild(sf.el('span', { className: 'sf-rail-timeline-lane-stat-value' }, String(stat.value))); - stats.appendChild(statRow); - }); - label.appendChild(stats); - } - - return label; - } - - function buildClusterTooltip(group, lane) { - var first = group.detailItems[0] || group.items[0]; - var payload = { - rows: [ - { key: 'Lane', value: lane.label }, - { key: 'Window', value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, - { key: 'Items', value: String(group.summary.count) }, - ], - title: group.label, - }; - - if (group.summary.openCount > 0) { - payload.rows.push({ key: 'Open', value: String(group.summary.openCount) }); - } - if (group.summary.toneSegments.length > 0) { - payload.rows.push({ key: 'Mix', value: describeToneSegments(group.summary.toneSegments) }); - } - - if (first && first.meta) { - payload.rows.push({ key: 'Sample', value: describeMeta(first.meta) }); - } - - return payload; - } - - function buildItemTooltip(item, lane) { - var rows = [ - { key: 'Lane', value: lane.label }, - { key: 'Time', value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) }, - ]; - - appendMetaRows(rows, item.meta); - - return { - rows: rows, - title: item.label, - }; - } - - function buildOverviewBlockMeta(group) { - if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; - var labels = []; - group.items.slice(0, 2).forEach(function (item) { - labels.push(item.label); - }); - if (group.count > 2) labels.push('+' + (group.count - 2) + ' more'); - return labels.join(' • '); - } - - function buildPresetViewport(axis, currentViewport, preset) { - var duration = preset === '1w' ? WEEK_MINUTES : preset === '2w' ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; - var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); - var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; - var start = Math.round(center - visibleDuration / 2); - return clampViewport(axis, { - startMinute: start, - endMinute: start + visibleDuration, - }); - } - - function clampNumber(value, min, max) { - return Math.min(Math.max(value, min), max); - } - - function clampViewport(axis, viewport) { - var totalDuration = axis.endMinute - axis.startMinute; - var next = viewport || axis.initialViewport || { - startMinute: axis.startMinute, - endMinute: axis.endMinute, - }; - var duration = next.endMinute - next.startMinute; - duration = Math.min(duration, totalDuration); - - var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); - - return { - endMinute: start + duration, - startMinute: start, - }; - } - - function assertFiniteNumber(value, label) { - sf.assert(typeof value === 'number' && isFinite(value), label + ' must be a finite number'); - return value; - } - - function assertMinuteValue(value, label) { - return assertInteger(value, label); - } - - function assertInteger(value, label) { - var number = assertFiniteNumber(value, label); - sf.assert(Math.floor(number) === number, label + ' must be an integer'); - return number; - } - - function assertNonNegativeInteger(value, label) { - var number = assertInteger(value, label); - sf.assert(number >= 0, label + ' must be greater than or equal to zero'); - return number; - } - - function describeMeta(meta) { - if (meta == null) return ''; - if (typeof meta === 'string') return meta; - if (typeof meta === 'number') return String(meta); - if (Array.isArray(meta)) { - return meta.map(function (entry) { - if (entry && entry.label && entry.value != null) return entry.label + ': ' + entry.value; - return String(entry || ''); - }).filter(Boolean).join(' • '); - } - if (typeof meta === 'object') { - return Object.keys(meta).map(function (key) { - return key + ': ' + meta[key]; - }).join(' • '); - } - return String(meta); - } - - function appendMetaRows(rows, meta) { - if (meta == null) return; - if (typeof meta === 'string' || typeof meta === 'number') { - rows.push({ key: 'Meta', value: String(meta) }); - return; - } - if (Array.isArray(meta)) { - meta.forEach(function (entry, index) { - if (!entry) return; - if (entry.label && entry.value != null) { - rows.push({ key: entry.label, value: String(entry.value) }); - return; - } - rows.push({ key: 'Meta ' + (index + 1), value: String(entry) }); - }); - return; - } - if (typeof meta === 'object') { - Object.keys(meta).forEach(function (key) { - rows.push({ key: key, value: String(meta[key]) }); - }); - } - } - - function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { - var startMinute = assertMinuteValue(startValue, startLabel); - var endMinute = assertMinuteValue(endValue, endLabel); - sf.assert(endMinute > startMinute, endLabel + ' must be greater than startMinute'); - return { - endMinute: endMinute, - startMinute: startMinute, - }; - } - - function normalizeId(value, prefix, suffix) { - return value != null ? String(value) : prefix + suffix; - } - - function buildScopedId(scope, suffix) { - return scope + '-' + suffix; - } - - function setExpandedCluster(state, laneId, clusterId) { - if (clusterId == null) delete state.expandedClusters[laneId]; - else state.expandedClusters[laneId] = String(clusterId); - } - - function normalizeAxis(axis) { - sf.assert(axis && axis.startMinute != null && axis.endMinute != null, 'createTimeline(model.axis.startMinute/endMinute) are required'); - var axisRange = normalizeMinuteRange( - axis.startMinute, - axis.endMinute, - 'createTimeline(model.axis.startMinute)', - 'createTimeline(model.axis.endMinute)' - ); - - var normalized = { - endMinute: axisRange.endMinute, - startMinute: axisRange.startMinute, - }; - - normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); - normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); - normalized.initialViewport = clampViewport( - normalized, - normalizeViewportInput(axis.initialViewport, 'createTimeline(model.axis.initialViewport)') || { - startMinute: normalized.startMinute, - endMinute: normalized.endMinute, - } - ); - - return normalized; - } - - function normalizeBadge(badge) { - if (!badge) return null; - if (typeof badge === 'string') return { label: badge }; - return { - label: badge.label || '', - style: badge.style || null, - }; - } - - function normalizeDays(days, startMinute, endMinute) { - var list = []; - var source = Array.isArray(days) && days.length > 0 ? days : null; - var cursor = startMinute; - var index = 0; - - if (!source) { - while (cursor < endMinute) { - list.push(makeDay({ - endMinute: Math.min(cursor + DAY_MINUTES, endMinute), - isWeekend: false, - label: 'Day ' + (index + 1), - startMinute: cursor, - }, index)); - cursor += DAY_MINUTES; - index += 1; - } - return list; - } - - source.forEach(function (day, dayIndex) { - if (cursor >= endMinute) return; - if (typeof day === 'string') { - var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); - list.push(makeDay({ - endMinute: generatedEnd, - isWeekend: inferWeekend(day), - label: day, - startMinute: cursor, - }, dayIndex)); - cursor = generatedEnd; - return; - } - - var nextStart = day.startMinute != null - ? day.startMinute - : cursor; - var nextEnd = day.endMinute != null - ? day.endMinute - : Math.min(nextStart + DAY_MINUTES, endMinute); - var dayRange = normalizeMinuteRange( - nextStart, - nextEnd, - 'createTimeline(model.axis.days[' + dayIndex + '].startMinute)', - 'createTimeline(model.axis.days[' + dayIndex + '].endMinute)' - ); - list.push(makeDay({ - endMinute: dayRange.endMinute, - isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), - label: day.label || 'Day ' + (dayIndex + 1), - startMinute: dayRange.startMinute, - subLabel: day.subLabel || day.meta || '', - }, dayIndex)); - cursor = dayRange.endMinute; - }); - - return list; - } - - function normalizeItem(item, pathKey, ordinal) { - sf.assert(item && item.startMinute != null && item.endMinute != null, 'timeline items require startMinute/endMinute'); - var itemRange = normalizeMinuteRange( - item.startMinute, - item.endMinute, - 'createTimeline(model.lanes[].items[].startMinute)', - 'createTimeline(model.lanes[].items[].endMinute)' - ); - - return { - clusterId: item.clusterId != null ? String(item.clusterId) : null, - detailItems: Array.isArray(item.detailItems) - ? item.detailItems.map(function (detailItem, detailIndex) { - return normalizeItem(detailItem, pathKey + '-' + detailIndex, detailIndex); - }) - : [], - endMinute: itemRange.endMinute, - id: normalizeId(item.id, 'item-', pathKey), - label: item.label || 'Item ' + (ordinal + 1), - meta: item.meta != null ? item.meta : '', - originalIndex: ordinal, - summary: normalizeOverviewSummary(item.summary, 'createTimeline(model.lanes[].items[].summary)'), - startMinute: itemRange.startMinute, - tone: resolveTone(item.tone || item.color || 'slate'), - }; - } - - function normalizeLane(lane, index, axis) { - sf.assert(lane && Array.isArray(lane.items), 'timeline lanes require an items array'); - - var normalizedLane = { - axis: axis, - badges: [], - id: normalizeId(lane.id, 'lane-', index), - items: lane.items.map(function (item, itemIndex) { - return normalizeItem(item, index + '-' + itemIndex, itemIndex); - }), - label: lane.label || 'Lane ' + (index + 1), - mode: lane.mode === 'overview' ? 'overview' : 'detailed', - overlays: Array.isArray(lane.overlays) - ? lane.overlays.map(function (overlay, overlayIndex) { - return normalizeOverlay(overlay, overlayIndex, axis); - }).filter(Boolean) - : [], - stats: Array.isArray(lane.stats) ? lane.stats : [], - }; - - normalizedLane.items.sort(compareItems); - - if (Array.isArray(lane.badges)) { - lane.badges.forEach(function (badge) { - var normalizedBadge = normalizeBadge(badge); - if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); - }); - } else { - var singleBadge = normalizeBadge(lane.badges); - if (singleBadge) normalizedLane.badges.push(singleBadge); - } - - return normalizedLane; - } - - function normalizeModel(model) { - sf.assert(model && model.axis && Array.isArray(model.lanes), 'createTimeline(model.axis/model.lanes) are required'); - var axis = normalizeAxis(model.axis); - - return { - axis: axis, - lanes: model.lanes.map(function (lane, index) { - return normalizeLane(lane, index, axis); - }), - }; - } - - function normalizeOverlay(overlay, index, axis) { - var label = 'createTimeline(model.lanes[].overlays[' + index + '])'; - sf.assert(overlay && typeof overlay === 'object', label + ' must be an object'); - - var startMinute = overlay.startMinute; - var endMinute = overlay.endMinute; - - if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { - var dayIndex = assertInteger(overlay.dayIndex, label + '.dayIndex'); - var day = axis.days[dayIndex]; - sf.assert(day, label + '.dayIndex must reference an existing day'); - var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + '.dayCount'); - sf.assert(dayCount > 0, label + '.dayCount must be greater than zero'); - var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; - startMinute = day.startMinute; - endMinute = lastDay.endMinute; - } - - sf.assert( - startMinute != null && endMinute != null, - label + ' requires startMinute/endMinute or dayIndex/dayCount' - ); - var overlayRange = normalizeMinuteRange( - startMinute, - endMinute, - label + '.startMinute', - label + '.endMinute' - ); - - return { - endMinute: overlayRange.endMinute, - id: normalizeId(overlay.id, 'overlay-', index), - label: overlay.label || '', - meta: overlay.meta || '', - startMinute: overlayRange.startMinute, - tone: resolveTone(overlay.tone || overlay.color || 'slate'), - }; - } - - function normalizeTicks(ticks, startMinute, endMinute) { - var list = []; - - if (Array.isArray(ticks) && ticks.length > 0) { - ticks.forEach(function (tick, index) { - if (typeof tick === 'number') { - var numericTick = assertMinuteValue(tick, 'createTimeline(model.axis.ticks[' + index + '])'); - list.push({ id: 'tick-' + index, label: formatClock(numericTick), minute: numericTick }); - return; - } - sf.assert(tick && typeof tick === 'object', 'createTimeline(model.axis.ticks[' + index + ']) must be a number or object'); - sf.assert(tick.minute != null, 'createTimeline(model.axis.ticks[' + index + '].minute) is required'); - var minute = assertMinuteValue(tick.minute, 'createTimeline(model.axis.ticks[' + index + '].minute)'); - list.push({ - id: normalizeId(tick.id, 'tick-', index), - label: tick.label || formatClock(minute), - minute: minute, - }); - }); - return list; - } - - for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { - list.push({ - id: 'tick-' + minute, - label: formatClock(minute), - minute: minute, - }); - } - - return list; - } - - function makeDay(day, index) { - return { - endMinute: day.endMinute, - id: normalizeId(day.id, 'day-', index), - isWeekend: !!day.isWeekend, - label: day.label || 'Day ' + (index + 1), - startMinute: day.startMinute, - subLabel: day.subLabel || '', - }; - } - - function compareItems(left, right) { - if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; - if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; - if (left.label !== right.label) return left.label < right.label ? -1 : 1; - return left.originalIndex - right.originalIndex; - } - - function normalizeOverviewSummary(summary, label) { - if (summary == null) return null; - sf.assert(summary && typeof summary === 'object', label + ' must be an object'); - - var normalized = { - count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + '.count'), - openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + '.openCount'), - primaryLabel: summary.primaryLabel == null ? '' : String(summary.primaryLabel), - secondaryLabel: summary.secondaryLabel == null ? '' : String(summary.secondaryLabel), - toneSegments: Array.isArray(summary.toneSegments) - ? summary.toneSegments.map(function (segment, index) { - sf.assert(segment && typeof segment === 'object', label + '.toneSegments[' + index + '] must be an object'); - return { - count: assertNonNegativeInteger(segment.count, label + '.toneSegments[' + index + '].count'), - tone: resolveTone(segment.tone || segment.color || 'slate'), - }; - }).filter(function (segment) { - return segment.count > 0; - }) - : [], - }; - - if (normalized.count != null && normalized.openCount != null) { - sf.assert(normalized.openCount <= normalized.count, label + '.openCount must not exceed count'); - } - - return normalized; - } - - function renderAxisDecor(track, axis, includeLabels) { - appendWeekendBands(track, axis); - appendDayDividers(track, axis); - appendTicks(track, axis, includeLabels); - if (includeLabels) appendDayBands(track, axis); - } - - function appendDayBands(track, axis) { - axis.days.forEach(function (day) { - var band = sf.el('div', { className: 'sf-rail-timeline-day-band' }); - band.style.left = positionPct(day.startMinute, axis) + '%'; - band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; - band.appendChild(sf.el('div', { className: 'sf-rail-timeline-day-label' }, day.label)); - if (day.subLabel) { - band.appendChild(sf.el('div', { className: 'sf-rail-timeline-day-sub' }, day.subLabel)); - } - track.appendChild(band); - }); - } - - function appendDayDividers(track, axis) { - axis.days.forEach(function (day, index) { - if (index === 0) return; - var divider = sf.el('div', { className: 'sf-rail-timeline-day-divider' }); - divider.style.left = positionPct(day.startMinute, axis) + '%'; - track.appendChild(divider); - }); - } - - function appendTicks(track, axis, includeLabels) { - axis.ticks.forEach(function (tick) { - if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; - var tickEl = sf.el('div', { className: 'sf-rail-timeline-tick' }); - tickEl.style.left = positionPct(tick.minute, axis) + '%'; - track.appendChild(tickEl); - - if (!includeLabels) return; - var label = sf.el('div', { className: 'sf-rail-timeline-tick-label' }, tick.label); - label.style.left = positionPct(tick.minute, axis) + '%'; - track.appendChild(label); - }); - } - - function appendWeekendBands(track, axis) { - axis.days.forEach(function (day) { - if (!day.isWeekend) return; - var band = sf.el('div', { className: 'sf-rail-timeline-weekend-band' }); - band.style.left = positionPct(day.startMinute, axis) + '%'; - band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; - track.appendChild(band); - }); - } - - function renderOverlays(track, overlays, axis) { - overlays.forEach(function (overlay) { - var band = sf.el('div', { className: 'sf-rail-timeline-overlay' }); - band.style.left = positionPct(overlay.startMinute, axis) + '%'; - band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + '%'; - band.style.background = overlay.tone.overlay; - band.style.borderColor = overlay.tone.border; - if (overlay.label) band.title = overlay.label; - track.appendChild(band); - }); - } - - function groupOverviewItems(lane) { - var groups = []; - var current = null; - - lane.items.forEach(function (item) { - if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { - if (current) groups.push(current); - current = { - clusterId: item.clusterId, - endMinute: item.endMinute, - items: [item], - lane: lane, - startMinute: item.startMinute, - }; - return; - } - current.items.push(item); - current.endMinute = Math.max(current.endMinute, item.endMinute); - if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; - }); - if (current) groups.push(current); - - groups.forEach(function (group, groupIndex) { - finalizeGroup(group, lane, groupIndex); - }); - assertUniqueClusterKeys(lane, groups); - - return groups; - } - - function finalizeGroup(group, lane, index) { - var detailItems = []; - - group.items.forEach(function (item) { - if (item.detailItems.length > 0) { - item.detailItems.forEach(function (detailItem) { - detailItems.push(detailItem); - }); - return; - } - detailItems.push(item); - }); - - detailItems.sort(compareItems); - group.detailItems = detailItems; - group.isCluster = detailItems.length > 1 || group.items.some(function (item) { - return item.detailItems.length > 0; - }); - group.renderId = group.isCluster - ? buildScopedId('cluster', lane.id + '-' + index + '-' + (group.items[0] ? group.items[0].id : 'group')) - : normalizeId(group.items[0] ? group.items[0].id : null, 'group-', lane.id + '-' + index); - group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; - group.summary = deriveOverviewSummary(group); - group.count = group.summary.count; - group.label = group.summary.primaryLabel; - group.metaLabel = group.summary.secondaryLabel; - group.tone = group.summary.primaryTone || dominantTone(group.detailItems); - } - - function assertUniqueClusterKeys(lane, groups) { - var seen = {}; - - groups.forEach(function (group) { - if (!group.clusterKey) return; - sf.assert( - !seen[group.clusterKey], - 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' - ); - seen[group.clusterKey] = true; - }); - } - - function dominantTone(items) { - var toneSegments = buildToneSegmentsFromItems(items); - if (!toneSegments.length) return resolveTone('slate'); - return toneSegments[0].tone; - } - - function effectiveOverviewItems(item) { - return item.detailItems.length > 0 ? item.detailItems : [item]; - } - - function deriveOverviewContribution(item) { - var items = effectiveOverviewItems(item); - var summary = item.summary; - var derivedCount = items.length; - var count = summary && summary.count != null ? summary.count : derivedCount; - var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; - var openCount = null; - var toneSegments = []; - - if (summary && summary.openCount != null) openCount = summary.openCount; - else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); - - if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; - else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); - - return { - count: count, - openCount: openCount, - openCountKnown: openCount != null, - toneSegments: toneSegments, - toneSegmentsKnown: summary && summary.toneSegments.length > 0 - ? true - : canDeriveAggregateMetrics, - }; - } - - function deriveOverviewSummary(group) { - var contributions = group.items.map(deriveOverviewContribution); - var summaries = group.items.map(function (item) { - return item.summary; - }).filter(Boolean); - var count = contributions.reduce(function (sum, contribution) { - return sum + contribution.count; - }, 0); - var openCount = contributions.every(function (contribution) { - return contribution.openCountKnown; - }) - ? contributions.reduce(function (sum, contribution) { - return sum + contribution.openCount; - }, 0) - : null; - var toneSegments = contributions.every(function (contribution) { - return contribution.toneSegmentsKnown; - }) - ? mergeToneSegments(contributions.reduce(function (segments, contribution) { - return segments.concat(contribution.toneSegments); - }, [])) - : []; - var primarySummary = summaries.length === 1 ? summaries[0] : null; - - return { - count: count, - openCount: openCount, - primaryLabel: primarySummary && primarySummary.primaryLabel - ? primarySummary.primaryLabel - : count > 1 - ? count + ' assignments' - : group.items[0].label, - primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), - secondaryLabel: primarySummary && primarySummary.secondaryLabel - ? primarySummary.secondaryLabel - : count > 1 - ? buildOverviewBlockMeta({ - count: count, - items: group.detailItems, - }) - : describeMeta(group.items[0].meta), - toneSegments: toneSegments, - }; - } - - function inferOpenCount(items) { - return items.reduce(function (count, item) { - if (!item) return count; - if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; - if (!item.meta || typeof item.meta !== 'object' || Array.isArray(item.meta)) return count; - if (typeof item.meta.openCount === 'number' && isFinite(item.meta.openCount)) return count + item.meta.openCount; - if (typeof item.meta.unassignedCount === 'number' && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; - if (item.meta.open === true || item.meta.unassigned === true) return count + 1; - if (typeof item.meta.status === 'string' && /open|unassigned/i.test(item.meta.status)) return count + 1; - return count; - }, 0); - } - - function mergeToneSegments(segments) { - var byTone = {}; - segments.forEach(function (segment) { - if (!segment || !(segment.count > 0)) return; - var toneId = segment.tone.id || segment.tone.border || 'slate'; - if (!byTone[toneId]) { - byTone[toneId] = { - count: 0, - tone: segment.tone, - }; - } - byTone[toneId].count += segment.count; - }); - return Object.keys(byTone).map(function (toneId) { - return byTone[toneId]; - }).sort(compareToneSegments); - } - - function buildToneSegmentsFromItems(items) { - return mergeToneSegments(items.map(function (item) { - return { - count: 1, - tone: item.tone, - }; - })); - } - - function compareToneSegments(left, right) { - if (left.count !== right.count) return right.count - left.count; - if (left.tone.id === right.tone.id) return 0; - return left.tone.id < right.tone.id ? -1 : 1; - } - - function buildOverviewBlockSummary(group, expanded) { - var badges = []; - if (group.summary.count > 1) { - badges.push({ kind: 'count', text: group.summary.count + ' total' }); - } - if (group.summary.openCount > 0) { - badges.push({ kind: 'open', text: group.summary.openCount + ' open' }); - } - if (group.isCluster) { - badges.push({ kind: 'action', text: expanded ? 'Enter to collapse' : 'Enter to inspect' }); - } - return { - badges: badges, - toneSegments: group.summary.toneSegments, - }; - } - - function buildItemAriaLabel(item, lane) { - var parts = [ - lane.label, - item.label, - formatMinuteRange(item.startMinute, item.endMinute, lane.axis), - ]; - var meta = describeMeta(item.meta); - if (meta) parts.push(meta); - return parts.join(' · '); - } - - function buildOverviewAriaLabel(group, lane, expanded) { - var parts = [ - lane.label, - group.summary.primaryLabel, - formatMinuteRange(group.startMinute, group.endMinute, lane.axis), - ]; - if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); - if (group.summary.count > 1) parts.push(group.summary.count + ' assignments'); - if (group.summary.openCount > 0) parts.push(group.summary.openCount + ' open'); - if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); - if (group.isCluster) parts.push(expanded ? 'Expanded. Press Enter to collapse' : 'Press Enter to expand'); - return parts.join(' · '); - } - - function describeToneSegments(segments) { - return segments.map(function (segment) { - return segment.count + ' ' + segment.tone.id; - }).join(', '); - } - - function buildOverviewTooltip(group, lane) { - if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { - return buildClusterTooltip(group, lane); - } - return buildItemTooltip(group.items[0], lane); - } - - function packItems(items) { - var trackEnds = []; - var packed = []; - - items.slice().sort(compareItems).forEach(function (item) { - var trackIndex = 0; - while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { - trackIndex += 1; - } - if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); - else trackEnds[trackIndex] = item.endMinute; - packed.push({ - item: item, - trackIndex: trackIndex, - }); - }); - - return { - items: packed, - trackCount: trackEnds.length, - }; - } - - function positionPct(minute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return ((minute - axis.startMinute) / total) * 100; - } - - function spanPct(startMinute, endMinute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return Math.max(((endMinute - startMinute) / total) * 100, 0.25); - } - - function spanPctExact(startMinute, endMinute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return Math.max(((endMinute - startMinute) / total) * 100, 0); - } - - function formatClock(minute) { - var normalized = minute % DAY_MINUTES; - if (normalized < 0) normalized += DAY_MINUTES; - var hours = Math.floor(normalized / 60); - var minutes = normalized % 60; - return pad(hours) + ':' + pad(minutes); - } - - function formatMinuteRange(startMinute, endMinute, axis) { - return formatMinute(startMinute, axis) + ' → ' + formatMinute(endMinute, axis); - } - - function formatMinute(minute, axis) { - var dayLabel = ''; - axis.days.forEach(function (day) { - if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { - dayLabel = day.label; - } - }); - return (dayLabel ? dayLabel + ' ' : '') + formatClock(minute); - } - - function pad(value) { - return value < 10 ? '0' + value : String(value); - } - - function inferWeekend(label) { - return /sat|sun|weekend/i.test(String(label || '')); - } - - function isColorString(value) { - return /^#|^rgb|^hsl/i.test(String(value || '')); - } - - function resolveTone(tone) { - if (tone && typeof tone === 'object') { - return { - id: tone.id || tone.name || tone.borderColor || tone.color || 'custom', - background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, - border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, - overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, - text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text, - }; - } - if (TONE_MAP[tone]) return TONE_MAP[tone]; - if (isColorString(tone)) { - return { - id: String(tone), - background: tone, - border: tone, - overlay: tone, - text: '#111827', - }; - } - return TONE_MAP.slate; - } - - function measureLayout(bodyViewport, state) { - var viewportWidth = getMeasuredViewportWidth(bodyViewport); - if (!(viewportWidth > 0)) return null; - - var preferredLabelWidth = state.labelWidth; - var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; - var effectiveLabelWidth = preferredLabelWidth; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; - var scale = totalDuration > 0 && visibleDuration > 0 - ? totalDuration / visibleDuration - : 1; - if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; - if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); - else effectiveLabelWidth = MIN_LABEL_WIDTH; - - var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); - var contentTrackWidth = Math.max( - Math.round(visibleTrackWidth * scale), - visibleTrackWidth, - MIN_CONTENT_TRACK_WIDTH - ); - var contentWidth = effectiveLabelWidth + contentTrackWidth; - - return { - contentWidth: contentWidth, - contentTrackWidth: contentTrackWidth, - effectiveLabelWidth: effectiveLabelWidth, - visibleTrackWidth: visibleTrackWidth, - viewportWidth: viewportWidth, - }; - } - - function viewportToScrollLeft(state, viewportEl) { - var axis = state.model.axis; - var totalDuration = axis.endMinute - axis.startMinute; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var remainingDuration = Math.max(totalDuration - visibleDuration, 0); - var maxScrollLeft = getMaxScrollLeft(viewportEl); - if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; - return Math.round(((state.viewport.startMinute - axis.startMinute) / remainingDuration) * maxScrollLeft); - } - - function scrollLeftToViewport(state, viewportEl) { - var axis = state.model.axis; - var totalDuration = axis.endMinute - axis.startMinute; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var remainingDuration = Math.max(totalDuration - visibleDuration, 0); - var maxScrollLeft = getMaxScrollLeft(viewportEl); - if (remainingDuration <= 0 || maxScrollLeft <= 0) { - return clampViewport(axis, { - startMinute: axis.startMinute, - endMinute: axis.startMinute + visibleDuration, - }); - } - var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); - var startMinute = axis.startMinute + remainingDuration * ratio; - return clampViewport(axis, { - startMinute: startMinute, - endMinute: startMinute + visibleDuration, - }); - } - - function getMaxScrollLeft(viewportEl) { - var scrollWidth = viewportEl.scrollWidth || 0; - var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; - return Math.max(scrollWidth - clientWidth, 0); - } - - function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { - if (typeof ResizeObserver !== 'function') return; - - var resizeObserver = new ResizeObserver(function () { - if (state.destroyed) return; - syncLayoutFromViewport(); - }); - resizeObserver.observe(bodyViewport); - state.cleanup.push(function () { - resizeObserver.disconnect(); - }); - } - - function bindWindowResize(state, syncLayoutFromViewport) { - if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return; - - function handleResize() { - if (state.destroyed) return; - syncLayoutFromViewport(); - } - - window.addEventListener('resize', handleResize); - state.cleanup.push(function () { - if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', handleResize); - }); - } - - function getMeasuredViewportWidth(bodyViewport) { - if (!bodyViewport) return 0; - if (typeof bodyViewport.clientWidth === 'number' && bodyViewport.clientWidth > 0) { - return Math.round(bodyViewport.clientWidth); - } - if (typeof bodyViewport.offsetWidth === 'number' && bodyViewport.offsetWidth > 0) { - return Math.round(bodyViewport.offsetWidth); - } - if (typeof bodyViewport.getBoundingClientRect === 'function') { - var rect = bodyViewport.getBoundingClientRect(); - if (rect && typeof rect.width === 'number' && rect.width > 0) { - return Math.round(rect.width); - } - } - return 0; - } - - function applyLayout(root, headerRow, lanes, layout) { - setCustomProperty(root.style, '--sf-rail-label-width', layout ? layout.effectiveLabelWidth + 'px' : ''); - setCustomProperty(root.style, '--sf-rail-content-width', layout ? layout.contentWidth + 'px' : ''); - headerRow.style.width = layout ? layout.contentWidth + 'px' : ''; - lanes.style.width = layout ? layout.contentWidth + 'px' : ''; - root.dataset.supportedViewportWidth = layout - ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) - : ''; - } - - function setCustomProperty(style, name, value) { - if (!style) return; - if (typeof style.setProperty === 'function') { - style.setProperty(name, value); - return; - } - style[name] = value; - } - - function queuePostMountSync(state, syncLayoutFromViewport) { - if (state.hasQueuedPostMountSync || typeof setTimeout !== 'function') return; - state.hasQueuedPostMountSync = true; - - var timerId = setTimeout(function () { - state.hasQueuedPostMountSync = false; - if (state.destroyed) return; - syncLayoutFromViewport(); - }, 0); - - state.cleanup.push(function () { - if (typeof clearTimeout === 'function') clearTimeout(timerId); - }); - } - - function normalizeViewportInput(viewport, label) { - if (viewport == null) return null; - sf.assert(typeof viewport === 'object', label + ' must be an object'); - - return normalizeMinuteRange( - viewport.startMinute, - viewport.endMinute, - label + '.startMinute', - label + '.endMinute' - ); - } - - function showTooltip(tooltip, root, payload, event) { - if (!payload) return; - tooltip.setAttribute('aria-hidden', 'false'); - tooltip.innerHTML = ''; - tooltip.appendChild(sf.el('div', { className: 'sf-tooltip-title' }, payload.title)); - (payload.rows || []).forEach(function (row) { - var rowEl = sf.el('div', { className: 'sf-tooltip-row' }); - rowEl.appendChild(sf.el('span', { className: 'sf-tooltip-key' }, row.key)); - rowEl.appendChild(sf.el('span', { className: 'sf-tooltip-val' }, row.value)); - tooltip.appendChild(rowEl); - }); - - var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; - var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; - var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - tooltip.classList.add('visible'); - } - - function showTooltipForElement(tooltip, root, payload, element) { - var rect = element && typeof element.getBoundingClientRect === 'function' - ? element.getBoundingClientRect() - : null; - showTooltip(tooltip, root, payload, rect ? { - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - } : null); - } - - function hideTooltip(tooltip) { - tooltip.setAttribute('aria-hidden', 'true'); - tooltip.classList.remove('visible'); - } - - function updateViewportMetadata(root, state) { - var axis = state.model.axis; - var duration = state.viewport.endMinute - state.viewport.startMinute; - root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); - root.dataset.viewportDurationMinutes = String(Math.round(duration)); - root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); - root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); - } - - function updateZoomButtons(buttons, state) { - var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); - var initial = state.model.axis.initialViewport; - buttons.forEach(function (button) { - var preset = button.dataset.zoom; - var active = false; - if (preset === 'reset') { - active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) - && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); - } else if (preset === '1w') active = duration === WEEK_MINUTES; - else if (preset === '2w') active = duration === WEEK_MINUTES * 2; - else if (preset === '4w') active = duration === WEEK_MINUTES * 4; - button.classList.toggle('active', active); - }); - } - - function normalizeZoomPresets(presets) { - if (presets == null) return ['1w', '2w', '4w', 'reset']; - sf.assert(Array.isArray(presets), 'rail.createTimeline(zoomPresets) must be an array'); - presets.forEach(function (preset, index) { - sf.assert( - ['1w', '2w', '4w', 'reset'].indexOf(preset) >= 0, - 'rail.createTimeline(zoomPresets[' + index + ']) must be one of 1w, 2w, 4w, reset' - ); - }); - return presets.slice(); - } - - function pruneExpandedClusters(state) { - Object.keys(state.expandedClusters).forEach(function (laneId) { - var exists = state.model.lanes.some(function (lane) { - return lane.id === laneId; - }); - if (!exists) delete state.expandedClusters[laneId]; - }); - } - -})(SF); diff --git a/js-src/14-gantt.js b/js-src/14-gantt.js deleted file mode 100644 index 109fc60..0000000 --- a/js-src/14-gantt.js +++ /dev/null @@ -1,400 +0,0 @@ -/* ============================================================================ - SolverForge UI — Gantt (Frappe Gantt + Split.js wrapper) - Requires: Frappe Gantt (Gantt) and Split (Split) loaded globally. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.gantt = {}; - - sf.gantt.create = function (config) { - config = config || {}; - var instanceId = sf.uid('sf-gantt'); - var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); - var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); - var chartContainerId = config.chartContainer || (instanceId + '-container'); - var svgId = config.svgId || (instanceId + '-svg'); - var ganttChart = null; - var splitInstance = null; - var mounted = false; - var mountTarget = null; - var resizeObserver = null; - var tasks = []; - var sortState = { key: null, direction: 'asc' }; - - // ── Build DOM ── - var wrapper = sf.el('div', { className: 'sf-gantt-split' }); - - // Grid pane - var gridPane = sf.el('div', { className: 'sf-gantt-pane', id: gridPaneId }); - var gridHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); - gridHeader.appendChild(sf.el('h3', null, config.gridTitle || 'Tasks')); - var gridControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); - gridHeader.appendChild(gridControls); - gridPane.appendChild(gridHeader); - - var gridContent = sf.el('div', { className: 'sf-gantt-pane-content' }); - var grid = sf.el('div', { className: 'sf-gantt-grid' }); - gridContent.appendChild(grid); - gridPane.appendChild(gridContent); - - // Chart pane - var chartPane = sf.el('div', { className: 'sf-gantt-pane', id: chartPaneId }); - var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); - chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline')); - - var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' }); - var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' }); - var modes = [ - { value: 'Quarter Day', label: 'Quarter Day' }, - { value: 'Half Day', label: 'Half Day' }, - { value: 'Day', label: 'Day' }, - { value: 'Week', label: 'Week' }, - { value: 'Month', label: 'Month' }, - ]; - modes.forEach(function (m) { - var opt = sf.el('option', { value: m.value }, m.label); - if (m.value === (config.viewMode || 'Quarter Day')) opt.selected = true; - viewSelect.appendChild(opt); - }); - viewSelect.addEventListener('change', function () { - if (ganttChart) ganttChart.change_view_mode(viewSelect.value); - }); - viewControls.appendChild(viewSelect); - - var chartControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); - chartHeader.appendChild(viewControls); - chartHeader.appendChild(chartControls); - chartPane.appendChild(chartHeader); - - var chartContent = sf.el('div', { className: 'sf-gantt-pane-content' }); - var chartContainer = sf.el('div', { className: 'sf-gantt-container', id: chartContainerId }); - chartContent.appendChild(chartContainer); - chartPane.appendChild(chartContent); - - wrapper.appendChild(gridPane); - wrapper.appendChild(chartPane); - - // ── API ── - var ctrl = { el: wrapper }; - - ctrl.mount = function (parent) { - sf.assert(parent, 'gantt.mount(parent) requires a mount target'); - var target = typeof parent === 'string' ? document.getElementById(parent) : parent; - sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); - validateMountTarget(target); - - if (mounted && mountTarget === target && wrapper.parentNode === target) { - return; - } - if (mounted) ctrl.destroy(); - target.appendChild(wrapper); - mounted = true; - mountTarget = target; - if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { - renderGrid(tasks); - renderChart(tasks); - } - initSplit(); - bindResizeObserver(); - }; - - ctrl.setTasks = function (newTasks) { - sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); - tasks = newTasks; - renderGrid(newTasks); - renderChart(newTasks); - }; - - ctrl.refresh = function () { - if (ganttChart && tasks.length > 0) { - ganttChart.refresh(tasksToFrappe(tasks)); - } - }; - - ctrl.getChart = function () { return ganttChart; }; - - ctrl.changeViewMode = function (mode) { - viewSelect.value = mode; - if (ganttChart) ganttChart.change_view_mode(mode); - }; - - ctrl.highlightTask = function (taskId) { - grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { - row.classList.toggle('selected', row.dataset.taskId === taskId); - }); - var svg = chartContainer.querySelector('svg'); - if (svg) { - svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { - bw.classList.remove('highlighted'); - }); - var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); - if (bar) bar.classList.add('highlighted'); - } - }; - - ctrl.destroy = function () { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = null; - } - if (splitInstance) { splitInstance.destroy(); splitInstance = null; } - ganttChart = null; - mounted = false; - mountTarget = null; - if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); - }; - - return ctrl; - - function initSplit() { - if (typeof Split !== 'function') return; - if (splitInstance) { - splitInstance.destroy(); - splitInstance = null; - } - - var splitSizes = normalizePair(config.splitSizes, [40, 60]); - var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); - - splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { - direction: 'vertical', - sizes: splitSizes, - minSize: splitMinSize, - snapOffset: 30, - gutterSize: 4, - cursor: 'col-resize', - onDragEnd: function () { - if (ganttChart) { - setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 100); - } - }, - }); - } - - function bindResizeObserver() { - if (typeof ResizeObserver !== 'function') return; - if (resizeObserver) { - resizeObserver.disconnect(); - } - resizeObserver = new ResizeObserver(function () { - if (!ganttChart) return; - setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); - }); - if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); - } - - function normalizePair(value, fallback) { - if (typeof value === 'number' && isFinite(value)) return [value, value]; - if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); - var n0 = Number(value[0]); - var n1 = Number(value[1]); - if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); - return [n0, n1]; - } - - function validateMountTarget(target) { - sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); - sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); - } - - function getElementSize(target, axis) { - var clientKey = 'client' + axis; - var offsetKey = 'offset' + axis; - var rectKey = axis === 'Width' ? 'width' : 'height'; - - if (typeof target[clientKey] === 'number') return target[clientKey]; - if (typeof target[offsetKey] === 'number') return target[offsetKey]; - if (typeof target.getBoundingClientRect === 'function') { - var rect = target.getBoundingClientRect(); - if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; - } - return 0; - } - - function tasksToFrappe(taskList) { - return taskList - .filter(function (t) { return t.start && t.end; }) - .map(function (t) { - var customClass = t.custom_class || ''; - if (t.pinned) { - customClass = customClass ? customClass + ' pinned' : 'pinned'; - } - return { - id: t.id, - name: t.name || t.label || t.id, - start: t.start, - end: t.end, - custom_class: customClass, - dependencies: t.dependencies || '', - }; - }); - } - - function renderChart(taskList) { - var frappeTasks = tasksToFrappe(taskList); - - if (frappeTasks.length === 0) { - chartContainer.textContent = ''; - chartContainer.appendChild(sf.el('div', { - className: 'sf-gantt-empty-state', - style: { - padding: '24px', - color: 'var(--sf-gray-400)', - fontFamily: 'var(--sf-font-mono)', - fontSize: '13px', - }, - }, 'No scheduled tasks to display.')); - ganttChart = null; - return; - } - - chartContainer.textContent = ''; - chartContainer.appendChild(createSvgRoot(svgId)); - - ganttChart = new Gantt('#' + svgId, frappeTasks, { - view_mode: viewSelect.value || 'Quarter Day', - date_format: 'YYYY-MM-DD HH:mm', - custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, - on_click: function (task) { - ctrl.highlightTask(task.id); - if (config.onTaskClick) config.onTaskClick(task); - }, - on_date_change: function (task, start, end) { - if (config.onDateChange) config.onDateChange(task, start, end); - }, - }); - } - - function renderGrid(taskList) { - while (grid.firstChild) grid.removeChild(grid.firstChild); - var table = sf.el('table', { className: 'sf-gantt-table' }); - var columns = config.columns || [ - { key: 'name', label: 'Task' }, - { key: 'start', label: 'Start' }, - { key: 'end', label: 'End' }, - ]; - var sortedTasks = sortTasks(taskList); - - var thead = sf.el('thead'); - var headerRow = sf.el('tr'); - columns.forEach(function (col) { - headerRow.appendChild(buildHeaderCell(col)); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - var tbody = sf.el('tbody'); - sortedTasks.forEach(function (task) { - var rowClasses = ['sf-gantt-row']; - if (task.custom_class) rowClasses.push(task.custom_class); - if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); - - var tr = sf.el('tr', { - className: rowClasses.join(' '), - dataset: { taskId: task.id }, - onClick: function () { - ctrl.highlightTask(task.id); - if (config.onTaskClick) config.onTaskClick(task); - }, - }); - - columns.forEach(function (col) { - var td = sf.el('td'); - if (col.key === 'name') { - td.className = 'sf-task-name'; - td.textContent = task.name || task.label || task.id; - } else if (col.render) { - var content = col.render(task); - if (typeof content === 'string') td.textContent = content; - else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; - else if (content instanceof Node) td.appendChild(content); - } else { - td.textContent = task[col.key] || ''; - td.style.fontFamily = 'var(--sf-font-mono)'; - td.style.fontSize = '12px'; - } - tr.appendChild(td); - }); - - tbody.appendChild(tr); - }); - table.appendChild(tbody); - grid.appendChild(table); - } - - function buildHeaderCell(col) { - if (!col.sortable) { - return sf.el('th', null, col.label); - } - - var isCurrent = sortState.key === col.key; - var th = sf.el('th', { - className: 'sortable' + (isCurrent ? ' active' : ''), - role: 'button', - tabIndex: 0, - 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', - }); - th.appendChild(document.createTextNode(col.label)); - th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : '')); - - sf.bindActivation(th, function () { - if (sortState.key === col.key) { - sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; - } else { - sortState.key = col.key; - sortState.direction = 'asc'; - } - renderGrid(tasks); - }); - - return th; - } - - function sortTasks(taskList) { - if (!sortState.key) return taskList.slice(); - var sorted = taskList.slice(); - sorted.sort(function (a, b) { - var aVal = sortValue(a[sortState.key], sortState.key); - var bVal = sortValue(b[sortState.key], sortState.key); - if (aVal === bVal) return 0; - if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; - return aVal > bVal ? -1 : 1; - }); - return sorted; - } - - function sortValue(value, key) { - if (value == null) return ''; - if (key === 'start' || key === 'end') { - var parsed = Date.parse(value); - return isNaN(parsed) ? String(value).toLowerCase() : parsed; - } - if (typeof value === 'number') return value; - return String(value).toLowerCase(); - } - - function defaultPopup(task) { - var t = tasks.find(function (x) { return x.id === task.id; }); - if (!t) return ''; - return '
' + - '

' + sf.escHtml(t.name || t.id) + '

' + - '

Start: ' + sf.escHtml(t.start) + '

' + - '

End: ' + sf.escHtml(t.end) + '

' + - (t.duration_minutes ? '

Duration: ' + t.duration_minutes + ' min

' : '') + - (t.pinned ? '

Pinned

' : '') + - '
'; - } - - function createSvgRoot(id) { - if (document.createElementNS) { - var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.id = id; - return svg; - } - return sf.el('svg', { id: id }); - } - }; - -})(SF); diff --git a/js-src/15-footer.js b/js-src/15-footer.js deleted file mode 100644 index 8073767..0000000 --- a/js-src/15-footer.js +++ /dev/null @@ -1,24 +0,0 @@ -/* ============================================================================ - SolverForge UI — Footer Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createFooter = function (config) { - sf.assert(config, 'createFooter(config) requires a configuration object'); - - var footer = sf.el('footer', { className: 'sf-footer' }); - if (config.links) { - config.links.forEach(function (link, i) { - if (i > 0) footer.appendChild(sf.el('span', { className: 'sf-vr' })); - footer.appendChild(sf.el('a', { href: link.url, target: '_blank' }, link.label)); - }); - } - if (config.version) { - footer.appendChild(sf.el('span', { style: { marginLeft: 'auto' } }, config.version)); - } - return footer; - }; - -})(SF); diff --git a/package-lock.json b/package-lock.json index 5774f71..dcb62f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,455 @@ "": { "name": "solverforge-ui", "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "esbuild": "^0.28.0", "eslint": "^10.2.1", "globals": "^17.5.0", - "playwright": "^1.59.1" + "playwright": "^1.59.1", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -205,6 +651,236 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -308,6 +984,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -494,6 +1212,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -797,6 +1533,19 @@ "node": ">=8" } }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -849,6 +1598,19 @@ "node": ">=6" } }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -872,6 +1634,36 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -885,6 +1677,20 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index ac49de0..ed79930 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,30 @@ { "name": "solverforge-ui", + "version": "0.6.5", "private": true, + "type": "module", + "exports": { + ".": "./static/sf/sf.mjs", + "./sf.js": "./static/sf/sf.js", + "./sf.css": "./static/sf/sf.css" + }, + "files": [ + "static/sf" + ], "scripts": { - "lint:frontend": "eslint js-src tests scripts", - "test:browser": "node tests/demo-browser-check.js" + "lint:frontend": "eslint ts-src tests scripts", + "typecheck:frontend": "tsc --noEmit", + "test:browser": "node tests/demo-browser-check.js", + "build:esm": "esbuild ts-src/index.ts --bundle --format=esm --outfile=static/sf/sf.mjs", + "build:iife": "esbuild ts-src/index.ts --bundle --format=iife --global-name=SF --footer:js=\"if (typeof window !== 'undefined') window.SF = SF;\" --outfile=static/sf/sf.js" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "esbuild": "^0.28.0", "eslint": "^10.2.1", "globals": "^17.5.0", - "playwright": "^1.59.1" + "playwright": "^1.59.1", + "typescript": "^6.0.3" } } diff --git a/scripts/cargo-version.js b/scripts/cargo-version.js index cf6b833..54e9b92 100644 --- a/scripts/cargo-version.js +++ b/scripts/cargo-version.js @@ -1,10 +1,10 @@ -const REGEX = /^version = "(.*)"/m +const REGEX = /^version = "(.*)"/m; -module.exports.readVersion = function (contents) { - const match = contents.match(REGEX) - return match ? match[1] : undefined +export function readVersion(contents) { + const match = contents.match(REGEX); + return match ? match[1] : undefined; } -module.exports.writeVersion = function (contents, version) { - return contents.replace(REGEX, `version = "${version}"`) +export function writeVersion(contents, version) { + return contents.replace(REGEX, `version = "${version}"`); } diff --git a/scripts/sync-version.py b/scripts/sync-version.py index 85844fd..d9d4c05 100644 --- a/scripts/sync-version.py +++ b/scripts/sync-version.py @@ -39,11 +39,12 @@ def main() -> None: old, new = sys.argv[1], sys.argv[2] + rewrite("package.json", rf'"version":\s*"{re.escape(old)}"', f'"version": "{new}"') rewrite("Cargo.toml", rf'^version = "{re.escape(old)}"$', f'version = "{new}"') rewrite( - "js-src/00-core.js", - rf"const sf = \{{ version: '{re.escape(old)}' \}};", - f"const sf = {{ version: '{new}' }};", + "ts-src/core/index.ts", + rf"export const version =\s*'{re.escape(old)}';", + f"export const version = '{new}';", ) rewrite( "README.md", diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh index 7c6f66a..96baa0d 100755 --- a/scripts/verify-package.sh +++ b/scripts/verify-package.sh @@ -5,6 +5,7 @@ manifest="$(mktemp)" trap 'rm -f "$manifest"' EXIT cargo package --allow-dirty --list > "$manifest" +version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" if command -v rg >/dev/null 2>&1; then search_exact() { @@ -56,6 +57,10 @@ require "CHANGELOG.md" require "src/lib.rs" require "static/sf/sf.css" require "static/sf/sf.js" +require "static/sf/sf.mjs" +require "static/sf/sf.${version}.css" +require "static/sf/sf.${version}.js" +require "static/sf/sf.${version}.mjs" require "static/sf/vendor/frappe-gantt/frappe-gantt.min.js" require "static/sf/vendor/split/split.min.js" require "static/sf/fonts/space-grotesk.woff2" @@ -63,7 +68,7 @@ require "static/sf/fonts/jetbrains-mono.woff2" require "static/sf/img/ouroboros.svg" reject_prefix "css-src/" -reject_prefix "js-src/" +reject_prefix "ts-src/" reject_prefix "screenshots/" reject_prefix "scripts/" reject_exact "WIREFRAME.md" diff --git a/src/lib.rs b/src/lib.rs index ea2a6c0..ec32868 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ fn mime_from_path(path: &str) -> &'static str { match path.rsplit('.').next() { Some("css") => "text/css; charset=utf-8", Some("js") => "application/javascript; charset=utf-8", + Some("mjs") => "application/javascript; charset=utf-8", Some("svg") => "image/svg+xml", Some("woff2") => "font/woff2", Some("woff") => "font/woff", @@ -71,7 +72,7 @@ fn is_versioned_bundle(path: &str) -> bool { || ch == '+' || ch.is_ascii_alphabetic() }) - && matches!(ext, "css" | "js") + && matches!(ext, "css" | "js" | "mjs") }) .unwrap_or(false) } @@ -89,11 +90,15 @@ mod tests { fn versioned_bundles_are_detected() { assert!(is_versioned_bundle("sf.0.3.0.css")); assert!(is_versioned_bundle("sf.0.3.0.js")); + assert!(is_versioned_bundle("sf.0.3.0.mjs")); assert!(is_versioned_bundle("sf.0.3.0-beta.1.js")); assert!(is_versioned_bundle("sf.0.3.0+build.7.css")); + assert!(is_versioned_bundle("sf.0.3.0+build.7.mjs")); assert!(!is_versioned_bundle("sf.css")); assert!(!is_versioned_bundle("sf.js")); + assert!(!is_versioned_bundle("sf.mjs")); assert!(!is_versioned_bundle("vendor/sf.0.3.0.js")); + assert!(!is_versioned_bundle("vendor/sf.0.3.0.mjs")); } #[test] @@ -103,6 +108,10 @@ mod tests { mime_from_path("scripts/sf.js"), "application/javascript; charset=utf-8" ); + assert_eq!( + mime_from_path("scripts/sf.mjs"), + "application/javascript; charset=utf-8" + ); assert_eq!(mime_from_path("img/logo.svg"), "image/svg+xml"); assert_eq!(mime_from_path("font.woff2"), "font/woff2"); @@ -112,6 +121,8 @@ mod tests { assert!(is_immutable("sf.0.3.0.css")); assert!(is_immutable("sf.0.3.0+build.7.js")); assert!(!is_immutable("sf.css")); + assert!(is_immutable("sf.0.3.0+build.7.mjs")); + assert!(!is_immutable("sf.mjs")); } #[test] @@ -121,6 +132,10 @@ mod tests { mime_from_path("sf.0.3.0+build.7.js"), "application/javascript; charset=utf-8" ); + assert_eq!( + mime_from_path("sf.0.3.0+build.7.mjs"), + "application/javascript; charset=utf-8" + ); } #[tokio::test] diff --git a/static/sf/sf.0.6.5.js b/static/sf/sf.0.6.5.js index 947a273..558b5b6 100644 --- a/static/sf/sf.0.6.5.js +++ b/static/sf/sf.0.6.5.js @@ -1,151 +1,70 @@ -/* ============================================================================ - SolverForge UI — Core - ============================================================================ */ - -const SF = (function () { - 'use strict'; - - const sf = { version: '0.6.5' }; - var uidCounter = 0; - - /* ── Utilities ── */ - - sf.escHtml = function (str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }; - - sf.assert = function (cond, message) { - if (!cond) throw new Error('[SolverForge] ' + message); - }; - - sf.normalizeCreateJobId = function (raw) { - var value = raw; - if (value && typeof value === 'object') { - if (value.id != null) value = value.id; - else if (value.jobId != null) value = value.jobId; - else if (value.job_id != null) value = value.job_id; - else if (value.data && typeof value.data === 'object' && value.data.id != null) value = value.data.id; - else return ''; - } - - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim(); - return ''; - }; - - sf.el = function (tag, attrs) { - var children = Array.prototype.slice.call(arguments, 2); - var el = document.createElement(tag); - if (attrs) { - Object.keys(attrs).forEach(function (key) { - if (key === 'className') el.className = attrs[key]; - else if (key === 'style' && typeof attrs[key] === 'object') { - Object.assign(el.style, attrs[key]); - } - else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); - else if (key === 'dataset') Object.assign(el.dataset, attrs[key]); - else if (key === 'html') el.textContent = attrs[key]; - else if (key === 'unsafeHtml') el.innerHTML = attrs[key]; - else el.setAttribute(key, attrs[key]); - }); - } - children.forEach(function (child) { - if (child == null) return; - if (typeof child === 'string') el.appendChild(document.createTextNode(child)); - else if (child instanceof Node) el.appendChild(child); - }); - return el; - }; - - sf.uid = function (prefix) { - uidCounter += 1; - return (prefix || 'sf') + '-' + uidCounter; +var SF = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); }; - - sf.bindActivation = function (el, onActivate) { - if (!el || typeof onActivate !== 'function') return; - - function handleActivate(e) { - if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; - if (e.type === 'keydown') e.preventDefault(); - onActivate(e); + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } - - el.addEventListener('click', handleActivate); - el.addEventListener('keydown', handleActivate); - }; - - if (typeof window !== 'undefined') window.SF = sf; - return sf; -})(); -/* ============================================================================ - SolverForge UI — Score Parsing - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.score = {}; - - sf.score.parseHard = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)hard/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.parseSoft = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)soft/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.parseMedium = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)medium/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.getComponents = function (scoreStr) { - return { - hard: sf.score.parseHard(scoreStr), - medium: sf.score.parseMedium(scoreStr), - soft: sf.score.parseSoft(scoreStr), - }; - }; - - sf.score.colorClass = function (scoreStr) { - var hard = sf.score.parseHard(scoreStr); - var soft = sf.score.parseSoft(scoreStr); - return hard < 0 ? 'score-red' : soft < 0 ? 'score-yellow' : 'score-green'; + return to; }; - -})(SF); -/* ============================================================================ - SolverForge UI — Color Factory - Tango palette + project color assignment. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8]; - var SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B]; - + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // ts-src/index.ts + var index_exports = {}; + __export(index_exports, { + assert: () => assert, + bindActivation: () => bindActivation, + colorClass: () => colorClass, + colors: () => colors, + createApiGuide: () => createApiGuide, + createBackend: () => createBackend, + createButton: () => createButton, + createFooter: () => createFooter, + createHeader: () => createHeader, + createModal: () => createModal, + createSolver: () => createSolver, + createStatusBar: () => createStatusBar, + createTable: () => createTable, + createTabs: () => createTabs, + el: () => el, + escHtml: () => escHtml, + gantt: () => gantt, + getComponents: () => getComponents, + normalizeCreateJobId: () => normalizeCreateJobId, + parseHard: () => parseHard, + parseMedium: () => parseMedium, + parseSoft: () => parseSoft, + pick: () => pick, + project: () => project, + rail: () => rail, + reset: () => reset, + score: () => score, + showError: () => showError, + showTab: () => showTab, + showToast: () => showToast, + uid: () => uid, + version: () => version + }); + + // ts-src/utils/colors.ts + var SEQUENCE_1 = [9101876, 16574799, 7512015, 15317358, 11370408]; + var SEQUENCE_2 = [7590422, 15586304, 3433892, 12680465, 7688315]; var colorMap = {}; var nextColorCount = 0; - function buildPercentageColor(floor, ceil, pct) { - var red = (floor & 0xFF0000) + Math.floor(pct * ((ceil & 0xFF0000) - (floor & 0xFF0000))) & 0xFF0000; - var green = (floor & 0x00FF00) + Math.floor(pct * ((ceil & 0x00FF00) - (floor & 0x00FF00))) & 0x00FF00; - var blue = (floor & 0x0000FF) + Math.floor(pct * ((ceil & 0x0000FF) - (floor & 0x0000FF))) & 0x0000FF; + var red = (floor & 16711680) + Math.floor(pct * ((ceil & 16711680) - (floor & 16711680))) & 16711680; + var green = (floor & 65280) + Math.floor(pct * ((ceil & 65280) - (floor & 65280))) & 65280; + var blue = (floor & 255) + Math.floor(pct * ((ceil & 255) - (floor & 255))) & 255; return red | green | blue; } - function nextColor() { var colorIndex = nextColorCount % SEQUENCE_1.length; var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); @@ -156,123 +75,230 @@ const SF = (function () { color = SEQUENCE_2[colorIndex]; } else { shadeIndex -= 3; - var base = Math.floor((shadeIndex / 2) + 1); + var base = Math.floor(shadeIndex / 2 + 1); var divisor = 2; while (base >= divisor) divisor *= 2; - base = (base * 2) - divisor + 1; + base = base * 2 - divisor + 1; color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); } nextColorCount++; - return '#' + color.toString(16).padStart(6, '0'); + return "#" + color.toString(16).padStart(6, "0"); } - - sf.colors = {}; - - sf.colors.pick = function (key) { - if (colorMap[key] !== undefined) return colorMap[key]; + var pick = function(key) { + if (colorMap[key] !== void 0) return colorMap[key]; var c = nextColor(); colorMap[key] = c; return c; }; - - sf.colors.reset = function () { + var reset = function() { colorMap = {}; nextColorCount = 0; }; - var PROJECT_COLORS = [ - { main: '#10b981', dark: '#047857', light: 'rgba(16,185,129,0.15)' }, - { main: '#3b82f6', dark: '#1d4ed8', light: 'rgba(59,130,246,0.15)' }, - { main: '#8b5cf6', dark: '#6d28d9', light: 'rgba(139,92,246,0.15)' }, - { main: '#f59e0b', dark: '#b45309', light: 'rgba(245,158,11,0.15)' }, - { main: '#ec4899', dark: '#be185d', light: 'rgba(236,72,153,0.15)' }, - { main: '#06b6d4', dark: '#0e7490', light: 'rgba(6,182,212,0.15)' }, - { main: '#f43f5e', dark: '#be123c', light: 'rgba(244,63,94,0.15)' }, - { main: '#84cc16', dark: '#4d7c0f', light: 'rgba(132,204,22,0.15)' }, + { main: "#10b981", dark: "#047857", light: "rgba(16,185,129,0.15)" }, + { main: "#3b82f6", dark: "#1d4ed8", light: "rgba(59,130,246,0.15)" }, + { main: "#8b5cf6", dark: "#6d28d9", light: "rgba(139,92,246,0.15)" }, + { main: "#f59e0b", dark: "#b45309", light: "rgba(245,158,11,0.15)" }, + { main: "#ec4899", dark: "#be185d", light: "rgba(236,72,153,0.15)" }, + { main: "#06b6d4", dark: "#0e7490", light: "rgba(6,182,212,0.15)" }, + { main: "#f43f5e", dark: "#be123c", light: "rgba(244,63,94,0.15)" }, + { main: "#84cc16", dark: "#4d7c0f", light: "rgba(132,204,22,0.15)" } ]; - - sf.colors.project = function (index) { + var project = function(index) { return PROJECT_COLORS[index % PROJECT_COLORS.length]; }; -})(SF); -/* ============================================================================ - SolverForge UI — Button Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createButton = function (config) { - sf.assert(config, 'createButton(config) requires a configuration object'); - - var classes = ['sf-btn']; + // ts-src/utils/score.ts + var parseHard = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)hard/); + return m ? parseInt(m[1], 10) : 0; + }; + var parseSoft = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)soft/); + return m ? parseInt(m[1], 10) : 0; + }; + var parseMedium = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)medium/); + return m ? parseInt(m[1], 10) : 0; + }; + var getComponents = function(scoreStr) { + return { + hard: parseHard(scoreStr), + medium: parseMedium(scoreStr), + soft: parseSoft(scoreStr) + }; + }; + var colorClass = function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + return hard < 0 ? "score-red" : soft < 0 ? "score-yellow" : "score-green"; + }; - if (config.variant) classes.push('sf-btn--' + config.variant); - if (config.size === 'small') classes.push('sf-btn--sm'); - if (config.size === 'large') classes.push('sf-btn--lg'); - if (config.pill) classes.push('sf-btn--pill'); - if (config.circle) classes.push('sf-btn--circle'); - if (config.outline) classes.push('sf-btn--outline'); - if (config.iconOnly) classes.push('sf-btn--icon'); + // ts-src/core/index.ts + var version = "0.6.5"; + var uidCounter = 0; + var escHtml = function(str) { + if (!str) return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + }; + var assert = function(cond, message) { + if (!cond) throw new Error("[SolverForge] " + message); + }; + var normalizeCreateJobId = function(raw) { + var value = raw; + if (value && typeof value === "object") { + if (value.id != null) value = value.id; + else if (value.jobId != null) value = value.jobId; + else if (value.job_id != null) value = value.job_id; + else if (value.data && typeof value.data === "object" && value.data.id != null) value = value.data.id; + else return ""; + } + if (typeof value === "string") return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value).trim(); + return ""; + }; + var el = function(tag, attrs = {}, ...children) { + var el2 = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function(key) { + var value = attrs[key]; + if (key === "className") el2.className = value; + else if (key === "style" && typeof value === "object") { + Object.assign(el2.style, value); + } else if (key.indexOf("on") === 0) { + el2.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === "dataset") Object.assign(el2.dataset, value); + else if (key === "html") el2.textContent = value; + else if (key === "unsafeHtml") el2.innerHTML = value; + else el2.setAttribute(key, value); + }); + } + children.forEach(function(child) { + if (child == null) return; + if (typeof child === "string") el2.appendChild(document.createTextNode(child)); + else if (child instanceof Node) el2.appendChild(child); + }); + return el2; + }; + var uid = function(prefix) { + uidCounter += 1; + return (prefix || "sf") + "-" + uidCounter; + }; + var bindActivation = function(el2, onActivate) { + if (!el2 || typeof onActivate !== "function") return; + function handleActivate(e) { + if (!e || e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return; + if (e.type === "keydown") e.preventDefault(); + onActivate(e); + } + el2.addEventListener("click", handleActivate); + el2.addEventListener("keydown", handleActivate); + }; - var btn = sf.el('button', { - className: classes.join(' '), - type: 'button', + // ts-src/components/api-guide.ts + var createApiGuide = function(config) { + assert(config, "createApiGuide(config) requires a configuration object"); + assert(Array.isArray(config.endpoints), "createApiGuide(config.endpoints) must be an array"); + var guide = el("div", { className: "sf-api-guide" }); + var endpoints = config.endpoints; + endpoints.forEach(function(ep) { + var section = el("div", { className: "sf-api-section" }); + section.appendChild(el("h3", null, (ep.method || "GET") + " " + ep.path)); + if (ep.description) { + section.appendChild(el("p", { style: { fontSize: "13px", color: "var(--sf-gray-600)", marginBottom: "8px" } }, ep.description)); + } + if (ep.curl) { + var block = el("div", { className: "sf-api-code-block" }); + block.appendChild(el("code", null, ep.curl)); + var copyBtn = el("button", { + className: "sf-copy-btn", + "aria-label": "Copy command", + onClick: function() { + navigator.clipboard.writeText(ep.curl).then(function() { + copyBtn.textContent = "Copied!"; + setTimeout(function() { + copyBtn.textContent = "Copy"; + }, 1500); + }); + } + }, "Copy"); + block.appendChild(copyBtn); + section.appendChild(block); + } + guide.appendChild(section); }); + return guide; + }; + // ts-src/components/buttons.ts + var createButton = function(config) { + assert(config, "createButton(config) requires a configuration object"); + var classes = ["sf-btn"]; + if (config.variant) classes.push("sf-btn--" + config.variant); + if (config.size === "small") classes.push("sf-btn--sm"); + if (config.size === "large") classes.push("sf-btn--lg"); + if (config.pill) classes.push("sf-btn--pill"); + if (config.circle) classes.push("sf-btn--circle"); + if (config.outline) classes.push("sf-btn--outline"); + if (config.iconOnly) classes.push("sf-btn--icon"); + var btn = el("button", { + className: classes.join(" "), + type: "button" + }); if (config.disabled) btn.disabled = true; - - sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); - + assert(!config.onClick || typeof config.onClick === "function", "createButton(onClick) must be a function"); if (config.icon) { - var icon = sf.el('i', { className: 'fa-solid ' + config.icon }); + var icon = el("i", { className: "fa-solid " + config.icon }); btn.appendChild(icon); } - if (config.text && !config.circle && !config.iconOnly) { btn.appendChild(document.createTextNode(config.text)); } - if (config.onClick) { - btn.addEventListener('click', config.onClick); + btn.addEventListener("click", config.onClick); } - if (config.tooltip) { btn.title = config.tooltip; } - if (config.ariaLabel) { - btn.setAttribute('aria-label', config.ariaLabel); + btn.setAttribute("aria-label", config.ariaLabel); } else if (config.iconOnly && config.text) { - btn.setAttribute('aria-label', config.text); + btn.setAttribute("aria-label", config.text); } else if (config.icon && !config.text) { - btn.setAttribute('aria-label', config.icon.replace(/fa-/, '').replace(/-/g, ' ')); + btn.setAttribute("aria-label", config.icon.replace(/fa-/, "").replace(/-/g, " ")); } - if (config.id) { btn.id = config.id; } - if (config.dataset) { Object.assign(btn.dataset, config.dataset); } - return btn; }; -})(SF); -/* ============================================================================ - SolverForge UI — Header Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createHeader = function (config) { - sf.assert(config, 'createHeader(config) requires a configuration object'); + // ts-src/components/footer.ts + var createFooter = function(config) { + assert(config, "createFooter(config) requires a configuration object"); + var footer = el("footer", { className: "sf-footer" }); + if (config.links) { + config.links.forEach(function(link, i) { + if (i > 0) footer.appendChild(el("span", { className: "sf-vr" })); + footer.appendChild(el("a", { href: link.url, target: "_blank" }, link.label)); + }); + } + if (config.version) { + footer.appendChild(el("span", { style: { marginLeft: "auto" } }, config.version)); + } + return footer; + }; - var header = sf.el('header', { className: 'sf-header' }); + // ts-src/components/header.ts + var createHeader = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + var header = el("header", { className: "sf-header" }); var controls = { actions: null, spinner: null, @@ -281,572 +307,397 @@ const SF = (function () { resumeBtn: null, cancelBtn: null, analyzeBtn: null, - nav: null, + nav: null }; - - // Logo - if (config.logo) { - var logo = sf.el('img', { - className: 'sf-header-logo', + if (config.logo) { + var logo = el("img", { + className: "sf-header-logo", src: config.logo, - alt: 'Logo', + alt: "Logo" }); header.appendChild(logo); } - - // Brand text - var brand = sf.el('div', { className: 'sf-header-brand' }); + var brand = el("div", { className: "sf-header-brand" }); if (config.title) { - brand.appendChild(sf.el('div', { className: 'sf-header-title' }, config.title)); + brand.appendChild(el("div", { className: "sf-header-title" }, config.title)); } if (config.subtitle) { - brand.appendChild(sf.el('div', { className: 'sf-header-subtitle' }, config.subtitle)); + brand.appendChild(el("div", { className: "sf-header-subtitle" }, config.subtitle)); } header.appendChild(brand); - - // Nav tabs if (config.tabs && config.tabs.length > 0) { - sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); - var nav = sf.el('nav', { className: 'sf-header-nav' }); + assert(Array.isArray(config.tabs), "createHeader(config.tabs) expects an array"); + var nav = el("nav", { className: "sf-header-nav" }); controls.nav = nav; - config.tabs.forEach(function (tab) { - sf.assert(tab && tab.id, 'createHeader tab entries require an id'); - sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); - var btn = sf.el('button', { - className: 'sf-nav-btn' + (tab.active ? ' active' : ''), - role: 'tab', - 'aria-selected': !!tab.active, + config.tabs.forEach(function(tab) { + assert(tab && tab.id, "createHeader tab entries require an id"); + assert(typeof tab.label === "string", "createHeader tab entries require a label"); + var btn = el("button", { + className: "sf-nav-btn" + (tab.active ? " active" : ""), + role: "tab", + "aria-selected": !!tab.active, tabIndex: 0, dataset: { tab: tab.id }, - onKeyDown: function (e) { - if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; - var buttons = nav.querySelectorAll('.sf-nav-btn'); + onKeyDown: function(e) { + if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; + var buttons = nav.querySelectorAll(".sf-nav-btn"); var list = Array.prototype.slice.call(buttons); - var nextIndex = e.key === 'ArrowRight' - ? (list.indexOf(btn) + 1) % list.length - : (list.length + list.indexOf(btn) - 1) % list.length; + var nextIndex = e.key === "ArrowRight" ? (list.indexOf(btn) + 1) % list.length : (list.length + list.indexOf(btn) - 1) % list.length; var next = list[nextIndex]; if (next && next.focus) next.focus(); }, - onClick: function () { - nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); - btn.classList.add('active'); - nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { - b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + onClick: function() { + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.classList.remove("active"); + }); + btn.classList.add("active"); + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.setAttribute("aria-selected", b === btn ? "true" : "false"); }); if (config.onTabChange) config.onTabChange(tab.id); - }, + } }); if (tab.icon) { - btn.appendChild(sf.el('i', { className: 'fa-solid ' + tab.icon })); + btn.appendChild(el("i", { className: "fa-solid " + tab.icon })); } btn.appendChild(document.createTextNode(tab.label)); nav.appendChild(btn); }); header.appendChild(nav); } - - // Action buttons if (config.actions) { - sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); - sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); - sf.assert(!config.actions.onPause || typeof config.actions.onPause === 'function', 'createHeader(config.actions.onPause) must be a function'); - sf.assert(!config.actions.onResume || typeof config.actions.onResume === 'function', 'createHeader(config.actions.onResume) must be a function'); - sf.assert(!config.actions.onCancel || typeof config.actions.onCancel === 'function', 'createHeader(config.actions.onCancel) must be a function'); - sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); - sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); - - var actions = sf.el('div', { className: 'sf-header-actions' }); + assert(typeof config.actions === "object", "createHeader(config.actions) expects an object"); + assert(!config.actions.onSolve || typeof config.actions.onSolve === "function", "createHeader(config.actions.onSolve) must be a function"); + assert(!config.actions.onPause || typeof config.actions.onPause === "function", "createHeader(config.actions.onPause) must be a function"); + assert(!config.actions.onResume || typeof config.actions.onResume === "function", "createHeader(config.actions.onResume) must be a function"); + assert(!config.actions.onCancel || typeof config.actions.onCancel === "function", "createHeader(config.actions.onCancel) must be a function"); + assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === "function", "createHeader(config.actions.onAnalyze) must be a function"); + assert(!config.onTabChange || typeof config.onTabChange === "function", "createHeader(config.onTabChange) must be a function"); + var actions = el("div", { className: "sf-header-actions" }); controls.actions = actions; - - // Spinner - var spinner = sf.el('div', { className: 'sf-solving-spinner' }); + var spinner = el("div", { className: "sf-solving-spinner" }); controls.spinner = spinner; actions.appendChild(spinner); - if (config.actions.onSolve) { - var solveBtn = sf.createButton({ - text: 'Solve', - variant: 'success', - icon: 'fa-play', - onClick: config.actions.onSolve, + var solveBtn = createButton({ + text: "Solve", + variant: "success", + icon: "fa-play", + onClick: config.actions.onSolve }); controls.solveBtn = solveBtn; actions.appendChild(solveBtn); } - if (config.actions.onPause) { - var pauseBtn = sf.createButton({ - text: 'Pause', - variant: 'default', - icon: 'fa-pause', - onClick: config.actions.onPause, + var pauseBtn = createButton({ + text: "Pause", + variant: "default", + icon: "fa-pause", + onClick: config.actions.onPause }); - pauseBtn.style.display = 'none'; + pauseBtn.style.display = "none"; controls.pauseBtn = pauseBtn; actions.appendChild(pauseBtn); } - if (config.actions.onResume) { - var resumeBtn = sf.createButton({ - text: 'Resume', - variant: 'primary', - icon: 'fa-play', - onClick: config.actions.onResume, + var resumeBtn = createButton({ + text: "Resume", + variant: "primary", + icon: "fa-play", + onClick: config.actions.onResume }); - resumeBtn.style.display = 'none'; + resumeBtn.style.display = "none"; controls.resumeBtn = resumeBtn; actions.appendChild(resumeBtn); } - if (config.actions.onCancel) { - var cancelBtn = sf.createButton({ - text: 'Stop', - variant: 'danger', - icon: 'fa-stop', - onClick: config.actions.onCancel, + var cancelBtn = createButton({ + text: "Stop", + variant: "danger", + icon: "fa-stop", + onClick: config.actions.onCancel }); - cancelBtn.style.display = 'none'; + cancelBtn.style.display = "none"; controls.cancelBtn = cancelBtn; actions.appendChild(cancelBtn); } - if (config.actions.onAnalyze) { - var analyzeBtn = sf.createButton({ - variant: 'ghost', - icon: 'fa-chart-bar', + var analyzeBtn = createButton({ + variant: "ghost", + icon: "fa-chart-bar", circle: true, - tooltip: 'Score Analysis', - onClick: config.actions.onAnalyze, + tooltip: "Score Analysis", + onClick: config.actions.onAnalyze }); controls.analyzeBtn = analyzeBtn; actions.appendChild(analyzeBtn); } - header.appendChild(actions); } - header.sfControls = controls; return header; }; -})(SF); -/* ============================================================================ - SolverForge UI — Status Bar Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; + // ts-src/components/modal.ts + var createModal = function(config) { + assert(config, "createModal(config) requires a configuration object"); + assert(!config.footer || Array.isArray(config.footer), "createModal(config.footer) must be an array"); + var overlay = el("div", { className: "sf-modal-overlay" }); + var dialogId = uid("sf-modal"); + var dialog = el("div", { + className: "sf-modal", + id: dialogId, + role: "dialog", + "aria-modal": "true", + "aria-labelledby": dialogId + "-title" + }); + var body = el("div", { className: "sf-modal-body" }); + var header = el("div", { className: "sf-modal-header" }); + var titleEl = el("div", { className: "sf-modal-title", id: dialogId + "-title" }, config.title || ""); + header.appendChild(titleEl); + var closeBtn = el("button", { + className: "sf-modal-close", + "aria-label": "Close modal", + onClick: function() { + api.close(); + } + }, "\xD7"); + header.appendChild(closeBtn); + dialog.appendChild(header); + setBodyContent(body, config.body, config.unsafeBody); + dialog.appendChild(body); + if (config.footer) { + var footer = el("div", { className: "sf-modal-footer" }); + config.footer.forEach(function(child) { + footer.appendChild(child); + }); + dialog.appendChild(footer); + } + overlay.appendChild(dialog); + var previousFocus = null; + overlay.addEventListener("click", function(e) { + if (e.target === overlay) api.close(); + }); + function onKeyDown(e) { + if (e.key === "Escape") api.close(); + } + var api = { el: overlay, body }; + api.open = function() { + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add("open"); + document.addEventListener("keydown", onKeyDown); + }; + api.close = function() { + overlay.classList.remove("open"); + document.removeEventListener("keydown", onKeyDown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); + if (config.onClose) config.onClose(); + }; + api.setBody = function(content) { + setBodyContent(body, content); + }; + if (config.width) { + dialog.style.maxWidth = config.width; + } + return api; + }; + function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ""; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === "string") { + target.textContent = content; + } else if (content && typeof content === "object" && "unsafeBody" in content) { + target.innerHTML = content.unsafeBody; + } else if (content && typeof content === "object" && "unsafeHtml" in content) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } + } - sf.createStatusBar = function (config) { - var bar = sf.el('div', { className: 'sf-statusbar' }); + // ts-src/components/statusbar.ts + var createStatusBar = function(config = {}) { + var bar = el("div", { className: "sf-statusbar" }); var lastScore = null; var controls = null; - - // Score display - var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); + var scoreEl = el("span", { className: "sf-statusbar-score", id: "sfScoreDisplay", "aria-live": "polite" }, "\u2014"); bar.appendChild(scoreEl); - - // Separator - bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); - - // Constraint dots container - var dotsContainer = sf.el('div', { className: 'sf-statusbar-constraints' }); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var dotsContainer = el("div", { className: "sf-statusbar-constraints" }); bar.appendChild(dotsContainer); - - // Separator + moves display - var movesSep = sf.el('span', { className: 'sf-statusbar-sep' }, '|'); - movesSep.style.display = 'none'; + var movesSep = el("span", { className: "sf-statusbar-sep" }, "|"); + movesSep.style.display = "none"; bar.appendChild(movesSep); - - var movesEl = sf.el('span'); - movesEl.style.display = 'none'; + var movesEl = el("span"); + movesEl.style.display = "none"; bar.appendChild(movesEl); - - // Separator + status text - bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); - var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var statusEl = el("span", { id: "sfStatusText", role: "status", "aria-live": "polite" }); bar.appendChild(statusEl); - - // Build initial constraint dots if (config && config.constraints) { buildDots(dotsContainer, config.constraints, config.onConstraintClick); } - - var api = { el: bar }; - - api.bindHeader = function (header) { - controls = header && header.sfControls ? header.sfControls : null; - return api; - }; - - api.updateScore = function (scoreStr) { - if (scoreStr && scoreStr !== lastScore) { - scoreEl.textContent = scoreStr; - var colorClass = sf.score.colorClass(scoreStr); - scoreEl.classList.remove('improved', 'score-green', 'score-red', 'score-yellow'); - scoreEl.classList.add(colorClass); - void scoreEl.offsetWidth; - scoreEl.classList.add('improved'); - lastScore = scoreStr; - } else if (!scoreStr) { - scoreEl.textContent = '\u2014'; - scoreEl.classList.remove('score-green', 'score-red', 'score-yellow', 'improved'); - lastScore = null; - } - }; - - api.setLifecycleState = function (state) { - var normalized = normalizeLifecycleState(state); - var solveBtn = controls && controls.solveBtn; - var pauseBtn = controls && controls.pauseBtn; - var resumeBtn = controls && controls.resumeBtn; - var cancelBtn = controls && controls.cancelBtn; - var spinner = controls && controls.spinner; - - if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? '' : 'none'; - if (pauseBtn) { - pauseBtn.style.display = shouldShowPause(normalized) ? '' : 'none'; - pauseBtn.disabled = normalized === 'PAUSE_REQUESTED'; - } - if (resumeBtn) { - resumeBtn.style.display = normalized === 'PAUSED' ? '' : 'none'; - resumeBtn.disabled = false; - } - if (cancelBtn) { - cancelBtn.style.display = shouldShowCancel(normalized) ? '' : 'none'; - cancelBtn.disabled = false; - } - if (spinner) spinner.classList.toggle('active', shouldSpin(normalized)); - - statusEl.textContent = lifecycleLabel(normalized); - statusEl.style.color = isActiveLifecycle(normalized) - ? 'var(--sf-emerald-600)' - : normalized === 'FAILED' - ? 'var(--sf-red-600)' - : normalized === 'CANCELLED' - ? 'var(--sf-amber-700)' - : 'var(--sf-gray-500)'; - }; - - api.setSolving = function (solving) { - api.setLifecycleState(solving ? 'SOLVING' : 'IDLE'); - }; - - api.updateMoves = function (mps) { - if (mps != null && mps > 0) { - movesEl.textContent = mps.toLocaleString() + ' moves/s'; - movesEl.style.display = ''; - movesSep.style.display = ''; - } else { - movesEl.style.display = 'none'; - movesSep.style.display = 'none'; + var api = { + el: bar, + bindHeader: function(header) { + controls = header && header.sfControls ? header.sfControls : null; + return api; + }, + updateScore: function(scoreStr) { + if (scoreStr && scoreStr !== lastScore) { + scoreEl.textContent = scoreStr; + var colorClassName = colorClass(scoreStr); + scoreEl.classList.remove("improved", "score-green", "score-red", "score-yellow"); + scoreEl.classList.add(colorClassName); + void scoreEl.offsetWidth; + scoreEl.classList.add("improved"); + lastScore = scoreStr; + } else if (!scoreStr) { + scoreEl.textContent = "\u2014"; + scoreEl.classList.remove("score-green", "score-red", "score-yellow", "improved"); + lastScore = null; + } + }, + setLifecycleState: function(state) { + var normalized = normalizeLifecycleState(state); + var solveBtn = controls && controls.solveBtn; + var pauseBtn = controls && controls.pauseBtn; + var resumeBtn = controls && controls.resumeBtn; + var cancelBtn = controls && controls.cancelBtn; + var spinner = controls && controls.spinner; + if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? "" : "none"; + if (pauseBtn) { + pauseBtn.style.display = shouldShowPause(normalized) ? "" : "none"; + pauseBtn.disabled = normalized === "PAUSE_REQUESTED"; + } + if (resumeBtn) { + resumeBtn.style.display = normalized === "PAUSED" ? "" : "none"; + resumeBtn.disabled = false; + } + if (cancelBtn) { + cancelBtn.style.display = shouldShowCancel(normalized) ? "" : "none"; + cancelBtn.disabled = false; + } + if (spinner) spinner.classList.toggle("active", shouldSpin(normalized)); + statusEl.textContent = lifecycleLabel(normalized); + statusEl.style.color = isActiveLifecycle(normalized) ? "var(--sf-emerald-600)" : normalized === "FAILED" ? "var(--sf-red-600)" : normalized === "CANCELLED" ? "var(--sf-amber-700)" : "var(--sf-gray-500)"; + }, + setSolving: function(solving) { + api.setLifecycleState(solving ? "SOLVING" : "IDLE"); + }, + updateMoves: function(mps) { + if (mps != null && mps > 0) { + movesEl.textContent = mps.toLocaleString() + " moves/s"; + movesEl.style.display = ""; + movesSep.style.display = ""; + } else { + movesEl.style.display = "none"; + movesSep.style.display = "none"; + } + }, + updateConstraintDots: function(constraints) { + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + }, + colorDotsByScore: function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot) { + var isHard = dot.dataset.type === "hard"; + dot.classList.toggle("violated", isHard && hard < 0); + dot.classList.toggle("violated-soft", !isHard && soft < 0); + }); + }, + colorDotsFromAnalysis: function(constraints) { + if (!constraints || constraints.length === 0) return; + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot, i) { + var c = constraints[i]; + if (!dot) return; + var isHardConstraint = c.type === "hard"; + var scoreVal = isHardConstraint ? parseHard(c.score) : parseSoft(c.score); + var violated = scoreVal < 0; + dot.classList.toggle("violated", isHardConstraint && violated); + dot.classList.toggle("violated-soft", !isHardConstraint && violated); + }); } }; - - api.updateConstraintDots = function (constraints) { - buildDots(dotsContainer, constraints, config && config.onConstraintClick); - }; - - api.colorDotsByScore = function (scoreStr) { - var hard = sf.score.parseHard(scoreStr); - var soft = sf.score.parseSoft(scoreStr); - dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot) { - var isHard = dot.dataset.type === 'hard'; - dot.classList.toggle('violated', isHard && hard < 0); - dot.classList.toggle('violated-soft', !isHard && soft < 0); - }); - }; - - api.colorDotsFromAnalysis = function (constraints) { - if (!constraints || constraints.length === 0) return; - buildDots(dotsContainer, constraints, config && config.onConstraintClick); - dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot, i) { - var c = constraints[i]; - if (!dot) return; - var isHard = c.type === 'hard'; - var scoreVal = isHard ? sf.score.parseHard(c.score) : sf.score.parseSoft(c.score); - var violated = scoreVal < 0; - dot.classList.toggle('violated', isHard && violated); - dot.classList.toggle('violated-soft', !isHard && violated); - }); - }; - if (config && config.header) { api.bindHeader(config.header); } - - api.setLifecycleState('IDLE'); - + api.setLifecycleState("IDLE"); return api; }; - - function buildDots(container, constraints, onClick) { - container.innerHTML = ''; + function buildDots(container2, constraints, onClick) { + container2.innerHTML = ""; if (!constraints) return; - constraints.forEach(function (c, i) { - var dot = sf.el('div', { - className: 'sf-constraint-dot', - id: 'sf-cdot-' + i, - title: c.name || ('Constraint ' + i), - role: onClick ? 'button' : null, - tabIndex: onClick ? '0' : null, - 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, - dataset: { type: c.type || 'hard', index: String(i) }, + constraints.forEach(function(c, i) { + var dot = el("div", { + className: "sf-constraint-dot", + id: "sf-cdot-" + i, + title: c.name || "Constraint " + i, + role: onClick ? "button" : null, + tabIndex: onClick ? "0" : null, + "aria-label": onClick ? "Open constraint " + (c.name || "Constraint " + i) : null, + dataset: { type: c.type || "hard", index: String(i) } }); if (onClick) { - dot.style.cursor = 'pointer'; - sf.bindActivation(dot, function () { onClick(i); }); + dot.style.cursor = "pointer"; + bindActivation(dot, function() { + onClick(i); + }); } - container.appendChild(dot); + container2.appendChild(dot); }); } - function normalizeLifecycleState(value) { - if (typeof value !== 'string' || !value.trim()) return 'IDLE'; - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toUpperCase(); + if (typeof value !== "string" || !value.trim()) return "IDLE"; + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); } - function shouldShowSolve(state) { - return state === 'IDLE' - || state === 'COMPLETED' - || state === 'CANCELLED' - || state === 'FAILED' - || state === 'TERMINATED_BY_CONFIG'; + return state === "IDLE" || state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; } - function shouldShowPause(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED'; + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED"; } - function shouldShowCancel(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'PAUSED' - || state === 'RESUMING' - || state === 'CANCELLING'; + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "PAUSED" || state === "RESUMING" || state === "CANCELLING"; } - function shouldSpin(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'RESUMING' - || state === 'CANCELLING'; + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; } - function isActiveLifecycle(state) { return shouldSpin(state); } - function lifecycleLabel(state) { - if (state === 'STARTING') return 'Starting...'; - if (state === 'SOLVING') return 'Solving...'; - if (state === 'PAUSE_REQUESTED') return 'Pause requested...'; - if (state === 'PAUSED') return 'Paused'; - if (state === 'RESUMING') return 'Resuming...'; - if (state === 'CANCELLING') return 'Cancelling...'; - if (state === 'COMPLETED') return 'Completed'; - if (state === 'CANCELLED') return 'Cancelled'; - if (state === 'FAILED') return 'Failed'; - if (state === 'TERMINATED_BY_CONFIG') return 'Completed'; - return 'Ready'; - } - -})(SF); -/* ============================================================================ - SolverForge UI — Modal Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createModal = function (config) { - sf.assert(config, 'createModal(config) requires a configuration object'); - sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); - - var overlay = sf.el('div', { className: 'sf-modal-overlay' }); - var dialogId = sf.uid('sf-modal'); - var dialog = sf.el('div', { - className: 'sf-modal', - id: dialogId, - role: 'dialog', - 'aria-modal': 'true', - 'aria-labelledby': dialogId + '-title', - }); - var body = sf.el('div', { className: 'sf-modal-body' }); - - // Header - var header = sf.el('div', { className: 'sf-modal-header' }); - var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); - header.appendChild(titleEl); - - var closeBtn = sf.el('button', { - className: 'sf-modal-close', - 'aria-label': 'Close modal', - onClick: function () { api.close(); }, - }, '×'); - header.appendChild(closeBtn); - - dialog.appendChild(header); - - // Body - setBodyContent(body, config.body, config.unsafeBody); - dialog.appendChild(body); - - // Footer - if (config.footer) { - var footer = sf.el('div', { className: 'sf-modal-footer' }); - config.footer.forEach(function (child) { - footer.appendChild(child); - }); - dialog.appendChild(footer); - } - - overlay.appendChild(dialog); - - var previousFocus = null; - - // Close on backdrop click - overlay.addEventListener('click', function (e) { - if (e.target === overlay) api.close(); - }); - - // Close on Escape - function onKeyDown(e) { - if (e.key === 'Escape') api.close(); - } - - var api = { el: overlay, body: body }; - - api.open = function () { - previousFocus = document.activeElement; - document.body.appendChild(overlay); - if (closeBtn.focus) closeBtn.focus(); - overlay.classList.add('open'); - document.addEventListener('keydown', onKeyDown); - }; - - api.close = function () { - overlay.classList.remove('open'); - document.removeEventListener('keydown', onKeyDown); - if (overlay.parentNode) overlay.parentNode.removeChild(overlay); - if (previousFocus && previousFocus.focus) previousFocus.focus(); - if (config.onClose) config.onClose(); - }; - - api.setBody = function (content) { - setBodyContent(body, content); - }; - - if (config.width) { - dialog.style.maxWidth = config.width; - } - - return api; - }; - - function setBodyContent(target, content, explicitUnsafeHtml) { - target.textContent = ''; - if (explicitUnsafeHtml != null) { - target.innerHTML = explicitUnsafeHtml; - } else if (typeof content === 'string') { - target.textContent = content; - } else if (content && content.unsafeBody) { - target.innerHTML = content.unsafeBody; - } else if (content && content.unsafeHtml) { - target.innerHTML = content.unsafeHtml; - } else if (content instanceof Node) { - target.appendChild(content); - } - } - -})(SF); -/* ============================================================================ - SolverForge UI — Tab Switching - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.showTab = function (tabId, root) { - if (root) { - activateTabInScope(root, tabId); - return; - } - - document.querySelectorAll('.sf-tabs-container').forEach(function (container) { - activateTabInScope(container, tabId); - }); - }; - - sf.createTabs = function (config) { - sf.assert(config, 'createTabs(config) requires a configuration object'); - sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); - - var container = sf.el('div', { className: 'sf-tabs-container' }); - var tabsId = sf.uid('sf-tabs'); - - config.tabs.forEach(function (tab) { - var panel = sf.el('div', { - className: 'sf-tab-panel' + (tab.active ? ' active' : ''), - id: tabsId + '-' + tab.id, - dataset: { tabId: tab.id }, - }); - if (tab.content) { - if (typeof tab.content === 'string') panel.textContent = tab.content; - else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; - else if (tab.content instanceof Node) panel.appendChild(tab.content); - } - container.appendChild(panel); - }); - - return { - el: container, - show: function (tabId) { - sf.showTab(tabId, container); - }, - }; - }; - - function activateTabInScope(scope, tabId) { - scope.querySelectorAll('.sf-tab-panel').forEach(function (p) { - p.classList.remove('active'); - }); - - var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); - if (panel) panel.classList.add('active'); - } - -})(SF); -/* ============================================================================ - SolverForge UI — Table Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createTable = function (config) { - sf.assert(config, 'createTable(config) requires a configuration object'); - sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); - sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); - - var wrapper = sf.el('div', { className: 'sf-table-container' }); - var table = sf.el('table', { className: 'sf-table' }); - - // Header + if (state === "STARTING") return "Starting..."; + if (state === "SOLVING") return "Solving..."; + if (state === "PAUSE_REQUESTED") return "Pause requested..."; + if (state === "PAUSED") return "Paused"; + if (state === "RESUMING") return "Resuming..."; + if (state === "CANCELLING") return "Cancelling..."; + if (state === "COMPLETED") return "Completed"; + if (state === "CANCELLED") return "Cancelled"; + if (state === "FAILED") return "Failed"; + if (state === "TERMINATED_BY_CONFIG") return "Completed"; + return "Ready"; + } + + // ts-src/components/table.ts + var createTable = function(config) { + assert(config, "createTable(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createTable(config.columns) must be an array"); + assert(!config.rows || Array.isArray(config.rows), "createTable(config.rows) must be an array"); + var wrapper = el("div", { className: "sf-table-container" }); + var table = el("table", { className: "sf-table" }); if (config.columns) { - var thead = sf.el('thead'); - var tr = sf.el('tr'); - config.columns.forEach(function (col) { - var th = sf.el('th', null, typeof col === 'string' ? col : col.label); + var thead = el("thead"); + var tr = el("tr"); + config.columns.forEach(function(col) { + var th = el("th", null, typeof col === "string" ? col : col.label); if (col.align) th.style.textAlign = col.align; if (col.width) th.style.width = col.width; tr.appendChild(th); @@ -854,16 +705,14 @@ const SF = (function () { thead.appendChild(tr); table.appendChild(thead); } - - // Body - var tbody = sf.el('tbody'); + var tbody = el("tbody"); if (config.rows) { - config.rows.forEach(function (row, rowIdx) { - var tr = sf.el('tr'); - row.forEach(function (cell, colIdx) { - var td = sf.el('td'); - if (typeof cell === 'string' || typeof cell === 'number') { - td.textContent = cell; + config.rows.forEach(function(row, rowIdx) { + var tr2 = el("tr"); + row.forEach(function(cell, colIdx) { + var td = el("td"); + if (typeof cell === "string" || typeof cell === "number") { + td.textContent = String(cell); } else if (cell instanceof Node) { td.appendChild(cell); } else if (cell && cell.unsafeHtml) { @@ -872,3694 +721,3292 @@ const SF = (function () { var col = config.columns && config.columns[colIdx]; if (col && col.align) td.style.textAlign = col.align; if (col && col.className) td.classList.add(col.className); - tr.appendChild(td); + tr2.appendChild(td); }); if (config.onRowClick) { - tr.style.cursor = 'pointer'; - tr.setAttribute('role', 'button'); - tr.tabIndex = 0; - sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); + tr2.style.cursor = "pointer"; + tr2.setAttribute("role", "button"); + tr2.tabIndex = 0; + bindActivation(tr2, function() { + config.onRowClick(rowIdx, row); + }); } - tbody.appendChild(tr); + tbody.appendChild(tr2); }); } table.appendChild(tbody); wrapper.appendChild(table); - return wrapper; }; -})(SF); -/* ============================================================================ - SolverForge UI — Toast Notifications - jQuery-free replacement for showError/showSimpleError. - ============================================================================ */ - -(function (sf) { - 'use strict'; + // ts-src/components/tabs.ts + var showTab = function(tabId, root) { + if (root) { + activateTabInScope(root, tabId); + return; + } + document.querySelectorAll(".sf-tabs-container").forEach(function(container2) { + activateTabInScope(container2, tabId); + }); + }; + var createTabs = function(config) { + assert(config, "createTabs(config) requires a configuration object"); + assert(Array.isArray(config.tabs), "createTabs(config.tabs) must be an array"); + var container2 = el("div", { className: "sf-tabs-container" }); + var tabsId = uid("sf-tabs"); + config.tabs.forEach(function(tab) { + var panel = el("div", { + className: "sf-tab-panel" + (tab.active ? " active" : ""), + id: tabsId + "-" + tab.id, + dataset: { tabId: tab.id } + }); + if (tab.content) { + if (typeof tab.content === "string") panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; + else if (tab.content instanceof Node) panel.appendChild(tab.content); + } + container2.appendChild(panel); + }); + return { + el: container2, + show: function(tabId) { + showTab(tabId, container2); + } + }; + }; + function activateTabInScope(scope, tabId) { + scope.querySelectorAll(".sf-tab-panel").forEach(function(p) { + p.classList.remove("active"); + }); + var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); + if (panel) panel.classList.add("active"); + } + // ts-src/components/toast.ts var container = null; - function ensureContainer() { if (container && document.body.contains(container)) return; - container = sf.el('div', { className: 'sf-toast-container' }); + container = el("div", { className: "sf-toast-container" }); document.body.appendChild(container); } - - sf.showToast = function (config) { - sf.assert(config, 'showToast(config) requires a configuration object'); - + var showToast = function(config) { + assert(config, "showToast(config) requires a configuration object"); ensureContainer(); - - var variant = config.variant || 'danger'; - var toast = sf.el('div', { - className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', - role: 'status', - 'aria-live': 'polite', + var variant = config.variant || "danger"; + var toast = el("div", { + className: "sf-toast sf-toast--" + variant + " sf-toast-enter", + role: "status", + "aria-live": "polite" }); - - var msg = sf.el('div', { className: 'sf-toast-message' }); + var msg = el("div", { className: "sf-toast-message" }); if (config.title) { - msg.appendChild(sf.el('div', { className: 'sf-toast-title' }, config.title)); + msg.appendChild(el("div", { className: "sf-toast-title" }, config.title)); } if (config.message) { - msg.appendChild(sf.el('div', null, config.message)); + msg.appendChild(el("div", null, config.message)); } if (config.detail) { - var pre = sf.el('pre', { style: { margin: '4px 0 0', fontSize: '11px', whiteSpace: 'pre-wrap' } }); - pre.appendChild(sf.el('code', null, config.detail)); + var pre = el("pre", { style: { margin: "4px 0 0", fontSize: "11px", whiteSpace: "pre-wrap" } }); + pre.appendChild(el("code", null, config.detail)); msg.appendChild(pre); } toast.appendChild(msg); - - var closeBtn = sf.el('button', { - className: 'sf-toast-close', - 'aria-label': 'Dismiss toast', - onClick: function () { dismiss(); }, - }, '×'); + var closeBtn = el("button", { + className: "sf-toast-close", + "aria-label": "Dismiss toast", + onClick: function() { + dismiss(); + } + }, "\xD7"); toast.appendChild(closeBtn); - container.appendChild(toast); - - var delay = config.delay || 10000; + var delay = config.delay || 1e4; var timer = setTimeout(dismiss, delay); - function dismiss() { clearTimeout(timer); - toast.classList.remove('sf-toast-enter'); - toast.classList.add('sf-toast-exit'); - setTimeout(function () { + toast.classList.remove("sf-toast-enter"); + toast.classList.add("sf-toast-exit"); + setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); } }; - - sf.showError = function (title, detail) { - sf.showToast({ title: 'Error', message: title, detail: detail, variant: 'danger', delay: 30000 }); + var showError = function(title, detail) { + showToast({ title: "Error", message: title, detail, variant: "danger", delay: 3e4 }); }; -})(SF); -/* ============================================================================ - SolverForge UI — Backend Adapters - Pluggable transport: Axum, Tauri IPC, generic fetch. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createBackend = function (config) { + // ts-src/gantt/gantt.ts + var create = function(config) { config = config || {}; - var type = config.type || 'axum'; - if (type === 'tauri') return createTauriBackend(config); - return createHttpBackend(config); - }; - - function resolveJobId(raw) { - return sf.normalizeCreateJobId(raw); - } - - function resolveEventJobId(payload) { - if (!payload || typeof payload !== 'object') return ''; - if (payload.jobId != null) return String(payload.jobId).trim(); - if (payload.job_id != null) return String(payload.job_id).trim(); - if (payload.id != null) return String(payload.id).trim(); - if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); - if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); - return ''; - } - - function withSnapshotRevision(path, snapshotRevision) { - if (snapshotRevision == null || snapshotRevision === '') return path; - return path + '?snapshot_revision=' + encodeURIComponent(String(snapshotRevision)); - } - - /* ── HTTP backend (Axum, Rails, anything) ── */ - - function createHttpBackend(config) { - var baseUrl = config.baseUrl || ''; - var jobsPath = config.jobsPath || '/jobs'; - var demoDataPath = config.demoDataPath || '/demo-data'; - var extraHeaders = config.headers || {}; - - function headers(extra) { - var h = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders, extra || {}); - return h; - } - - function createRequestError(method, path, res) { - var err = new Error(res.status + ' ' + res.statusText); - err.status = res.status; - err.statusText = res.statusText; - err.method = method; - err.path = path; - err.url = baseUrl + path; - return err; + var instanceId = uid("sf-gantt"); + var chartPaneId = config.chartPane || instanceId + "-chart-pane"; + var gridPaneId = config.gridPane || instanceId + "-grid-pane"; + var chartContainerId = config.chartContainer || instanceId + "-container"; + var svgId = config.svgId || instanceId + "-svg"; + var ganttChart = null; + var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; + var tasks = []; + var sortState = { key: null, direction: "asc" }; + var wrapper = el("div", { className: "sf-gantt-split" }); + var gridPane = el("div", { className: "sf-gantt-pane", id: gridPaneId }); + var gridHeader = el("div", { className: "sf-gantt-pane-header" }); + gridHeader.appendChild(el("h3", null, config.gridTitle || "Tasks")); + var gridControls = el("div", { className: "sf-gantt-pane-controls" }); + gridHeader.appendChild(gridControls); + gridPane.appendChild(gridHeader); + var gridContent = el("div", { className: "sf-gantt-pane-content" }); + var grid = el("div", { className: "sf-gantt-grid" }); + gridContent.appendChild(grid); + gridPane.appendChild(gridContent); + var chartPane = el("div", { className: "sf-gantt-pane", id: chartPaneId }); + var chartHeader = el("div", { className: "sf-gantt-pane-header" }); + chartHeader.appendChild(el("h3", null, config.chartTitle || "Timeline")); + var viewControls = el("div", { className: "sf-gantt-view-controls" }); + var viewSelect = el("select", { className: "sf-gantt-view-select" }); + var modes = [ + { value: "Quarter Day", label: "Quarter Day" }, + { value: "Half Day", label: "Half Day" }, + { value: "Day", label: "Day" }, + { value: "Week", label: "Week" }, + { value: "Month", label: "Month" } + ]; + modes.forEach(function(m) { + var opt = el("option", { value: m.value }, m.label); + if (m.value === (config.viewMode || "Quarter Day")) opt.selected = true; + viewSelect.appendChild(opt); + }); + viewSelect.addEventListener("change", function() { + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); + }); + viewControls.appendChild(viewSelect); + var chartControls = el("div", { className: "sf-gantt-pane-controls" }); + chartHeader.appendChild(viewControls); + chartHeader.appendChild(chartControls); + chartPane.appendChild(chartHeader); + var chartContent = el("div", { className: "sf-gantt-pane-content" }); + var chartContainer = el("div", { className: "sf-gantt-container", id: chartContainerId }); + chartContent.appendChild(chartContainer); + chartPane.appendChild(chartContent); + wrapper.appendChild(gridPane); + wrapper.appendChild(chartPane); + var ctrl = { el: wrapper }; + ctrl.mount = function(parent) { + assert(parent, "gantt.mount(parent) requires a mount target"); + var target = typeof parent === "string" ? document.getElementById(parent) : parent; + assert(target, "gantt.mount(parent) target not found: " + parent); + validateMountTarget(target); + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); + target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } + initSplit(); + bindResizeObserver2(); + }; + ctrl.setTasks = function(newTasks) { + assert(Array.isArray(newTasks), "gantt.setTasks(tasks) expects an array"); + tasks = newTasks; + renderGrid(newTasks); + renderChart(newTasks); + }; + ctrl.refresh = function() { + if (ganttChart && tasks.length > 0) { + ganttChart.refresh(tasksToFrappe(tasks)); + } + }; + ctrl.getChart = function() { + return ganttChart; + }; + ctrl.changeViewMode = function(mode) { + viewSelect.value = mode; + if (ganttChart) ganttChart.change_view_mode(mode); + }; + ctrl.highlightTask = function(taskId) { + grid.querySelectorAll(".sf-gantt-row").forEach(function(row) { + row.classList.toggle("selected", row.dataset.taskId === taskId); + }); + var svg = chartContainer.querySelector("svg"); + if (svg) { + svg.querySelectorAll(".bar-wrapper").forEach(function(bw) { + bw.classList.remove("highlighted"); + }); + var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); + if (bar) bar.classList.add("highlighted"); + } + }; + ctrl.destroy = function() { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + ganttChart = null; + mounted = false; + mountTarget = null; + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); + }; + return ctrl; + function initSplit() { + if (typeof Split !== "function") return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + splitInstance = Split(["#" + gridPaneId, "#" + chartPaneId], { + direction: "vertical", + sizes: splitSizes, + minSize: splitMinSize, + snapOffset: 30, + gutterSize: 4, + cursor: "col-resize", + onDragEnd: function() { + if (ganttChart) { + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 100); + } + } + }); } - - function request(method, path, body) { - var opts = { method: method, headers: headers() }; - if (body !== undefined) opts.body = JSON.stringify(body); - return fetch(baseUrl + path, opts).then(function (res) { - if (!res.ok) throw createRequestError(method, path, res); - var ct = res.headers.get('content-type') || ''; - return ct.indexOf('json') !== -1 ? res.json() : res.text(); + function bindResizeObserver2() { + if (typeof ResizeObserver !== "function") return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function() { + if (!ganttChart) return; + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 0); }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); } - - return { - createJob: function (data) { - return request('POST', jobsPath, data).then(resolveJobId); - }, - getJob: function (id) { - return request('GET', jobsPath + '/' + id); - }, - getJobStatus: function (id) { - return request('GET', jobsPath + '/' + id + '/status'); - }, - getSnapshot: function (id, snapshotRevision) { - return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/snapshot', snapshotRevision)); - }, - analyzeSnapshot: function (id, snapshotRevision) { - return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/analysis', snapshotRevision)); - }, - pauseJob: function (id) { - return request('POST', jobsPath + '/' + id + '/pause'); - }, - resumeJob: function (id) { - return request('POST', jobsPath + '/' + id + '/resume'); - }, - cancelJob: function (id) { - return request('POST', jobsPath + '/' + id + '/cancel'); - }, - deleteJob: function (id) { - return request('DELETE', jobsPath + '/' + id); - }, - getDemoData: function (name) { - return request('GET', demoDataPath + '/' + (name || 'STANDARD')); - }, - listDemoData: function () { - return request('GET', demoDataPath); - }, - streamJobEvents: function (id, onMessage, onError) { - var url = baseUrl + jobsPath + '/' + id + '/events'; - var es = new EventSource(url); - var closed = false; - es.onmessage = function (e) { - try { onMessage(JSON.parse(e.data)); } catch (_) {} + function normalizePair(value, fallback) { + if (typeof value === "number" && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + function validateMountTarget(target) { + assert(target && typeof target.appendChild === "function", "gantt.mount(parent) requires a valid DOM container"); + assert(getElementSize(target, "Width") > 0 && getElementSize(target, "Height") > 0, "gantt.mount(parent) target is not laid out yet"); + } + function getElementSize(target, axis) { + var clientKey = "client" + axis; + var offsetKey = "offset" + axis; + var rectKey = axis === "Width" ? "width" : "height"; + if (typeof target[clientKey] === "number") return target[clientKey]; + if (typeof target[offsetKey] === "number") return target[offsetKey]; + if (typeof target.getBoundingClientRect === "function") { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === "number") return rect[rectKey]; + } + return 0; + } + function tasksToFrappe(taskList) { + return taskList.filter(function(t) { + return t.start && t.end; + }).map(function(t) { + var customClass = t.custom_class || ""; + if (t.pinned) { + customClass = customClass ? customClass + " pinned" : "pinned"; + } + return { + id: t.id, + name: t.name || t.label || t.id, + start: t.start, + end: t.end, + custom_class: customClass, + dependencies: t.dependencies || "" }; - es.onerror = function () { - if (closed || !onError) return; - if (typeof EventSource !== 'undefined' && es.readyState === EventSource.CLOSED) { - onError(createSseClosedError(url)); + }); + } + function renderChart(taskList) { + var frappeTasks = tasksToFrappe(taskList); + if (frappeTasks.length === 0) { + chartContainer.textContent = ""; + chartContainer.appendChild(el("div", { + className: "sf-gantt-empty-state", + style: { + padding: "24px", + color: "var(--sf-gray-400)", + fontFamily: "var(--sf-font-mono)", + fontSize: "13px" } - }; - return function close() { - closed = true; - es.onmessage = null; - es.onerror = null; - es.close(); - }; - }, - }; - } - - /* ── Tauri IPC backend ── */ - - function createTauriBackend(config) { - sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); - sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); - sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); - - var invoke = config.invoke; - var listen = config.listen; - var commands = config.commands || {}; - var eventName = config.eventName || 'solver-update'; - - return { - createJob: function (data) { - return invoke(commands.createJob || 'create_job', { request: data }).then(resolveJobId); - }, - getJob: function (id) { - return invoke(commands.getJob || 'get_job', { id: id }); - }, - getJobStatus: function (id) { - return invoke(commands.getJobStatus || 'get_job_status', { id: id }); - }, - getSnapshot: function (id, snapshotRevision) { - var payload = { id: id }; - if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision; - return invoke(commands.getSnapshot || 'get_snapshot', payload); - }, - analyzeSnapshot: function (id, snapshotRevision) { - var payload = { id: id }; - if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision; - return invoke(commands.analyzeSnapshot || 'analyze_snapshot', payload); - }, - pauseJob: function (id) { - return invoke(commands.pauseJob || 'pause_job', { id: id }); - }, - resumeJob: function (id) { - return invoke(commands.resumeJob || 'resume_job', { id: id }); - }, - cancelJob: function (id) { - return invoke(commands.cancelJob || 'cancel_job', { id: id }); - }, - deleteJob: function (id) { - return invoke(commands.deleteJob || 'delete_job', { id: id }); - }, - getDemoData: function (name) { - return invoke(commands.demoData || 'demo_seed', { name: name }); - }, - listDemoData: function () { - return Promise.resolve([]); - }, - streamJobEvents: function (id, onMessage) { - var targetId = String(id); - var unlisten = null; - listen(eventName, function (event) { - var payload = event && event.payload ? event.payload : {}; - var payloadId = resolveEventJobId(payload); - if (payloadId && payloadId !== targetId) return; - onMessage(payload); - }).then(function (fn) { unlisten = fn; }); - return function close() { if (unlisten) unlisten(); }; - }, - }; - } - - function createSseClosedError(url) { - var err = new Error('Event stream closed for ' + url); - err.code = 'SSE_CLOSED'; - err.transport = 'sse'; - err.url = url; - return err; - } - -})(SF); -/* ============================================================================ - SolverForge UI — Solver Lifecycle - Shared job orchestration for start, pause, resume, cancel, and snapshots. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createSolver = function (config) { - sf.assert(config, 'createSolver(config) requires a configuration object'); - sf.assert(config.backend, 'createSolver(config.backend) is required'); - sf.assert(hasFunction(config.backend, 'createJob'), 'createSolver(config.backend.createJob) must be a function'); - sf.assert(hasFunction(config.backend, 'getSnapshot'), 'createSolver(config.backend.getSnapshot) must be a function'); - sf.assert(hasFunction(config.backend, 'analyzeSnapshot'), 'createSolver(config.backend.analyzeSnapshot) must be a function'); - sf.assert(hasFunction(config.backend, 'pauseJob'), 'createSolver(config.backend.pauseJob) must be a function'); - sf.assert(hasFunction(config.backend, 'resumeJob'), 'createSolver(config.backend.resumeJob) must be a function'); - sf.assert(hasFunction(config.backend, 'cancelJob'), 'createSolver(config.backend.cancelJob) must be a function'); - sf.assert(hasFunction(config.backend, 'deleteJob'), 'createSolver(config.backend.deleteJob) must be a function'); - sf.assert(hasFunction(config.backend, 'streamJobEvents'), 'createSolver(config.backend.streamJobEvents) must be a function'); - sf.assert(!config.onProgress || typeof config.onProgress === 'function', 'createSolver(config.onProgress) must be a function'); - sf.assert(!config.onSolution || typeof config.onSolution === 'function', 'createSolver(config.onSolution) must be a function'); - sf.assert(!config.onPauseRequested || typeof config.onPauseRequested === 'function', 'createSolver(config.onPauseRequested) must be a function'); - sf.assert(!config.onPaused || typeof config.onPaused === 'function', 'createSolver(config.onPaused) must be a function'); - sf.assert(!config.onResumed || typeof config.onResumed === 'function', 'createSolver(config.onResumed) must be a function'); - sf.assert(!config.onCancelled || typeof config.onCancelled === 'function', 'createSolver(config.onCancelled) must be a function'); - sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); - sf.assert(!config.onFailure || typeof config.onFailure === 'function', 'createSolver(config.onFailure) must be a function'); - sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); - sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); - - var backend = config.backend; - var statusBar = config.statusBar; - var closeStream = null; - var activeJobId = null; - var retainedJobId = null; - var lifecycleState = 'IDLE'; - var phase = 'idle'; - var runToken = 0; - var lastSnapshotRevision = null; - var lastMeta = null; - var lastNotifiedError = null; - var queuedAction = null; - var pendingPause = null; - var pendingResume = null; - var pendingCancel = null; - var terminalSync = null; - - var api = {}; - - api.start = function (data) { - if (retainedJobId) { - return Promise.reject(new Error('Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first')); + }, "No scheduled tasks to display.")); + ganttChart = null; + return; } - if (phase !== 'idle') return Promise.resolve(); - - resetForStart(); - phase = 'starting'; - runToken += 1; - applyLifecycleState('STARTING'); - updateMoves(null); - - var token = runToken; - return backend.createJob(data).then(function (id) { - if (token !== runToken) return; - var jobId = ensureJobId(id); - - activeJobId = jobId; - retainedJobId = jobId; - phase = 'solving'; - applyLifecycleState('SOLVING'); - - attachStream(token, jobId); - - if (queuedAction === 'pause') { - queuedAction = null; - requestPause(token, jobId); - } else if (queuedAction === 'cancel') { - queuedAction = null; - requestCancel(token, jobId); - } - }).catch(function (err) { - if (token !== runToken) return; - if (retainedJobId) { - failTransport(err); - } else { - failStartup(err); + chartContainer.textContent = ""; + chartContainer.appendChild(createSvgRoot(svgId)); + ganttChart = new Gantt("#" + svgId, frappeTasks, { + view_mode: viewSelect.value || "Quarter Day", + date_format: "YYYY-MM-DD HH:mm", + custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, + on_click: function(task) { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + on_date_change: function(task, start, end) { + if (config.onDateChange) config.onDateChange(task, start, end); } - throw err; }); - }; - - api.pause = function () { - if (pendingPause) return pendingPause.promise; - if (phase === 'starting' && !activeJobId) { - queuedAction = 'pause'; - pendingPause = createDeferred(); - return pendingPause.promise; - } - var jobId = currentJobId(); - if (phase !== 'solving' || !jobId) return Promise.resolve(); - - pendingPause = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'pause')) return pendingPause.promise; - requestPause(runToken, jobId); - return pendingPause.promise; - }; - - api.resume = function () { - if (pendingResume) return pendingResume.promise; - var jobId = currentJobId(); - if (phase !== 'paused' || !jobId) return Promise.resolve(); - - pendingResume = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'resume')) return pendingResume.promise; - requestResume(runToken, jobId); - return pendingResume.promise; - }; - - api.cancel = function () { - if (pendingCancel) return pendingCancel.promise; - if (phase === 'starting' && !activeJobId) { - queuedAction = 'cancel'; - pendingCancel = createDeferred(); - return pendingCancel.promise; - } - var jobId = currentJobId(); - if (phase === 'cancelling' && jobId) { - pendingCancel = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'cancel')) return pendingCancel.promise; - return pendingCancel.promise; - } - if (!jobId || !isCancelablePhase()) return Promise.resolve(); - - pendingCancel = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'cancel')) return pendingCancel.promise; - requestCancel(runToken, jobId); - return pendingCancel.promise; - }; - - api.delete = function () { - if (!retainedJobId) return Promise.resolve(); - if (!isTerminalLifecycle(lifecycleState)) { - return Promise.reject(new Error('Cannot delete a retained job before it reaches a terminal lifecycle state')); - } - - var jobId = retainedJobId; - return ensureTerminalSyncBeforeDelete(jobId).then(function () { - if (retainedJobId !== jobId) return; - return backend.deleteJob(jobId); - }).then(function () { - if (retainedJobId !== jobId) return; - resetAfterDelete(); - }).catch(function (err) { - notifyError(err); - throw err; + } + function renderGrid(taskList) { + while (grid.firstChild) grid.removeChild(grid.firstChild); + var table = el("table", { className: "sf-gantt-table" }); + var columns = config.columns || [ + { key: "name", label: "Task" }, + { key: "start", label: "Start" }, + { key: "end", label: "End" } + ]; + var sortedTasks = sortTasks(taskList); + var thead = el("thead"); + var headerRow = el("tr"); + columns.forEach(function(col) { + headerRow.appendChild(buildHeaderCell(col)); }); - }; - - api.getSnapshot = function (snapshotRevision) { - var jobId = currentJobId(); - if (!jobId) return Promise.reject(new Error('No retained job is available')); - var revision = resolveRequestedSnapshotRevision(snapshotRevision); - return backend.getSnapshot(jobId, revision).then(function (payload) { - return normalizeSnapshot(payload, lastMeta); + thead.appendChild(headerRow); + table.appendChild(thead); + var tbody = el("tbody"); + sortedTasks.forEach(function(task) { + var rowClasses = ["sf-gantt-row"]; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push("sf-project-" + task.projectIndex); + var tr = el("tr", { + className: rowClasses.join(" "), + dataset: { taskId: task.id }, + onClick: function() { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + } + }); + columns.forEach(function(col) { + var td = el("td"); + if (col.key === "name") { + td.className = "sf-task-name"; + td.textContent = task.name || task.label || task.id; + } else if (col.render) { + var content = col.render(task); + if (typeof content === "string") td.textContent = content; + else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; + else if (content instanceof Node) td.appendChild(content); + } else { + td.textContent = task[col.key] || ""; + td.style.fontFamily = "var(--sf-font-mono)"; + td.style.fontSize = "12px"; + } + tr.appendChild(td); + }); + tbody.appendChild(tr); }); - }; - - api.analyzeSnapshot = function (snapshotRevision) { - var jobId = currentJobId(); - if (!jobId) return Promise.reject(new Error('No retained job is available')); - var revision = resolveRequestedSnapshotRevision(snapshotRevision); - return backend.analyzeSnapshot(jobId, revision).then(function (payload) { - return normalizeAnalysis(payload, lastMeta); + table.appendChild(tbody); + grid.appendChild(table); + } + function buildHeaderCell(col) { + if (!col.sortable) { + return el("th", null, col.label); + } + var isCurrent = sortState.key === col.key; + var th = el("th", { + className: "sortable" + (isCurrent ? " active" : ""), + role: "button", + tabIndex: 0, + "aria-sort": isCurrent ? sortState.direction === "asc" ? "ascending" : "descending" : "none" }); - }; - - api.isRunning = function () { - return phase !== 'idle' && phase !== 'paused'; - }; - - api.getJobId = function () { - return activeJobId != null ? activeJobId : retainedJobId; - }; - - api.getLifecycleState = function () { - return lifecycleState; - }; - - api.getSnapshotRevision = function () { - return lastSnapshotRevision; - }; - - return api; - - function requestPause(token, id) { - phase = 'pause-requested'; - backend.pauseJob(id).catch(function (err) { - if (token !== runToken) return; - phase = 'solving'; - rejectDeferred('pause', err); - notifyError(err); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(el("span", { className: "sort-icon" }, isCurrent ? sortState.direction === "asc" ? "\u25B2" : "\u25BC" : "")); + bindActivation(th, function() { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === "asc" ? "desc" : "asc"; + } else { + sortState.key = col.key; + sortState.direction = "asc"; + } + renderGrid(tasks); }); + return th; } - - function attachStream(token, id) { - closeStream = backend.streamJobEvents(id, function (payload) { - if (token !== runToken) return; - handleEvent(token, id, payload); - }, function (err) { - if (token !== runToken) return; - failTransport(err); + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function(a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === "asc") return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; }); + return sorted; } - - function ensureStreamAttached(token, id, pendingName) { - if (closeStream) return true; - try { - attachStream(token, id); - return true; - } catch (err) { - failTransport(err); - rejectDeferred(pendingName, err); - return false; + function sortValue(value, key) { + if (value == null) return ""; + if (key === "start" || key === "end") { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; } + if (typeof value === "number") return value; + return String(value).toLowerCase(); } - - function requestResume(token, id) { - phase = 'resuming'; - backend.resumeJob(id).catch(function (err) { - if (token !== runToken) return; - phase = 'paused'; - rejectDeferred('resume', err); - notifyError(err); + function defaultPopup(task) { + var t = tasks.find(function(x) { + return x.id === task.id; }); + if (!t) return ""; + return '

' + escHtml(t.name || t.id) + "

Start: " + escHtml(t.start) + "

End: " + escHtml(t.end) + "

" + (t.duration_minutes ? "

Duration: " + t.duration_minutes + " min

" : "") + (t.pinned ? '

Pinned

' : "") + "
"; } - - function requestCancel(token, id) { - phase = 'cancelling'; - backend.cancelJob(id).catch(function (err) { - if (token !== runToken) return; - phase = lifecycleState === 'PAUSED' ? 'paused' : 'solving'; - rejectDeferred('cancel', err); - notifyError(err); - }); + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = id; + return svg; + } + return el("svg", { id }); } + }; + var gantt = { create }; - function handleEvent(token, expectedId, payload) { - var event = normalizeJobEvent(payload, expectedId); - if (!event) return; - - lastMeta = event.meta; - if (event.meta.snapshotRevision != null) { - lastSnapshotRevision = event.meta.snapshotRevision; - } - retainedJobId = event.meta.jobId; - activeJobId = event.meta.jobId; - - if (event.eventType === 'progress') { - if (!event.meta.currentScore) return; - phase = phaseForLifecycleState(event.meta.lifecycleState); - applyEventMeta(event.meta); - if (config.onProgress) config.onProgress(event.meta); - return; - } - - if (event.eventType === 'best_solution') { - if (!event.solution || !event.meta.currentScore) return; - phase = phaseForLifecycleState(event.meta.lifecycleState); - applyEventMeta(event.meta); - if (config.onSolution) { - config.onSolution(buildLiveSnapshot(event), event.meta); + // ts-src/rail/card.ts + var createHeader2 = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createHeader(config.columns) expects an array"); + var labelWidth = config.labelWidth || 200; + var columns = config.columns || []; + var header = el("div", { className: "sf-timeline-header" }); + header.style.gridTemplateColumns = labelWidth + "px 1fr"; + var spacer = el("div", { className: "sf-timeline-label-spacer" }, config.label || ""); + header.appendChild(spacer); + var days = el("div", { className: "sf-timeline-days" }); + days.style.gridTemplateColumns = "repeat(" + columns.length + ", 1fr)"; + columns.forEach(function(col) { + var colEl = el("div", { className: "sf-timeline-day-col" }); + colEl.appendChild(el("span", null, typeof col === "string" ? col : col.label)); + days.appendChild(colEl); + }); + header.appendChild(days); + return header; + }; + var createCard = function(config) { + assert(config, "createCard(config) requires a configuration object"); + var labelWidth = config.labelWidth || 200; + var card = el("div", { className: "sf-resource-card" }); + var state = { + unassigned: [], + railConfig: config + }; + if (config.id) card.dataset.resourceId = config.id; + var resHeader = el("div", { className: "sf-resource-header" }); + resHeader.style.gridTemplateColumns = labelWidth + "px 1fr"; + var identity = el("div", { className: "sf-resource-identity" }); + if (config.name) { + identity.appendChild(el("div", { className: "sf-resource-name" }, config.name)); + } + if (config.badges || config.type) { + var meta = el("div", { className: "sf-resource-meta" }); + if (config.type) { + var badge = el("span", { className: "sf-resource-type-badge" }, config.type); + if (config.typeStyle) { + badge.style.background = config.typeStyle.bg || ""; + badge.style.color = config.typeStyle.color || ""; + badge.style.border = config.typeStyle.border || ""; } - return; - } - - if (event.eventType === 'pause_requested') { - phase = 'pause-requested'; - applyEventMeta(event.meta); - if (config.onPauseRequested) config.onPauseRequested(event.meta); - return; + meta.appendChild(badge); } - - if (event.eventType === 'paused') { - phase = 'paused'; - applyEventMeta(event.meta); - syncSnapshotBundle(event.meta, true).then(function (bundle) { - if (token !== runToken || hasNewerEvent(event.meta)) return; - applyBundle(bundle); - if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); - resolveDeferred('pause', bundle); - }).catch(function (err) { - if (token !== runToken || hasNewerEvent(event.meta)) return; - rejectDeferred('pause', err); - notifyError(err); + var badges = Array.isArray(config.badges) ? config.badges : config.badges ? [config.badges] : []; + if (badges.length) { + badges.forEach(function(entry) { + if (!entry) return; + if (typeof entry === "string") { + meta.appendChild(el("span", { className: "sf-resource-type-badge" }, entry)); + return; + } + var extraBadge = el("span", { className: "sf-resource-type-badge" }, entry.label || ""); + if (entry.style) { + extraBadge.style.background = entry.style.bg || ""; + extraBadge.style.color = entry.style.color || ""; + extraBadge.style.border = entry.style.border || ""; + } + meta.appendChild(extraBadge); }); - return; - } - - if (event.eventType === 'resumed') { - phase = 'solving'; - applyEventMeta(event.meta); - if (config.onResumed) config.onResumed(event.meta); - resolveDeferred('resume', event.meta); - return; - } - - if (event.eventType === 'completed') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, true); - return; - } - - if (event.eventType === 'cancelled') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, false); - return; - } - - if (event.eventType === 'failed') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, false); } + identity.appendChild(meta); } - - function syncSnapshotBundle(meta, requireSnapshot) { - var analysisRequired = !!config.onAnalysis; - var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; - - return backend.getSnapshot(meta.jobId, snapshotRevision).then(function (snapshotPayload) { - var snapshot = normalizeSnapshot(snapshotPayload, meta); - if (!snapshot) throw new Error('Solver backend returned an invalid snapshot payload'); - - var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); - var result = { - meta: mergedMeta, - snapshot: snapshot, - analysis: null, - }; - - if (!analysisRequired) return result; - - return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function (analysisPayload) { - result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); - return result; - }); - }).catch(function (err) { - if (requireSnapshot) throw err; - - var fallback = { meta: meta, snapshot: null, analysis: null }; - if (!analysisRequired || snapshotRevision == null) return fallback; - - return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function (analysisPayload) { - fallback.analysis = normalizeAnalysis(analysisPayload, meta); - return fallback; - }).catch(function () { - return fallback; + resHeader.appendChild(identity); + if (config.gauges && config.gauges.length > 0) { + var gauges = el("div", { className: "sf-gauges" }); + config.gauges.forEach(function(g) { + var row = el("div", { className: "sf-gauge-row" }); + row.appendChild(el("span", { className: "sf-gauge-label" }, g.label)); + var track = el("div", { className: "sf-gauge-track" }); + var fill = el("div", { + className: "sf-gauge-fill" + (g.style ? " sf-gauge-fill--" + g.style : "") }); + fill.style.width = Math.min(g.pct || 0, 100) + "%"; + track.appendChild(fill); + row.appendChild(track); + if (g.text) row.appendChild(el("span", { className: "sf-gauge-value" }, g.text)); + gauges.appendChild(row); }); + resHeader.appendChild(gauges); } - - function applyBundle(bundle) { - if (!bundle) return; - lastMeta = bundle.meta; - if (bundle.meta && bundle.meta.snapshotRevision != null) { - lastSnapshotRevision = bundle.meta.snapshotRevision; - } - applyEventMeta(bundle.meta, bundle.analysis); - if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + card.appendChild(resHeader); + var body = el("div", { className: "sf-resource-body" }); + body.style.gridTemplateColumns = labelWidth + "px 1fr"; + var stats = el("div", { className: "sf-resource-stats" }); + if (config.stats) { + config.stats.forEach(function(s) { + var row = el("div", { className: "sf-stat-row" }); + row.appendChild(el("span", { className: "sf-stat-label" }, s.label)); + row.appendChild(el("span", { className: "sf-stat-value" }, String(s.value))); + stats.appendChild(row); + }); } - - function finalizeTerminal(meta) { - closeCurrentStream(); - activeJobId = null; - queuedAction = null; - phase = 'idle'; - applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : 'IDLE'); - updateMoves(null); + body.appendChild(stats); + var railContainer = el("div", { className: "sf-rail-container" }); + var rail2 = el("div", { className: "sf-rail" }); + if (config.id) rail2.id = "sf-rail-" + config.id; + var numCols = config.columns || 5; + var dayGrid = el("div", { className: "sf-day-grid" }); + dayGrid.style.gridTemplateColumns = "repeat(" + numCols + ", 1fr)"; + for (var i = 0; i < numCols; i++) { + dayGrid.appendChild(el("div", { className: "sf-day-col" })); } - - function failTransport(err) { - var jobId = activeJobId || retainedJobId; - retainedJobId = jobId; - closeCurrentStream(); - activeJobId = null; - phase = phaseForLifecycleState(lifecycleState); - queuedAction = null; - rejectDeferred('pause', err); - rejectDeferred('resume', err); - rejectDeferred('cancel', err); - notifyError(err); + rail2.appendChild(dayGrid); + railContainer.appendChild(rail2); + body.appendChild(railContainer); + card.appendChild(body); + if (config.heatmap) { + var heatmapCfg = { + horizon: config.heatmap.horizon || 1, + label: config.heatmap.label, + segments: config.heatmap.segments, + labelWidth + }; + heatmapCfg.railConfig = config; + var heatmap = createHeatmap(heatmapCfg); + if (heatmap) card.appendChild(heatmap); } - - function failStartup(err) { - closeCurrentStream(); - activeJobId = null; - retainedJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - lastNotifiedError = null; - phase = 'idle'; - queuedAction = null; - rejectDeferred('pause', err); - rejectDeferred('resume', err); - rejectDeferred('cancel', err); - applyLifecycleState('IDLE'); - updateMoves(null); - notifyError(err); + var unassignedRail = el("div", { className: "sf-unassigned-rail" }); + if (config.unassigned) { + state.unassigned = config.unassigned; + renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); } - - function applyEventMeta(meta, analysis) { - applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); - updateScore(readDisplayScore(meta, analysis)); - updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); - if (analysis) { - var constraints = readAnalysisConstraints(analysis); - if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { - statusBar.colorDotsFromAnalysis(constraints); + if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); + var cardApi = { + el: card, + rail: rail2, + addBlock: function(blockConfig) { + return addBlock(rail2, blockConfig); + }, + setUnassigned: function(items) { + state.unassigned = Array.isArray(items) ? items : []; + if (state.unassigned.length === 0 && unassignedRail.parentNode) { + unassignedRail.innerHTML = ""; + unassignedRail.parentNode?.removeChild(unassignedRail); + return; } + if (state.unassigned.length > 0) { + renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); + } else { + unassignedRail.innerHTML = ""; + } + if (state.unassigned.length > 0 && !unassignedRail.parentNode) { + card.appendChild(unassignedRail); + } + }, + clearBlocks: function() { + rail2.querySelectorAll(".sf-block, .sf-changeover").forEach(function(el2) { + el2.remove(); + }); + }, + setSolving: function(solving) { + card.classList.toggle("solving", solving); } + }; + return cardApi; + }; + var createHeatmap = function(config) { + if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; + var heatmap = el("div", { className: "sf-heatmap" }); + heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + "px 1fr"; + var label = el("div", { className: "sf-heatmap-label" }, config.label || ""); + heatmap.appendChild(label); + var track = el("div", { className: "sf-heatmap-track" }); + var columns = config.railConfig && config.railConfig.columns || 1; + track.style.gridTemplateColumns = "repeat(" + columns + ", 1fr)"; + heatmap.appendChild(track); + var horizon = config.horizon || 1; + config.segments.forEach(function(segment) { + if (!segment || segment.end <= segment.start) return; + var band = el("div", { className: "sf-heatmap-segment" }); + var start = Math.max(0, segment.start); + var width = Math.max(0, segment.end - start); + band.style.left = start / horizon * 100 + "%"; + band.style.width = Math.max(width / horizon * 100, 0.25) + "%"; + if (segment.color) band.style.background = segment.color; + if (segment.opacity != null) band.style.opacity = segment.opacity; + if (segment.tooltip) band.title = segment.tooltip; + track.appendChild(band); + }); + return heatmap; + }; + var createUnassignedRail = function(tasks, onTaskClick) { + var rail2 = el("div", { className: "sf-unassigned-rail" }); + renderUnassigned(rail2, tasks, onTaskClick); + return rail2; + }; + var addBlock = function(rail2, config) { + assert(rail2, "addBlock(rail) requires a rail element"); + assert(config && config.horizon != null, "addBlock(config.horizon) is required"); + assert(config.start != null && config.end != null, "addBlock(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; + var block = el("div", { className: "sf-block" }); + block.style.left = startPct + "%"; + block.style.width = Math.max(widthPct, minWidthPct) + "%"; + if (config.color) { + block.style.background = config.color; + block.style.borderLeftColor = config.borderColor || config.color; } - - function readDisplayScore(meta, analysis) { - if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; - if (analysis && analysis.score != null) return analysis.score; - return null; + if (config.className) block.classList.add(config.className); + if (config.late) block.classList.add("late"); + if (config.id) block.dataset.blockId = config.id; + if (config.delay) block.style.animationDelay = config.delay; + if (config.label) { + block.appendChild(el("div", { className: "sf-block-label" }, config.label)); } - - function applyLifecycleState(state) { - lifecycleState = state || 'IDLE'; - if (!statusBar) return; - if (typeof statusBar.setLifecycleState === 'function') { - statusBar.setLifecycleState(lifecycleState); - return; - } - if (typeof statusBar.setSolving === 'function') { - statusBar.setSolving(isActiveLifecycle(lifecycleState)); - } + if (config.meta) { + block.appendChild(el("div", { className: "sf-block-meta" }, config.meta)); } - - function updateScore(score) { - if (statusBar && typeof statusBar.updateScore === 'function') { - statusBar.updateScore(score); - } + if (config.onHover) { + block.addEventListener("mouseenter", function(e) { + config.onHover(e, config); + }); } - - function updateMoves(value) { - if (statusBar && typeof statusBar.updateMoves === 'function') { - statusBar.updateMoves(value); - } + if (config.onLeave) { + block.addEventListener("mouseleave", function() { + config.onLeave(); + }); } - - function resetForStart() { - closeCurrentStream(); - activeJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - lastNotifiedError = null; - queuedAction = null; - pendingPause = null; - pendingResume = null; - pendingCancel = null; - terminalSync = null; + if (config.onClick) { + block.setAttribute("role", "button"); + block.tabIndex = 0; + bindActivation(block, function(e) { + config.onClick(e, config); + }); } + rail2.appendChild(block); + return block; + }; + var addChangeover = function(rail2, config) { + assert(rail2, "addChangeover(rail) requires a rail element"); + assert(config && config.horizon != null, "addChangeover(config.horizon) is required"); + assert(config.start != null && config.end != null, "addChangeover(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var co = el("div", { className: "sf-changeover" }); + co.style.left = startPct + "%"; + co.style.width = widthPct + "%"; + rail2.appendChild(co); + return co; + }; + function renderUnassigned(unassignedRail, items, onTaskClick) { + unassignedRail.innerHTML = ""; + (items || []).forEach(function(item) { + var label = typeof item === "string" ? item : item.label || item.id || ""; + if (!label) return; + var pill = el("button", { + className: "sf-unassigned-pill", + onClick: function() { + if (onTaskClick) onTaskClick(item); + } + }, label); + unassignedRail.appendChild(pill); + }); + } - function resetAfterDelete() { - closeCurrentStream(); - rejectDeferred('pause', new Error('Solver job was deleted before pause settled')); - rejectDeferred('resume', new Error('Solver job was deleted before resume settled')); - rejectDeferred('cancel', new Error('Solver job was deleted before cancel settled')); - runToken += 1; - activeJobId = null; - retainedJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - queuedAction = null; - pendingPause = null; - pendingResume = null; - pendingCancel = null; - terminalSync = null; - phase = 'idle'; - applyLifecycleState('IDLE'); - updateScore(null); - updateMoves(null); - } - - function closeCurrentStream() { - if (!closeStream) return; - closeStream(); - closeStream = null; - } - - function currentJobId() { - return activeJobId != null ? activeJobId : retainedJobId; - } - - function hasNewerEvent(meta) { - var currentSequence = lastMeta && typeof lastMeta.eventSequence === 'number' ? lastMeta.eventSequence : null; - var candidateSequence = meta && typeof meta.eventSequence === 'number' ? meta.eventSequence : null; - if (currentSequence == null || candidateSequence == null) return false; - return currentSequence > candidateSequence; - } - - function resolveRequestedSnapshotRevision(snapshotRevision) { - if (snapshotRevision != null && snapshotRevision !== '') return snapshotRevision; - return lastSnapshotRevision; - } - - function createTerminalSync(event) { - var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; - terminalSync = { - jobId: event.meta.jobId, - eventType: event.eventType, - meta: event.meta, - status: 'pending', - promise: null, - error: null, - callbackDelivered: existing ? existing.callbackDelivered : false, - }; - return terminalSync; - } - - function runTerminalSync(record, token, event, requireSnapshot) { - record.status = 'pending'; - record.error = null; - record.meta = event.meta; - record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function (bundle) { - if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; - record.status = 'synced'; - record.error = null; - record.meta = bundle.meta; - finalizeTerminal(bundle.meta); - applyBundle(bundle); - deliverTerminalCallback(record, event, bundle); - settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); - return record; - }).catch(function (err) { - if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; - record.status = 'failed'; - record.error = err; - finalizeTerminal(event.meta); - deliverTerminalFailureCallback(record, event); - settlePendingFromTerminal(event.eventType, null, err); - notifyError(err); - return record; - }); - return record.promise; - } - - function ensureTerminalSyncBeforeDelete(jobId) { - var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; - if (!record) return Promise.resolve(); - - return Promise.resolve(record.promise).then(function () { - if (!requiresSuccessfulTerminalSync(record)) return; - if (record.status === 'synced') return; - return retryTerminalSync(record); - }); + // ts-src/rail/timeline.ts + var DAY_MINUTES = 24 * 60; + var SIX_HOUR_MINUTES = 6 * 60; + var WEEK_MINUTES = 7 * DAY_MINUTES; + var TRACK_HEIGHT = 34; + var TRACK_GAP = 8; + var TRACK_PADDING = 12; + var OVERVIEW_HEIGHT = 68; + var OVERVIEW_BLOCK_HEIGHT = 34; + var OVERVIEW_GROUP_GAP_MINUTES = 30; + var MIN_LABEL_WIDTH = 180; + var MIN_VISIBLE_TRACK_WIDTH = 320; + var MIN_CONTENT_TRACK_WIDTH = 480; + var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; + var TONE_MAP = { + emerald: { + id: "emerald", + background: "rgba(16, 185, 129, 0.22)", + border: "#059669", + text: "#064e3b", + overlay: "rgba(16, 185, 129, 0.10)" + }, + blue: { + id: "blue", + background: "rgba(59, 130, 246, 0.22)", + border: "#2563eb", + text: "#1e40af", + overlay: "rgba(59, 130, 246, 0.10)" + }, + amber: { + id: "amber", + background: "rgba(245, 158, 11, 0.24)", + border: "#d97706", + text: "#92400e", + overlay: "rgba(245, 158, 11, 0.10)" + }, + rose: { + id: "rose", + background: "rgba(244, 63, 94, 0.22)", + border: "#e11d48", + text: "#9f1239", + overlay: "rgba(244, 63, 94, 0.10)" + }, + violet: { + id: "violet", + background: "rgba(139, 92, 246, 0.22)", + border: "#7c3aed", + text: "#5b21b6", + overlay: "rgba(139, 92, 246, 0.10)" + }, + cyan: { + id: "cyan", + background: "rgba(6, 182, 212, 0.22)", + border: "#0891b2", + text: "#155e75", + overlay: "rgba(6, 182, 212, 0.10)" + }, + red: { + id: "red", + background: "rgba(239, 68, 68, 0.22)", + border: "#dc2626", + text: "#991b1b", + overlay: "rgba(239, 68, 68, 0.10)" + }, + slate: { + id: "slate", + background: "rgba(100, 116, 139, 0.20)", + border: "#475569", + text: "#1e293b", + overlay: "rgba(100, 116, 139, 0.08)" } - - function retryTerminalSync(record) { - var retryEvent = { - eventType: record.eventType, - meta: record.meta, - error: null, - }; - return runTerminalSync(record, runToken, retryEvent, true).then(function () { - if (record.status !== 'synced') { - throw record.error || new Error('Terminal snapshot synchronization failed'); + }; + var createTimeline = function(config) { + assert(config && config.model, "rail.createTimeline(config.model) requires a normalized model"); + var labelWidth = config.labelWidth == null ? 280 : assertFiniteNumber(config.labelWidth, "rail.createTimeline(labelWidth)"); + assert(labelWidth > 0, "rail.createTimeline(labelWidth) must be greater than zero"); + var state = { + cleanup: [], + config, + destroyed: false, + expandedClusters: {}, + hasQueuedPostMountSync: false, + instanceId: uid("sf-rail-timeline"), + labelWidth, + model: normalizeModel(config.model), + scrollSync: null, + viewport: null, + layout: null + }; + state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); + var root = el("section", { + className: "sf-rail-timeline", + dataset: { + labelWidth: String(labelWidth) + } + }); + root.setAttribute("role", "region"); + root.setAttribute("aria-label", config.title || "Scheduling timeline"); + var toolbar = el("div", { className: "sf-rail-timeline-toolbar" }); + var toolbarCopy = el("div", { className: "sf-rail-timeline-toolbar-copy" }); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-title" }, config.title || "Scheduling timeline")); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-subtitle" }, config.subtitle || "Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.")); + toolbar.appendChild(toolbarCopy); + var zoomControls = el("div", { className: "sf-rail-timeline-zoom-controls" }); + var zoomButtons = []; + normalizeZoomPresets(config.zoomPresets).forEach(function(preset) { + var button = el("button", { + className: "sf-rail-timeline-zoom-button", + type: "button", + dataset: { zoom: preset } + }, preset === "reset" ? "Reset" : preset.toUpperCase()); + button.addEventListener("click", function() { + if (preset === "reset") { + api.setViewport(state.model.axis.initialViewport); + return; } + api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); }); + zoomButtons.push(button); + zoomControls.appendChild(button); + }); + if (zoomButtons.length) { + toolbar.appendChild(zoomControls); } - - function requiresSuccessfulTerminalSync(record) { - return record.eventType === 'completed' - && (record.meta.lifecycleState === 'COMPLETED' || record.meta.lifecycleState === 'TERMINATED_BY_CONFIG'); + root.appendChild(toolbar); + var shell = el("div", { className: "sf-rail-timeline-shell" }); + var headerViewport = el("div", { className: "sf-rail-timeline-header-viewport" }); + var bodyViewport = el("div", { className: "sf-rail-timeline-body-viewport" }); + var headerRow = el("div", { className: "sf-rail-timeline-header-row" }); + var lanes = el("div", { className: "sf-rail-timeline-lanes" }); + headerViewport.appendChild(headerRow); + bodyViewport.appendChild(lanes); + shell.appendChild(headerViewport); + shell.appendChild(bodyViewport); + root.appendChild(shell); + var tooltip = el("div", { className: "sf-tooltip sf-rail-timeline-tooltip" }); + tooltip.id = uid("sf-rail-timeline-tooltip"); + tooltip.setAttribute("role", "tooltip"); + tooltip.setAttribute("aria-hidden", "true"); + root.appendChild(tooltip); + bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); + bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); + bindWindowResize(state, syncLayoutFromViewport); + function renderStructure() { + renderHeader(); + renderLanes(); } - - function deliverTerminalCallback(record, event, bundle) { - if (record.callbackDelivered) return; - if (event.eventType === 'completed') { - if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); - } else if (event.eventType === 'cancelled') { - if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); - } else if (event.eventType === 'failed') { - if (config.onFailure) config.onFailure(event.error || 'Solver job failed', bundle.meta, bundle.snapshot, bundle.analysis); - } - record.callbackDelivered = true; + function applyMeasuredLayout() { + state.layout = measureLayout(bodyViewport, state); + applyLayout(root, headerRow, lanes, state.layout); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); } - - function deliverTerminalFailureCallback(record, event) { - if (record.callbackDelivered || event.eventType !== 'failed') return; - if (config.onFailure) config.onFailure(event.error || 'Solver job failed', event.meta, null, null); - record.callbackDelivered = true; + function renderHeader() { + headerRow.innerHTML = ""; + var corner = el("div", { className: "sf-rail-timeline-label-corner" }, config.label || "Lane"); + headerRow.appendChild(corner); + var axis = el("div", { className: "sf-rail-timeline-axis sf-rail-timeline-axis--header" }); + axis.style.height = "82px"; + renderAxisDecor(axis, state.model.axis, true); + headerRow.appendChild(axis); } - - function terminalEventError(event) { - if (event.eventType !== 'failed') return null; - return new Error(event.error || 'Solver job failed'); + function renderLanes() { + lanes.innerHTML = ""; + state.model.lanes.forEach(function(lane, laneIndex) { + var laneRender = lane.mode === "overview" ? buildOverviewRender(lane, state, function() { + rerenderTimeline(); + }) : buildDetailedRender(lane, lane.items); + var row = el("div", { + className: "sf-rail-timeline-row sf-rail-timeline-row--" + lane.mode + (laneRender.expandedClusterId ? " sf-rail-timeline-row--expanded" : ""), + dataset: { + laneId: lane.id, + mode: lane.mode, + trackCount: String(laneRender.trackCount) + } + }); + if (laneRender.expandedClusterId) { + row.dataset.expandedClusterId = laneRender.expandedClusterId; + } + row.setAttribute("role", "group"); + var label = buildLaneLabel( + lane, + laneRender, + row, + buildScopedId(state.instanceId, "lane-title-" + laneIndex) + ); + row.appendChild(label); + var track = el("div", { className: "sf-rail-timeline-track" }); + track.style.height = laneRender.height + "px"; + renderAxisDecor(track, state.model.axis, false); + renderOverlays(track, lane.overlays, state.model.axis); + laneRender.blocks.forEach(function(blockConfig) { + appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); + }); + row.appendChild(track); + lanes.appendChild(row); + }); } - - function isCancelablePhase() { - return phase === 'solving' || phase === 'pause-requested' || phase === 'paused' || phase === 'resuming'; + function rerenderTimeline() { + renderStructure(); + syncLayoutFromViewport(); } - - function phaseForLifecycleState(state) { - if (state === 'STARTING') return 'starting'; - if (state === 'SOLVING') return 'solving'; - if (state === 'PAUSE_REQUESTED') return 'pause-requested'; - if (state === 'PAUSED') return 'paused'; - if (state === 'RESUMING') return 'resuming'; - if (state === 'CANCELLING') return 'cancelling'; - return 'idle'; + function syncLayoutFromViewport() { + applyMeasuredLayout(); + syncScrollToViewport(); } - - function isTerminalLifecycle(state) { - return state === 'COMPLETED' - || state === 'CANCELLED' - || state === 'FAILED' - || state === 'TERMINATED_BY_CONFIG'; + function syncScrollToViewport() { + if (!state.layout) return; + var scrollLeft = viewportToScrollLeft(state, bodyViewport); + state.scrollSync = bodyViewport; + bodyViewport.scrollLeft = scrollLeft; + headerViewport.scrollLeft = scrollLeft; + state.scrollSync = null; } - - function settlePendingFromTerminal(eventType, bundle, err) { - if (eventType === 'cancelled') { - resolveDeferred('cancel', bundle); - } else if (pendingCancel) { - if (bundle) pendingCancel.resolve(bundle); - else pendingCancel.reject(err || new Error('Cancel did not settle before the job terminated')); - pendingCancel = null; + var api = { + destroy: function() { + if (state.destroyed) return; + state.destroyed = true; + state.cleanup.forEach(function(cleanup) { + if (typeof cleanup === "function") cleanup(); + }); + root.innerHTML = ""; + }, + el: root, + expandCluster: function(laneId, clusterId) { + setExpandedCluster(state, laneId, clusterId); + rerenderTimeline(); + }, + setModel: function(nextModel) { + state.model = normalizeModel(nextModel); + state.viewport = clampViewport(state.model.axis, state.viewport); + pruneExpandedClusters(state); + rerenderTimeline(); + queuePostMountSync(state, syncLayoutFromViewport); + }, + setViewport: function(nextViewport) { + state.viewport = clampViewport( + state.model.axis, + normalizeViewportInput(nextViewport, "rail.createTimeline().setViewport(viewport)") + ); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); } - - rejectDeferred('pause', err || new Error('Job terminated before pause settled')); - rejectDeferred('resume', err || new Error('Job terminated before resume settled')); - } - - function resolveDeferred(name, value) { - var deferred = getDeferred(name); - if (!deferred) return; - deferred.resolve(value); - setDeferred(name, null); - } - - function rejectDeferred(name, err) { - var deferred = getDeferred(name); - if (!deferred) return; - deferred.reject(err); - setDeferred(name, null); - } - - function getDeferred(name) { - if (name === 'pause') return pendingPause; - if (name === 'resume') return pendingResume; - if (name === 'cancel') return pendingCancel; - return null; + }; + renderStructure(); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + return api; + }; + function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { + var tone = blockConfig.tone; + var relativeStart = blockConfig.startMinute - axis.startMinute; + var relativeEnd = blockConfig.endMinute - axis.startMinute; + var horizon = axis.endMinute - axis.startMinute; + var block = addBlock(track, { + start: relativeStart, + end: relativeEnd, + horizon, + label: blockConfig.label, + meta: blockConfig.metaLabel, + color: tone.background, + borderColor: tone.border, + minWidthPct: 0, + onClick: blockConfig.onClick, + onHover: function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }, + onLeave: function() { + hideTooltip(tooltip); + } + }); + block.classList.add("sf-rail-timeline-item"); + block.classList.add(blockConfig.kindClass); + block.style.left = positionPct(blockConfig.startMinute, axis) + "%"; + block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + "%"; + block.style.top = blockConfig.top + "px"; + block.style.height = blockConfig.height + "px"; + block.style.bottom = "auto"; + block.style.color = tone.text; + block.tabIndex = 0; + block.dataset.itemId = blockConfig.itemId; + block.dataset.laneId = lane.id; + block.dataset.startMinute = String(blockConfig.startMinute); + block.dataset.endMinute = String(blockConfig.endMinute); + if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); + if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; + if (blockConfig.onClick) { + block.setAttribute("role", "button"); + block.setAttribute("aria-expanded", blockConfig.expanded ? "true" : "false"); + } else { + block.setAttribute("role", "group"); } - - function setDeferred(name, value) { - if (name === 'pause') pendingPause = value; - if (name === 'resume') pendingResume = value; - if (name === 'cancel') pendingCancel = value; + if (blockConfig.ariaLabel) block.setAttribute("aria-label", blockConfig.ariaLabel); + block.setAttribute("aria-describedby", tooltip.id); + if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); + if (blockConfig.detailHint) { + block.appendChild(el("span", { className: "sf-rail-timeline-detail-hint" }, blockConfig.detailHint)); } - - function notifyError(err) { - if (err && lastNotifiedError === err) return; - lastNotifiedError = err || null; - if (config.onError) config.onError(err && err.message ? err.message : String(err)); + block.title = blockConfig.tooltip.title; + block.addEventListener("mousemove", function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }); + block.addEventListener("focus", function() { + showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + }); + block.addEventListener("blur", function() { + hideTooltip(tooltip); + }); + block.addEventListener("keydown", function(event) { + if (event && event.key === "Escape") hideTooltip(tooltip); + }); + } + function appendOverviewSummary(block, summary) { + var footer = el("div", { className: "sf-rail-timeline-summary-footer" }); + if (summary.badges.length > 0) { + var badgeRail = el("div", { className: "sf-rail-timeline-summary-badges" }); + summary.badges.forEach(function(badge) { + badgeRail.appendChild(el("span", { + className: "sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--" + badge.kind + }, badge.text)); + }); + footer.appendChild(badgeRail); } - - function ensureJobId(id) { - var jobId = sf.normalizeCreateJobId(id); - if (jobId) return jobId; - throw new Error('Invalid solver backend createJob response'); + if (summary.toneSegments.length > 0) { + var toneBar = el("div", { + className: "sf-rail-timeline-summary-tonebar", + "aria-hidden": "true" + }); + var total = summary.toneSegments.reduce(function(sum, segment) { + return sum + segment.count; + }, 0) || 1; + summary.toneSegments.forEach(function(segment) { + var toneSegment = el("span", { className: "sf-rail-timeline-summary-tone-segment" }); + toneSegment.style.background = segment.tone.border; + toneSegment.style.width = segment.count / total * 100 + "%"; + toneBar.appendChild(toneSegment); + }); + footer.appendChild(toneBar); } - }; - - function hasFunction(object, key) { - return !!(object && typeof object[key] === 'function'); + if (footer.children.length > 0) block.appendChild(footer); } - - function createDeferred() { - var resolve; - var reject; - var promise = new Promise(function (res, rej) { - resolve = res; - reject = rej; + function bindScrollSync(source, target, state, root, zoomButtons) { + source.addEventListener("scroll", function() { + handleScroll(source, target, state, root, zoomButtons); + }); + target.addEventListener("scroll", function() { + handleScroll(target, source, state, root, zoomButtons); }); - return { promise: promise, resolve: resolve, reject: reject }; } - - function normalizeJobEvent(payload, expectedId) { - if (!payload || typeof payload !== 'object') return null; - - var eventType = normalizeEventType(readField(payload, ['eventType', 'event_type', 'type'])); - if (!eventType) return null; - - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); - if (jobId == null || jobId === '') jobId = expectedId; - if (jobId == null || jobId === '') return null; - if (String(jobId) !== String(expectedId)) return null; - - var solution = payload.solution || (payload.data && payload.data.solution) || null; - var solutionScore = readField(solution, ['score'], [solution]); - var meta = { - id: String(jobId), - jobId: String(jobId), - eventType: eventType, - eventSequence: readField(payload, ['eventSequence', 'event_sequence'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state', 'solverStatus', 'solver_status'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, - telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), - currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || solutionScore || null, - bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || solutionScore || null, - snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), - }; - - return { - eventType: eventType, - meta: meta, - solution: solution, - error: readField(payload, ['error'], [payload, payload.data]) || null, + function bindDragPan(source, target, state, root, zoomButtons) { + var drag = { + active: false, + startClientX: 0, + startScrollLeft: 0 }; + source.addEventListener("mousedown", function(event) { + if (event.button != null && event.button !== 0) return; + drag.active = true; + drag.startClientX = event.clientX != null ? event.clientX : 0; + drag.startScrollLeft = source.scrollLeft || 0; + source.classList.add("is-dragging"); + if (event.preventDefault) event.preventDefault(); + }); + source.addEventListener("mousemove", function(event) { + if (!drag.active) return; + var clientX = event.clientX != null ? event.clientX : drag.startClientX; + var delta = clientX - drag.startClientX; + source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); + handleScroll(source, target, state, root, zoomButtons); + if (event.preventDefault) event.preventDefault(); + }); + function finishDrag() { + if (!drag.active) return; + drag.active = false; + source.classList.remove("is-dragging"); + } + source.addEventListener("mouseup", finishDrag); + source.addEventListener("mouseleave", finishDrag); } - - function normalizeSnapshot(payload, fallbackMeta) { - if (!payload || typeof payload !== 'object') return null; - - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]); - if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; - var solution = payload.solution || (payload.data && payload.data.solution) || null; - var solutionScore = readField(solution, ['score'], [solution]); - return { - id: jobId != null ? String(jobId) : null, - jobId: jobId != null ? String(jobId) : null, - snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]), - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || null, - currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.data]) || solutionScore || null, - bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.data]) || solutionScore || null, - telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.data]), payload), - solution: solution, - }; + function handleScroll(source, target, state, root, zoomButtons) { + if (state.destroyed) return; + if (!state.layout) return; + if (state.scrollSync === source) return; + state.scrollSync = source; + target.scrollLeft = source.scrollLeft; + state.viewport = scrollLeftToViewport(state, source); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + state.scrollSync = null; } - - function normalizeAnalysis(payload, fallbackMeta) { - if (!payload || typeof payload !== 'object') return null; - - var analysisBody = payload.analysis || (payload.data && payload.data.analysis) || payload; - var constraints = readAnalysisConstraints(analysisBody); - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]); - if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; - var snapshotRevision = readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]); - if (snapshotRevision == null || snapshotRevision === '') { - snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; - } + function measurePackedHeight(packed) { + return packed.trackCount > 0 ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP : OVERVIEW_HEIGHT; + } + function buildDetailBlockConfig(item, lane, trackIndex, top, config = {}) { + const i = item; + const l = lane; return { - jobId: jobId != null ? String(jobId) : null, - snapshotRevision: snapshotRevision != null ? snapshotRevision : null, - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.terminalReason) || null, - analysis: analysisBody, - score: analysisBody && analysisBody.score != null ? analysisBody.score : null, - constraints: constraints, + clusterId: config.clusterId || null, + detailHint: config.detailHint || "", + endMinute: i.endMinute, + height: TRACK_HEIGHT, + itemId: i.id, + kindClass: "sf-rail-timeline-item--detail", + label: i.label, + metaLabel: describeMeta(i.meta), + startMinute: i.startMinute, + top, + ariaLabel: buildItemAriaLabel(i, l), + tooltip: buildItemTooltip(i, l), + tone: i.tone, + trackIndex }; } - - function buildLiveSnapshot(event) { + function buildOverviewBlockConfig(group, height, options) { + var config = options || {}; return { - id: event.meta.jobId, - jobId: event.meta.jobId, - snapshotRevision: event.meta.snapshotRevision, - lifecycleState: event.meta.lifecycleState, - terminalReason: event.meta.terminalReason, - currentScore: event.meta.currentScore, - bestScore: event.meta.bestScore, - telemetry: event.meta.telemetry, - solution: event.solution, + clusterId: config.clusterId || null, + endMinute: group.endMinute, + height: OVERVIEW_BLOCK_HEIGHT, + itemId: config.itemId, + kindClass: config.kindClass, + label: group.summary.primaryLabel, + metaLabel: group.summary.secondaryLabel, + onClick: config.onClick || null, + startMinute: group.startMinute, + summary: buildOverviewBlockSummary(group, !!config.expanded), + top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), + ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), + expanded: !!config.expanded, + tooltip: config.tooltip, + tone: group.tone }; } - - function mergeMeta(meta, snapshot, eventType) { - if (!snapshot) return meta; + function buildDetailedRender(lane, items) { + var packed = packItems(items); + var height = measurePackedHeight(packed); + var blocks = packed.items.map(function(entry) { + return buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) + ); + }); return { - id: meta && meta.id != null ? meta.id : snapshot.id, - jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, - eventType: meta && meta.eventType ? meta.eventType : eventType, - eventSequence: meta ? meta.eventSequence : null, - lifecycleState: (meta && meta.lifecycleState) || snapshot.lifecycleState || normalizeLifecycleState(null, eventType), - terminalReason: (meta && meta.terminalReason) || snapshot.terminalReason || null, - telemetry: snapshot.telemetry || (meta && meta.telemetry) || null, - currentScore: snapshot.currentScore || (meta && meta.currentScore) || null, - bestScore: snapshot.bestScore || (meta && meta.bestScore) || null, - snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : (meta && meta.snapshotRevision), + blocks, + height, + trackCount: packed.trackCount || 1 }; } - - function readField(payload, names, sources) { - var fields = Array.isArray(names) ? names : [names]; - var roots = sources || [payload]; - for (var i = 0; i < roots.length; i++) { - var source = roots[i]; - if (!source || typeof source !== 'object') continue; - for (var j = 0; j < fields.length; j++) { - if (source[fields[j]] != null) return source[fields[j]]; + function buildOverviewRender(lane, state, rerender) { + var groups = groupOverviewItems(lane); + var expandedClusterId = state.expandedClusters[lane.id] || null; + var expandedGroup = null; + var packedExpanded = null; + var expandedDetailsTop = 0; + groups.forEach(function(group) { + if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { + expandedGroup = group; } + }); + if (expandedGroup) { + packedExpanded = packItems(expandedGroup.detailItems); + expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; } - return null; + var height = packedExpanded ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) : OVERVIEW_HEIGHT; + var blocks = []; + groups.forEach(function(group) { + if (group.isCluster) { + var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); + blocks.push(buildOverviewBlockConfig(group, height, { + clusterId: group.clusterKey, + itemId: group.renderId, + kindClass: "sf-rail-timeline-item--cluster", + expanded: isExpanded, + onClick: function() { + setExpandedCluster( + state, + lane.id, + state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey + ); + if (state.config && state.config.onClusterToggle) { + state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); + } + if (typeof rerender === "function") rerender(); + }, + top: isExpanded ? TRACK_PADDING : null, + tooltip: buildClusterTooltip(group, lane) + })); + if (isExpanded) { + packedExpanded.items.forEach(function(entry) { + blocks.push(buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), + { + clusterId: group.clusterKey, + detailHint: "Expanded" + } + )); + }); + } + return; + } + blocks.push(buildOverviewBlockConfig(group, height, { + itemId: group.items[0].id, + kindClass: "sf-rail-timeline-item--overview", + tooltip: buildOverviewTooltip(group, lane) + })); + }); + return { + blocks, + expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, + height, + trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1 + }; } - - function normalizeEventType(value) { - if (typeof value !== 'string') return null; - var normalized = value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toLowerCase(); - if (!normalized) return null; - if (normalized === 'finished') return 'completed'; - return normalized; + function buildLaneLabel(lane, laneRender, row, headingId) { + var label = el("div", { + className: "sf-rail-timeline-lane-label", + dataset: { laneId: lane.id } + }); + label.style.minHeight = laneRender.height + "px"; + var heading = el("div", { className: "sf-rail-timeline-lane-heading" }); + var title = el("div", { className: "sf-rail-timeline-lane-title" }, lane.label); + title.id = headingId; + heading.appendChild(title); + if (lane.mode) { + heading.appendChild(el("div", { className: "sf-rail-timeline-lane-mode" }, lane.mode)); + } + label.appendChild(heading); + if (row) row.setAttribute("aria-labelledby", title.id); + if (lane.badges.length > 0) { + var badges = el("div", { className: "sf-rail-timeline-lane-badges" }); + lane.badges.forEach(function(badge) { + var badgeEl = el("span", { className: "sf-rail-timeline-lane-badge" }, badge.label); + if (badge.style) { + badgeEl.style.background = badge.style.bg || ""; + badgeEl.style.border = badge.style.border || ""; + badgeEl.style.color = badge.style.color || ""; + } + badges.appendChild(badgeEl); + }); + label.appendChild(badges); + } + if (lane.stats.length > 0) { + var stats = el("div", { className: "sf-rail-timeline-lane-stats" }); + lane.stats.forEach(function(stat) { + var statRow = el("div", { className: "sf-rail-timeline-lane-stat" }); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-label" }, stat.label)); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-value" }, String(stat.value))); + stats.appendChild(statRow); + }); + label.appendChild(stats); + } + return label; } - - function normalizeLifecycleState(value, eventType) { - if (typeof value === 'string' && value.trim()) { - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toUpperCase(); + function buildClusterTooltip(group, lane) { + var first = group.detailItems[0] || group.items[0]; + var payload = { + rows: [ + { key: "Lane", value: lane.label }, + { key: "Window", value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, + { key: "Items", value: String(group.summary.count) } + ], + title: group.label + }; + if (group.summary.openCount > 0) { + payload.rows.push({ key: "Open", value: String(group.summary.openCount) }); } - - if (eventType === 'progress' || eventType === 'best_solution' || eventType === 'resumed') return 'SOLVING'; - if (eventType === 'pause_requested') return 'PAUSE_REQUESTED'; - if (eventType === 'paused') return 'PAUSED'; - if (eventType === 'completed') return 'COMPLETED'; - if (eventType === 'cancelled') return 'CANCELLED'; - if (eventType === 'failed') return 'FAILED'; - return 'IDLE'; + if (group.summary.toneSegments.length > 0) { + payload.rows.push({ key: "Mix", value: describeToneSegments(group.summary.toneSegments) }); + } + if (first && first.meta) { + payload.rows.push({ key: "Sample", value: describeMeta(first.meta) }); + } + return payload; } - - function normalizeTelemetry(rawTelemetry, payload) { - if (rawTelemetry && typeof rawTelemetry === 'object') return rawTelemetry; - - var telemetry = {}; - var movesPerSecond = readField(payload, ['movesPerSecond', 'moves_per_second']); - var stepCount = readField(payload, ['stepCount', 'step_count']); - if (movesPerSecond != null) telemetry.movesPerSecond = movesPerSecond; - if (stepCount != null) telemetry.stepCount = stepCount; - return Object.keys(telemetry).length ? telemetry : null; + function buildItemTooltip(item, lane) { + var rows = [ + { key: "Lane", value: lane.label }, + { key: "Time", value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) } + ]; + appendMetaRows(rows, item.meta); + return { + rows, + title: item.label + }; } - - function readMovesPerSecond(telemetry) { - if (!telemetry || typeof telemetry !== 'object') return null; - if (telemetry.movesPerSecond != null) return telemetry.movesPerSecond; - if (telemetry.moves_per_second != null) return telemetry.moves_per_second; - return null; + function buildOverviewBlockMeta(group) { + if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; + var labels = []; + group.items.slice(0, 2).forEach(function(item) { + labels.push(item.label); + }); + if (group.count > 2) labels.push("+" + (group.count - 2) + " more"); + return labels.join(" \u2022 "); } - - function readAnalysisConstraints(analysis) { - if (!analysis || typeof analysis !== 'object') return null; - if (Array.isArray(analysis.constraints)) return analysis.constraints; - if (analysis.analysis && Array.isArray(analysis.analysis.constraints)) return analysis.analysis.constraints; - return null; + function buildPresetViewport(axis, currentViewport, preset) { + var duration = preset === "1w" ? WEEK_MINUTES : preset === "2w" ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; + var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); + var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; + var start = Math.round(center - visibleDuration / 2); + return clampViewport(axis, { + startMinute: start, + endMinute: start + visibleDuration + }); } - - function isActiveLifecycle(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'RESUMING' - || state === 'CANCELLING'; + function clampNumber(value, min, max) { + return Math.min(Math.max(value, min), max); } - -})(SF); -/* ============================================================================ - SolverForge UI — API Guide Panel - Generates REST API documentation from endpoint definitions. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createApiGuide = function (config) { - sf.assert(config, 'createApiGuide(config) requires a configuration object'); - sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); - - var guide = sf.el('div', { className: 'sf-api-guide' }); - var endpoints = config.endpoints; - - endpoints.forEach(function (ep) { - var section = sf.el('div', { className: 'sf-api-section' }); - section.appendChild(sf.el('h3', null, (ep.method || 'GET') + ' ' + ep.path)); - if (ep.description) { - section.appendChild(sf.el('p', { style: { fontSize: '13px', color: 'var(--sf-gray-600)', marginBottom: '8px' } }, ep.description)); - } - - if (ep.curl) { - var block = sf.el('div', { className: 'sf-api-code-block' }); - block.appendChild(sf.el('code', null, ep.curl)); - var copyBtn = sf.el('button', { - className: 'sf-copy-btn', - 'aria-label': 'Copy command', - onClick: function () { - navigator.clipboard.writeText(ep.curl).then(function () { - copyBtn.textContent = 'Copied!'; - setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1500); - }); - }, - }, 'Copy'); - block.appendChild(copyBtn); - section.appendChild(block); - } - - guide.appendChild(section); - }); - - return guide; - }; -})(SF); -/* ============================================================================ - SolverForge UI — Timeline Rail - Resource-lane timeline: header + cards with positioned blocks. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.rail = {}; - - sf.rail.createHeader = function (config) { - sf.assert(config, 'createHeader(config) requires a configuration object'); - sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); - - var labelWidth = config.labelWidth || 200; - var columns = config.columns || []; - - var header = sf.el('div', { className: 'sf-timeline-header' }); - header.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - var spacer = sf.el('div', { className: 'sf-timeline-label-spacer' }, config.label || ''); - header.appendChild(spacer); - - var days = sf.el('div', { className: 'sf-timeline-days' }); - days.style.gridTemplateColumns = 'repeat(' + columns.length + ', 1fr)'; - - columns.forEach(function (col) { - var colEl = sf.el('div', { className: 'sf-timeline-day-col' }); - colEl.appendChild(sf.el('span', null, typeof col === 'string' ? col : col.label)); - days.appendChild(colEl); - }); - - header.appendChild(days); - return header; - }; - - sf.rail.createCard = function (config) { - sf.assert(config, 'createCard(config) requires a configuration object'); - - var labelWidth = config.labelWidth || 200; - var card = sf.el('div', { className: 'sf-resource-card' }); - var state = { - unassigned: [], - railConfig: config, + function clampViewport(axis, viewport) { + var totalDuration = axis.endMinute - axis.startMinute; + var next = viewport || axis.initialViewport || { + startMinute: axis.startMinute, + endMinute: axis.endMinute }; - - if (config.id) card.dataset.resourceId = config.id; - - // Header row (identity + gauges) - var resHeader = sf.el('div', { className: 'sf-resource-header' }); - resHeader.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - var identity = sf.el('div', { className: 'sf-resource-identity' }); - if (config.name) { - identity.appendChild(sf.el('div', { className: 'sf-resource-name' }, config.name)); + var duration = next.endMinute - next.startMinute; + duration = Math.min(duration, totalDuration); + var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); + return { + endMinute: start + duration, + startMinute: start + }; + } + function assertFiniteNumber(value, label) { + assert(typeof value === "number" && isFinite(value), label + " must be a finite number"); + return value; + } + function assertMinuteValue(value, label) { + return assertInteger(value, label); + } + function assertInteger(value, label) { + var number = assertFiniteNumber(value, label); + assert(Math.floor(number) === number, label + " must be an integer"); + return number; + } + function assertNonNegativeInteger(value, label) { + var number = assertInteger(value, label); + assert(number >= 0, label + " must be greater than or equal to zero"); + return number; + } + function describeMeta(meta) { + if (meta == null) return ""; + if (typeof meta === "string") return meta; + if (typeof meta === "number") return String(meta); + if (Array.isArray(meta)) { + return meta.map(function(entry) { + if (entry && entry.label && entry.value != null) return entry.label + ": " + entry.value; + return String(entry || ""); + }).filter(Boolean).join(" \u2022 "); } - if (config.badges || config.type) { - var meta = sf.el('div', { className: 'sf-resource-meta' }); - if (config.type) { - var badge = sf.el('span', { className: 'sf-resource-type-badge' }, config.type); - if (config.typeStyle) { - badge.style.background = config.typeStyle.bg || ''; - badge.style.color = config.typeStyle.color || ''; - badge.style.border = config.typeStyle.border || ''; - } - meta.appendChild(badge); - } - var badges = Array.isArray(config.badges) - ? config.badges - : config.badges - ? [config.badges] - : []; - if (badges.length) { - badges.forEach(function (entry) { - if (!entry) return; - if (typeof entry === 'string') { - meta.appendChild(sf.el('span', { className: 'sf-resource-type-badge' }, entry)); - return; - } - var extraBadge = sf.el('span', { className: 'sf-resource-type-badge' }, entry.label || ''); - if (entry.style) { - extraBadge.style.background = entry.style.bg || ''; - extraBadge.style.color = entry.style.color || ''; - extraBadge.style.border = entry.style.border || ''; - } - meta.appendChild(extraBadge); - }); - } - identity.appendChild(meta); + if (typeof meta === "object") { + return Object.keys(meta).map(function(key) { + return key + ": " + meta[key]; + }).join(" \u2022 "); } - resHeader.appendChild(identity); - - // Gauges - if (config.gauges && config.gauges.length > 0) { - var gauges = sf.el('div', { className: 'sf-gauges' }); - config.gauges.forEach(function (g) { - var row = sf.el('div', { className: 'sf-gauge-row' }); - row.appendChild(sf.el('span', { className: 'sf-gauge-label' }, g.label)); - var track = sf.el('div', { className: 'sf-gauge-track' }); - var fill = sf.el('div', { - className: 'sf-gauge-fill' + (g.style ? ' sf-gauge-fill--' + g.style : ''), - }); - fill.style.width = Math.min(g.pct || 0, 100) + '%'; - track.appendChild(fill); - row.appendChild(track); - if (g.text) row.appendChild(sf.el('span', { className: 'sf-gauge-value' }, g.text)); - gauges.appendChild(row); - }); - resHeader.appendChild(gauges); + return String(meta); + } + function appendMetaRows(rows, meta) { + if (meta == null) return; + if (typeof meta === "string" || typeof meta === "number") { + rows.push({ key: "Meta", value: String(meta) }); + return; } - - card.appendChild(resHeader); - - // Body (stats + rail) - var body = sf.el('div', { className: 'sf-resource-body' }); - body.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - // Stats panel - var stats = sf.el('div', { className: 'sf-resource-stats' }); - if (config.stats) { - config.stats.forEach(function (s) { - var row = sf.el('div', { className: 'sf-stat-row' }); - row.appendChild(sf.el('span', { className: 'sf-stat-label' }, s.label)); - row.appendChild(sf.el('span', { className: 'sf-stat-value' }, String(s.value))); - stats.appendChild(row); + if (Array.isArray(meta)) { + meta.forEach(function(entry, index) { + if (!entry) return; + if (entry.label && entry.value != null) { + rows.push({ key: entry.label, value: String(entry.value) }); + return; + } + rows.push({ key: "Meta " + (index + 1), value: String(entry) }); }); + return; } - body.appendChild(stats); - - // Rail - var railContainer = sf.el('div', { className: 'sf-rail-container' }); - var rail = sf.el('div', { className: 'sf-rail' }); - if (config.id) rail.id = 'sf-rail-' + config.id; - - // Day grid - var numCols = config.columns || 5; - var dayGrid = sf.el('div', { className: 'sf-day-grid' }); - dayGrid.style.gridTemplateColumns = 'repeat(' + numCols + ', 1fr)'; - for (var i = 0; i < numCols; i++) { - dayGrid.appendChild(sf.el('div', { className: 'sf-day-col' })); - } - rail.appendChild(dayGrid); - - railContainer.appendChild(rail); - body.appendChild(railContainer); - card.appendChild(body); - - // Optional heatmap strip - if (config.heatmap) { - var heatmapCfg = { - horizon: config.heatmap.horizon || 1, - label: config.heatmap.label, - segments: config.heatmap.segments, - labelWidth: labelWidth, - }; - heatmapCfg.railConfig = config; - var heatmap = sf.rail.createHeatmap(heatmapCfg); - if (heatmap) card.appendChild(heatmap); - } - - // Optional unassigned list - var unassignedRail = sf.el('div', { className: 'sf-unassigned-rail' }); - if (config.unassigned) { - state.unassigned = config.unassigned; - renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); + if (typeof meta === "object") { + Object.keys(meta).forEach(function(key) { + rows.push({ key, value: String(meta[key]) }); + }); } - if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); - - // API - var cardApi = { el: card, rail: rail }; - - cardApi.addBlock = function (blockConfig) { - return sf.rail.addBlock(rail, blockConfig); + } + function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { + var startMinute = assertMinuteValue(startValue, startLabel); + var endMinute = assertMinuteValue(endValue, endLabel); + assert(endMinute > startMinute, endLabel + " must be greater than startMinute"); + return { + endMinute, + startMinute }; - - cardApi.setUnassigned = function (items) { - state.unassigned = Array.isArray(items) ? items : []; - if (state.unassigned.length === 0 && unassignedRail.parentNode) { - unassignedRail.innerHTML = ''; - unassignedRail.parentNode && unassignedRail.parentNode.removeChild(unassignedRail); - return; - } - if (state.unassigned.length > 0) { - renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); - } else { - unassignedRail.innerHTML = ''; - } - if (state.unassigned.length > 0 && !unassignedRail.parentNode) { - card.appendChild(unassignedRail); - } + } + function normalizeId(value, prefix, suffix) { + return value != null ? String(value) : prefix + suffix; + } + function buildScopedId(scope, suffix) { + return scope + "-" + suffix; + } + function setExpandedCluster(state, laneId, clusterId) { + if (clusterId == null) delete state.expandedClusters[laneId]; + else state.expandedClusters[laneId] = String(clusterId); + } + function normalizeAxis(axis) { + assert(axis && axis.startMinute != null && axis.endMinute != null, "createTimeline(model.axis.startMinute/endMinute) are required"); + var axisRange = normalizeMinuteRange( + axis.startMinute, + axis.endMinute, + "createTimeline(model.axis.startMinute)", + "createTimeline(model.axis.endMinute)" + ); + var normalized = { + endMinute: axisRange.endMinute, + startMinute: axisRange.startMinute }; - - cardApi.clearBlocks = function () { - rail.querySelectorAll('.sf-block, .sf-changeover').forEach(function (el) { - el.remove(); - }); + normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); + normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); + normalized.initialViewport = clampViewport( + normalized, + normalizeViewportInput(axis.initialViewport, "createTimeline(model.axis.initialViewport)") || { + startMinute: normalized.startMinute, + endMinute: normalized.endMinute + } + ); + return normalized; + } + function normalizeBadge(badge) { + if (!badge) return null; + if (typeof badge === "string") return { label: badge }; + return { + label: badge.label || "", + style: badge.style || null }; - - cardApi.setSolving = function (solving) { - card.classList.toggle('solving', solving); - }; - - return cardApi; - }; - - sf.rail.createHeatmap = function (config) { - if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; - - var heatmap = sf.el('div', { className: 'sf-heatmap' }); - heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + 'px 1fr'; - var label = sf.el('div', { className: 'sf-heatmap-label' }, config.label || ''); - heatmap.appendChild(label); - - var track = sf.el('div', { className: 'sf-heatmap-track' }); - var columns = config.railConfig && config.railConfig.columns || 1; - track.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)'; - heatmap.appendChild(track); - - var horizon = config.horizon || 1; - config.segments.forEach(function (segment) { - if (!segment || segment.end <= segment.start) return; - var band = sf.el('div', { className: 'sf-heatmap-segment' }); - var start = Math.max(0, segment.start); - var width = Math.max(0, segment.end - start); - band.style.left = (start / horizon * 100) + '%'; - band.style.width = Math.max(width / horizon * 100, 0.25) + '%'; - if (segment.color) band.style.background = segment.color; - if (segment.opacity != null) band.style.opacity = segment.opacity; - if (segment.tooltip) band.title = segment.tooltip; - track.appendChild(band); - }); - - return heatmap; - }; - - sf.rail.createUnassignedRail = function (tasks, onTaskClick) { - var rail = sf.el('div', { className: 'sf-unassigned-rail' }); - renderUnassigned(rail, tasks, onTaskClick); - return rail; - }; - - sf.rail.addBlock = function (rail, config) { - sf.assert(rail, 'addBlock(rail) requires a rail element'); - sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); - sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); - - var horizon = config.horizon || 1; - var startPct = (config.start / horizon) * 100; - var widthPct = ((config.end - config.start) / horizon) * 100; - var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; - - var block = sf.el('div', { className: 'sf-block' }); - block.style.left = startPct + '%'; - block.style.width = Math.max(widthPct, minWidthPct) + '%'; - - if (config.color) { - block.style.background = config.color; - block.style.borderLeftColor = config.borderColor || config.color; - } - if (config.className) block.classList.add(config.className); - if (config.late) block.classList.add('late'); - if (config.id) block.dataset.blockId = config.id; - if (config.delay) block.style.animationDelay = config.delay; - - if (config.label) { - block.appendChild(sf.el('div', { className: 'sf-block-label' }, config.label)); - } - if (config.meta) { - block.appendChild(sf.el('div', { className: 'sf-block-meta' }, config.meta)); - } - - if (config.onHover) { - block.addEventListener('mouseenter', function (e) { config.onHover(e, config); }); - } - if (config.onLeave) { - block.addEventListener('mouseleave', function () { config.onLeave(); }); - } - if (config.onClick) { - block.setAttribute('role', 'button'); - block.tabIndex = 0; - sf.bindActivation(block, function (e) { config.onClick(e, config); }); + } + function normalizeDays(days, startMinute, endMinute) { + var list = []; + var source = Array.isArray(days) && days.length > 0 ? days : null; + var cursor = startMinute; + var index = 0; + if (!source) { + while (cursor < endMinute) { + list.push(makeDay({ + endMinute: Math.min(cursor + DAY_MINUTES, endMinute), + isWeekend: false, + label: "Day " + (index + 1), + startMinute: cursor + }, index)); + cursor += DAY_MINUTES; + index += 1; + } + return list; } - - rail.appendChild(block); - return block; - }; - - sf.rail.addChangeover = function (rail, config) { - sf.assert(rail, 'addChangeover(rail) requires a rail element'); - sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); - sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); - - var horizon = config.horizon || 1; - var startPct = (config.start / horizon) * 100; - var widthPct = ((config.end - config.start) / horizon) * 100; - - var co = sf.el('div', { className: 'sf-changeover' }); - co.style.left = startPct + '%'; - co.style.width = widthPct + '%'; - rail.appendChild(co); - return co; - }; - - function renderUnassigned(unassignedRail, items, onTaskClick) { - unassignedRail.innerHTML = ''; - (items || []).forEach(function (item) { - var label = typeof item === 'string' ? item : item.label || item.id || ''; - if (!label) return; - var pill = sf.el('button', { - className: 'sf-unassigned-pill', - onClick: function () { - if (onTaskClick) onTaskClick(item); - }, - }, label); - unassignedRail.appendChild(pill); + source.forEach(function(day, dayIndex) { + if (cursor >= endMinute) return; + if (typeof day === "string") { + var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); + list.push(makeDay({ + endMinute: generatedEnd, + isWeekend: inferWeekend(day), + label: day, + startMinute: cursor + }, dayIndex)); + cursor = generatedEnd; + return; + } + var nextStart = day.startMinute != null ? day.startMinute : cursor; + var nextEnd = day.endMinute != null ? day.endMinute : Math.min(nextStart + DAY_MINUTES, endMinute); + var dayRange = normalizeMinuteRange( + nextStart, + nextEnd, + "createTimeline(model.axis.days[" + dayIndex + "].startMinute)", + "createTimeline(model.axis.days[" + dayIndex + "].endMinute)" + ); + list.push(makeDay({ + endMinute: dayRange.endMinute, + isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), + label: day.label || "Day " + (dayIndex + 1), + startMinute: dayRange.startMinute, + subLabel: day.subLabel || day.meta || "" + }, dayIndex)); + cursor = dayRange.endMinute; }); + return list; } - -})(SF); -/* ============================================================================ - SolverForge UI — Rail Timeline - Canonical dense scheduling surface for resource-lane timelines. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var DAY_MINUTES = 24 * 60; - var SIX_HOUR_MINUTES = 6 * 60; - var WEEK_MINUTES = 7 * DAY_MINUTES; - var TRACK_HEIGHT = 34; - var TRACK_GAP = 8; - var TRACK_PADDING = 12; - var OVERVIEW_HEIGHT = 68; - var OVERVIEW_BLOCK_HEIGHT = 34; - var OVERVIEW_GROUP_GAP_MINUTES = 30; - var MIN_LABEL_WIDTH = 180; - var MIN_VISIBLE_TRACK_WIDTH = 320; - var MIN_CONTENT_TRACK_WIDTH = 480; - var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; - - var TONE_MAP = { - emerald: { - id: 'emerald', - background: 'rgba(16, 185, 129, 0.22)', - border: '#059669', - text: '#064e3b', - overlay: 'rgba(16, 185, 129, 0.10)', - }, - blue: { - id: 'blue', - background: 'rgba(59, 130, 246, 0.22)', - border: '#2563eb', - text: '#1e40af', - overlay: 'rgba(59, 130, 246, 0.10)', - }, - amber: { - id: 'amber', - background: 'rgba(245, 158, 11, 0.24)', - border: '#d97706', - text: '#92400e', - overlay: 'rgba(245, 158, 11, 0.10)', - }, - rose: { - id: 'rose', - background: 'rgba(244, 63, 94, 0.22)', - border: '#e11d48', - text: '#9f1239', - overlay: 'rgba(244, 63, 94, 0.10)', - }, - violet: { - id: 'violet', - background: 'rgba(139, 92, 246, 0.22)', - border: '#7c3aed', - text: '#5b21b6', - overlay: 'rgba(139, 92, 246, 0.10)', - }, - cyan: { - id: 'cyan', - background: 'rgba(6, 182, 212, 0.22)', - border: '#0891b2', - text: '#155e75', - overlay: 'rgba(6, 182, 212, 0.10)', - }, - red: { - id: 'red', - background: 'rgba(239, 68, 68, 0.22)', - border: '#dc2626', - text: '#991b1b', - overlay: 'rgba(239, 68, 68, 0.10)', - }, - slate: { - id: 'slate', - background: 'rgba(100, 116, 139, 0.20)', - border: '#475569', - text: '#1e293b', - overlay: 'rgba(100, 116, 139, 0.08)', - }, - }; - - sf.rail = sf.rail || {}; - - sf.rail.createTimeline = function (config) { - sf.assert(config && config.model, 'rail.createTimeline(config.model) requires a normalized model'); - - var labelWidth = config.labelWidth == null - ? 280 - : assertFiniteNumber(config.labelWidth, 'rail.createTimeline(labelWidth)'); - sf.assert(labelWidth > 0, 'rail.createTimeline(labelWidth) must be greater than zero'); - var state = { - cleanup: [], - config: config, - destroyed: false, - expandedClusters: {}, - hasQueuedPostMountSync: false, - instanceId: sf.uid('sf-rail-timeline'), - labelWidth: labelWidth, - model: normalizeModel(config.model), - scrollSync: null, - viewport: null, - layout: null, + function normalizeItem(item, pathKey, ordinal) { + assert(item && item.startMinute != null && item.endMinute != null, "timeline items require startMinute/endMinute"); + var itemRange = normalizeMinuteRange( + item.startMinute, + item.endMinute, + "createTimeline(model.lanes[].items[].startMinute)", + "createTimeline(model.lanes[].items[].endMinute)" + ); + return { + clusterId: item.clusterId != null ? String(item.clusterId) : null, + detailItems: Array.isArray(item.detailItems) ? item.detailItems.map(function(detailItem, detailIndex) { + return normalizeItem(detailItem, pathKey + "-" + detailIndex, detailIndex); + }) : [], + endMinute: itemRange.endMinute, + id: normalizeId(item.id, "item-", pathKey), + label: item.label || "Item " + (ordinal + 1), + meta: item.meta != null ? item.meta : "", + originalIndex: ordinal, + summary: normalizeOverviewSummary(item.summary, "createTimeline(model.lanes[].items[].summary)"), + startMinute: itemRange.startMinute, + tone: resolveTone(item.tone || item.color || "slate") }; - - state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); - - var root = sf.el('section', { - className: 'sf-rail-timeline', - dataset: { - labelWidth: String(labelWidth), - }, - }); - root.setAttribute('role', 'region'); - root.setAttribute('aria-label', config.title || 'Scheduling timeline'); - - var toolbar = sf.el('div', { className: 'sf-rail-timeline-toolbar' }); - var toolbarCopy = sf.el('div', { className: 'sf-rail-timeline-toolbar-copy' }); - toolbarCopy.appendChild(sf.el('div', { className: 'sf-rail-timeline-toolbar-title' }, config.title || 'Scheduling timeline')); - toolbarCopy.appendChild(sf.el('div', { className: 'sf-rail-timeline-toolbar-subtitle' }, config.subtitle || 'Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.')); - toolbar.appendChild(toolbarCopy); - - var zoomControls = sf.el('div', { className: 'sf-rail-timeline-zoom-controls' }); - var zoomButtons = []; - normalizeZoomPresets(config.zoomPresets).forEach(function (preset) { - var button = sf.el('button', { - className: 'sf-rail-timeline-zoom-button', - type: 'button', - dataset: { zoom: preset }, - }, preset === 'reset' ? 'Reset' : preset.toUpperCase()); - button.addEventListener('click', function () { - if (preset === 'reset') { - api.setViewport(state.model.axis.initialViewport); + } + function normalizeLane(lane, index, axis) { + assert(lane && Array.isArray(lane.items), "timeline lanes require an items array"); + var normalizedLane = { + axis, + badges: [], + id: normalizeId(lane.id, "lane-", index), + items: lane.items.map(function(item, itemIndex) { + return normalizeItem(item, index + "-" + itemIndex, itemIndex); + }), + label: lane.label || "Lane " + (index + 1), + mode: lane.mode === "overview" ? "overview" : "detailed", + overlays: Array.isArray(lane.overlays) ? lane.overlays.map(function(overlay, overlayIndex) { + return normalizeOverlay(overlay, overlayIndex, axis); + }).filter(Boolean) : [], + stats: Array.isArray(lane.stats) ? lane.stats : [] + }; + normalizedLane.items.sort(compareItems); + if (Array.isArray(lane.badges)) { + lane.badges.forEach(function(badge) { + var normalizedBadge = normalizeBadge(badge); + if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); + }); + } else { + var singleBadge = normalizeBadge(lane.badges); + if (singleBadge) normalizedLane.badges.push(singleBadge); + } + return normalizedLane; + } + function normalizeModel(model) { + assert(model && model.axis && Array.isArray(model.lanes), "createTimeline(model.axis/model.lanes) are required"); + var axis = normalizeAxis(model.axis); + return { + axis, + lanes: model.lanes.map(function(lane, index) { + return normalizeLane(lane, index, axis); + }) + }; + } + function normalizeOverlay(overlay, index, axis) { + var label = "createTimeline(model.lanes[].overlays[" + index + "])"; + assert(overlay && typeof overlay === "object", label + " must be an object"); + var startMinute = overlay.startMinute; + var endMinute = overlay.endMinute; + if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { + var dayIndex = assertInteger(overlay.dayIndex, label + ".dayIndex"); + var day = axis.days[dayIndex]; + assert(day, label + ".dayIndex must reference an existing day"); + var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + ".dayCount"); + assert(dayCount > 0, label + ".dayCount must be greater than zero"); + var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; + startMinute = day.startMinute; + endMinute = lastDay.endMinute; + } + assert( + startMinute != null && endMinute != null, + label + " requires startMinute/endMinute or dayIndex/dayCount" + ); + var overlayRange = normalizeMinuteRange( + startMinute, + endMinute, + label + ".startMinute", + label + ".endMinute" + ); + return { + endMinute: overlayRange.endMinute, + id: normalizeId(overlay.id, "overlay-", index), + label: overlay.label || "", + meta: overlay.meta || "", + startMinute: overlayRange.startMinute, + tone: resolveTone(overlay.tone || overlay.color || "slate") + }; + } + function normalizeTicks(ticks, startMinute, endMinute) { + var list = []; + if (Array.isArray(ticks) && ticks.length > 0) { + ticks.forEach(function(tick, index) { + if (typeof tick === "number") { + var numericTick = assertMinuteValue(tick, "createTimeline(model.axis.ticks[" + index + "])"); + list.push({ id: "tick-" + index, label: formatClock(numericTick), minute: numericTick }); return; } - api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); + assert(tick && typeof tick === "object", "createTimeline(model.axis.ticks[" + index + "]) must be a number or object"); + assert(tick.minute != null, "createTimeline(model.axis.ticks[" + index + "].minute) is required"); + var minute2 = assertMinuteValue(tick.minute, "createTimeline(model.axis.ticks[" + index + "].minute)"); + list.push({ + id: normalizeId(tick.id, "tick-", index), + label: tick.label || formatClock(minute2), + minute: minute2 + }); }); - zoomButtons.push(button); - zoomControls.appendChild(button); - }); - if (zoomButtons.length) { - toolbar.appendChild(zoomControls); + return list; } - root.appendChild(toolbar); - - var shell = sf.el('div', { className: 'sf-rail-timeline-shell' }); - var headerViewport = sf.el('div', { className: 'sf-rail-timeline-header-viewport' }); - var bodyViewport = sf.el('div', { className: 'sf-rail-timeline-body-viewport' }); - var headerRow = sf.el('div', { className: 'sf-rail-timeline-header-row' }); - var lanes = sf.el('div', { className: 'sf-rail-timeline-lanes' }); - headerViewport.appendChild(headerRow); - bodyViewport.appendChild(lanes); - shell.appendChild(headerViewport); - shell.appendChild(bodyViewport); - root.appendChild(shell); - - var tooltip = sf.el('div', { className: 'sf-tooltip sf-rail-timeline-tooltip' }); - tooltip.id = sf.uid('sf-rail-timeline-tooltip'); - tooltip.setAttribute('role', 'tooltip'); - tooltip.setAttribute('aria-hidden', 'true'); - root.appendChild(tooltip); - - bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); - bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); - bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); - bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); - bindWindowResize(state, syncLayoutFromViewport); - - function renderStructure() { - renderHeader(); - renderLanes(); + for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { + list.push({ + id: "tick-" + minute, + label: formatClock(minute), + minute + }); } - - function applyMeasuredLayout() { - state.layout = measureLayout(bodyViewport, state); - applyLayout(root, headerRow, lanes, state.layout); - updateViewportMetadata(root, state); - updateZoomButtons(zoomButtons, state); - } - - function renderHeader() { - headerRow.innerHTML = ''; - - var corner = sf.el('div', { className: 'sf-rail-timeline-label-corner' }, config.label || 'Lane'); - headerRow.appendChild(corner); - - var axis = sf.el('div', { className: 'sf-rail-timeline-axis sf-rail-timeline-axis--header' }); - axis.style.height = '82px'; - renderAxisDecor(axis, state.model.axis, true); - headerRow.appendChild(axis); - } - - function renderLanes() { - lanes.innerHTML = ''; - - state.model.lanes.forEach(function (lane, laneIndex) { - var laneRender = lane.mode === 'overview' - ? buildOverviewRender(lane, state, function () { - rerenderTimeline(); - }) - : buildDetailedRender(lane, lane.items); - - var row = sf.el('div', { - className: 'sf-rail-timeline-row sf-rail-timeline-row--' + lane.mode + (laneRender.expandedClusterId ? ' sf-rail-timeline-row--expanded' : ''), - dataset: { - laneId: lane.id, - mode: lane.mode, - trackCount: String(laneRender.trackCount), - }, - }); - if (laneRender.expandedClusterId) { - row.dataset.expandedClusterId = laneRender.expandedClusterId; - } - row.setAttribute('role', 'group'); - - var label = buildLaneLabel( - lane, - laneRender, - row, - buildScopedId(state.instanceId, 'lane-title-' + laneIndex) - ); - row.appendChild(label); - - var track = sf.el('div', { className: 'sf-rail-timeline-track' }); - track.style.height = laneRender.height + 'px'; - renderAxisDecor(track, state.model.axis, false); - renderOverlays(track, lane.overlays, state.model.axis); - laneRender.blocks.forEach(function (blockConfig) { - appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); - }); - row.appendChild(track); - lanes.appendChild(row); - }); - } - - function rerenderTimeline() { - renderStructure(); - syncLayoutFromViewport(); - } - - function syncLayoutFromViewport() { - applyMeasuredLayout(); - syncScrollToViewport(); - } - - function syncScrollToViewport() { - if (!state.layout) return; - var scrollLeft = viewportToScrollLeft(state, bodyViewport); - state.scrollSync = bodyViewport; - bodyViewport.scrollLeft = scrollLeft; - headerViewport.scrollLeft = scrollLeft; - state.scrollSync = null; - } - - var api = { - destroy: function () { - if (state.destroyed) return; - state.destroyed = true; - state.cleanup.forEach(function (cleanup) { - if (typeof cleanup === 'function') cleanup(); - }); - root.innerHTML = ''; - }, - el: root, - expandCluster: function (laneId, clusterId) { - setExpandedCluster(state, laneId, clusterId); - rerenderTimeline(); - }, - setModel: function (nextModel) { - state.model = normalizeModel(nextModel); - state.viewport = clampViewport(state.model.axis, state.viewport); - pruneExpandedClusters(state); - rerenderTimeline(); - queuePostMountSync(state, syncLayoutFromViewport); - }, - setViewport: function (nextViewport) { - state.viewport = clampViewport( - state.model.axis, - normalizeViewportInput(nextViewport, 'rail.createTimeline().setViewport(viewport)') - ); - syncLayoutFromViewport(); - queuePostMountSync(state, syncLayoutFromViewport); - }, + return list; + } + function makeDay(day, index) { + return { + endMinute: day.endMinute, + id: normalizeId(day.id, "day-", index), + isWeekend: !!day.isWeekend, + label: day.label || "Day " + (index + 1), + startMinute: day.startMinute, + subLabel: day.subLabel || "" }; - - renderStructure(); - syncLayoutFromViewport(); - queuePostMountSync(state, syncLayoutFromViewport); - - return api; - }; - - function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { - var tone = blockConfig.tone; - var relativeStart = blockConfig.startMinute - axis.startMinute; - var relativeEnd = blockConfig.endMinute - axis.startMinute; - var horizon = axis.endMinute - axis.startMinute; - var block = sf.rail.addBlock(track, { - start: relativeStart, - end: relativeEnd, - horizon: horizon, - label: blockConfig.label, - meta: blockConfig.metaLabel, - color: tone.background, - borderColor: tone.border, - minWidthPct: 0, - onClick: blockConfig.onClick, - onHover: function (event) { - showTooltip(tooltip, root, blockConfig.tooltip, event); - }, - onLeave: function () { - hideTooltip(tooltip); - }, - }); - - block.classList.add('sf-rail-timeline-item'); - block.classList.add(blockConfig.kindClass); - block.style.left = positionPct(blockConfig.startMinute, axis) + '%'; - block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + '%'; - block.style.top = blockConfig.top + 'px'; - block.style.height = blockConfig.height + 'px'; - block.style.bottom = 'auto'; - block.style.color = tone.text; - block.tabIndex = 0; - block.dataset.itemId = blockConfig.itemId; - block.dataset.laneId = lane.id; - block.dataset.startMinute = String(blockConfig.startMinute); - block.dataset.endMinute = String(blockConfig.endMinute); - if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); - if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; - if (blockConfig.onClick) { - block.setAttribute('role', 'button'); - block.setAttribute('aria-expanded', blockConfig.expanded ? 'true' : 'false'); - } else { - block.setAttribute('role', 'group'); - } - if (blockConfig.ariaLabel) block.setAttribute('aria-label', blockConfig.ariaLabel); - block.setAttribute('aria-describedby', tooltip.id); - if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); - if (blockConfig.detailHint) { - block.appendChild(sf.el('span', { className: 'sf-rail-timeline-detail-hint' }, blockConfig.detailHint)); + } + function compareItems(left, right) { + if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; + if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; + if (left.label !== right.label) return left.label < right.label ? -1 : 1; + return left.originalIndex - right.originalIndex; + } + function normalizeOverviewSummary(summary, label) { + if (summary == null) return null; + assert(summary && typeof summary === "object", label + " must be an object"); + var normalized = { + count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + ".count"), + openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + ".openCount"), + primaryLabel: summary.primaryLabel == null ? "" : String(summary.primaryLabel), + secondaryLabel: summary.secondaryLabel == null ? "" : String(summary.secondaryLabel), + toneSegments: Array.isArray(summary.toneSegments) ? summary.toneSegments.map(function(segment, index) { + assert(segment && typeof segment === "object", label + ".toneSegments[" + index + "] must be an object"); + return { + count: assertNonNegativeInteger(segment.count, label + ".toneSegments[" + index + "].count"), + tone: resolveTone(segment.tone || segment.color || "slate") + }; + }).filter(function(segment) { + return segment.count > 0; + }) : [] + }; + if (normalized.count != null && normalized.openCount != null) { + assert(normalized.openCount <= normalized.count, label + ".openCount must not exceed count"); } - block.title = blockConfig.tooltip.title; - block.addEventListener('mousemove', function (event) { - showTooltip(tooltip, root, blockConfig.tooltip, event); + return normalized; + } + function renderAxisDecor(track, axis, includeLabels) { + appendWeekendBands(track, axis); + appendDayDividers(track, axis); + appendTicks(track, axis, includeLabels); + if (includeLabels) appendDayBands(track, axis); + } + function appendDayBands(track, axis) { + axis.days.forEach(function(day) { + var band = el("div", { className: "sf-rail-timeline-day-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + band.appendChild(el("div", { className: "sf-rail-timeline-day-label" }, day.label)); + if (day.subLabel) { + band.appendChild(el("div", { className: "sf-rail-timeline-day-sub" }, day.subLabel)); + } + track.appendChild(band); }); - block.addEventListener('focus', function () { - showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + } + function appendDayDividers(track, axis) { + axis.days.forEach(function(day, index) { + if (index === 0) return; + var divider = el("div", { className: "sf-rail-timeline-day-divider" }); + divider.style.left = positionPct(day.startMinute, axis) + "%"; + track.appendChild(divider); }); - block.addEventListener('blur', function () { - hideTooltip(tooltip); + } + function appendTicks(track, axis, includeLabels) { + axis.ticks.forEach(function(tick) { + if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; + var tickEl = el("div", { className: "sf-rail-timeline-tick" }); + tickEl.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(tickEl); + if (!includeLabels) return; + var label = el("div", { className: "sf-rail-timeline-tick-label" }, tick.label); + label.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(label); }); - block.addEventListener('keydown', function (event) { - if (event && event.key === 'Escape') hideTooltip(tooltip); + } + function appendWeekendBands(track, axis) { + axis.days.forEach(function(day) { + if (!day.isWeekend) return; + var band = el("div", { className: "sf-rail-timeline-weekend-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + track.appendChild(band); }); } - - function appendOverviewSummary(block, summary) { - var footer = sf.el('div', { className: 'sf-rail-timeline-summary-footer' }); - if (summary.badges.length > 0) { - var badgeRail = sf.el('div', { className: 'sf-rail-timeline-summary-badges' }); - summary.badges.forEach(function (badge) { - badgeRail.appendChild(sf.el('span', { - className: 'sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--' + badge.kind, - }, badge.text)); - }); - footer.appendChild(badgeRail); - } - if (summary.toneSegments.length > 0) { - var toneBar = sf.el('div', { - className: 'sf-rail-timeline-summary-tonebar', - 'aria-hidden': 'true', - }); - var total = summary.toneSegments.reduce(function (sum, segment) { - return sum + segment.count; - }, 0) || 1; - summary.toneSegments.forEach(function (segment) { - var toneSegment = sf.el('span', { className: 'sf-rail-timeline-summary-tone-segment' }); - toneSegment.style.background = segment.tone.border; - toneSegment.style.width = ((segment.count / total) * 100) + '%'; - toneBar.appendChild(toneSegment); - }); - footer.appendChild(toneBar); - } - if (footer.children.length > 0) block.appendChild(footer); + function renderOverlays(track, overlays, axis) { + overlays.forEach(function(overlay) { + var band = el("div", { className: "sf-rail-timeline-overlay" }); + band.style.left = positionPct(overlay.startMinute, axis) + "%"; + band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + "%"; + band.style.background = overlay.tone.overlay; + band.style.borderColor = overlay.tone.border; + if (overlay.label) band.title = overlay.label; + track.appendChild(band); + }); } - - function bindScrollSync(source, target, state, root, zoomButtons) { - source.addEventListener('scroll', function () { - handleScroll(source, target, state, root, zoomButtons); + function groupOverviewItems(lane) { + var groups = []; + var current = null; + lane.items.forEach(function(item) { + if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { + if (current) groups.push(current); + current = { + clusterId: item.clusterId, + endMinute: item.endMinute, + items: [item], + lane, + startMinute: item.startMinute + }; + return; + } + current.items.push(item); + current.endMinute = Math.max(current.endMinute, item.endMinute); + if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; }); - target.addEventListener('scroll', function () { - handleScroll(target, source, state, root, zoomButtons); + if (current) groups.push(current); + groups.forEach(function(group, groupIndex) { + finalizeGroup(group, lane, groupIndex); }); + assertUniqueClusterKeys(lane, groups); + return groups; } - - function bindDragPan(source, target, state, root, zoomButtons) { - var drag = { - active: false, - startClientX: 0, - startScrollLeft: 0, - }; - - source.addEventListener('mousedown', function (event) { - if (event.button != null && event.button !== 0) return; - drag.active = true; - drag.startClientX = event.clientX != null ? event.clientX : 0; - drag.startScrollLeft = source.scrollLeft || 0; - source.classList.add('is-dragging'); - if (event.preventDefault) event.preventDefault(); + function finalizeGroup(group, lane, index) { + var detailItems = []; + group.items.forEach(function(item) { + if (item.detailItems.length > 0) { + item.detailItems.forEach(function(detailItem) { + detailItems.push(detailItem); + }); + return; + } + detailItems.push(item); }); - - source.addEventListener('mousemove', function (event) { - if (!drag.active) return; - var clientX = event.clientX != null ? event.clientX : drag.startClientX; - var delta = clientX - drag.startClientX; - source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); - handleScroll(source, target, state, root, zoomButtons); - if (event.preventDefault) event.preventDefault(); + detailItems.sort(compareItems); + group.detailItems = detailItems; + group.isCluster = detailItems.length > 1 || group.items.some(function(item) { + return item.detailItems.length > 0; }); - - function finishDrag() { - if (!drag.active) return; - drag.active = false; - source.classList.remove('is-dragging'); - } - - source.addEventListener('mouseup', finishDrag); - source.addEventListener('mouseleave', finishDrag); - } - - function handleScroll(source, target, state, root, zoomButtons) { - if (state.destroyed) return; - if (!state.layout) return; - if (state.scrollSync === source) return; - - state.scrollSync = source; - target.scrollLeft = source.scrollLeft; - state.viewport = scrollLeftToViewport(state, source); - updateViewportMetadata(root, state); - updateZoomButtons(zoomButtons, state); - state.scrollSync = null; + group.renderId = group.isCluster ? buildScopedId("cluster", lane.id + "-" + index + "-" + (group.items[0] ? group.items[0].id : "group")) : normalizeId(group.items[0] ? group.items[0].id : null, "group-", lane.id + "-" + index); + group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; + group.summary = deriveOverviewSummary(group); + group.count = group.summary.count; + group.label = group.summary.primaryLabel; + group.metaLabel = group.summary.secondaryLabel; + group.tone = group.summary.primaryTone || dominantTone(group.detailItems); } - - function measurePackedHeight(packed) { - return packed.trackCount > 0 - ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP - : OVERVIEW_HEIGHT; + function assertUniqueClusterKeys(lane, groups) { + var seen = {}; + groups.forEach(function(group) { + if (!group.clusterKey) return; + assert( + !seen[group.clusterKey], + 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + ); + seen[group.clusterKey] = true; + }); } - - function buildDetailBlockConfig(item, lane, trackIndex, top, options) { - var config = options || {}; - return { - clusterId: config.clusterId || null, - detailHint: config.detailHint || '', - endMinute: item.endMinute, - height: TRACK_HEIGHT, - itemId: item.id, - kindClass: 'sf-rail-timeline-item--detail', - label: item.label, - metaLabel: describeMeta(item.meta), - startMinute: item.startMinute, - top: top, - ariaLabel: buildItemAriaLabel(item, lane), - tooltip: buildItemTooltip(item, lane), - tone: item.tone, - trackIndex: trackIndex, - }; + function dominantTone(items) { + var toneSegments = buildToneSegmentsFromItems(items); + if (!toneSegments.length) return resolveTone("slate"); + return toneSegments[0].tone; } - - function buildOverviewBlockConfig(group, height, options) { - var config = options || {}; + function effectiveOverviewItems(item) { + return item.detailItems.length > 0 ? item.detailItems : [item]; + } + function deriveOverviewContribution(item) { + var items = effectiveOverviewItems(item); + var summary = item.summary; + var derivedCount = items.length; + var count = summary && summary.count != null ? summary.count : derivedCount; + var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; + var openCount = null; + var toneSegments = []; + if (summary && summary.openCount != null) openCount = summary.openCount; + else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); + if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; + else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); return { - clusterId: config.clusterId || null, - endMinute: group.endMinute, - height: OVERVIEW_BLOCK_HEIGHT, - itemId: config.itemId, - kindClass: config.kindClass, - label: group.summary.primaryLabel, - metaLabel: group.summary.secondaryLabel, - onClick: config.onClick || null, - startMinute: group.startMinute, - summary: buildOverviewBlockSummary(group, !!config.expanded), - top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), - ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), - expanded: !!config.expanded, - tooltip: config.tooltip, - tone: group.tone, + count, + openCount, + openCountKnown: openCount != null, + toneSegments, + toneSegmentsKnown: summary && summary.toneSegments.length > 0 ? true : canDeriveAggregateMetrics }; } - - function buildDetailedRender(lane, items) { - var packed = packItems(items); - var height = measurePackedHeight(packed); - - var blocks = packed.items.map(function (entry) { - return buildDetailBlockConfig( - entry.item, - lane, - entry.trackIndex, - TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) - ); - }); - + function deriveOverviewSummary(group) { + var contributions = group.items.map(deriveOverviewContribution); + var summaries = group.items.map(function(item) { + return item.summary; + }).filter(Boolean); + var count = contributions.reduce(function(sum, contribution) { + return sum + contribution.count; + }, 0); + var openCount = contributions.every(function(contribution) { + return contribution.openCountKnown; + }) ? contributions.reduce(function(sum, contribution) { + return sum + contribution.openCount; + }, 0) : null; + var toneSegments = contributions.every(function(contribution) { + return contribution.toneSegmentsKnown; + }) ? mergeToneSegments(contributions.reduce(function(segments, contribution) { + return segments.concat(contribution.toneSegments); + }, [])) : []; + var primarySummary = summaries.length === 1 ? summaries[0] : null; return { - blocks: blocks, - height: height, - trackCount: packed.trackCount || 1, + count, + openCount, + primaryLabel: primarySummary && primarySummary.primaryLabel ? primarySummary.primaryLabel : count > 1 ? count + " assignments" : group.items[0].label, + primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), + secondaryLabel: primarySummary && primarySummary.secondaryLabel ? primarySummary.secondaryLabel : count > 1 ? buildOverviewBlockMeta({ + count, + items: group.detailItems + }) : describeMeta(group.items[0].meta), + toneSegments }; } - - function buildOverviewRender(lane, state, rerender) { - var groups = groupOverviewItems(lane); - var expandedClusterId = state.expandedClusters[lane.id] || null; - var expandedGroup = null; - var packedExpanded = null; - var expandedDetailsTop = 0; - - groups.forEach(function (group) { - if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { - expandedGroup = group; - } - }); - - if (expandedGroup) { - packedExpanded = packItems(expandedGroup.detailItems); - expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; - } - - var height = packedExpanded - ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) - : OVERVIEW_HEIGHT; - - var blocks = []; - groups.forEach(function (group) { - if (group.isCluster) { - var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); - blocks.push(buildOverviewBlockConfig(group, height, { - clusterId: group.clusterKey, - itemId: group.renderId, - kindClass: 'sf-rail-timeline-item--cluster', - expanded: isExpanded, - onClick: function () { - setExpandedCluster( - state, - lane.id, - state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey - ); - if (state.config && state.config.onClusterToggle) { - state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); - } - if (typeof rerender === 'function') rerender(); - }, - top: isExpanded ? TRACK_PADDING : null, - tooltip: buildClusterTooltip(group, lane), - })); - if (isExpanded) { - packedExpanded.items.forEach(function (entry) { - blocks.push(buildDetailBlockConfig( - entry.item, - lane, - entry.trackIndex, - expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), - { - clusterId: group.clusterKey, - detailHint: 'Expanded', - } - )); - }); - } - return; + function inferOpenCount(items) { + return items.reduce(function(count, item) { + if (!item) return count; + if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; + if (!item.meta || typeof item.meta !== "object" || Array.isArray(item.meta)) return count; + if (typeof item.meta.openCount === "number" && isFinite(item.meta.openCount)) return count + item.meta.openCount; + if (typeof item.meta.unassignedCount === "number" && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; + if (item.meta.open === true || item.meta.unassigned === true) return count + 1; + if (typeof item.meta.status === "string" && /open|unassigned/i.test(item.meta.status)) return count + 1; + return count; + }, 0); + } + function mergeToneSegments(segments) { + var byTone = {}; + segments.forEach(function(segment) { + if (!segment || !(segment.count > 0)) return; + var toneId = segment.tone.id || segment.tone.border || "slate"; + if (!byTone[toneId]) { + byTone[toneId] = { + count: 0, + tone: segment.tone + }; } - - blocks.push(buildOverviewBlockConfig(group, height, { - itemId: group.items[0].id, - kindClass: 'sf-rail-timeline-item--overview', - tooltip: buildOverviewTooltip(group, lane), - })); + byTone[toneId].count += segment.count; }); - - return { - blocks: blocks, - expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, - height: height, - trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1, - }; + return Object.keys(byTone).map(function(toneId) { + return byTone[toneId]; + }).sort(compareToneSegments); } - - function buildLaneLabel(lane, laneRender, row, headingId) { - var label = sf.el('div', { - className: 'sf-rail-timeline-lane-label', - dataset: { laneId: lane.id }, - }); - label.style.minHeight = laneRender.height + 'px'; - - var heading = sf.el('div', { className: 'sf-rail-timeline-lane-heading' }); - var title = sf.el('div', { className: 'sf-rail-timeline-lane-title' }, lane.label); - title.id = headingId; - heading.appendChild(title); - if (lane.mode) { - heading.appendChild(sf.el('div', { className: 'sf-rail-timeline-lane-mode' }, lane.mode)); - } - label.appendChild(heading); - if (row) row.setAttribute('aria-labelledby', title.id); - - if (lane.badges.length > 0) { - var badges = sf.el('div', { className: 'sf-rail-timeline-lane-badges' }); - lane.badges.forEach(function (badge) { - var badgeEl = sf.el('span', { className: 'sf-rail-timeline-lane-badge' }, badge.label); - if (badge.style) { - badgeEl.style.background = badge.style.bg || ''; - badgeEl.style.border = badge.style.border || ''; - badgeEl.style.color = badge.style.color || ''; - } - badges.appendChild(badgeEl); - }); - label.appendChild(badges); - } - - if (lane.stats.length > 0) { - var stats = sf.el('div', { className: 'sf-rail-timeline-lane-stats' }); - lane.stats.forEach(function (stat) { - var statRow = sf.el('div', { className: 'sf-rail-timeline-lane-stat' }); - statRow.appendChild(sf.el('span', { className: 'sf-rail-timeline-lane-stat-label' }, stat.label)); - statRow.appendChild(sf.el('span', { className: 'sf-rail-timeline-lane-stat-value' }, String(stat.value))); - stats.appendChild(statRow); - }); - label.appendChild(stats); - } - - return label; + function buildToneSegmentsFromItems(items) { + return mergeToneSegments(items.map(function(item) { + return { + count: 1, + tone: item.tone + }; + })); } - - function buildClusterTooltip(group, lane) { - var first = group.detailItems[0] || group.items[0]; - var payload = { - rows: [ - { key: 'Lane', value: lane.label }, - { key: 'Window', value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, - { key: 'Items', value: String(group.summary.count) }, - ], - title: group.label, - }; - - if (group.summary.openCount > 0) { - payload.rows.push({ key: 'Open', value: String(group.summary.openCount) }); + function compareToneSegments(left, right) { + if (left.count !== right.count) return right.count - left.count; + if (left.tone.id === right.tone.id) return 0; + return left.tone.id < right.tone.id ? -1 : 1; + } + function buildOverviewBlockSummary(group, expanded) { + var badges = []; + if (group.summary.count > 1) { + badges.push({ kind: "count", text: group.summary.count + " total" }); } - if (group.summary.toneSegments.length > 0) { - payload.rows.push({ key: 'Mix', value: describeToneSegments(group.summary.toneSegments) }); + if (group.summary.openCount > 0) { + badges.push({ kind: "open", text: group.summary.openCount + " open" }); } - - if (first && first.meta) { - payload.rows.push({ key: 'Sample', value: describeMeta(first.meta) }); + if (group.isCluster) { + badges.push({ kind: "action", text: expanded ? "Enter to collapse" : "Enter to inspect" }); } - - return payload; - } - - function buildItemTooltip(item, lane) { - var rows = [ - { key: 'Lane', value: lane.label }, - { key: 'Time', value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) }, - ]; - - appendMetaRows(rows, item.meta); - return { - rows: rows, - title: item.label, + badges, + toneSegments: group.summary.toneSegments }; } - - function buildOverviewBlockMeta(group) { - if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; - var labels = []; - group.items.slice(0, 2).forEach(function (item) { - labels.push(item.label); - }); - if (group.count > 2) labels.push('+' + (group.count - 2) + ' more'); - return labels.join(' • '); + function buildItemAriaLabel(item, lane) { + var parts = [ + lane.label, + item.label, + formatMinuteRange(item.startMinute, item.endMinute, lane.axis) + ]; + var meta = describeMeta(item.meta); + if (meta) parts.push(meta); + return parts.join(" \xB7 "); } - - function buildPresetViewport(axis, currentViewport, preset) { - var duration = preset === '1w' ? WEEK_MINUTES : preset === '2w' ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; - var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); - var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; - var start = Math.round(center - visibleDuration / 2); - return clampViewport(axis, { - startMinute: start, - endMinute: start + visibleDuration, - }); + function buildOverviewAriaLabel(group, lane, expanded) { + var parts = [ + lane.label, + group.summary.primaryLabel, + formatMinuteRange(group.startMinute, group.endMinute, lane.axis) + ]; + if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); + if (group.summary.count > 1) parts.push(group.summary.count + " assignments"); + if (group.summary.openCount > 0) parts.push(group.summary.openCount + " open"); + if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); + if (group.isCluster) parts.push(expanded ? "Expanded. Press Enter to collapse" : "Press Enter to expand"); + return parts.join(" \xB7 "); } - - function clampNumber(value, min, max) { - return Math.min(Math.max(value, min), max); + function describeToneSegments(segments) { + return segments.map(function(segment) { + return segment.count + " " + segment.tone.id; + }).join(", "); } - - function clampViewport(axis, viewport) { - var totalDuration = axis.endMinute - axis.startMinute; - var next = viewport || axis.initialViewport || { - startMinute: axis.startMinute, - endMinute: axis.endMinute, - }; - var duration = next.endMinute - next.startMinute; - duration = Math.min(duration, totalDuration); - - var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); - + function buildOverviewTooltip(group, lane) { + if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { + return buildClusterTooltip(group, lane); + } + return buildItemTooltip(group.items[0], lane); + } + function packItems(items) { + var trackEnds = []; + var packed = []; + items.slice().sort(compareItems).forEach(function(item) { + var trackIndex = 0; + while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { + trackIndex += 1; + } + if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); + else trackEnds[trackIndex] = item.endMinute; + packed.push({ + item, + trackIndex + }); + }); return { - endMinute: start + duration, - startMinute: start, + items: packed, + trackCount: trackEnds.length }; } - - function assertFiniteNumber(value, label) { - sf.assert(typeof value === 'number' && isFinite(value), label + ' must be a finite number'); - return value; + function positionPct(minute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return (minute - axis.startMinute) / total * 100; } - - function assertMinuteValue(value, label) { - return assertInteger(value, label); + function spanPct(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0.25); } - - function assertInteger(value, label) { - var number = assertFiniteNumber(value, label); - sf.assert(Math.floor(number) === number, label + ' must be an integer'); - return number; + function spanPctExact(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0); } - - function assertNonNegativeInteger(value, label) { - var number = assertInteger(value, label); - sf.assert(number >= 0, label + ' must be greater than or equal to zero'); - return number; + function formatClock(minute) { + var normalized = minute % DAY_MINUTES; + if (normalized < 0) normalized += DAY_MINUTES; + var hours = Math.floor(normalized / 60); + var minutes = normalized % 60; + return pad(hours) + ":" + pad(minutes); } - - function describeMeta(meta) { - if (meta == null) return ''; - if (typeof meta === 'string') return meta; - if (typeof meta === 'number') return String(meta); - if (Array.isArray(meta)) { - return meta.map(function (entry) { - if (entry && entry.label && entry.value != null) return entry.label + ': ' + entry.value; - return String(entry || ''); - }).filter(Boolean).join(' • '); - } - if (typeof meta === 'object') { - return Object.keys(meta).map(function (key) { - return key + ': ' + meta[key]; - }).join(' • '); - } - return String(meta); + function formatMinuteRange(startMinute, endMinute, axis) { + return formatMinute(startMinute, axis) + " \u2192 " + formatMinute(endMinute, axis); } - - function appendMetaRows(rows, meta) { - if (meta == null) return; - if (typeof meta === 'string' || typeof meta === 'number') { - rows.push({ key: 'Meta', value: String(meta) }); - return; - } - if (Array.isArray(meta)) { - meta.forEach(function (entry, index) { - if (!entry) return; - if (entry.label && entry.value != null) { - rows.push({ key: entry.label, value: String(entry.value) }); - return; - } - rows.push({ key: 'Meta ' + (index + 1), value: String(entry) }); - }); - return; - } - if (typeof meta === 'object') { - Object.keys(meta).forEach(function (key) { - rows.push({ key: key, value: String(meta[key]) }); - }); - } + function formatMinute(minute, axis) { + var dayLabel = ""; + axis.days.forEach(function(day) { + if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { + dayLabel = day.label; + } + }); + return (dayLabel ? dayLabel + " " : "") + formatClock(minute); } - - function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { - var startMinute = assertMinuteValue(startValue, startLabel); - var endMinute = assertMinuteValue(endValue, endLabel); - sf.assert(endMinute > startMinute, endLabel + ' must be greater than startMinute'); - return { - endMinute: endMinute, - startMinute: startMinute, - }; + function pad(value) { + return value < 10 ? "0" + value : String(value); } - - function normalizeId(value, prefix, suffix) { - return value != null ? String(value) : prefix + suffix; + function inferWeekend(label) { + return /sat|sun|weekend/i.test(String(label || "")); } - - function buildScopedId(scope, suffix) { - return scope + '-' + suffix; + function isColorString(value) { + return /^#|^rgb|^hsl/i.test(String(value || "")); } - - function setExpandedCluster(state, laneId, clusterId) { - if (clusterId == null) delete state.expandedClusters[laneId]; - else state.expandedClusters[laneId] = String(clusterId); + function resolveTone(tone) { + if (tone && typeof tone === "object") { + return { + id: tone.id || tone.name || tone.borderColor || tone.color || "custom", + background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, + border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, + overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, + text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text + }; + } + if (TONE_MAP[tone]) return TONE_MAP[tone]; + if (isColorString(tone)) { + return { + id: String(tone), + background: tone, + border: tone, + overlay: tone, + text: "#111827" + }; + } + return TONE_MAP.slate; } - - function normalizeAxis(axis) { - sf.assert(axis && axis.startMinute != null && axis.endMinute != null, 'createTimeline(model.axis.startMinute/endMinute) are required'); - var axisRange = normalizeMinuteRange( - axis.startMinute, - axis.endMinute, - 'createTimeline(model.axis.startMinute)', - 'createTimeline(model.axis.endMinute)' - ); - - var normalized = { - endMinute: axisRange.endMinute, - startMinute: axisRange.startMinute, - }; - - normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); - normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); - normalized.initialViewport = clampViewport( - normalized, - normalizeViewportInput(axis.initialViewport, 'createTimeline(model.axis.initialViewport)') || { - startMinute: normalized.startMinute, - endMinute: normalized.endMinute, - } + function measureLayout(bodyViewport, state) { + var viewportWidth = getMeasuredViewportWidth(bodyViewport); + if (!(viewportWidth > 0)) return null; + var preferredLabelWidth = state.labelWidth; + var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; + var effectiveLabelWidth = preferredLabelWidth; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; + var scale = totalDuration > 0 && visibleDuration > 0 ? totalDuration / visibleDuration : 1; + if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; + if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); + else effectiveLabelWidth = MIN_LABEL_WIDTH; + var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); + var contentTrackWidth = Math.max( + Math.round(visibleTrackWidth * scale), + visibleTrackWidth, + MIN_CONTENT_TRACK_WIDTH ); - - return normalized; - } - - function normalizeBadge(badge) { - if (!badge) return null; - if (typeof badge === 'string') return { label: badge }; + var contentWidth = effectiveLabelWidth + contentTrackWidth; return { - label: badge.label || '', - style: badge.style || null, + contentWidth, + contentTrackWidth, + effectiveLabelWidth, + visibleTrackWidth, + viewportWidth }; } - - function normalizeDays(days, startMinute, endMinute) { - var list = []; - var source = Array.isArray(days) && days.length > 0 ? days : null; - var cursor = startMinute; - var index = 0; - - if (!source) { - while (cursor < endMinute) { - list.push(makeDay({ - endMinute: Math.min(cursor + DAY_MINUTES, endMinute), - isWeekend: false, - label: 'Day ' + (index + 1), - startMinute: cursor, - }, index)); - cursor += DAY_MINUTES; - index += 1; - } - return list; + function viewportToScrollLeft(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; + return Math.round((state.viewport.startMinute - axis.startMinute) / remainingDuration * maxScrollLeft); + } + function scrollLeftToViewport(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) { + return clampViewport(axis, { + startMinute: axis.startMinute, + endMinute: axis.startMinute + visibleDuration + }); } - - source.forEach(function (day, dayIndex) { - if (cursor >= endMinute) return; - if (typeof day === 'string') { - var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); - list.push(makeDay({ - endMinute: generatedEnd, - isWeekend: inferWeekend(day), - label: day, - startMinute: cursor, - }, dayIndex)); - cursor = generatedEnd; - return; - } - - var nextStart = day.startMinute != null - ? day.startMinute - : cursor; - var nextEnd = day.endMinute != null - ? day.endMinute - : Math.min(nextStart + DAY_MINUTES, endMinute); - var dayRange = normalizeMinuteRange( - nextStart, - nextEnd, - 'createTimeline(model.axis.days[' + dayIndex + '].startMinute)', - 'createTimeline(model.axis.days[' + dayIndex + '].endMinute)' - ); - list.push(makeDay({ - endMinute: dayRange.endMinute, - isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), - label: day.label || 'Day ' + (dayIndex + 1), - startMinute: dayRange.startMinute, - subLabel: day.subLabel || day.meta || '', - }, dayIndex)); - cursor = dayRange.endMinute; + var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); + var startMinute = axis.startMinute + remainingDuration * ratio; + return clampViewport(axis, { + startMinute, + endMinute: startMinute + visibleDuration }); - - return list; } - - function normalizeItem(item, pathKey, ordinal) { - sf.assert(item && item.startMinute != null && item.endMinute != null, 'timeline items require startMinute/endMinute'); - var itemRange = normalizeMinuteRange( - item.startMinute, - item.endMinute, - 'createTimeline(model.lanes[].items[].startMinute)', - 'createTimeline(model.lanes[].items[].endMinute)' - ); - - return { - clusterId: item.clusterId != null ? String(item.clusterId) : null, - detailItems: Array.isArray(item.detailItems) - ? item.detailItems.map(function (detailItem, detailIndex) { - return normalizeItem(detailItem, pathKey + '-' + detailIndex, detailIndex); - }) - : [], - endMinute: itemRange.endMinute, - id: normalizeId(item.id, 'item-', pathKey), - label: item.label || 'Item ' + (ordinal + 1), - meta: item.meta != null ? item.meta : '', - originalIndex: ordinal, - summary: normalizeOverviewSummary(item.summary, 'createTimeline(model.lanes[].items[].summary)'), - startMinute: itemRange.startMinute, - tone: resolveTone(item.tone || item.color || 'slate'), - }; + function getMaxScrollLeft(viewportEl) { + var scrollWidth = viewportEl.scrollWidth || 0; + var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; + return Math.max(scrollWidth - clientWidth, 0); } - - function normalizeLane(lane, index, axis) { - sf.assert(lane && Array.isArray(lane.items), 'timeline lanes require an items array'); - - var normalizedLane = { - axis: axis, - badges: [], - id: normalizeId(lane.id, 'lane-', index), - items: lane.items.map(function (item, itemIndex) { - return normalizeItem(item, index + '-' + itemIndex, itemIndex); - }), - label: lane.label || 'Lane ' + (index + 1), - mode: lane.mode === 'overview' ? 'overview' : 'detailed', - overlays: Array.isArray(lane.overlays) - ? lane.overlays.map(function (overlay, overlayIndex) { - return normalizeOverlay(overlay, overlayIndex, axis); - }).filter(Boolean) - : [], - stats: Array.isArray(lane.stats) ? lane.stats : [], - }; - - normalizedLane.items.sort(compareItems); - - if (Array.isArray(lane.badges)) { - lane.badges.forEach(function (badge) { - var normalizedBadge = normalizeBadge(badge); - if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); - }); - } else { - var singleBadge = normalizeBadge(lane.badges); - if (singleBadge) normalizedLane.badges.push(singleBadge); - } - - return normalizedLane; - } - - function normalizeModel(model) { - sf.assert(model && model.axis && Array.isArray(model.lanes), 'createTimeline(model.axis/model.lanes) are required'); - var axis = normalizeAxis(model.axis); - - return { - axis: axis, - lanes: model.lanes.map(function (lane, index) { - return normalizeLane(lane, index, axis); - }), - }; + function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { + if (typeof ResizeObserver !== "function") return; + var resizeObserver = new ResizeObserver(function() { + if (state.destroyed) return; + syncLayoutFromViewport(); + }); + resizeObserver.observe(bodyViewport); + state.cleanup.push(function() { + resizeObserver.disconnect(); + }); } - - function normalizeOverlay(overlay, index, axis) { - var label = 'createTimeline(model.lanes[].overlays[' + index + '])'; - sf.assert(overlay && typeof overlay === 'object', label + ' must be an object'); - - var startMinute = overlay.startMinute; - var endMinute = overlay.endMinute; - - if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { - var dayIndex = assertInteger(overlay.dayIndex, label + '.dayIndex'); - var day = axis.days[dayIndex]; - sf.assert(day, label + '.dayIndex must reference an existing day'); - var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + '.dayCount'); - sf.assert(dayCount > 0, label + '.dayCount must be greater than zero'); - var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; - startMinute = day.startMinute; - endMinute = lastDay.endMinute; + function bindWindowResize(state, syncLayoutFromViewport) { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") return; + function handleResize() { + if (state.destroyed) return; + syncLayoutFromViewport(); } - - sf.assert( - startMinute != null && endMinute != null, - label + ' requires startMinute/endMinute or dayIndex/dayCount' - ); - var overlayRange = normalizeMinuteRange( - startMinute, - endMinute, - label + '.startMinute', - label + '.endMinute' - ); - - return { - endMinute: overlayRange.endMinute, - id: normalizeId(overlay.id, 'overlay-', index), - label: overlay.label || '', - meta: overlay.meta || '', - startMinute: overlayRange.startMinute, - tone: resolveTone(overlay.tone || overlay.color || 'slate'), - }; + window.addEventListener("resize", handleResize); + state.cleanup.push(function() { + if (typeof window.removeEventListener === "function") window.removeEventListener("resize", handleResize); + }); } - - function normalizeTicks(ticks, startMinute, endMinute) { - var list = []; - - if (Array.isArray(ticks) && ticks.length > 0) { - ticks.forEach(function (tick, index) { - if (typeof tick === 'number') { - var numericTick = assertMinuteValue(tick, 'createTimeline(model.axis.ticks[' + index + '])'); - list.push({ id: 'tick-' + index, label: formatClock(numericTick), minute: numericTick }); - return; - } - sf.assert(tick && typeof tick === 'object', 'createTimeline(model.axis.ticks[' + index + ']) must be a number or object'); - sf.assert(tick.minute != null, 'createTimeline(model.axis.ticks[' + index + '].minute) is required'); - var minute = assertMinuteValue(tick.minute, 'createTimeline(model.axis.ticks[' + index + '].minute)'); - list.push({ - id: normalizeId(tick.id, 'tick-', index), - label: tick.label || formatClock(minute), - minute: minute, - }); - }); - return list; + function getMeasuredViewportWidth(bodyViewport) { + if (!bodyViewport) return 0; + if (typeof bodyViewport.clientWidth === "number" && bodyViewport.clientWidth > 0) { + return Math.round(bodyViewport.clientWidth); } - - for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { - list.push({ - id: 'tick-' + minute, - label: formatClock(minute), - minute: minute, - }); + if (typeof bodyViewport.offsetWidth === "number" && bodyViewport.offsetWidth > 0) { + return Math.round(bodyViewport.offsetWidth); } - - return list; - } - - function makeDay(day, index) { - return { - endMinute: day.endMinute, - id: normalizeId(day.id, 'day-', index), - isWeekend: !!day.isWeekend, - label: day.label || 'Day ' + (index + 1), - startMinute: day.startMinute, - subLabel: day.subLabel || '', - }; + if (typeof bodyViewport.getBoundingClientRect === "function") { + var rect = bodyViewport.getBoundingClientRect(); + if (rect && typeof rect.width === "number" && rect.width > 0) { + return Math.round(rect.width); + } + } + return 0; } - - function compareItems(left, right) { - if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; - if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; - if (left.label !== right.label) return left.label < right.label ? -1 : 1; - return left.originalIndex - right.originalIndex; + function applyLayout(root, headerRow, lanes, layout) { + setCustomProperty(root.style, "--sf-rail-label-width", layout ? layout.effectiveLabelWidth + "px" : ""); + setCustomProperty(root.style, "--sf-rail-content-width", layout ? layout.contentWidth + "px" : ""); + headerRow.style.width = layout ? layout.contentWidth + "px" : ""; + lanes.style.width = layout ? layout.contentWidth + "px" : ""; + root.dataset.supportedViewportWidth = layout ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) : ""; } - - function normalizeOverviewSummary(summary, label) { - if (summary == null) return null; - sf.assert(summary && typeof summary === 'object', label + ' must be an object'); - - var normalized = { - count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + '.count'), - openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + '.openCount'), - primaryLabel: summary.primaryLabel == null ? '' : String(summary.primaryLabel), - secondaryLabel: summary.secondaryLabel == null ? '' : String(summary.secondaryLabel), - toneSegments: Array.isArray(summary.toneSegments) - ? summary.toneSegments.map(function (segment, index) { - sf.assert(segment && typeof segment === 'object', label + '.toneSegments[' + index + '] must be an object'); - return { - count: assertNonNegativeInteger(segment.count, label + '.toneSegments[' + index + '].count'), - tone: resolveTone(segment.tone || segment.color || 'slate'), - }; - }).filter(function (segment) { - return segment.count > 0; - }) - : [], - }; - - if (normalized.count != null && normalized.openCount != null) { - sf.assert(normalized.openCount <= normalized.count, label + '.openCount must not exceed count'); + function setCustomProperty(style, name, value) { + if (!style) return; + if (typeof style.setProperty === "function") { + style.setProperty(name, value); + return; } - - return normalized; - } - - function renderAxisDecor(track, axis, includeLabels) { - appendWeekendBands(track, axis); - appendDayDividers(track, axis); - appendTicks(track, axis, includeLabels); - if (includeLabels) appendDayBands(track, axis); + style[name] = value; } - - function appendDayBands(track, axis) { - axis.days.forEach(function (day) { - var band = sf.el('div', { className: 'sf-rail-timeline-day-band' }); - band.style.left = positionPct(day.startMinute, axis) + '%'; - band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; - band.appendChild(sf.el('div', { className: 'sf-rail-timeline-day-label' }, day.label)); - if (day.subLabel) { - band.appendChild(sf.el('div', { className: 'sf-rail-timeline-day-sub' }, day.subLabel)); - } - track.appendChild(band); + function queuePostMountSync(state, syncLayoutFromViewport) { + if (state.hasQueuedPostMountSync || typeof setTimeout !== "function") return; + state.hasQueuedPostMountSync = true; + var timerId = setTimeout(function() { + state.hasQueuedPostMountSync = false; + if (state.destroyed) return; + syncLayoutFromViewport(); + }, 0); + state.cleanup.push(function() { + if (typeof clearTimeout === "function") clearTimeout(timerId); }); } - - function appendDayDividers(track, axis) { - axis.days.forEach(function (day, index) { - if (index === 0) return; - var divider = sf.el('div', { className: 'sf-rail-timeline-day-divider' }); - divider.style.left = positionPct(day.startMinute, axis) + '%'; - track.appendChild(divider); - }); + function normalizeViewportInput(viewport, label) { + if (viewport == null) return null; + assert(typeof viewport === "object", label + " must be an object"); + return normalizeMinuteRange( + viewport.startMinute, + viewport.endMinute, + label + ".startMinute", + label + ".endMinute" + ); } - - function appendTicks(track, axis, includeLabels) { - axis.ticks.forEach(function (tick) { - if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; - var tickEl = sf.el('div', { className: 'sf-rail-timeline-tick' }); - tickEl.style.left = positionPct(tick.minute, axis) + '%'; - track.appendChild(tickEl); - - if (!includeLabels) return; - var label = sf.el('div', { className: 'sf-rail-timeline-tick-label' }, tick.label); - label.style.left = positionPct(tick.minute, axis) + '%'; - track.appendChild(label); + function showTooltip(tooltip, root, payload, event) { + if (!payload) return; + tooltip.setAttribute("aria-hidden", "false"); + tooltip.innerHTML = ""; + tooltip.appendChild(el("div", { className: "sf-tooltip-title" }, payload.title)); + (payload.rows || []).forEach(function(row) { + var rowEl = el("div", { className: "sf-tooltip-row" }); + rowEl.appendChild(el("span", { className: "sf-tooltip-key" }, row.key)); + rowEl.appendChild(el("span", { className: "sf-tooltip-val" }, row.value)); + tooltip.appendChild(rowEl); }); + var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; + var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; + var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; + tooltip.style.left = left + "px"; + tooltip.style.top = top + "px"; + tooltip.classList.add("visible"); } - - function appendWeekendBands(track, axis) { - axis.days.forEach(function (day) { - if (!day.isWeekend) return; - var band = sf.el('div', { className: 'sf-rail-timeline-weekend-band' }); - band.style.left = positionPct(day.startMinute, axis) + '%'; - band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; - track.appendChild(band); - }); + function showTooltipForElement(tooltip, root, payload, element) { + var rect = element && typeof element.getBoundingClientRect === "function" ? element.getBoundingClientRect() : null; + showTooltip(tooltip, root, payload, rect ? { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2 + } : null); } - - function renderOverlays(track, overlays, axis) { - overlays.forEach(function (overlay) { - var band = sf.el('div', { className: 'sf-rail-timeline-overlay' }); - band.style.left = positionPct(overlay.startMinute, axis) + '%'; - band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + '%'; - band.style.background = overlay.tone.overlay; - band.style.borderColor = overlay.tone.border; - if (overlay.label) band.title = overlay.label; - track.appendChild(band); - }); + function hideTooltip(tooltip) { + tooltip.setAttribute("aria-hidden", "true"); + tooltip.classList.remove("visible"); } - - function groupOverviewItems(lane) { - var groups = []; - var current = null; - - lane.items.forEach(function (item) { - if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { - if (current) groups.push(current); - current = { - clusterId: item.clusterId, - endMinute: item.endMinute, - items: [item], - lane: lane, - startMinute: item.startMinute, - }; - return; - } - current.items.push(item); - current.endMinute = Math.max(current.endMinute, item.endMinute); - if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; - }); - if (current) groups.push(current); - - groups.forEach(function (group, groupIndex) { - finalizeGroup(group, lane, groupIndex); - }); - assertUniqueClusterKeys(lane, groups); - - return groups; + function updateViewportMetadata(root, state) { + var axis = state.model.axis; + var duration = state.viewport.endMinute - state.viewport.startMinute; + root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); + root.dataset.viewportDurationMinutes = String(Math.round(duration)); + root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); + root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); } - - function finalizeGroup(group, lane, index) { - var detailItems = []; - - group.items.forEach(function (item) { - if (item.detailItems.length > 0) { - item.detailItems.forEach(function (detailItem) { - detailItems.push(detailItem); - }); - return; - } - detailItems.push(item); - }); - - detailItems.sort(compareItems); - group.detailItems = detailItems; - group.isCluster = detailItems.length > 1 || group.items.some(function (item) { - return item.detailItems.length > 0; + function updateZoomButtons(buttons, state) { + var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); + var initial = state.model.axis.initialViewport; + buttons.forEach(function(button) { + var preset = button.dataset.zoom; + var active = false; + if (preset === "reset") { + active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); + } else if (preset === "1w") active = duration === WEEK_MINUTES; + else if (preset === "2w") active = duration === WEEK_MINUTES * 2; + else if (preset === "4w") active = duration === WEEK_MINUTES * 4; + button.classList.toggle("active", active); }); - group.renderId = group.isCluster - ? buildScopedId('cluster', lane.id + '-' + index + '-' + (group.items[0] ? group.items[0].id : 'group')) - : normalizeId(group.items[0] ? group.items[0].id : null, 'group-', lane.id + '-' + index); - group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; - group.summary = deriveOverviewSummary(group); - group.count = group.summary.count; - group.label = group.summary.primaryLabel; - group.metaLabel = group.summary.secondaryLabel; - group.tone = group.summary.primaryTone || dominantTone(group.detailItems); } - - function assertUniqueClusterKeys(lane, groups) { - var seen = {}; - - groups.forEach(function (group) { - if (!group.clusterKey) return; - sf.assert( - !seen[group.clusterKey], - 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + function normalizeZoomPresets(presets) { + if (presets == null) return ["1w", "2w", "4w", "reset"]; + assert(Array.isArray(presets), "rail.createTimeline(zoomPresets) must be an array"); + presets.forEach(function(preset, index) { + assert( + ["1w", "2w", "4w", "reset"].indexOf(preset) >= 0, + "rail.createTimeline(zoomPresets[" + index + "]) must be one of 1w, 2w, 4w, reset" ); - seen[group.clusterKey] = true; }); + return presets.slice(); } - - function dominantTone(items) { - var toneSegments = buildToneSegmentsFromItems(items); - if (!toneSegments.length) return resolveTone('slate'); - return toneSegments[0].tone; + function pruneExpandedClusters(state) { + Object.keys(state.expandedClusters).forEach(function(laneId) { + var exists = state.model.lanes.some(function(lane) { + return lane.id === laneId; + }); + if (!exists) delete state.expandedClusters[laneId]; + }); } - function effectiveOverviewItems(item) { - return item.detailItems.length > 0 ? item.detailItems : [item]; - } + // ts-src/rail/index.ts + var rail = { + createHeader: createHeader2, + createCard, + createHeatmap, + createUnassignedRail, + addBlock, + addChangeover, + createTimeline + }; - function deriveOverviewContribution(item) { - var items = effectiveOverviewItems(item); - var summary = item.summary; - var derivedCount = items.length; - var count = summary && summary.count != null ? summary.count : derivedCount; - var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; - var openCount = null; - var toneSegments = []; - - if (summary && summary.openCount != null) openCount = summary.openCount; - else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); - - if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; - else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); - - return { - count: count, - openCount: openCount, - openCountKnown: openCount != null, - toneSegments: toneSegments, - toneSegmentsKnown: summary && summary.toneSegments.length > 0 - ? true - : canDeriveAggregateMetrics, - }; + // ts-src/solver/backend.ts + function createBackend(config = {}) { + const resolvedConfig = config || {}; + const type = resolvedConfig.type ?? "axum"; + if (type === "tauri") { + return createTauriBackend(resolvedConfig); + } + return createHttpBackend(resolvedConfig); } - - function deriveOverviewSummary(group) { - var contributions = group.items.map(deriveOverviewContribution); - var summaries = group.items.map(function (item) { - return item.summary; - }).filter(Boolean); - var count = contributions.reduce(function (sum, contribution) { - return sum + contribution.count; - }, 0); - var openCount = contributions.every(function (contribution) { - return contribution.openCountKnown; - }) - ? contributions.reduce(function (sum, contribution) { - return sum + contribution.openCount; - }, 0) - : null; - var toneSegments = contributions.every(function (contribution) { - return contribution.toneSegmentsKnown; - }) - ? mergeToneSegments(contributions.reduce(function (segments, contribution) { - return segments.concat(contribution.toneSegments); - }, [])) - : []; - var primarySummary = summaries.length === 1 ? summaries[0] : null; - - return { - count: count, - openCount: openCount, - primaryLabel: primarySummary && primarySummary.primaryLabel - ? primarySummary.primaryLabel - : count > 1 - ? count + ' assignments' - : group.items[0].label, - primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), - secondaryLabel: primarySummary && primarySummary.secondaryLabel - ? primarySummary.secondaryLabel - : count > 1 - ? buildOverviewBlockMeta({ - count: count, - items: group.detailItems, - }) - : describeMeta(group.items[0].meta), - toneSegments: toneSegments, - }; + function resolveJobId(raw) { + return normalizeCreateJobId(raw); } - - function inferOpenCount(items) { - return items.reduce(function (count, item) { - if (!item) return count; - if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; - if (!item.meta || typeof item.meta !== 'object' || Array.isArray(item.meta)) return count; - if (typeof item.meta.openCount === 'number' && isFinite(item.meta.openCount)) return count + item.meta.openCount; - if (typeof item.meta.unassignedCount === 'number' && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; - if (item.meta.open === true || item.meta.unassigned === true) return count + 1; - if (typeof item.meta.status === 'string' && /open|unassigned/i.test(item.meta.status)) return count + 1; - return count; - }, 0); + function resolveEventJobId(payload) { + if (!payload || typeof payload !== "object") return ""; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.id != null) return String(payload.data.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ""; } - - function mergeToneSegments(segments) { - var byTone = {}; - segments.forEach(function (segment) { - if (!segment || !(segment.count > 0)) return; - var toneId = segment.tone.id || segment.tone.border || 'slate'; - if (!byTone[toneId]) { - byTone[toneId] = { - count: 0, - tone: segment.tone, - }; - } - byTone[toneId].count += segment.count; - }); - return Object.keys(byTone).map(function (toneId) { - return byTone[toneId]; - }).sort(compareToneSegments); + function withSnapshotRevision(path, snapshotRevision) { + if (snapshotRevision == null || snapshotRevision === "") return path; + return path + "?snapshot_revision=" + encodeURIComponent(String(snapshotRevision)); } - - function buildToneSegmentsFromItems(items) { - return mergeToneSegments(items.map(function (item) { + function createHttpBackend(config) { + var baseUrl = config.baseUrl || ""; + var jobsPath = config.jobsPath || "/jobs"; + var demoDataPath = config.demoDataPath || "/demo-data"; + var extraHeaders = config.headers || {}; + function headers(extra = {}) { return { - count: 1, - tone: item.tone, + "Content-Type": "application/json", + ...extraHeaders, + ...extra }; - })); - } - - function compareToneSegments(left, right) { - if (left.count !== right.count) return right.count - left.count; - if (left.tone.id === right.tone.id) return 0; - return left.tone.id < right.tone.id ? -1 : 1; - } - - function buildOverviewBlockSummary(group, expanded) { - var badges = []; - if (group.summary.count > 1) { - badges.push({ kind: 'count', text: group.summary.count + ' total' }); } - if (group.summary.openCount > 0) { - badges.push({ kind: 'open', text: group.summary.openCount + ' open' }); + function createRequestError(method, path, res) { + var err = new Error(res.status + " " + res.statusText); + err.status = res.status; + err.statusText = res.statusText; + err.method = method; + err.path = path; + err.url = baseUrl + path; + return err; } - if (group.isCluster) { - badges.push({ kind: 'action', text: expanded ? 'Enter to collapse' : 'Enter to inspect' }); + function request(method, path, body) { + const opts = { + method, + headers: headers() + }; + if (body !== void 0) opts.body = JSON.stringify(body); + return fetch(baseUrl + path, opts).then(function(res) { + if (!res.ok) throw createRequestError(method, path, res); + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("json")) { + return res.json(); + } + return res.text(); + }); } return { - badges: badges, - toneSegments: group.summary.toneSegments, - }; - } - - function buildItemAriaLabel(item, lane) { - var parts = [ - lane.label, - item.label, - formatMinuteRange(item.startMinute, item.endMinute, lane.axis), - ]; - var meta = describeMeta(item.meta); - if (meta) parts.push(meta); - return parts.join(' · '); - } - - function buildOverviewAriaLabel(group, lane, expanded) { - var parts = [ - lane.label, - group.summary.primaryLabel, - formatMinuteRange(group.startMinute, group.endMinute, lane.axis), - ]; - if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); - if (group.summary.count > 1) parts.push(group.summary.count + ' assignments'); - if (group.summary.openCount > 0) parts.push(group.summary.openCount + ' open'); - if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); - if (group.isCluster) parts.push(expanded ? 'Expanded. Press Enter to collapse' : 'Press Enter to expand'); - return parts.join(' · '); - } - - function describeToneSegments(segments) { - return segments.map(function (segment) { - return segment.count + ' ' + segment.tone.id; - }).join(', '); - } - - function buildOverviewTooltip(group, lane) { - if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { - return buildClusterTooltip(group, lane); - } - return buildItemTooltip(group.items[0], lane); - } - - function packItems(items) { - var trackEnds = []; - var packed = []; - - items.slice().sort(compareItems).forEach(function (item) { - var trackIndex = 0; - while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { - trackIndex += 1; + createJob: function(data) { + return request("POST", jobsPath, data).then(resolveJobId); + }, + getJob: function(id) { + return request("GET", jobsPath + "/" + id); + }, + getJobStatus: function(id) { + return request("GET", jobsPath + "/" + id + "/status"); + }, + getSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/snapshot", snapshotRevision)); + }, + analyzeSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/analysis", snapshotRevision)); + }, + pauseJob: function(id) { + return request("POST", jobsPath + "/" + id + "/pause"); + }, + resumeJob: function(id) { + return request("POST", jobsPath + "/" + id + "/resume"); + }, + cancelJob: function(id) { + return request("POST", jobsPath + "/" + id + "/cancel"); + }, + deleteJob: function(id) { + return request("DELETE", jobsPath + "/" + id); + }, + getDemoData: function(name) { + return request("GET", demoDataPath + "/" + (name || "STANDARD")); + }, + listDemoData: function() { + return request("GET", demoDataPath); + }, + streamJobEvents: function(id, onMessage, onError) { + var url = baseUrl + jobsPath + "/" + id + "/events"; + var es = new EventSource(url); + var closed = false; + es.onmessage = function(e) { + try { + onMessage(JSON.parse(e.data)); + } catch { + } + }; + es.onerror = function() { + if (closed || !onError) return; + if (typeof EventSource !== "undefined" && es.readyState === EventSource.CLOSED) { + onError(createSseClosedError(url)); + } + }; + return function close() { + closed = true; + es.onmessage = null; + es.onerror = null; + es.close(); + }; } - if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); - else trackEnds[trackIndex] = item.endMinute; - packed.push({ - item: item, - trackIndex: trackIndex, - }); - }); - - return { - items: packed, - trackCount: trackEnds.length, }; } - - function positionPct(minute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return ((minute - axis.startMinute) / total) * 100; - } - - function spanPct(startMinute, endMinute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return Math.max(((endMinute - startMinute) / total) * 100, 0.25); - } - - function spanPctExact(startMinute, endMinute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return Math.max(((endMinute - startMinute) / total) * 100, 0); - } - - function formatClock(minute) { - var normalized = minute % DAY_MINUTES; - if (normalized < 0) normalized += DAY_MINUTES; - var hours = Math.floor(normalized / 60); - var minutes = normalized % 60; - return pad(hours) + ':' + pad(minutes); - } - - function formatMinuteRange(startMinute, endMinute, axis) { - return formatMinute(startMinute, axis) + ' → ' + formatMinute(endMinute, axis); - } - - function formatMinute(minute, axis) { - var dayLabel = ''; - axis.days.forEach(function (day) { - if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { - dayLabel = day.label; + function createTauriBackend(config) { + assert(typeof config === "object", "createBackend({}) is required for Tauri adapter"); + assert(typeof config.invoke === "function", "Tauri backend requires config.invoke"); + assert(typeof config.listen === "function", "Tauri backend requires config.listen"); + var invoke = config.invoke; + var listen = config.listen; + var commands = config.commands || {}; + var eventName = config.eventName || "solver-update"; + return { + createJob: function(data) { + return invoke(commands.createJob || "create_job", { request: data }).then(resolveJobId); + }, + getJob: function(id) { + return invoke(commands.getJob || "get_job", { id }); + }, + getJobStatus: function(id) { + return invoke(commands.getJobStatus || "get_job_status", { id }); + }, + getSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.getSnapshot || "get_snapshot", payload); + }, + analyzeSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.analyzeSnapshot || "analyze_snapshot", payload); + }, + pauseJob: function(id) { + return invoke(commands.pauseJob || "pause_job", { id }); + }, + resumeJob: function(id) { + return invoke(commands.resumeJob || "resume_job", { id }); + }, + cancelJob: function(id) { + return invoke(commands.cancelJob || "cancel_job", { id }); + }, + deleteJob: function(id) { + return invoke(commands.deleteJob || "delete_job", { id }); + }, + getDemoData: function(name) { + return invoke(commands.demoData || "demo_seed", { name }); + }, + listDemoData: function() { + return Promise.resolve([]); + }, + streamJobEvents: function(id, onMessage, _onError) { + var targetId = String(id); + var unlisten = null; + listen(eventName, function(event) { + var payload = event && event.payload || {}; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; + onMessage(payload); + }).then(function(fn) { + unlisten = fn; + }); + return function close() { + if (unlisten) unlisten(); + }; } - }); - return (dayLabel ? dayLabel + ' ' : '') + formatClock(minute); - } - - function pad(value) { - return value < 10 ? '0' + value : String(value); - } - - function inferWeekend(label) { - return /sat|sun|weekend/i.test(String(label || '')); - } - - function isColorString(value) { - return /^#|^rgb|^hsl/i.test(String(value || '')); + }; } - - function resolveTone(tone) { - if (tone && typeof tone === 'object') { - return { - id: tone.id || tone.name || tone.borderColor || tone.color || 'custom', - background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, - border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, - overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, - text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text, - }; - } - if (TONE_MAP[tone]) return TONE_MAP[tone]; - if (isColorString(tone)) { - return { - id: String(tone), - background: tone, - border: tone, - overlay: tone, - text: '#111827', - }; - } - return TONE_MAP.slate; + function createSseClosedError(url) { + var err = new Error("Event stream closed for " + url); + err.code = "SSE_CLOSED"; + err.transport = "sse"; + err.url = url; + return err; } - function measureLayout(bodyViewport, state) { - var viewportWidth = getMeasuredViewportWidth(bodyViewport); - if (!(viewportWidth > 0)) return null; - - var preferredLabelWidth = state.labelWidth; - var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; - var effectiveLabelWidth = preferredLabelWidth; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; - var scale = totalDuration > 0 && visibleDuration > 0 - ? totalDuration / visibleDuration - : 1; - if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; - if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); - else effectiveLabelWidth = MIN_LABEL_WIDTH; - - var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); - var contentTrackWidth = Math.max( - Math.round(visibleTrackWidth * scale), - visibleTrackWidth, - MIN_CONTENT_TRACK_WIDTH - ); - var contentWidth = effectiveLabelWidth + contentTrackWidth; - - return { - contentWidth: contentWidth, - contentTrackWidth: contentTrackWidth, - effectiveLabelWidth: effectiveLabelWidth, - visibleTrackWidth: visibleTrackWidth, - viewportWidth: viewportWidth, + // ts-src/solver/solver.ts + var createSolver = function(config) { + assert(config, "createSolver(config) requires a configuration object"); + assert(config.backend, "createSolver(config.backend) is required"); + assert(hasFunction(config.backend, "createJob"), "createSolver(config.backend.createJob) must be a function"); + assert(hasFunction(config.backend, "getSnapshot"), "createSolver(config.backend.getSnapshot) must be a function"); + assert(hasFunction(config.backend, "analyzeSnapshot"), "createSolver(config.backend.analyzeSnapshot) must be a function"); + assert(hasFunction(config.backend, "pauseJob"), "createSolver(config.backend.pauseJob) must be a function"); + assert(hasFunction(config.backend, "resumeJob"), "createSolver(config.backend.resumeJob) must be a function"); + assert(hasFunction(config.backend, "cancelJob"), "createSolver(config.backend.cancelJob) must be a function"); + assert(hasFunction(config.backend, "deleteJob"), "createSolver(config.backend.deleteJob) must be a function"); + assert(hasFunction(config.backend, "streamJobEvents"), "createSolver(config.backend.streamJobEvents) must be a function"); + assert(!config.onProgress || typeof config.onProgress === "function", "createSolver(config.onProgress) must be a function"); + assert(!config.onSolution || typeof config.onSolution === "function", "createSolver(config.onSolution) must be a function"); + assert(!config.onPauseRequested || typeof config.onPauseRequested === "function", "createSolver(config.onPauseRequested) must be a function"); + assert(!config.onPaused || typeof config.onPaused === "function", "createSolver(config.onPaused) must be a function"); + assert(!config.onResumed || typeof config.onResumed === "function", "createSolver(config.onResumed) must be a function"); + assert(!config.onCancelled || typeof config.onCancelled === "function", "createSolver(config.onCancelled) must be a function"); + assert(!config.onComplete || typeof config.onComplete === "function", "createSolver(config.onComplete) must be a function"); + assert(!config.onFailure || typeof config.onFailure === "function", "createSolver(config.onFailure) must be a function"); + assert(!config.onAnalysis || typeof config.onAnalysis === "function", "createSolver(config.onAnalysis) must be a function"); + assert(!config.onError || typeof config.onError === "function", "createSolver(config.onError) must be a function"); + var backend = config.backend; + var statusBar = config.statusBar; + var closeStream = null; + var activeJobId = null; + var retainedJobId = null; + var lifecycleState = "IDLE"; + var phase = "idle"; + var runToken = 0; + var lastSnapshotRevision = null; + var lastMeta = null; + var lastNotifiedError = null; + var queuedAction = null; + var pendingPause = null; + var pendingResume = null; + var pendingCancel = null; + var terminalSync = null; + var api = { + /** + * Start a new solver job. + */ + start: function(data) { + if (retainedJobId) { + return Promise.reject( + new Error( + "Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first" + ) + ); + } + if (phase !== "idle") { + return Promise.resolve(); + } + resetForStart(); + phase = "starting"; + runToken += 1; + applyLifecycleState("STARTING"); + updateMoves(null); + var token = runToken; + return backend.createJob(data).then(function(id) { + if (token !== runToken) return; + var jobId = ensureJobId(id); + activeJobId = jobId; + retainedJobId = jobId; + phase = "solving"; + applyLifecycleState("SOLVING"); + attachStream(token, jobId); + if (queuedAction === "pause") { + queuedAction = null; + requestPause(token, jobId); + } else if (queuedAction === "cancel") { + queuedAction = null; + requestCancel(token, jobId); + } + }).catch(function(err) { + if (token !== runToken) return; + if (retainedJobId) { + failTransport(err); + } else { + failStartup(err); + } + throw err; + }); + }, + /** + * Request to pause the current solver job. + */ + pause: function() { + if (pendingPause) { + return pendingPause.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "pause"; + pendingPause = createDeferred(); + return pendingPause.promise; + } + var jobId = currentJobId(); + if (phase !== "solving" || !jobId) { + return Promise.resolve(); + } + pendingPause = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "pause")) { + return pendingPause.promise; + } + requestPause(runToken, jobId); + return pendingPause.promise; + }, + /** + * Resume a paused solver job. + */ + resume: function() { + if (pendingResume) { + return pendingResume.promise; + } + var jobId = currentJobId(); + if (phase !== "paused" || !jobId) { + return Promise.resolve(); + } + pendingResume = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "resume")) { + return pendingResume.promise; + } + requestResume(runToken, jobId); + return pendingResume.promise; + }, + /** + * Request to cancel the current solver job. + */ + cancel: function() { + if (pendingCancel) { + return pendingCancel.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "cancel"; + pendingCancel = createDeferred(); + return pendingCancel.promise; + } + var jobId = currentJobId(); + if (phase === "cancelling" && jobId) { + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + return pendingCancel.promise; + } + if (!jobId || !isCancelablePhase()) { + return Promise.resolve(); + } + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + requestCancel(runToken, jobId); + return pendingCancel.promise; + }, + /** + * Delete the retained job and its backend state. + */ + delete: function() { + if (!retainedJobId) { + return Promise.resolve(); + } + if (!isTerminalLifecycle(lifecycleState)) { + return Promise.reject( + new Error( + "Cannot delete a retained job before it reaches a terminal lifecycle state" + ) + ); + } + var jobId = retainedJobId; + return ensureTerminalSyncBeforeDelete(jobId).then(function() { + if (retainedJobId !== jobId) return; + return backend.deleteJob(jobId); + }).then(function() { + if (retainedJobId !== jobId) return; + resetAfterDelete(); + }).catch(function(err) { + notifyError(err); + throw err; + }); + }, + /** + * Get a snapshot for the current job. + */ + getSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.getSnapshot(jobId, revision).then(function(payload) { + return normalizeSnapshot(payload, lastMeta); + }); + }, + /** + * Get analysis for a snapshot of the current job. + */ + analyzeSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.analyzeSnapshot(jobId, revision).then(function(payload) { + return normalizeAnalysis(payload, lastMeta); + }); + }, + /** + * Check if the solver is currently running. + */ + isRunning: function() { + return phase !== "idle" && phase !== "paused"; + }, + /** + * Get the current job ID. + */ + getJobId: function() { + return activeJobId != null ? activeJobId : retainedJobId; + }, + /** + * Get the current lifecycle state. + */ + getLifecycleState: function() { + return lifecycleState; + }, + /** + * Get the current snapshot revision. + */ + getSnapshotRevision: function() { + return lastSnapshotRevision; + } }; - } - - function viewportToScrollLeft(state, viewportEl) { - var axis = state.model.axis; - var totalDuration = axis.endMinute - axis.startMinute; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var remainingDuration = Math.max(totalDuration - visibleDuration, 0); - var maxScrollLeft = getMaxScrollLeft(viewportEl); - if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; - return Math.round(((state.viewport.startMinute - axis.startMinute) / remainingDuration) * maxScrollLeft); - } - - function scrollLeftToViewport(state, viewportEl) { - var axis = state.model.axis; - var totalDuration = axis.endMinute - axis.startMinute; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var remainingDuration = Math.max(totalDuration - visibleDuration, 0); - var maxScrollLeft = getMaxScrollLeft(viewportEl); - if (remainingDuration <= 0 || maxScrollLeft <= 0) { - return clampViewport(axis, { - startMinute: axis.startMinute, - endMinute: axis.startMinute + visibleDuration, + return api; + function requestPause(token, id) { + phase = "pause-requested"; + backend.pauseJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "solving"; + rejectDeferred("pause", err); + notifyError(err); }); } - var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); - var startMinute = axis.startMinute + remainingDuration * ratio; - return clampViewport(axis, { - startMinute: startMinute, - endMinute: startMinute + visibleDuration, - }); - } - - function getMaxScrollLeft(viewportEl) { - var scrollWidth = viewportEl.scrollWidth || 0; - var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; - return Math.max(scrollWidth - clientWidth, 0); - } - - function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { - if (typeof ResizeObserver !== 'function') return; - - var resizeObserver = new ResizeObserver(function () { - if (state.destroyed) return; - syncLayoutFromViewport(); - }); - resizeObserver.observe(bodyViewport); - state.cleanup.push(function () { - resizeObserver.disconnect(); - }); - } - - function bindWindowResize(state, syncLayoutFromViewport) { - if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return; - - function handleResize() { - if (state.destroyed) return; - syncLayoutFromViewport(); + function attachStream(token, id) { + closeStream = backend.streamJobEvents(id, function(payload) { + if (token !== runToken) return; + handleEvent(token, id, payload); + }, function(err) { + if (token !== runToken) return; + failTransport(err); + }); } - - window.addEventListener('resize', handleResize); - state.cleanup.push(function () { - if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', handleResize); - }); - } - - function getMeasuredViewportWidth(bodyViewport) { - if (!bodyViewport) return 0; - if (typeof bodyViewport.clientWidth === 'number' && bodyViewport.clientWidth > 0) { - return Math.round(bodyViewport.clientWidth); + function ensureStreamAttached(token, id, pendingName) { + if (closeStream) return true; + try { + attachStream(token, id); + return true; + } catch (err) { + failTransport(err); + rejectDeferred(pendingName, err); + return false; + } } - if (typeof bodyViewport.offsetWidth === 'number' && bodyViewport.offsetWidth > 0) { - return Math.round(bodyViewport.offsetWidth); + function requestResume(token, id) { + phase = "resuming"; + backend.resumeJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "paused"; + rejectDeferred("resume", err); + notifyError(err); + }); } - if (typeof bodyViewport.getBoundingClientRect === 'function') { - var rect = bodyViewport.getBoundingClientRect(); - if (rect && typeof rect.width === 'number' && rect.width > 0) { - return Math.round(rect.width); + function requestCancel(token, id) { + phase = "cancelling"; + backend.cancelJob(id).catch(function(err) { + if (token !== runToken) return; + phase = lifecycleState === "PAUSED" ? "paused" : "solving"; + rejectDeferred("cancel", err); + notifyError(err); + }); + } + function handleEvent(token, expectedId, payload) { + var event = normalizeJobEvent(payload, expectedId); + if (!event) return; + lastMeta = event.meta; + if (event.meta.snapshotRevision != null) { + lastSnapshotRevision = event.meta.snapshotRevision; + } + retainedJobId = event.meta.jobId; + activeJobId = event.meta.jobId; + if (event.eventType === "progress") { + if (!event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onProgress) config.onProgress(event.meta); + return; + } + if (event.eventType === "best_solution") { + if (!event.solution || !event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onSolution) { + config.onSolution(buildLiveSnapshot(event), event.meta); + } + return; + } + if (event.eventType === "pause_requested") { + phase = "pause-requested"; + applyEventMeta(event.meta); + if (config.onPauseRequested) config.onPauseRequested(event.meta); + return; + } + if (event.eventType === "paused") { + phase = "paused"; + applyEventMeta(event.meta); + syncSnapshotBundle(event.meta, true).then(function(bundle) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + applyBundle(bundle); + if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); + resolveDeferred("pause", bundle); + }).catch(function(err) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + rejectDeferred("pause", err); + notifyError(err); + }); + return; + } + if (event.eventType === "resumed") { + phase = "solving"; + applyEventMeta(event.meta); + if (config.onResumed) config.onResumed(event.meta); + resolveDeferred("resume", event.meta); + return; + } + if (event.eventType === "completed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, true); + return; + } + if (event.eventType === "cancelled") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + return; + } + if (event.eventType === "failed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); } } - return 0; - } - - function applyLayout(root, headerRow, lanes, layout) { - setCustomProperty(root.style, '--sf-rail-label-width', layout ? layout.effectiveLabelWidth + 'px' : ''); - setCustomProperty(root.style, '--sf-rail-content-width', layout ? layout.contentWidth + 'px' : ''); - headerRow.style.width = layout ? layout.contentWidth + 'px' : ''; - lanes.style.width = layout ? layout.contentWidth + 'px' : ''; - root.dataset.supportedViewportWidth = layout - ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) - : ''; - } - - function setCustomProperty(style, name, value) { - if (!style) return; - if (typeof style.setProperty === 'function') { - style.setProperty(name, value); - return; + function syncSnapshotBundle(meta, requireSnapshot) { + var analysisRequired = !!config.onAnalysis; + var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; + return backend.getSnapshot(meta.jobId, snapshotRevision).then(function(snapshotPayload) { + var snapshot = normalizeSnapshot(snapshotPayload, meta); + if (!snapshot) throw new Error("Solver backend returned an invalid snapshot payload"); + var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); + var result = { + meta: mergedMeta, + snapshot, + analysis: null + }; + if (!analysisRequired) return result; + return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function(analysisPayload) { + result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); + return result; + }); + }).catch(function(err) { + if (requireSnapshot) throw err; + var fallback = { meta, snapshot: null, analysis: null }; + if (!analysisRequired || snapshotRevision == null) return fallback; + return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function(analysisPayload) { + fallback.analysis = normalizeAnalysis(analysisPayload, meta); + return fallback; + }).catch(function() { + return fallback; + }); + }); } - style[name] = value; - } - - function queuePostMountSync(state, syncLayoutFromViewport) { - if (state.hasQueuedPostMountSync || typeof setTimeout !== 'function') return; - state.hasQueuedPostMountSync = true; - - var timerId = setTimeout(function () { - state.hasQueuedPostMountSync = false; - if (state.destroyed) return; - syncLayoutFromViewport(); - }, 0); - - state.cleanup.push(function () { - if (typeof clearTimeout === 'function') clearTimeout(timerId); - }); - } - - function normalizeViewportInput(viewport, label) { - if (viewport == null) return null; - sf.assert(typeof viewport === 'object', label + ' must be an object'); - - return normalizeMinuteRange( - viewport.startMinute, - viewport.endMinute, - label + '.startMinute', - label + '.endMinute' - ); - } - - function showTooltip(tooltip, root, payload, event) { - if (!payload) return; - tooltip.setAttribute('aria-hidden', 'false'); - tooltip.innerHTML = ''; - tooltip.appendChild(sf.el('div', { className: 'sf-tooltip-title' }, payload.title)); - (payload.rows || []).forEach(function (row) { - var rowEl = sf.el('div', { className: 'sf-tooltip-row' }); - rowEl.appendChild(sf.el('span', { className: 'sf-tooltip-key' }, row.key)); - rowEl.appendChild(sf.el('span', { className: 'sf-tooltip-val' }, row.value)); - tooltip.appendChild(rowEl); - }); - - var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; - var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; - var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - tooltip.classList.add('visible'); - } - - function showTooltipForElement(tooltip, root, payload, element) { - var rect = element && typeof element.getBoundingClientRect === 'function' - ? element.getBoundingClientRect() - : null; - showTooltip(tooltip, root, payload, rect ? { - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - } : null); - } - - function hideTooltip(tooltip) { - tooltip.setAttribute('aria-hidden', 'true'); - tooltip.classList.remove('visible'); - } - - function updateViewportMetadata(root, state) { - var axis = state.model.axis; - var duration = state.viewport.endMinute - state.viewport.startMinute; - root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); - root.dataset.viewportDurationMinutes = String(Math.round(duration)); - root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); - root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); - } - - function updateZoomButtons(buttons, state) { - var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); - var initial = state.model.axis.initialViewport; - buttons.forEach(function (button) { - var preset = button.dataset.zoom; - var active = false; - if (preset === 'reset') { - active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) - && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); - } else if (preset === '1w') active = duration === WEEK_MINUTES; - else if (preset === '2w') active = duration === WEEK_MINUTES * 2; - else if (preset === '4w') active = duration === WEEK_MINUTES * 4; - button.classList.toggle('active', active); - }); - } - - function normalizeZoomPresets(presets) { - if (presets == null) return ['1w', '2w', '4w', 'reset']; - sf.assert(Array.isArray(presets), 'rail.createTimeline(zoomPresets) must be an array'); - presets.forEach(function (preset, index) { - sf.assert( - ['1w', '2w', '4w', 'reset'].indexOf(preset) >= 0, - 'rail.createTimeline(zoomPresets[' + index + ']) must be one of 1w, 2w, 4w, reset' - ); - }); - return presets.slice(); - } - - function pruneExpandedClusters(state) { - Object.keys(state.expandedClusters).forEach(function (laneId) { - var exists = state.model.lanes.some(function (lane) { - return lane.id === laneId; - }); - if (!exists) delete state.expandedClusters[laneId]; - }); - } - -})(SF); -/* ============================================================================ - SolverForge UI — Gantt (Frappe Gantt + Split.js wrapper) - Requires: Frappe Gantt (Gantt) and Split (Split) loaded globally. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.gantt = {}; - - sf.gantt.create = function (config) { - config = config || {}; - var instanceId = sf.uid('sf-gantt'); - var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); - var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); - var chartContainerId = config.chartContainer || (instanceId + '-container'); - var svgId = config.svgId || (instanceId + '-svg'); - var ganttChart = null; - var splitInstance = null; - var mounted = false; - var mountTarget = null; - var resizeObserver = null; - var tasks = []; - var sortState = { key: null, direction: 'asc' }; - - // ── Build DOM ── - var wrapper = sf.el('div', { className: 'sf-gantt-split' }); - - // Grid pane - var gridPane = sf.el('div', { className: 'sf-gantt-pane', id: gridPaneId }); - var gridHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); - gridHeader.appendChild(sf.el('h3', null, config.gridTitle || 'Tasks')); - var gridControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); - gridHeader.appendChild(gridControls); - gridPane.appendChild(gridHeader); - - var gridContent = sf.el('div', { className: 'sf-gantt-pane-content' }); - var grid = sf.el('div', { className: 'sf-gantt-grid' }); - gridContent.appendChild(grid); - gridPane.appendChild(gridContent); - - // Chart pane - var chartPane = sf.el('div', { className: 'sf-gantt-pane', id: chartPaneId }); - var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); - chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline')); - - var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' }); - var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' }); - var modes = [ - { value: 'Quarter Day', label: 'Quarter Day' }, - { value: 'Half Day', label: 'Half Day' }, - { value: 'Day', label: 'Day' }, - { value: 'Week', label: 'Week' }, - { value: 'Month', label: 'Month' }, - ]; - modes.forEach(function (m) { - var opt = sf.el('option', { value: m.value }, m.label); - if (m.value === (config.viewMode || 'Quarter Day')) opt.selected = true; - viewSelect.appendChild(opt); - }); - viewSelect.addEventListener('change', function () { - if (ganttChart) ganttChart.change_view_mode(viewSelect.value); - }); - viewControls.appendChild(viewSelect); - - var chartControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); - chartHeader.appendChild(viewControls); - chartHeader.appendChild(chartControls); - chartPane.appendChild(chartHeader); - - var chartContent = sf.el('div', { className: 'sf-gantt-pane-content' }); - var chartContainer = sf.el('div', { className: 'sf-gantt-container', id: chartContainerId }); - chartContent.appendChild(chartContainer); - chartPane.appendChild(chartContent); - - wrapper.appendChild(gridPane); - wrapper.appendChild(chartPane); - - // ── API ── - var ctrl = { el: wrapper }; - - ctrl.mount = function (parent) { - sf.assert(parent, 'gantt.mount(parent) requires a mount target'); - var target = typeof parent === 'string' ? document.getElementById(parent) : parent; - sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); - validateMountTarget(target); - - if (mounted && mountTarget === target && wrapper.parentNode === target) { - return; + function applyBundle(bundle) { + if (!bundle) return; + lastMeta = bundle.meta; + if (bundle.meta && bundle.meta.snapshotRevision != null) { + lastSnapshotRevision = bundle.meta.snapshotRevision; } - if (mounted) ctrl.destroy(); - target.appendChild(wrapper); - mounted = true; - mountTarget = target; - if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { - renderGrid(tasks); - renderChart(tasks); + applyEventMeta(bundle.meta, bundle.analysis); + if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + } + function finalizeTerminal(meta) { + closeCurrentStream(); + activeJobId = null; + queuedAction = null; + phase = "idle"; + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : "IDLE"); + updateMoves(null); + } + function failTransport(err) { + var jobId = activeJobId || retainedJobId; + retainedJobId = jobId; + closeCurrentStream(); + activeJobId = null; + phase = phaseForLifecycleState(lifecycleState); + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + notifyError(err); + } + function failStartup(err) { + closeCurrentStream(); + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + phase = "idle"; + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + applyLifecycleState("IDLE"); + updateMoves(null); + notifyError(err); + } + function applyEventMeta(meta, analysis) { + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); + updateScore(readDisplayScore(meta, analysis)); + updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); + if (analysis) { + var constraints = readAnalysisConstraints(analysis); + if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { + statusBar.colorDotsFromAnalysis(constraints); + } } - initSplit(); - bindResizeObserver(); - }; - - ctrl.setTasks = function (newTasks) { - sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); - tasks = newTasks; - renderGrid(newTasks); - renderChart(newTasks); - }; - - ctrl.refresh = function () { - if (ganttChart && tasks.length > 0) { - ganttChart.refresh(tasksToFrappe(tasks)); + } + function readDisplayScore(meta, analysis) { + if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; + if (analysis && analysis.score != null) return analysis.score; + return null; + } + function applyLifecycleState(state) { + lifecycleState = state || "IDLE"; + if (!statusBar) return; + if (typeof statusBar.setLifecycleState === "function") { + statusBar.setLifecycleState(lifecycleState); + return; } - }; - - ctrl.getChart = function () { return ganttChart; }; - - ctrl.changeViewMode = function (mode) { - viewSelect.value = mode; - if (ganttChart) ganttChart.change_view_mode(mode); - }; - - ctrl.highlightTask = function (taskId) { - grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { - row.classList.toggle('selected', row.dataset.taskId === taskId); - }); - var svg = chartContainer.querySelector('svg'); - if (svg) { - svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { - bw.classList.remove('highlighted'); - }); - var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); - if (bar) bar.classList.add('highlighted'); + if (typeof statusBar.setSolving === "function") { + statusBar.setSolving(isActiveLifecycle2(lifecycleState)); } - }; - - ctrl.destroy = function () { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = null; + } + function updateScore(score2) { + if (statusBar && typeof statusBar.updateScore === "function") { + statusBar.updateScore(score2); } - if (splitInstance) { splitInstance.destroy(); splitInstance = null; } - ganttChart = null; - mounted = false; - mountTarget = null; - if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); - }; - - return ctrl; - - function initSplit() { - if (typeof Split !== 'function') return; - if (splitInstance) { - splitInstance.destroy(); - splitInstance = null; + } + function updateMoves(value) { + if (statusBar && typeof statusBar.updateMoves === "function") { + statusBar.updateMoves(value); } - - var splitSizes = normalizePair(config.splitSizes, [40, 60]); - var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); - - splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { - direction: 'vertical', - sizes: splitSizes, - minSize: splitMinSize, - snapOffset: 30, - gutterSize: 4, - cursor: 'col-resize', - onDragEnd: function () { - if (ganttChart) { - setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 100); - } - }, + } + function resetForStart() { + closeCurrentStream(); + activeJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + } + function resetAfterDelete() { + closeCurrentStream(); + rejectDeferred("pause", new Error("Solver job was deleted before pause settled")); + rejectDeferred("resume", new Error("Solver job was deleted before resume settled")); + rejectDeferred("cancel", new Error("Solver job was deleted before cancel settled")); + runToken += 1; + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + phase = "idle"; + applyLifecycleState("IDLE"); + updateScore(null); + updateMoves(null); + } + function closeCurrentStream() { + if (!closeStream) return; + closeStream(); + closeStream = null; + } + function currentJobId() { + return activeJobId != null ? activeJobId : retainedJobId; + } + function hasNewerEvent(meta) { + var currentSequence = lastMeta && typeof lastMeta.eventSequence === "number" ? lastMeta.eventSequence : null; + var candidateSequence = meta && typeof meta.eventSequence === "number" ? meta.eventSequence : null; + if (currentSequence == null || candidateSequence == null) return false; + return currentSequence > candidateSequence; + } + function resolveRequestedSnapshotRevision(snapshotRevision) { + if (snapshotRevision != null && snapshotRevision !== "") return snapshotRevision; + return lastSnapshotRevision; + } + function createTerminalSync(event) { + var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; + terminalSync = { + jobId: event.meta.jobId, + eventType: event.eventType, + meta: event.meta, + status: "pending", + promise: null, + error: null, + callbackDelivered: existing ? existing.callbackDelivered : false + }; + return terminalSync; + } + function runTerminalSync(record, token, event, requireSnapshot) { + record.status = "pending"; + record.error = null; + record.meta = event.meta; + record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function(bundle) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "synced"; + record.error = null; + record.meta = bundle.meta; + finalizeTerminal(bundle.meta); + applyBundle(bundle); + deliverTerminalCallback(record, event, bundle); + settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); + return record; + }).catch(function(err) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "failed"; + record.error = err; + finalizeTerminal(event.meta); + deliverTerminalFailureCallback(record, event); + settlePendingFromTerminal(event.eventType, null, err); + notifyError(err); + return record; + }); + return record.promise; + } + function ensureTerminalSyncBeforeDelete(jobId) { + var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; + if (!record) return Promise.resolve(); + return Promise.resolve(record.promise).then(function() { + if (!requiresSuccessfulTerminalSync(record)) return; + if (record.status === "synced") return; + return retryTerminalSync(record); + }); + } + function retryTerminalSync(record) { + var retryEvent = { + eventType: record.eventType, + meta: record.meta, + error: null + }; + return runTerminalSync(record, runToken, retryEvent, true).then(function() { + if (record.status !== "synced") { + throw record.error || new Error("Terminal snapshot synchronization failed"); + } }); } - - function bindResizeObserver() { - if (typeof ResizeObserver !== 'function') return; - if (resizeObserver) { - resizeObserver.disconnect(); + function requiresSuccessfulTerminalSync(record) { + return record.eventType === "completed"; + } + function deliverTerminalCallback(record, event, bundle) { + if (record.callbackDelivered) return; + if (event.eventType === "completed") { + if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); + } else if (event.eventType === "cancelled") { + if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); + } else if (event.eventType === "failed") { + if (config.onFailure) config.onFailure(event.error || "Solver job failed", bundle.meta, bundle.snapshot, bundle.analysis); } - resizeObserver = new ResizeObserver(function () { - if (!ganttChart) return; - setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); - }); - if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + record.callbackDelivered = true; } - - function normalizePair(value, fallback) { - if (typeof value === 'number' && isFinite(value)) return [value, value]; - if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); - var n0 = Number(value[0]); - var n1 = Number(value[1]); - if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); - return [n0, n1]; + function deliverTerminalFailureCallback(record, event) { + if (record.callbackDelivered || event.eventType !== "failed") return; + if (config.onFailure) config.onFailure(event.error || "Solver job failed", event.meta, null, null); + record.callbackDelivered = true; } - - function validateMountTarget(target) { - sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); - sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); + function terminalEventError(event) { + if (event.eventType !== "failed") return null; + return new Error(event.error || "Solver job failed"); } - - function getElementSize(target, axis) { - var clientKey = 'client' + axis; - var offsetKey = 'offset' + axis; - var rectKey = axis === 'Width' ? 'width' : 'height'; - - if (typeof target[clientKey] === 'number') return target[clientKey]; - if (typeof target[offsetKey] === 'number') return target[offsetKey]; - if (typeof target.getBoundingClientRect === 'function') { - var rect = target.getBoundingClientRect(); - if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; - } - return 0; + function isCancelablePhase() { + return phase === "solving" || phase === "pause-requested" || phase === "paused" || phase === "resuming"; } - - function tasksToFrappe(taskList) { - return taskList - .filter(function (t) { return t.start && t.end; }) - .map(function (t) { - var customClass = t.custom_class || ''; - if (t.pinned) { - customClass = customClass ? customClass + ' pinned' : 'pinned'; - } - return { - id: t.id, - name: t.name || t.label || t.id, - start: t.start, - end: t.end, - custom_class: customClass, - dependencies: t.dependencies || '', - }; - }); + function phaseForLifecycleState(state) { + if (state === "STARTING") return "starting"; + if (state === "SOLVING") return "solving"; + if (state === "PAUSE_REQUESTED") return "pause-requested"; + if (state === "PAUSED") return "paused"; + if (state === "RESUMING") return "resuming"; + if (state === "CANCELLING") return "cancelling"; + return "idle"; } - - function renderChart(taskList) { - var frappeTasks = tasksToFrappe(taskList); - - if (frappeTasks.length === 0) { - chartContainer.textContent = ''; - chartContainer.appendChild(sf.el('div', { - className: 'sf-gantt-empty-state', - style: { - padding: '24px', - color: 'var(--sf-gray-400)', - fontFamily: 'var(--sf-font-mono)', - fontSize: '13px', - }, - }, 'No scheduled tasks to display.')); - ganttChart = null; - return; + function isTerminalLifecycle(state) { + return state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; + } + function settlePendingFromTerminal(eventType, bundle, err) { + if (eventType === "cancelled") { + if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + } else if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + if (pendingPause) { + pendingPause.reject(err || new Error("Job terminated before pause settled")); + pendingPause = null; + } + if (pendingResume) { + pendingResume.reject(err || new Error("Job terminated before resume settled")); + pendingResume = null; } - - chartContainer.textContent = ''; - chartContainer.appendChild(createSvgRoot(svgId)); - - ganttChart = new Gantt('#' + svgId, frappeTasks, { - view_mode: viewSelect.value || 'Quarter Day', - date_format: 'YYYY-MM-DD HH:mm', - custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, - on_click: function (task) { - ctrl.highlightTask(task.id); - if (config.onTaskClick) config.onTaskClick(task); - }, - on_date_change: function (task, start, end) { - if (config.onDateChange) config.onDateChange(task, start, end); - }, - }); } - - function renderGrid(taskList) { - while (grid.firstChild) grid.removeChild(grid.firstChild); - var table = sf.el('table', { className: 'sf-gantt-table' }); - var columns = config.columns || [ - { key: 'name', label: 'Task' }, - { key: 'start', label: 'Start' }, - { key: 'end', label: 'End' }, - ]; - var sortedTasks = sortTasks(taskList); - - var thead = sf.el('thead'); - var headerRow = sf.el('tr'); - columns.forEach(function (col) { - headerRow.appendChild(buildHeaderCell(col)); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - var tbody = sf.el('tbody'); - sortedTasks.forEach(function (task) { - var rowClasses = ['sf-gantt-row']; - if (task.custom_class) rowClasses.push(task.custom_class); - if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); - - var tr = sf.el('tr', { - className: rowClasses.join(' '), - dataset: { taskId: task.id }, - onClick: function () { - ctrl.highlightTask(task.id); - if (config.onTaskClick) config.onTaskClick(task); - }, - }); - - columns.forEach(function (col) { - var td = sf.el('td'); - if (col.key === 'name') { - td.className = 'sf-task-name'; - td.textContent = task.name || task.label || task.id; - } else if (col.render) { - var content = col.render(task); - if (typeof content === 'string') td.textContent = content; - else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; - else if (content instanceof Node) td.appendChild(content); - } else { - td.textContent = task[col.key] || ''; - td.style.fontFamily = 'var(--sf-font-mono)'; - td.style.fontSize = '12px'; - } - tr.appendChild(td); - }); - - tbody.appendChild(tr); - }); - table.appendChild(tbody); - grid.appendChild(table); + function resolveDeferred(name, value) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.resolve(value); + setDeferred(name, null); } - - function buildHeaderCell(col) { - if (!col.sortable) { - return sf.el('th', null, col.label); - } - - var isCurrent = sortState.key === col.key; - var th = sf.el('th', { - className: 'sortable' + (isCurrent ? ' active' : ''), - role: 'button', - tabIndex: 0, - 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', - }); - th.appendChild(document.createTextNode(col.label)); - th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : '')); - - sf.bindActivation(th, function () { - if (sortState.key === col.key) { - sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; - } else { - sortState.key = col.key; - sortState.direction = 'asc'; - } - renderGrid(tasks); - }); - - return th; + function rejectDeferred(name, err) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.reject(err); + setDeferred(name, null); } - - function sortTasks(taskList) { - if (!sortState.key) return taskList.slice(); - var sorted = taskList.slice(); - sorted.sort(function (a, b) { - var aVal = sortValue(a[sortState.key], sortState.key); - var bVal = sortValue(b[sortState.key], sortState.key); - if (aVal === bVal) return 0; - if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; - return aVal > bVal ? -1 : 1; - }); - return sorted; + function getDeferred(name) { + if (name === "pause") return pendingPause; + if (name === "resume") return pendingResume; + if (name === "cancel") return pendingCancel; + return null; } - - function sortValue(value, key) { - if (value == null) return ''; - if (key === 'start' || key === 'end') { - var parsed = Date.parse(value); - return isNaN(parsed) ? String(value).toLowerCase() : parsed; - } - if (typeof value === 'number') return value; - return String(value).toLowerCase(); + function setDeferred(name, value) { + if (name === "pause") pendingPause = value; + if (name === "resume") pendingResume = value; + if (name === "cancel") pendingCancel = value; } - - function defaultPopup(task) { - var t = tasks.find(function (x) { return x.id === task.id; }); - if (!t) return ''; - return '
' + - '

' + sf.escHtml(t.name || t.id) + '

' + - '

Start: ' + sf.escHtml(t.start) + '

' + - '

End: ' + sf.escHtml(t.end) + '

' + - (t.duration_minutes ? '

Duration: ' + t.duration_minutes + ' min

' : '') + - (t.pinned ? '

Pinned

' : '') + - '
'; + function notifyError(err) { + if (err && lastNotifiedError === err) return; + lastNotifiedError = err || null; + if (config.onError) config.onError(err && err.message ? err.message : String(err)); } - - function createSvgRoot(id) { - if (document.createElementNS) { - var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.id = id; - return svg; - } - return sf.el('svg', { id: id }); + function ensureJobId(id) { + var jobId = normalizeCreateJobId(id); + if (jobId) return jobId; + throw new Error("Invalid solver backend createJob response"); } }; - -})(SF); -/* ============================================================================ - SolverForge UI — Footer Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createFooter = function (config) { - sf.assert(config, 'createFooter(config) requires a configuration object'); - - var footer = sf.el('footer', { className: 'sf-footer' }); - if (config.links) { - config.links.forEach(function (link, i) { - if (i > 0) footer.appendChild(sf.el('span', { className: 'sf-vr' })); - footer.appendChild(sf.el('a', { href: link.url, target: '_blank' }, link.label)); - }); + function hasFunction(object, key) { + return !!(object && typeof object[key] === "function"); + } + function createDeferred() { + var resolve; + var reject; + var promise = new Promise(function(res, rej) { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + function normalizeJobEvent(payload, expectedId) { + if (!payload || typeof payload !== "object") return null; + var eventType = normalizeEventType(readField(payload, ["eventType", "event_type", "type"])); + if (!eventType) return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); + if (jobId == null || jobId === "") jobId = expectedId; + if (jobId == null || jobId === "") return null; + if (String(jobId) !== String(expectedId)) return null; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + var meta = { + id: String(jobId), + jobId: String(jobId), + eventType, + eventSequence: readField(payload, ["eventSequence", "event_sequence"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state", "solverStatus", "solver_status"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) + }; + return { + eventType, + meta, + solution, + error: readField(payload, ["error"], [payload, payload.data]) || null + }; + } + function normalizeSnapshot(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + return { + id: jobId != null ? String(jobId) : null, + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || null, + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.data]), payload), + solution + }; + } + function normalizeAnalysis(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var analysisBody = payload.analysis || payload.data && payload.data.analysis || payload; + var constraints = readAnalysisConstraints(analysisBody); + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var snapshotRevision = readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]); + if (snapshotRevision == null || snapshotRevision === "") { + snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; } - if (config.version) { - footer.appendChild(sf.el('span', { style: { marginLeft: 'auto' } }, config.version)); + return { + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: snapshotRevision != null ? snapshotRevision : null, + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || fallbackMeta && fallbackMeta.terminalReason || null, + analysis: analysisBody, + score: analysisBody.score != null ? analysisBody.score : null, + constraints + }; + } + function buildLiveSnapshot(event) { + return { + id: event.meta.jobId, + jobId: event.meta.jobId, + snapshotRevision: event.meta.snapshotRevision, + lifecycleState: event.meta.lifecycleState, + terminalReason: event.meta.terminalReason, + currentScore: event.meta.currentScore, + bestScore: event.meta.bestScore, + telemetry: event.meta.telemetry, + solution: event.solution + }; + } + function mergeMeta(meta, snapshot, eventType) { + if (!snapshot) return meta; + return { + id: meta && meta.id != null ? meta.id : snapshot.id, + jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, + eventType: meta && meta.eventType ? meta.eventType : eventType, + eventSequence: meta ? meta.eventSequence : null, + lifecycleState: meta && meta.lifecycleState || snapshot.lifecycleState || normalizeLifecycleState2(null, eventType), + terminalReason: meta && meta.terminalReason || snapshot.terminalReason || null, + telemetry: snapshot.telemetry || meta && meta.telemetry || null, + currentScore: snapshot.currentScore || meta && meta.currentScore || null, + bestScore: snapshot.bestScore || meta && meta.bestScore || null, + snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : meta && meta.snapshotRevision + }; + } + function readField(payload, names, sources) { + var fields = Array.isArray(names) ? names : [names]; + var roots = sources || [payload]; + for (var i = 0; i < roots.length; i++) { + var source = roots[i]; + if (!source || typeof source !== "object") continue; + for (var j = 0; j < fields.length; j++) { + if (source[fields[j]] != null) return source[fields[j]]; + } } - return footer; - }; + return null; + } + function normalizeEventType(value) { + if (typeof value !== "string") return null; + var normalized = value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase(); + if (!normalized) return null; + if (normalized === "finished") return "completed"; + return normalized; + } + function normalizeLifecycleState2(value, eventType) { + if (typeof value === "string" && value.trim()) { + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); + } + if (eventType === "progress" || eventType === "best_solution" || eventType === "resumed") return "SOLVING"; + if (eventType === "pause_requested") return "PAUSE_REQUESTED"; + if (eventType === "paused") return "PAUSED"; + if (eventType === "completed") return "COMPLETED"; + if (eventType === "cancelled") return "CANCELLED"; + if (eventType === "failed") return "FAILED"; + return "IDLE"; + } + function normalizeTelemetry(rawTelemetry, payload) { + if (rawTelemetry && typeof rawTelemetry === "object") return rawTelemetry; + var telemetry = {}; + var movesPerSecond = readField(payload, ["movesPerSecond", "moves_per_second"]); + var stepCount = readField(payload, ["stepCount", "step_count"]); + if (movesPerSecond != null) telemetry.movesPerSecond = Number(movesPerSecond); + if (stepCount != null) telemetry.stepCount = Number(stepCount); + return Object.keys(telemetry).length ? telemetry : null; + } + function readMovesPerSecond(telemetry) { + if (!telemetry || typeof telemetry !== "object") return null; + const value = telemetry.movesPerSecond ?? telemetry.moves_per_second; + if (value == null) return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; + } + function readAnalysisConstraints(analysis) { + if (!analysis || typeof analysis !== "object") return null; + const a = analysis; + if (Array.isArray(a.constraints)) return a.constraints; + const nested = a.analysis; + if (nested && Array.isArray(nested.constraints)) return nested.constraints; + return null; + } + function isActiveLifecycle2(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; + } -})(SF); + // ts-src/index.ts + var colors = { + pick, + project, + reset + }; + var score = { + parseHard, + parseSoft, + parseMedium, + getComponents, + colorClass + }; + return __toCommonJS(index_exports); +})(); +if (typeof window !== 'undefined') window.SF = SF; diff --git a/static/sf/sf.0.6.5.mjs b/static/sf/sf.0.6.5.mjs new file mode 100644 index 0000000..7e13c37 --- /dev/null +++ b/static/sf/sf.0.6.5.mjs @@ -0,0 +1,3987 @@ +// ts-src/utils/colors.ts +var SEQUENCE_1 = [9101876, 16574799, 7512015, 15317358, 11370408]; +var SEQUENCE_2 = [7590422, 15586304, 3433892, 12680465, 7688315]; +var colorMap = {}; +var nextColorCount = 0; +function buildPercentageColor(floor, ceil, pct) { + var red = (floor & 16711680) + Math.floor(pct * ((ceil & 16711680) - (floor & 16711680))) & 16711680; + var green = (floor & 65280) + Math.floor(pct * ((ceil & 65280) - (floor & 65280))) & 65280; + var blue = (floor & 255) + Math.floor(pct * ((ceil & 255) - (floor & 255))) & 255; + return red | green | blue; +} +function nextColor() { + var colorIndex = nextColorCount % SEQUENCE_1.length; + var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); + var color; + if (shadeIndex === 0) { + color = SEQUENCE_1[colorIndex]; + } else if (shadeIndex === 1) { + color = SEQUENCE_2[colorIndex]; + } else { + shadeIndex -= 3; + var base = Math.floor(shadeIndex / 2 + 1); + var divisor = 2; + while (base >= divisor) divisor *= 2; + base = base * 2 - divisor + 1; + color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); + } + nextColorCount++; + return "#" + color.toString(16).padStart(6, "0"); +} +var pick = function(key) { + if (colorMap[key] !== void 0) return colorMap[key]; + var c = nextColor(); + colorMap[key] = c; + return c; +}; +var reset = function() { + colorMap = {}; + nextColorCount = 0; +}; +var PROJECT_COLORS = [ + { main: "#10b981", dark: "#047857", light: "rgba(16,185,129,0.15)" }, + { main: "#3b82f6", dark: "#1d4ed8", light: "rgba(59,130,246,0.15)" }, + { main: "#8b5cf6", dark: "#6d28d9", light: "rgba(139,92,246,0.15)" }, + { main: "#f59e0b", dark: "#b45309", light: "rgba(245,158,11,0.15)" }, + { main: "#ec4899", dark: "#be185d", light: "rgba(236,72,153,0.15)" }, + { main: "#06b6d4", dark: "#0e7490", light: "rgba(6,182,212,0.15)" }, + { main: "#f43f5e", dark: "#be123c", light: "rgba(244,63,94,0.15)" }, + { main: "#84cc16", dark: "#4d7c0f", light: "rgba(132,204,22,0.15)" } +]; +var project = function(index) { + return PROJECT_COLORS[index % PROJECT_COLORS.length]; +}; + +// ts-src/utils/score.ts +var parseHard = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)hard/); + return m ? parseInt(m[1], 10) : 0; +}; +var parseSoft = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)soft/); + return m ? parseInt(m[1], 10) : 0; +}; +var parseMedium = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)medium/); + return m ? parseInt(m[1], 10) : 0; +}; +var getComponents = function(scoreStr) { + return { + hard: parseHard(scoreStr), + medium: parseMedium(scoreStr), + soft: parseSoft(scoreStr) + }; +}; +var colorClass = function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + return hard < 0 ? "score-red" : soft < 0 ? "score-yellow" : "score-green"; +}; + +// ts-src/core/index.ts +var version = "0.6.5"; +var uidCounter = 0; +var escHtml = function(str) { + if (!str) return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +}; +var assert = function(cond, message) { + if (!cond) throw new Error("[SolverForge] " + message); +}; +var normalizeCreateJobId = function(raw) { + var value = raw; + if (value && typeof value === "object") { + if (value.id != null) value = value.id; + else if (value.jobId != null) value = value.jobId; + else if (value.job_id != null) value = value.job_id; + else if (value.data && typeof value.data === "object" && value.data.id != null) value = value.data.id; + else return ""; + } + if (typeof value === "string") return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value).trim(); + return ""; +}; +var el = function(tag, attrs = {}, ...children) { + var el2 = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function(key) { + var value = attrs[key]; + if (key === "className") el2.className = value; + else if (key === "style" && typeof value === "object") { + Object.assign(el2.style, value); + } else if (key.indexOf("on") === 0) { + el2.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === "dataset") Object.assign(el2.dataset, value); + else if (key === "html") el2.textContent = value; + else if (key === "unsafeHtml") el2.innerHTML = value; + else el2.setAttribute(key, value); + }); + } + children.forEach(function(child) { + if (child == null) return; + if (typeof child === "string") el2.appendChild(document.createTextNode(child)); + else if (child instanceof Node) el2.appendChild(child); + }); + return el2; +}; +var uid = function(prefix) { + uidCounter += 1; + return (prefix || "sf") + "-" + uidCounter; +}; +var bindActivation = function(el2, onActivate) { + if (!el2 || typeof onActivate !== "function") return; + function handleActivate(e) { + if (!e || e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return; + if (e.type === "keydown") e.preventDefault(); + onActivate(e); + } + el2.addEventListener("click", handleActivate); + el2.addEventListener("keydown", handleActivate); +}; + +// ts-src/components/api-guide.ts +var createApiGuide = function(config) { + assert(config, "createApiGuide(config) requires a configuration object"); + assert(Array.isArray(config.endpoints), "createApiGuide(config.endpoints) must be an array"); + var guide = el("div", { className: "sf-api-guide" }); + var endpoints = config.endpoints; + endpoints.forEach(function(ep) { + var section = el("div", { className: "sf-api-section" }); + section.appendChild(el("h3", null, (ep.method || "GET") + " " + ep.path)); + if (ep.description) { + section.appendChild(el("p", { style: { fontSize: "13px", color: "var(--sf-gray-600)", marginBottom: "8px" } }, ep.description)); + } + if (ep.curl) { + var block = el("div", { className: "sf-api-code-block" }); + block.appendChild(el("code", null, ep.curl)); + var copyBtn = el("button", { + className: "sf-copy-btn", + "aria-label": "Copy command", + onClick: function() { + navigator.clipboard.writeText(ep.curl).then(function() { + copyBtn.textContent = "Copied!"; + setTimeout(function() { + copyBtn.textContent = "Copy"; + }, 1500); + }); + } + }, "Copy"); + block.appendChild(copyBtn); + section.appendChild(block); + } + guide.appendChild(section); + }); + return guide; +}; + +// ts-src/components/buttons.ts +var createButton = function(config) { + assert(config, "createButton(config) requires a configuration object"); + var classes = ["sf-btn"]; + if (config.variant) classes.push("sf-btn--" + config.variant); + if (config.size === "small") classes.push("sf-btn--sm"); + if (config.size === "large") classes.push("sf-btn--lg"); + if (config.pill) classes.push("sf-btn--pill"); + if (config.circle) classes.push("sf-btn--circle"); + if (config.outline) classes.push("sf-btn--outline"); + if (config.iconOnly) classes.push("sf-btn--icon"); + var btn = el("button", { + className: classes.join(" "), + type: "button" + }); + if (config.disabled) btn.disabled = true; + assert(!config.onClick || typeof config.onClick === "function", "createButton(onClick) must be a function"); + if (config.icon) { + var icon = el("i", { className: "fa-solid " + config.icon }); + btn.appendChild(icon); + } + if (config.text && !config.circle && !config.iconOnly) { + btn.appendChild(document.createTextNode(config.text)); + } + if (config.onClick) { + btn.addEventListener("click", config.onClick); + } + if (config.tooltip) { + btn.title = config.tooltip; + } + if (config.ariaLabel) { + btn.setAttribute("aria-label", config.ariaLabel); + } else if (config.iconOnly && config.text) { + btn.setAttribute("aria-label", config.text); + } else if (config.icon && !config.text) { + btn.setAttribute("aria-label", config.icon.replace(/fa-/, "").replace(/-/g, " ")); + } + if (config.id) { + btn.id = config.id; + } + if (config.dataset) { + Object.assign(btn.dataset, config.dataset); + } + return btn; +}; + +// ts-src/components/footer.ts +var createFooter = function(config) { + assert(config, "createFooter(config) requires a configuration object"); + var footer = el("footer", { className: "sf-footer" }); + if (config.links) { + config.links.forEach(function(link, i) { + if (i > 0) footer.appendChild(el("span", { className: "sf-vr" })); + footer.appendChild(el("a", { href: link.url, target: "_blank" }, link.label)); + }); + } + if (config.version) { + footer.appendChild(el("span", { style: { marginLeft: "auto" } }, config.version)); + } + return footer; +}; + +// ts-src/components/header.ts +var createHeader = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + var header = el("header", { className: "sf-header" }); + var controls = { + actions: null, + spinner: null, + solveBtn: null, + pauseBtn: null, + resumeBtn: null, + cancelBtn: null, + analyzeBtn: null, + nav: null + }; + if (config.logo) { + var logo = el("img", { + className: "sf-header-logo", + src: config.logo, + alt: "Logo" + }); + header.appendChild(logo); + } + var brand = el("div", { className: "sf-header-brand" }); + if (config.title) { + brand.appendChild(el("div", { className: "sf-header-title" }, config.title)); + } + if (config.subtitle) { + brand.appendChild(el("div", { className: "sf-header-subtitle" }, config.subtitle)); + } + header.appendChild(brand); + if (config.tabs && config.tabs.length > 0) { + assert(Array.isArray(config.tabs), "createHeader(config.tabs) expects an array"); + var nav = el("nav", { className: "sf-header-nav" }); + controls.nav = nav; + config.tabs.forEach(function(tab) { + assert(tab && tab.id, "createHeader tab entries require an id"); + assert(typeof tab.label === "string", "createHeader tab entries require a label"); + var btn = el("button", { + className: "sf-nav-btn" + (tab.active ? " active" : ""), + role: "tab", + "aria-selected": !!tab.active, + tabIndex: 0, + dataset: { tab: tab.id }, + onKeyDown: function(e) { + if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; + var buttons = nav.querySelectorAll(".sf-nav-btn"); + var list = Array.prototype.slice.call(buttons); + var nextIndex = e.key === "ArrowRight" ? (list.indexOf(btn) + 1) % list.length : (list.length + list.indexOf(btn) - 1) % list.length; + var next = list[nextIndex]; + if (next && next.focus) next.focus(); + }, + onClick: function() { + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.classList.remove("active"); + }); + btn.classList.add("active"); + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.setAttribute("aria-selected", b === btn ? "true" : "false"); + }); + if (config.onTabChange) config.onTabChange(tab.id); + } + }); + if (tab.icon) { + btn.appendChild(el("i", { className: "fa-solid " + tab.icon })); + } + btn.appendChild(document.createTextNode(tab.label)); + nav.appendChild(btn); + }); + header.appendChild(nav); + } + if (config.actions) { + assert(typeof config.actions === "object", "createHeader(config.actions) expects an object"); + assert(!config.actions.onSolve || typeof config.actions.onSolve === "function", "createHeader(config.actions.onSolve) must be a function"); + assert(!config.actions.onPause || typeof config.actions.onPause === "function", "createHeader(config.actions.onPause) must be a function"); + assert(!config.actions.onResume || typeof config.actions.onResume === "function", "createHeader(config.actions.onResume) must be a function"); + assert(!config.actions.onCancel || typeof config.actions.onCancel === "function", "createHeader(config.actions.onCancel) must be a function"); + assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === "function", "createHeader(config.actions.onAnalyze) must be a function"); + assert(!config.onTabChange || typeof config.onTabChange === "function", "createHeader(config.onTabChange) must be a function"); + var actions = el("div", { className: "sf-header-actions" }); + controls.actions = actions; + var spinner = el("div", { className: "sf-solving-spinner" }); + controls.spinner = spinner; + actions.appendChild(spinner); + if (config.actions.onSolve) { + var solveBtn = createButton({ + text: "Solve", + variant: "success", + icon: "fa-play", + onClick: config.actions.onSolve + }); + controls.solveBtn = solveBtn; + actions.appendChild(solveBtn); + } + if (config.actions.onPause) { + var pauseBtn = createButton({ + text: "Pause", + variant: "default", + icon: "fa-pause", + onClick: config.actions.onPause + }); + pauseBtn.style.display = "none"; + controls.pauseBtn = pauseBtn; + actions.appendChild(pauseBtn); + } + if (config.actions.onResume) { + var resumeBtn = createButton({ + text: "Resume", + variant: "primary", + icon: "fa-play", + onClick: config.actions.onResume + }); + resumeBtn.style.display = "none"; + controls.resumeBtn = resumeBtn; + actions.appendChild(resumeBtn); + } + if (config.actions.onCancel) { + var cancelBtn = createButton({ + text: "Stop", + variant: "danger", + icon: "fa-stop", + onClick: config.actions.onCancel + }); + cancelBtn.style.display = "none"; + controls.cancelBtn = cancelBtn; + actions.appendChild(cancelBtn); + } + if (config.actions.onAnalyze) { + var analyzeBtn = createButton({ + variant: "ghost", + icon: "fa-chart-bar", + circle: true, + tooltip: "Score Analysis", + onClick: config.actions.onAnalyze + }); + controls.analyzeBtn = analyzeBtn; + actions.appendChild(analyzeBtn); + } + header.appendChild(actions); + } + header.sfControls = controls; + return header; +}; + +// ts-src/components/modal.ts +var createModal = function(config) { + assert(config, "createModal(config) requires a configuration object"); + assert(!config.footer || Array.isArray(config.footer), "createModal(config.footer) must be an array"); + var overlay = el("div", { className: "sf-modal-overlay" }); + var dialogId = uid("sf-modal"); + var dialog = el("div", { + className: "sf-modal", + id: dialogId, + role: "dialog", + "aria-modal": "true", + "aria-labelledby": dialogId + "-title" + }); + var body = el("div", { className: "sf-modal-body" }); + var header = el("div", { className: "sf-modal-header" }); + var titleEl = el("div", { className: "sf-modal-title", id: dialogId + "-title" }, config.title || ""); + header.appendChild(titleEl); + var closeBtn = el("button", { + className: "sf-modal-close", + "aria-label": "Close modal", + onClick: function() { + api.close(); + } + }, "\xD7"); + header.appendChild(closeBtn); + dialog.appendChild(header); + setBodyContent(body, config.body, config.unsafeBody); + dialog.appendChild(body); + if (config.footer) { + var footer = el("div", { className: "sf-modal-footer" }); + config.footer.forEach(function(child) { + footer.appendChild(child); + }); + dialog.appendChild(footer); + } + overlay.appendChild(dialog); + var previousFocus = null; + overlay.addEventListener("click", function(e) { + if (e.target === overlay) api.close(); + }); + function onKeyDown(e) { + if (e.key === "Escape") api.close(); + } + var api = { el: overlay, body }; + api.open = function() { + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add("open"); + document.addEventListener("keydown", onKeyDown); + }; + api.close = function() { + overlay.classList.remove("open"); + document.removeEventListener("keydown", onKeyDown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); + if (config.onClose) config.onClose(); + }; + api.setBody = function(content) { + setBodyContent(body, content); + }; + if (config.width) { + dialog.style.maxWidth = config.width; + } + return api; +}; +function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ""; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === "string") { + target.textContent = content; + } else if (content && typeof content === "object" && "unsafeBody" in content) { + target.innerHTML = content.unsafeBody; + } else if (content && typeof content === "object" && "unsafeHtml" in content) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } +} + +// ts-src/components/statusbar.ts +var createStatusBar = function(config = {}) { + var bar = el("div", { className: "sf-statusbar" }); + var lastScore = null; + var controls = null; + var scoreEl = el("span", { className: "sf-statusbar-score", id: "sfScoreDisplay", "aria-live": "polite" }, "\u2014"); + bar.appendChild(scoreEl); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var dotsContainer = el("div", { className: "sf-statusbar-constraints" }); + bar.appendChild(dotsContainer); + var movesSep = el("span", { className: "sf-statusbar-sep" }, "|"); + movesSep.style.display = "none"; + bar.appendChild(movesSep); + var movesEl = el("span"); + movesEl.style.display = "none"; + bar.appendChild(movesEl); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var statusEl = el("span", { id: "sfStatusText", role: "status", "aria-live": "polite" }); + bar.appendChild(statusEl); + if (config && config.constraints) { + buildDots(dotsContainer, config.constraints, config.onConstraintClick); + } + var api = { + el: bar, + bindHeader: function(header) { + controls = header && header.sfControls ? header.sfControls : null; + return api; + }, + updateScore: function(scoreStr) { + if (scoreStr && scoreStr !== lastScore) { + scoreEl.textContent = scoreStr; + var colorClassName = colorClass(scoreStr); + scoreEl.classList.remove("improved", "score-green", "score-red", "score-yellow"); + scoreEl.classList.add(colorClassName); + void scoreEl.offsetWidth; + scoreEl.classList.add("improved"); + lastScore = scoreStr; + } else if (!scoreStr) { + scoreEl.textContent = "\u2014"; + scoreEl.classList.remove("score-green", "score-red", "score-yellow", "improved"); + lastScore = null; + } + }, + setLifecycleState: function(state) { + var normalized = normalizeLifecycleState(state); + var solveBtn = controls && controls.solveBtn; + var pauseBtn = controls && controls.pauseBtn; + var resumeBtn = controls && controls.resumeBtn; + var cancelBtn = controls && controls.cancelBtn; + var spinner = controls && controls.spinner; + if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? "" : "none"; + if (pauseBtn) { + pauseBtn.style.display = shouldShowPause(normalized) ? "" : "none"; + pauseBtn.disabled = normalized === "PAUSE_REQUESTED"; + } + if (resumeBtn) { + resumeBtn.style.display = normalized === "PAUSED" ? "" : "none"; + resumeBtn.disabled = false; + } + if (cancelBtn) { + cancelBtn.style.display = shouldShowCancel(normalized) ? "" : "none"; + cancelBtn.disabled = false; + } + if (spinner) spinner.classList.toggle("active", shouldSpin(normalized)); + statusEl.textContent = lifecycleLabel(normalized); + statusEl.style.color = isActiveLifecycle(normalized) ? "var(--sf-emerald-600)" : normalized === "FAILED" ? "var(--sf-red-600)" : normalized === "CANCELLED" ? "var(--sf-amber-700)" : "var(--sf-gray-500)"; + }, + setSolving: function(solving) { + api.setLifecycleState(solving ? "SOLVING" : "IDLE"); + }, + updateMoves: function(mps) { + if (mps != null && mps > 0) { + movesEl.textContent = mps.toLocaleString() + " moves/s"; + movesEl.style.display = ""; + movesSep.style.display = ""; + } else { + movesEl.style.display = "none"; + movesSep.style.display = "none"; + } + }, + updateConstraintDots: function(constraints) { + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + }, + colorDotsByScore: function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot) { + var isHard = dot.dataset.type === "hard"; + dot.classList.toggle("violated", isHard && hard < 0); + dot.classList.toggle("violated-soft", !isHard && soft < 0); + }); + }, + colorDotsFromAnalysis: function(constraints) { + if (!constraints || constraints.length === 0) return; + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot, i) { + var c = constraints[i]; + if (!dot) return; + var isHardConstraint = c.type === "hard"; + var scoreVal = isHardConstraint ? parseHard(c.score) : parseSoft(c.score); + var violated = scoreVal < 0; + dot.classList.toggle("violated", isHardConstraint && violated); + dot.classList.toggle("violated-soft", !isHardConstraint && violated); + }); + } + }; + if (config && config.header) { + api.bindHeader(config.header); + } + api.setLifecycleState("IDLE"); + return api; +}; +function buildDots(container2, constraints, onClick) { + container2.innerHTML = ""; + if (!constraints) return; + constraints.forEach(function(c, i) { + var dot = el("div", { + className: "sf-constraint-dot", + id: "sf-cdot-" + i, + title: c.name || "Constraint " + i, + role: onClick ? "button" : null, + tabIndex: onClick ? "0" : null, + "aria-label": onClick ? "Open constraint " + (c.name || "Constraint " + i) : null, + dataset: { type: c.type || "hard", index: String(i) } + }); + if (onClick) { + dot.style.cursor = "pointer"; + bindActivation(dot, function() { + onClick(i); + }); + } + container2.appendChild(dot); + }); +} +function normalizeLifecycleState(value) { + if (typeof value !== "string" || !value.trim()) return "IDLE"; + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); +} +function shouldShowSolve(state) { + return state === "IDLE" || state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; +} +function shouldShowPause(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED"; +} +function shouldShowCancel(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "PAUSED" || state === "RESUMING" || state === "CANCELLING"; +} +function shouldSpin(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; +} +function isActiveLifecycle(state) { + return shouldSpin(state); +} +function lifecycleLabel(state) { + if (state === "STARTING") return "Starting..."; + if (state === "SOLVING") return "Solving..."; + if (state === "PAUSE_REQUESTED") return "Pause requested..."; + if (state === "PAUSED") return "Paused"; + if (state === "RESUMING") return "Resuming..."; + if (state === "CANCELLING") return "Cancelling..."; + if (state === "COMPLETED") return "Completed"; + if (state === "CANCELLED") return "Cancelled"; + if (state === "FAILED") return "Failed"; + if (state === "TERMINATED_BY_CONFIG") return "Completed"; + return "Ready"; +} + +// ts-src/components/table.ts +var createTable = function(config) { + assert(config, "createTable(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createTable(config.columns) must be an array"); + assert(!config.rows || Array.isArray(config.rows), "createTable(config.rows) must be an array"); + var wrapper = el("div", { className: "sf-table-container" }); + var table = el("table", { className: "sf-table" }); + if (config.columns) { + var thead = el("thead"); + var tr = el("tr"); + config.columns.forEach(function(col) { + var th = el("th", null, typeof col === "string" ? col : col.label); + if (col.align) th.style.textAlign = col.align; + if (col.width) th.style.width = col.width; + tr.appendChild(th); + }); + thead.appendChild(tr); + table.appendChild(thead); + } + var tbody = el("tbody"); + if (config.rows) { + config.rows.forEach(function(row, rowIdx) { + var tr2 = el("tr"); + row.forEach(function(cell, colIdx) { + var td = el("td"); + if (typeof cell === "string" || typeof cell === "number") { + td.textContent = String(cell); + } else if (cell instanceof Node) { + td.appendChild(cell); + } else if (cell && cell.unsafeHtml) { + td.innerHTML = cell.unsafeHtml; + } + var col = config.columns && config.columns[colIdx]; + if (col && col.align) td.style.textAlign = col.align; + if (col && col.className) td.classList.add(col.className); + tr2.appendChild(td); + }); + if (config.onRowClick) { + tr2.style.cursor = "pointer"; + tr2.setAttribute("role", "button"); + tr2.tabIndex = 0; + bindActivation(tr2, function() { + config.onRowClick(rowIdx, row); + }); + } + tbody.appendChild(tr2); + }); + } + table.appendChild(tbody); + wrapper.appendChild(table); + return wrapper; +}; + +// ts-src/components/tabs.ts +var showTab = function(tabId, root) { + if (root) { + activateTabInScope(root, tabId); + return; + } + document.querySelectorAll(".sf-tabs-container").forEach(function(container2) { + activateTabInScope(container2, tabId); + }); +}; +var createTabs = function(config) { + assert(config, "createTabs(config) requires a configuration object"); + assert(Array.isArray(config.tabs), "createTabs(config.tabs) must be an array"); + var container2 = el("div", { className: "sf-tabs-container" }); + var tabsId = uid("sf-tabs"); + config.tabs.forEach(function(tab) { + var panel = el("div", { + className: "sf-tab-panel" + (tab.active ? " active" : ""), + id: tabsId + "-" + tab.id, + dataset: { tabId: tab.id } + }); + if (tab.content) { + if (typeof tab.content === "string") panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; + else if (tab.content instanceof Node) panel.appendChild(tab.content); + } + container2.appendChild(panel); + }); + return { + el: container2, + show: function(tabId) { + showTab(tabId, container2); + } + }; +}; +function activateTabInScope(scope, tabId) { + scope.querySelectorAll(".sf-tab-panel").forEach(function(p) { + p.classList.remove("active"); + }); + var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); + if (panel) panel.classList.add("active"); +} + +// ts-src/components/toast.ts +var container = null; +function ensureContainer() { + if (container && document.body.contains(container)) return; + container = el("div", { className: "sf-toast-container" }); + document.body.appendChild(container); +} +var showToast = function(config) { + assert(config, "showToast(config) requires a configuration object"); + ensureContainer(); + var variant = config.variant || "danger"; + var toast = el("div", { + className: "sf-toast sf-toast--" + variant + " sf-toast-enter", + role: "status", + "aria-live": "polite" + }); + var msg = el("div", { className: "sf-toast-message" }); + if (config.title) { + msg.appendChild(el("div", { className: "sf-toast-title" }, config.title)); + } + if (config.message) { + msg.appendChild(el("div", null, config.message)); + } + if (config.detail) { + var pre = el("pre", { style: { margin: "4px 0 0", fontSize: "11px", whiteSpace: "pre-wrap" } }); + pre.appendChild(el("code", null, config.detail)); + msg.appendChild(pre); + } + toast.appendChild(msg); + var closeBtn = el("button", { + className: "sf-toast-close", + "aria-label": "Dismiss toast", + onClick: function() { + dismiss(); + } + }, "\xD7"); + toast.appendChild(closeBtn); + container.appendChild(toast); + var delay = config.delay || 1e4; + var timer = setTimeout(dismiss, delay); + function dismiss() { + clearTimeout(timer); + toast.classList.remove("sf-toast-enter"); + toast.classList.add("sf-toast-exit"); + setTimeout(function() { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 200); + } +}; +var showError = function(title, detail) { + showToast({ title: "Error", message: title, detail, variant: "danger", delay: 3e4 }); +}; + +// ts-src/gantt/gantt.ts +var create = function(config) { + config = config || {}; + var instanceId = uid("sf-gantt"); + var chartPaneId = config.chartPane || instanceId + "-chart-pane"; + var gridPaneId = config.gridPane || instanceId + "-grid-pane"; + var chartContainerId = config.chartContainer || instanceId + "-container"; + var svgId = config.svgId || instanceId + "-svg"; + var ganttChart = null; + var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; + var tasks = []; + var sortState = { key: null, direction: "asc" }; + var wrapper = el("div", { className: "sf-gantt-split" }); + var gridPane = el("div", { className: "sf-gantt-pane", id: gridPaneId }); + var gridHeader = el("div", { className: "sf-gantt-pane-header" }); + gridHeader.appendChild(el("h3", null, config.gridTitle || "Tasks")); + var gridControls = el("div", { className: "sf-gantt-pane-controls" }); + gridHeader.appendChild(gridControls); + gridPane.appendChild(gridHeader); + var gridContent = el("div", { className: "sf-gantt-pane-content" }); + var grid = el("div", { className: "sf-gantt-grid" }); + gridContent.appendChild(grid); + gridPane.appendChild(gridContent); + var chartPane = el("div", { className: "sf-gantt-pane", id: chartPaneId }); + var chartHeader = el("div", { className: "sf-gantt-pane-header" }); + chartHeader.appendChild(el("h3", null, config.chartTitle || "Timeline")); + var viewControls = el("div", { className: "sf-gantt-view-controls" }); + var viewSelect = el("select", { className: "sf-gantt-view-select" }); + var modes = [ + { value: "Quarter Day", label: "Quarter Day" }, + { value: "Half Day", label: "Half Day" }, + { value: "Day", label: "Day" }, + { value: "Week", label: "Week" }, + { value: "Month", label: "Month" } + ]; + modes.forEach(function(m) { + var opt = el("option", { value: m.value }, m.label); + if (m.value === (config.viewMode || "Quarter Day")) opt.selected = true; + viewSelect.appendChild(opt); + }); + viewSelect.addEventListener("change", function() { + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); + }); + viewControls.appendChild(viewSelect); + var chartControls = el("div", { className: "sf-gantt-pane-controls" }); + chartHeader.appendChild(viewControls); + chartHeader.appendChild(chartControls); + chartPane.appendChild(chartHeader); + var chartContent = el("div", { className: "sf-gantt-pane-content" }); + var chartContainer = el("div", { className: "sf-gantt-container", id: chartContainerId }); + chartContent.appendChild(chartContainer); + chartPane.appendChild(chartContent); + wrapper.appendChild(gridPane); + wrapper.appendChild(chartPane); + var ctrl = { el: wrapper }; + ctrl.mount = function(parent) { + assert(parent, "gantt.mount(parent) requires a mount target"); + var target = typeof parent === "string" ? document.getElementById(parent) : parent; + assert(target, "gantt.mount(parent) target not found: " + parent); + validateMountTarget(target); + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); + target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } + initSplit(); + bindResizeObserver2(); + }; + ctrl.setTasks = function(newTasks) { + assert(Array.isArray(newTasks), "gantt.setTasks(tasks) expects an array"); + tasks = newTasks; + renderGrid(newTasks); + renderChart(newTasks); + }; + ctrl.refresh = function() { + if (ganttChart && tasks.length > 0) { + ganttChart.refresh(tasksToFrappe(tasks)); + } + }; + ctrl.getChart = function() { + return ganttChart; + }; + ctrl.changeViewMode = function(mode) { + viewSelect.value = mode; + if (ganttChart) ganttChart.change_view_mode(mode); + }; + ctrl.highlightTask = function(taskId) { + grid.querySelectorAll(".sf-gantt-row").forEach(function(row) { + row.classList.toggle("selected", row.dataset.taskId === taskId); + }); + var svg = chartContainer.querySelector("svg"); + if (svg) { + svg.querySelectorAll(".bar-wrapper").forEach(function(bw) { + bw.classList.remove("highlighted"); + }); + var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); + if (bar) bar.classList.add("highlighted"); + } + }; + ctrl.destroy = function() { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + ganttChart = null; + mounted = false; + mountTarget = null; + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); + }; + return ctrl; + function initSplit() { + if (typeof Split !== "function") return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + splitInstance = Split(["#" + gridPaneId, "#" + chartPaneId], { + direction: "vertical", + sizes: splitSizes, + minSize: splitMinSize, + snapOffset: 30, + gutterSize: 4, + cursor: "col-resize", + onDragEnd: function() { + if (ganttChart) { + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 100); + } + } + }); + } + function bindResizeObserver2() { + if (typeof ResizeObserver !== "function") return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function() { + if (!ganttChart) return; + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 0); + }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + } + function normalizePair(value, fallback) { + if (typeof value === "number" && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + function validateMountTarget(target) { + assert(target && typeof target.appendChild === "function", "gantt.mount(parent) requires a valid DOM container"); + assert(getElementSize(target, "Width") > 0 && getElementSize(target, "Height") > 0, "gantt.mount(parent) target is not laid out yet"); + } + function getElementSize(target, axis) { + var clientKey = "client" + axis; + var offsetKey = "offset" + axis; + var rectKey = axis === "Width" ? "width" : "height"; + if (typeof target[clientKey] === "number") return target[clientKey]; + if (typeof target[offsetKey] === "number") return target[offsetKey]; + if (typeof target.getBoundingClientRect === "function") { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === "number") return rect[rectKey]; + } + return 0; + } + function tasksToFrappe(taskList) { + return taskList.filter(function(t) { + return t.start && t.end; + }).map(function(t) { + var customClass = t.custom_class || ""; + if (t.pinned) { + customClass = customClass ? customClass + " pinned" : "pinned"; + } + return { + id: t.id, + name: t.name || t.label || t.id, + start: t.start, + end: t.end, + custom_class: customClass, + dependencies: t.dependencies || "" + }; + }); + } + function renderChart(taskList) { + var frappeTasks = tasksToFrappe(taskList); + if (frappeTasks.length === 0) { + chartContainer.textContent = ""; + chartContainer.appendChild(el("div", { + className: "sf-gantt-empty-state", + style: { + padding: "24px", + color: "var(--sf-gray-400)", + fontFamily: "var(--sf-font-mono)", + fontSize: "13px" + } + }, "No scheduled tasks to display.")); + ganttChart = null; + return; + } + chartContainer.textContent = ""; + chartContainer.appendChild(createSvgRoot(svgId)); + ganttChart = new Gantt("#" + svgId, frappeTasks, { + view_mode: viewSelect.value || "Quarter Day", + date_format: "YYYY-MM-DD HH:mm", + custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, + on_click: function(task) { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + on_date_change: function(task, start, end) { + if (config.onDateChange) config.onDateChange(task, start, end); + } + }); + } + function renderGrid(taskList) { + while (grid.firstChild) grid.removeChild(grid.firstChild); + var table = el("table", { className: "sf-gantt-table" }); + var columns = config.columns || [ + { key: "name", label: "Task" }, + { key: "start", label: "Start" }, + { key: "end", label: "End" } + ]; + var sortedTasks = sortTasks(taskList); + var thead = el("thead"); + var headerRow = el("tr"); + columns.forEach(function(col) { + headerRow.appendChild(buildHeaderCell(col)); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + var tbody = el("tbody"); + sortedTasks.forEach(function(task) { + var rowClasses = ["sf-gantt-row"]; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push("sf-project-" + task.projectIndex); + var tr = el("tr", { + className: rowClasses.join(" "), + dataset: { taskId: task.id }, + onClick: function() { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + } + }); + columns.forEach(function(col) { + var td = el("td"); + if (col.key === "name") { + td.className = "sf-task-name"; + td.textContent = task.name || task.label || task.id; + } else if (col.render) { + var content = col.render(task); + if (typeof content === "string") td.textContent = content; + else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; + else if (content instanceof Node) td.appendChild(content); + } else { + td.textContent = task[col.key] || ""; + td.style.fontFamily = "var(--sf-font-mono)"; + td.style.fontSize = "12px"; + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + grid.appendChild(table); + } + function buildHeaderCell(col) { + if (!col.sortable) { + return el("th", null, col.label); + } + var isCurrent = sortState.key === col.key; + var th = el("th", { + className: "sortable" + (isCurrent ? " active" : ""), + role: "button", + tabIndex: 0, + "aria-sort": isCurrent ? sortState.direction === "asc" ? "ascending" : "descending" : "none" + }); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(el("span", { className: "sort-icon" }, isCurrent ? sortState.direction === "asc" ? "\u25B2" : "\u25BC" : "")); + bindActivation(th, function() { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === "asc" ? "desc" : "asc"; + } else { + sortState.key = col.key; + sortState.direction = "asc"; + } + renderGrid(tasks); + }); + return th; + } + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function(a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === "asc") return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; + }); + return sorted; + } + function sortValue(value, key) { + if (value == null) return ""; + if (key === "start" || key === "end") { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; + } + if (typeof value === "number") return value; + return String(value).toLowerCase(); + } + function defaultPopup(task) { + var t = tasks.find(function(x) { + return x.id === task.id; + }); + if (!t) return ""; + return '

' + escHtml(t.name || t.id) + "

Start: " + escHtml(t.start) + "

End: " + escHtml(t.end) + "

" + (t.duration_minutes ? "

Duration: " + t.duration_minutes + " min

" : "") + (t.pinned ? '

Pinned

' : "") + "
"; + } + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = id; + return svg; + } + return el("svg", { id }); + } +}; +var gantt = { create }; + +// ts-src/rail/card.ts +var createHeader2 = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createHeader(config.columns) expects an array"); + var labelWidth = config.labelWidth || 200; + var columns = config.columns || []; + var header = el("div", { className: "sf-timeline-header" }); + header.style.gridTemplateColumns = labelWidth + "px 1fr"; + var spacer = el("div", { className: "sf-timeline-label-spacer" }, config.label || ""); + header.appendChild(spacer); + var days = el("div", { className: "sf-timeline-days" }); + days.style.gridTemplateColumns = "repeat(" + columns.length + ", 1fr)"; + columns.forEach(function(col) { + var colEl = el("div", { className: "sf-timeline-day-col" }); + colEl.appendChild(el("span", null, typeof col === "string" ? col : col.label)); + days.appendChild(colEl); + }); + header.appendChild(days); + return header; +}; +var createCard = function(config) { + assert(config, "createCard(config) requires a configuration object"); + var labelWidth = config.labelWidth || 200; + var card = el("div", { className: "sf-resource-card" }); + var state = { + unassigned: [], + railConfig: config + }; + if (config.id) card.dataset.resourceId = config.id; + var resHeader = el("div", { className: "sf-resource-header" }); + resHeader.style.gridTemplateColumns = labelWidth + "px 1fr"; + var identity = el("div", { className: "sf-resource-identity" }); + if (config.name) { + identity.appendChild(el("div", { className: "sf-resource-name" }, config.name)); + } + if (config.badges || config.type) { + var meta = el("div", { className: "sf-resource-meta" }); + if (config.type) { + var badge = el("span", { className: "sf-resource-type-badge" }, config.type); + if (config.typeStyle) { + badge.style.background = config.typeStyle.bg || ""; + badge.style.color = config.typeStyle.color || ""; + badge.style.border = config.typeStyle.border || ""; + } + meta.appendChild(badge); + } + var badges = Array.isArray(config.badges) ? config.badges : config.badges ? [config.badges] : []; + if (badges.length) { + badges.forEach(function(entry) { + if (!entry) return; + if (typeof entry === "string") { + meta.appendChild(el("span", { className: "sf-resource-type-badge" }, entry)); + return; + } + var extraBadge = el("span", { className: "sf-resource-type-badge" }, entry.label || ""); + if (entry.style) { + extraBadge.style.background = entry.style.bg || ""; + extraBadge.style.color = entry.style.color || ""; + extraBadge.style.border = entry.style.border || ""; + } + meta.appendChild(extraBadge); + }); + } + identity.appendChild(meta); + } + resHeader.appendChild(identity); + if (config.gauges && config.gauges.length > 0) { + var gauges = el("div", { className: "sf-gauges" }); + config.gauges.forEach(function(g) { + var row = el("div", { className: "sf-gauge-row" }); + row.appendChild(el("span", { className: "sf-gauge-label" }, g.label)); + var track = el("div", { className: "sf-gauge-track" }); + var fill = el("div", { + className: "sf-gauge-fill" + (g.style ? " sf-gauge-fill--" + g.style : "") + }); + fill.style.width = Math.min(g.pct || 0, 100) + "%"; + track.appendChild(fill); + row.appendChild(track); + if (g.text) row.appendChild(el("span", { className: "sf-gauge-value" }, g.text)); + gauges.appendChild(row); + }); + resHeader.appendChild(gauges); + } + card.appendChild(resHeader); + var body = el("div", { className: "sf-resource-body" }); + body.style.gridTemplateColumns = labelWidth + "px 1fr"; + var stats = el("div", { className: "sf-resource-stats" }); + if (config.stats) { + config.stats.forEach(function(s) { + var row = el("div", { className: "sf-stat-row" }); + row.appendChild(el("span", { className: "sf-stat-label" }, s.label)); + row.appendChild(el("span", { className: "sf-stat-value" }, String(s.value))); + stats.appendChild(row); + }); + } + body.appendChild(stats); + var railContainer = el("div", { className: "sf-rail-container" }); + var rail2 = el("div", { className: "sf-rail" }); + if (config.id) rail2.id = "sf-rail-" + config.id; + var numCols = config.columns || 5; + var dayGrid = el("div", { className: "sf-day-grid" }); + dayGrid.style.gridTemplateColumns = "repeat(" + numCols + ", 1fr)"; + for (var i = 0; i < numCols; i++) { + dayGrid.appendChild(el("div", { className: "sf-day-col" })); + } + rail2.appendChild(dayGrid); + railContainer.appendChild(rail2); + body.appendChild(railContainer); + card.appendChild(body); + if (config.heatmap) { + var heatmapCfg = { + horizon: config.heatmap.horizon || 1, + label: config.heatmap.label, + segments: config.heatmap.segments, + labelWidth + }; + heatmapCfg.railConfig = config; + var heatmap = createHeatmap(heatmapCfg); + if (heatmap) card.appendChild(heatmap); + } + var unassignedRail = el("div", { className: "sf-unassigned-rail" }); + if (config.unassigned) { + state.unassigned = config.unassigned; + renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); + } + if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); + var cardApi = { + el: card, + rail: rail2, + addBlock: function(blockConfig) { + return addBlock(rail2, blockConfig); + }, + setUnassigned: function(items) { + state.unassigned = Array.isArray(items) ? items : []; + if (state.unassigned.length === 0 && unassignedRail.parentNode) { + unassignedRail.innerHTML = ""; + unassignedRail.parentNode?.removeChild(unassignedRail); + return; + } + if (state.unassigned.length > 0) { + renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); + } else { + unassignedRail.innerHTML = ""; + } + if (state.unassigned.length > 0 && !unassignedRail.parentNode) { + card.appendChild(unassignedRail); + } + }, + clearBlocks: function() { + rail2.querySelectorAll(".sf-block, .sf-changeover").forEach(function(el2) { + el2.remove(); + }); + }, + setSolving: function(solving) { + card.classList.toggle("solving", solving); + } + }; + return cardApi; +}; +var createHeatmap = function(config) { + if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; + var heatmap = el("div", { className: "sf-heatmap" }); + heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + "px 1fr"; + var label = el("div", { className: "sf-heatmap-label" }, config.label || ""); + heatmap.appendChild(label); + var track = el("div", { className: "sf-heatmap-track" }); + var columns = config.railConfig && config.railConfig.columns || 1; + track.style.gridTemplateColumns = "repeat(" + columns + ", 1fr)"; + heatmap.appendChild(track); + var horizon = config.horizon || 1; + config.segments.forEach(function(segment) { + if (!segment || segment.end <= segment.start) return; + var band = el("div", { className: "sf-heatmap-segment" }); + var start = Math.max(0, segment.start); + var width = Math.max(0, segment.end - start); + band.style.left = start / horizon * 100 + "%"; + band.style.width = Math.max(width / horizon * 100, 0.25) + "%"; + if (segment.color) band.style.background = segment.color; + if (segment.opacity != null) band.style.opacity = segment.opacity; + if (segment.tooltip) band.title = segment.tooltip; + track.appendChild(band); + }); + return heatmap; +}; +var createUnassignedRail = function(tasks, onTaskClick) { + var rail2 = el("div", { className: "sf-unassigned-rail" }); + renderUnassigned(rail2, tasks, onTaskClick); + return rail2; +}; +var addBlock = function(rail2, config) { + assert(rail2, "addBlock(rail) requires a rail element"); + assert(config && config.horizon != null, "addBlock(config.horizon) is required"); + assert(config.start != null && config.end != null, "addBlock(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; + var block = el("div", { className: "sf-block" }); + block.style.left = startPct + "%"; + block.style.width = Math.max(widthPct, minWidthPct) + "%"; + if (config.color) { + block.style.background = config.color; + block.style.borderLeftColor = config.borderColor || config.color; + } + if (config.className) block.classList.add(config.className); + if (config.late) block.classList.add("late"); + if (config.id) block.dataset.blockId = config.id; + if (config.delay) block.style.animationDelay = config.delay; + if (config.label) { + block.appendChild(el("div", { className: "sf-block-label" }, config.label)); + } + if (config.meta) { + block.appendChild(el("div", { className: "sf-block-meta" }, config.meta)); + } + if (config.onHover) { + block.addEventListener("mouseenter", function(e) { + config.onHover(e, config); + }); + } + if (config.onLeave) { + block.addEventListener("mouseleave", function() { + config.onLeave(); + }); + } + if (config.onClick) { + block.setAttribute("role", "button"); + block.tabIndex = 0; + bindActivation(block, function(e) { + config.onClick(e, config); + }); + } + rail2.appendChild(block); + return block; +}; +var addChangeover = function(rail2, config) { + assert(rail2, "addChangeover(rail) requires a rail element"); + assert(config && config.horizon != null, "addChangeover(config.horizon) is required"); + assert(config.start != null && config.end != null, "addChangeover(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var co = el("div", { className: "sf-changeover" }); + co.style.left = startPct + "%"; + co.style.width = widthPct + "%"; + rail2.appendChild(co); + return co; +}; +function renderUnassigned(unassignedRail, items, onTaskClick) { + unassignedRail.innerHTML = ""; + (items || []).forEach(function(item) { + var label = typeof item === "string" ? item : item.label || item.id || ""; + if (!label) return; + var pill = el("button", { + className: "sf-unassigned-pill", + onClick: function() { + if (onTaskClick) onTaskClick(item); + } + }, label); + unassignedRail.appendChild(pill); + }); +} + +// ts-src/rail/timeline.ts +var DAY_MINUTES = 24 * 60; +var SIX_HOUR_MINUTES = 6 * 60; +var WEEK_MINUTES = 7 * DAY_MINUTES; +var TRACK_HEIGHT = 34; +var TRACK_GAP = 8; +var TRACK_PADDING = 12; +var OVERVIEW_HEIGHT = 68; +var OVERVIEW_BLOCK_HEIGHT = 34; +var OVERVIEW_GROUP_GAP_MINUTES = 30; +var MIN_LABEL_WIDTH = 180; +var MIN_VISIBLE_TRACK_WIDTH = 320; +var MIN_CONTENT_TRACK_WIDTH = 480; +var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; +var TONE_MAP = { + emerald: { + id: "emerald", + background: "rgba(16, 185, 129, 0.22)", + border: "#059669", + text: "#064e3b", + overlay: "rgba(16, 185, 129, 0.10)" + }, + blue: { + id: "blue", + background: "rgba(59, 130, 246, 0.22)", + border: "#2563eb", + text: "#1e40af", + overlay: "rgba(59, 130, 246, 0.10)" + }, + amber: { + id: "amber", + background: "rgba(245, 158, 11, 0.24)", + border: "#d97706", + text: "#92400e", + overlay: "rgba(245, 158, 11, 0.10)" + }, + rose: { + id: "rose", + background: "rgba(244, 63, 94, 0.22)", + border: "#e11d48", + text: "#9f1239", + overlay: "rgba(244, 63, 94, 0.10)" + }, + violet: { + id: "violet", + background: "rgba(139, 92, 246, 0.22)", + border: "#7c3aed", + text: "#5b21b6", + overlay: "rgba(139, 92, 246, 0.10)" + }, + cyan: { + id: "cyan", + background: "rgba(6, 182, 212, 0.22)", + border: "#0891b2", + text: "#155e75", + overlay: "rgba(6, 182, 212, 0.10)" + }, + red: { + id: "red", + background: "rgba(239, 68, 68, 0.22)", + border: "#dc2626", + text: "#991b1b", + overlay: "rgba(239, 68, 68, 0.10)" + }, + slate: { + id: "slate", + background: "rgba(100, 116, 139, 0.20)", + border: "#475569", + text: "#1e293b", + overlay: "rgba(100, 116, 139, 0.08)" + } +}; +var createTimeline = function(config) { + assert(config && config.model, "rail.createTimeline(config.model) requires a normalized model"); + var labelWidth = config.labelWidth == null ? 280 : assertFiniteNumber(config.labelWidth, "rail.createTimeline(labelWidth)"); + assert(labelWidth > 0, "rail.createTimeline(labelWidth) must be greater than zero"); + var state = { + cleanup: [], + config, + destroyed: false, + expandedClusters: {}, + hasQueuedPostMountSync: false, + instanceId: uid("sf-rail-timeline"), + labelWidth, + model: normalizeModel(config.model), + scrollSync: null, + viewport: null, + layout: null + }; + state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); + var root = el("section", { + className: "sf-rail-timeline", + dataset: { + labelWidth: String(labelWidth) + } + }); + root.setAttribute("role", "region"); + root.setAttribute("aria-label", config.title || "Scheduling timeline"); + var toolbar = el("div", { className: "sf-rail-timeline-toolbar" }); + var toolbarCopy = el("div", { className: "sf-rail-timeline-toolbar-copy" }); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-title" }, config.title || "Scheduling timeline")); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-subtitle" }, config.subtitle || "Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.")); + toolbar.appendChild(toolbarCopy); + var zoomControls = el("div", { className: "sf-rail-timeline-zoom-controls" }); + var zoomButtons = []; + normalizeZoomPresets(config.zoomPresets).forEach(function(preset) { + var button = el("button", { + className: "sf-rail-timeline-zoom-button", + type: "button", + dataset: { zoom: preset } + }, preset === "reset" ? "Reset" : preset.toUpperCase()); + button.addEventListener("click", function() { + if (preset === "reset") { + api.setViewport(state.model.axis.initialViewport); + return; + } + api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); + }); + zoomButtons.push(button); + zoomControls.appendChild(button); + }); + if (zoomButtons.length) { + toolbar.appendChild(zoomControls); + } + root.appendChild(toolbar); + var shell = el("div", { className: "sf-rail-timeline-shell" }); + var headerViewport = el("div", { className: "sf-rail-timeline-header-viewport" }); + var bodyViewport = el("div", { className: "sf-rail-timeline-body-viewport" }); + var headerRow = el("div", { className: "sf-rail-timeline-header-row" }); + var lanes = el("div", { className: "sf-rail-timeline-lanes" }); + headerViewport.appendChild(headerRow); + bodyViewport.appendChild(lanes); + shell.appendChild(headerViewport); + shell.appendChild(bodyViewport); + root.appendChild(shell); + var tooltip = el("div", { className: "sf-tooltip sf-rail-timeline-tooltip" }); + tooltip.id = uid("sf-rail-timeline-tooltip"); + tooltip.setAttribute("role", "tooltip"); + tooltip.setAttribute("aria-hidden", "true"); + root.appendChild(tooltip); + bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); + bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); + bindWindowResize(state, syncLayoutFromViewport); + function renderStructure() { + renderHeader(); + renderLanes(); + } + function applyMeasuredLayout() { + state.layout = measureLayout(bodyViewport, state); + applyLayout(root, headerRow, lanes, state.layout); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + } + function renderHeader() { + headerRow.innerHTML = ""; + var corner = el("div", { className: "sf-rail-timeline-label-corner" }, config.label || "Lane"); + headerRow.appendChild(corner); + var axis = el("div", { className: "sf-rail-timeline-axis sf-rail-timeline-axis--header" }); + axis.style.height = "82px"; + renderAxisDecor(axis, state.model.axis, true); + headerRow.appendChild(axis); + } + function renderLanes() { + lanes.innerHTML = ""; + state.model.lanes.forEach(function(lane, laneIndex) { + var laneRender = lane.mode === "overview" ? buildOverviewRender(lane, state, function() { + rerenderTimeline(); + }) : buildDetailedRender(lane, lane.items); + var row = el("div", { + className: "sf-rail-timeline-row sf-rail-timeline-row--" + lane.mode + (laneRender.expandedClusterId ? " sf-rail-timeline-row--expanded" : ""), + dataset: { + laneId: lane.id, + mode: lane.mode, + trackCount: String(laneRender.trackCount) + } + }); + if (laneRender.expandedClusterId) { + row.dataset.expandedClusterId = laneRender.expandedClusterId; + } + row.setAttribute("role", "group"); + var label = buildLaneLabel( + lane, + laneRender, + row, + buildScopedId(state.instanceId, "lane-title-" + laneIndex) + ); + row.appendChild(label); + var track = el("div", { className: "sf-rail-timeline-track" }); + track.style.height = laneRender.height + "px"; + renderAxisDecor(track, state.model.axis, false); + renderOverlays(track, lane.overlays, state.model.axis); + laneRender.blocks.forEach(function(blockConfig) { + appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); + }); + row.appendChild(track); + lanes.appendChild(row); + }); + } + function rerenderTimeline() { + renderStructure(); + syncLayoutFromViewport(); + } + function syncLayoutFromViewport() { + applyMeasuredLayout(); + syncScrollToViewport(); + } + function syncScrollToViewport() { + if (!state.layout) return; + var scrollLeft = viewportToScrollLeft(state, bodyViewport); + state.scrollSync = bodyViewport; + bodyViewport.scrollLeft = scrollLeft; + headerViewport.scrollLeft = scrollLeft; + state.scrollSync = null; + } + var api = { + destroy: function() { + if (state.destroyed) return; + state.destroyed = true; + state.cleanup.forEach(function(cleanup) { + if (typeof cleanup === "function") cleanup(); + }); + root.innerHTML = ""; + }, + el: root, + expandCluster: function(laneId, clusterId) { + setExpandedCluster(state, laneId, clusterId); + rerenderTimeline(); + }, + setModel: function(nextModel) { + state.model = normalizeModel(nextModel); + state.viewport = clampViewport(state.model.axis, state.viewport); + pruneExpandedClusters(state); + rerenderTimeline(); + queuePostMountSync(state, syncLayoutFromViewport); + }, + setViewport: function(nextViewport) { + state.viewport = clampViewport( + state.model.axis, + normalizeViewportInput(nextViewport, "rail.createTimeline().setViewport(viewport)") + ); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + } + }; + renderStructure(); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + return api; +}; +function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { + var tone = blockConfig.tone; + var relativeStart = blockConfig.startMinute - axis.startMinute; + var relativeEnd = blockConfig.endMinute - axis.startMinute; + var horizon = axis.endMinute - axis.startMinute; + var block = addBlock(track, { + start: relativeStart, + end: relativeEnd, + horizon, + label: blockConfig.label, + meta: blockConfig.metaLabel, + color: tone.background, + borderColor: tone.border, + minWidthPct: 0, + onClick: blockConfig.onClick, + onHover: function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }, + onLeave: function() { + hideTooltip(tooltip); + } + }); + block.classList.add("sf-rail-timeline-item"); + block.classList.add(blockConfig.kindClass); + block.style.left = positionPct(blockConfig.startMinute, axis) + "%"; + block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + "%"; + block.style.top = blockConfig.top + "px"; + block.style.height = blockConfig.height + "px"; + block.style.bottom = "auto"; + block.style.color = tone.text; + block.tabIndex = 0; + block.dataset.itemId = blockConfig.itemId; + block.dataset.laneId = lane.id; + block.dataset.startMinute = String(blockConfig.startMinute); + block.dataset.endMinute = String(blockConfig.endMinute); + if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); + if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; + if (blockConfig.onClick) { + block.setAttribute("role", "button"); + block.setAttribute("aria-expanded", blockConfig.expanded ? "true" : "false"); + } else { + block.setAttribute("role", "group"); + } + if (blockConfig.ariaLabel) block.setAttribute("aria-label", blockConfig.ariaLabel); + block.setAttribute("aria-describedby", tooltip.id); + if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); + if (blockConfig.detailHint) { + block.appendChild(el("span", { className: "sf-rail-timeline-detail-hint" }, blockConfig.detailHint)); + } + block.title = blockConfig.tooltip.title; + block.addEventListener("mousemove", function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }); + block.addEventListener("focus", function() { + showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + }); + block.addEventListener("blur", function() { + hideTooltip(tooltip); + }); + block.addEventListener("keydown", function(event) { + if (event && event.key === "Escape") hideTooltip(tooltip); + }); +} +function appendOverviewSummary(block, summary) { + var footer = el("div", { className: "sf-rail-timeline-summary-footer" }); + if (summary.badges.length > 0) { + var badgeRail = el("div", { className: "sf-rail-timeline-summary-badges" }); + summary.badges.forEach(function(badge) { + badgeRail.appendChild(el("span", { + className: "sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--" + badge.kind + }, badge.text)); + }); + footer.appendChild(badgeRail); + } + if (summary.toneSegments.length > 0) { + var toneBar = el("div", { + className: "sf-rail-timeline-summary-tonebar", + "aria-hidden": "true" + }); + var total = summary.toneSegments.reduce(function(sum, segment) { + return sum + segment.count; + }, 0) || 1; + summary.toneSegments.forEach(function(segment) { + var toneSegment = el("span", { className: "sf-rail-timeline-summary-tone-segment" }); + toneSegment.style.background = segment.tone.border; + toneSegment.style.width = segment.count / total * 100 + "%"; + toneBar.appendChild(toneSegment); + }); + footer.appendChild(toneBar); + } + if (footer.children.length > 0) block.appendChild(footer); +} +function bindScrollSync(source, target, state, root, zoomButtons) { + source.addEventListener("scroll", function() { + handleScroll(source, target, state, root, zoomButtons); + }); + target.addEventListener("scroll", function() { + handleScroll(target, source, state, root, zoomButtons); + }); +} +function bindDragPan(source, target, state, root, zoomButtons) { + var drag = { + active: false, + startClientX: 0, + startScrollLeft: 0 + }; + source.addEventListener("mousedown", function(event) { + if (event.button != null && event.button !== 0) return; + drag.active = true; + drag.startClientX = event.clientX != null ? event.clientX : 0; + drag.startScrollLeft = source.scrollLeft || 0; + source.classList.add("is-dragging"); + if (event.preventDefault) event.preventDefault(); + }); + source.addEventListener("mousemove", function(event) { + if (!drag.active) return; + var clientX = event.clientX != null ? event.clientX : drag.startClientX; + var delta = clientX - drag.startClientX; + source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); + handleScroll(source, target, state, root, zoomButtons); + if (event.preventDefault) event.preventDefault(); + }); + function finishDrag() { + if (!drag.active) return; + drag.active = false; + source.classList.remove("is-dragging"); + } + source.addEventListener("mouseup", finishDrag); + source.addEventListener("mouseleave", finishDrag); +} +function handleScroll(source, target, state, root, zoomButtons) { + if (state.destroyed) return; + if (!state.layout) return; + if (state.scrollSync === source) return; + state.scrollSync = source; + target.scrollLeft = source.scrollLeft; + state.viewport = scrollLeftToViewport(state, source); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + state.scrollSync = null; +} +function measurePackedHeight(packed) { + return packed.trackCount > 0 ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP : OVERVIEW_HEIGHT; +} +function buildDetailBlockConfig(item, lane, trackIndex, top, config = {}) { + const i = item; + const l = lane; + return { + clusterId: config.clusterId || null, + detailHint: config.detailHint || "", + endMinute: i.endMinute, + height: TRACK_HEIGHT, + itemId: i.id, + kindClass: "sf-rail-timeline-item--detail", + label: i.label, + metaLabel: describeMeta(i.meta), + startMinute: i.startMinute, + top, + ariaLabel: buildItemAriaLabel(i, l), + tooltip: buildItemTooltip(i, l), + tone: i.tone, + trackIndex + }; +} +function buildOverviewBlockConfig(group, height, options) { + var config = options || {}; + return { + clusterId: config.clusterId || null, + endMinute: group.endMinute, + height: OVERVIEW_BLOCK_HEIGHT, + itemId: config.itemId, + kindClass: config.kindClass, + label: group.summary.primaryLabel, + metaLabel: group.summary.secondaryLabel, + onClick: config.onClick || null, + startMinute: group.startMinute, + summary: buildOverviewBlockSummary(group, !!config.expanded), + top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), + ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), + expanded: !!config.expanded, + tooltip: config.tooltip, + tone: group.tone + }; +} +function buildDetailedRender(lane, items) { + var packed = packItems(items); + var height = measurePackedHeight(packed); + var blocks = packed.items.map(function(entry) { + return buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) + ); + }); + return { + blocks, + height, + trackCount: packed.trackCount || 1 + }; +} +function buildOverviewRender(lane, state, rerender) { + var groups = groupOverviewItems(lane); + var expandedClusterId = state.expandedClusters[lane.id] || null; + var expandedGroup = null; + var packedExpanded = null; + var expandedDetailsTop = 0; + groups.forEach(function(group) { + if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { + expandedGroup = group; + } + }); + if (expandedGroup) { + packedExpanded = packItems(expandedGroup.detailItems); + expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; + } + var height = packedExpanded ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) : OVERVIEW_HEIGHT; + var blocks = []; + groups.forEach(function(group) { + if (group.isCluster) { + var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); + blocks.push(buildOverviewBlockConfig(group, height, { + clusterId: group.clusterKey, + itemId: group.renderId, + kindClass: "sf-rail-timeline-item--cluster", + expanded: isExpanded, + onClick: function() { + setExpandedCluster( + state, + lane.id, + state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey + ); + if (state.config && state.config.onClusterToggle) { + state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); + } + if (typeof rerender === "function") rerender(); + }, + top: isExpanded ? TRACK_PADDING : null, + tooltip: buildClusterTooltip(group, lane) + })); + if (isExpanded) { + packedExpanded.items.forEach(function(entry) { + blocks.push(buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), + { + clusterId: group.clusterKey, + detailHint: "Expanded" + } + )); + }); + } + return; + } + blocks.push(buildOverviewBlockConfig(group, height, { + itemId: group.items[0].id, + kindClass: "sf-rail-timeline-item--overview", + tooltip: buildOverviewTooltip(group, lane) + })); + }); + return { + blocks, + expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, + height, + trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1 + }; +} +function buildLaneLabel(lane, laneRender, row, headingId) { + var label = el("div", { + className: "sf-rail-timeline-lane-label", + dataset: { laneId: lane.id } + }); + label.style.minHeight = laneRender.height + "px"; + var heading = el("div", { className: "sf-rail-timeline-lane-heading" }); + var title = el("div", { className: "sf-rail-timeline-lane-title" }, lane.label); + title.id = headingId; + heading.appendChild(title); + if (lane.mode) { + heading.appendChild(el("div", { className: "sf-rail-timeline-lane-mode" }, lane.mode)); + } + label.appendChild(heading); + if (row) row.setAttribute("aria-labelledby", title.id); + if (lane.badges.length > 0) { + var badges = el("div", { className: "sf-rail-timeline-lane-badges" }); + lane.badges.forEach(function(badge) { + var badgeEl = el("span", { className: "sf-rail-timeline-lane-badge" }, badge.label); + if (badge.style) { + badgeEl.style.background = badge.style.bg || ""; + badgeEl.style.border = badge.style.border || ""; + badgeEl.style.color = badge.style.color || ""; + } + badges.appendChild(badgeEl); + }); + label.appendChild(badges); + } + if (lane.stats.length > 0) { + var stats = el("div", { className: "sf-rail-timeline-lane-stats" }); + lane.stats.forEach(function(stat) { + var statRow = el("div", { className: "sf-rail-timeline-lane-stat" }); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-label" }, stat.label)); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-value" }, String(stat.value))); + stats.appendChild(statRow); + }); + label.appendChild(stats); + } + return label; +} +function buildClusterTooltip(group, lane) { + var first = group.detailItems[0] || group.items[0]; + var payload = { + rows: [ + { key: "Lane", value: lane.label }, + { key: "Window", value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, + { key: "Items", value: String(group.summary.count) } + ], + title: group.label + }; + if (group.summary.openCount > 0) { + payload.rows.push({ key: "Open", value: String(group.summary.openCount) }); + } + if (group.summary.toneSegments.length > 0) { + payload.rows.push({ key: "Mix", value: describeToneSegments(group.summary.toneSegments) }); + } + if (first && first.meta) { + payload.rows.push({ key: "Sample", value: describeMeta(first.meta) }); + } + return payload; +} +function buildItemTooltip(item, lane) { + var rows = [ + { key: "Lane", value: lane.label }, + { key: "Time", value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) } + ]; + appendMetaRows(rows, item.meta); + return { + rows, + title: item.label + }; +} +function buildOverviewBlockMeta(group) { + if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; + var labels = []; + group.items.slice(0, 2).forEach(function(item) { + labels.push(item.label); + }); + if (group.count > 2) labels.push("+" + (group.count - 2) + " more"); + return labels.join(" \u2022 "); +} +function buildPresetViewport(axis, currentViewport, preset) { + var duration = preset === "1w" ? WEEK_MINUTES : preset === "2w" ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; + var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); + var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; + var start = Math.round(center - visibleDuration / 2); + return clampViewport(axis, { + startMinute: start, + endMinute: start + visibleDuration + }); +} +function clampNumber(value, min, max) { + return Math.min(Math.max(value, min), max); +} +function clampViewport(axis, viewport) { + var totalDuration = axis.endMinute - axis.startMinute; + var next = viewport || axis.initialViewport || { + startMinute: axis.startMinute, + endMinute: axis.endMinute + }; + var duration = next.endMinute - next.startMinute; + duration = Math.min(duration, totalDuration); + var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); + return { + endMinute: start + duration, + startMinute: start + }; +} +function assertFiniteNumber(value, label) { + assert(typeof value === "number" && isFinite(value), label + " must be a finite number"); + return value; +} +function assertMinuteValue(value, label) { + return assertInteger(value, label); +} +function assertInteger(value, label) { + var number = assertFiniteNumber(value, label); + assert(Math.floor(number) === number, label + " must be an integer"); + return number; +} +function assertNonNegativeInteger(value, label) { + var number = assertInteger(value, label); + assert(number >= 0, label + " must be greater than or equal to zero"); + return number; +} +function describeMeta(meta) { + if (meta == null) return ""; + if (typeof meta === "string") return meta; + if (typeof meta === "number") return String(meta); + if (Array.isArray(meta)) { + return meta.map(function(entry) { + if (entry && entry.label && entry.value != null) return entry.label + ": " + entry.value; + return String(entry || ""); + }).filter(Boolean).join(" \u2022 "); + } + if (typeof meta === "object") { + return Object.keys(meta).map(function(key) { + return key + ": " + meta[key]; + }).join(" \u2022 "); + } + return String(meta); +} +function appendMetaRows(rows, meta) { + if (meta == null) return; + if (typeof meta === "string" || typeof meta === "number") { + rows.push({ key: "Meta", value: String(meta) }); + return; + } + if (Array.isArray(meta)) { + meta.forEach(function(entry, index) { + if (!entry) return; + if (entry.label && entry.value != null) { + rows.push({ key: entry.label, value: String(entry.value) }); + return; + } + rows.push({ key: "Meta " + (index + 1), value: String(entry) }); + }); + return; + } + if (typeof meta === "object") { + Object.keys(meta).forEach(function(key) { + rows.push({ key, value: String(meta[key]) }); + }); + } +} +function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { + var startMinute = assertMinuteValue(startValue, startLabel); + var endMinute = assertMinuteValue(endValue, endLabel); + assert(endMinute > startMinute, endLabel + " must be greater than startMinute"); + return { + endMinute, + startMinute + }; +} +function normalizeId(value, prefix, suffix) { + return value != null ? String(value) : prefix + suffix; +} +function buildScopedId(scope, suffix) { + return scope + "-" + suffix; +} +function setExpandedCluster(state, laneId, clusterId) { + if (clusterId == null) delete state.expandedClusters[laneId]; + else state.expandedClusters[laneId] = String(clusterId); +} +function normalizeAxis(axis) { + assert(axis && axis.startMinute != null && axis.endMinute != null, "createTimeline(model.axis.startMinute/endMinute) are required"); + var axisRange = normalizeMinuteRange( + axis.startMinute, + axis.endMinute, + "createTimeline(model.axis.startMinute)", + "createTimeline(model.axis.endMinute)" + ); + var normalized = { + endMinute: axisRange.endMinute, + startMinute: axisRange.startMinute + }; + normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); + normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); + normalized.initialViewport = clampViewport( + normalized, + normalizeViewportInput(axis.initialViewport, "createTimeline(model.axis.initialViewport)") || { + startMinute: normalized.startMinute, + endMinute: normalized.endMinute + } + ); + return normalized; +} +function normalizeBadge(badge) { + if (!badge) return null; + if (typeof badge === "string") return { label: badge }; + return { + label: badge.label || "", + style: badge.style || null + }; +} +function normalizeDays(days, startMinute, endMinute) { + var list = []; + var source = Array.isArray(days) && days.length > 0 ? days : null; + var cursor = startMinute; + var index = 0; + if (!source) { + while (cursor < endMinute) { + list.push(makeDay({ + endMinute: Math.min(cursor + DAY_MINUTES, endMinute), + isWeekend: false, + label: "Day " + (index + 1), + startMinute: cursor + }, index)); + cursor += DAY_MINUTES; + index += 1; + } + return list; + } + source.forEach(function(day, dayIndex) { + if (cursor >= endMinute) return; + if (typeof day === "string") { + var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); + list.push(makeDay({ + endMinute: generatedEnd, + isWeekend: inferWeekend(day), + label: day, + startMinute: cursor + }, dayIndex)); + cursor = generatedEnd; + return; + } + var nextStart = day.startMinute != null ? day.startMinute : cursor; + var nextEnd = day.endMinute != null ? day.endMinute : Math.min(nextStart + DAY_MINUTES, endMinute); + var dayRange = normalizeMinuteRange( + nextStart, + nextEnd, + "createTimeline(model.axis.days[" + dayIndex + "].startMinute)", + "createTimeline(model.axis.days[" + dayIndex + "].endMinute)" + ); + list.push(makeDay({ + endMinute: dayRange.endMinute, + isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), + label: day.label || "Day " + (dayIndex + 1), + startMinute: dayRange.startMinute, + subLabel: day.subLabel || day.meta || "" + }, dayIndex)); + cursor = dayRange.endMinute; + }); + return list; +} +function normalizeItem(item, pathKey, ordinal) { + assert(item && item.startMinute != null && item.endMinute != null, "timeline items require startMinute/endMinute"); + var itemRange = normalizeMinuteRange( + item.startMinute, + item.endMinute, + "createTimeline(model.lanes[].items[].startMinute)", + "createTimeline(model.lanes[].items[].endMinute)" + ); + return { + clusterId: item.clusterId != null ? String(item.clusterId) : null, + detailItems: Array.isArray(item.detailItems) ? item.detailItems.map(function(detailItem, detailIndex) { + return normalizeItem(detailItem, pathKey + "-" + detailIndex, detailIndex); + }) : [], + endMinute: itemRange.endMinute, + id: normalizeId(item.id, "item-", pathKey), + label: item.label || "Item " + (ordinal + 1), + meta: item.meta != null ? item.meta : "", + originalIndex: ordinal, + summary: normalizeOverviewSummary(item.summary, "createTimeline(model.lanes[].items[].summary)"), + startMinute: itemRange.startMinute, + tone: resolveTone(item.tone || item.color || "slate") + }; +} +function normalizeLane(lane, index, axis) { + assert(lane && Array.isArray(lane.items), "timeline lanes require an items array"); + var normalizedLane = { + axis, + badges: [], + id: normalizeId(lane.id, "lane-", index), + items: lane.items.map(function(item, itemIndex) { + return normalizeItem(item, index + "-" + itemIndex, itemIndex); + }), + label: lane.label || "Lane " + (index + 1), + mode: lane.mode === "overview" ? "overview" : "detailed", + overlays: Array.isArray(lane.overlays) ? lane.overlays.map(function(overlay, overlayIndex) { + return normalizeOverlay(overlay, overlayIndex, axis); + }).filter(Boolean) : [], + stats: Array.isArray(lane.stats) ? lane.stats : [] + }; + normalizedLane.items.sort(compareItems); + if (Array.isArray(lane.badges)) { + lane.badges.forEach(function(badge) { + var normalizedBadge = normalizeBadge(badge); + if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); + }); + } else { + var singleBadge = normalizeBadge(lane.badges); + if (singleBadge) normalizedLane.badges.push(singleBadge); + } + return normalizedLane; +} +function normalizeModel(model) { + assert(model && model.axis && Array.isArray(model.lanes), "createTimeline(model.axis/model.lanes) are required"); + var axis = normalizeAxis(model.axis); + return { + axis, + lanes: model.lanes.map(function(lane, index) { + return normalizeLane(lane, index, axis); + }) + }; +} +function normalizeOverlay(overlay, index, axis) { + var label = "createTimeline(model.lanes[].overlays[" + index + "])"; + assert(overlay && typeof overlay === "object", label + " must be an object"); + var startMinute = overlay.startMinute; + var endMinute = overlay.endMinute; + if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { + var dayIndex = assertInteger(overlay.dayIndex, label + ".dayIndex"); + var day = axis.days[dayIndex]; + assert(day, label + ".dayIndex must reference an existing day"); + var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + ".dayCount"); + assert(dayCount > 0, label + ".dayCount must be greater than zero"); + var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; + startMinute = day.startMinute; + endMinute = lastDay.endMinute; + } + assert( + startMinute != null && endMinute != null, + label + " requires startMinute/endMinute or dayIndex/dayCount" + ); + var overlayRange = normalizeMinuteRange( + startMinute, + endMinute, + label + ".startMinute", + label + ".endMinute" + ); + return { + endMinute: overlayRange.endMinute, + id: normalizeId(overlay.id, "overlay-", index), + label: overlay.label || "", + meta: overlay.meta || "", + startMinute: overlayRange.startMinute, + tone: resolveTone(overlay.tone || overlay.color || "slate") + }; +} +function normalizeTicks(ticks, startMinute, endMinute) { + var list = []; + if (Array.isArray(ticks) && ticks.length > 0) { + ticks.forEach(function(tick, index) { + if (typeof tick === "number") { + var numericTick = assertMinuteValue(tick, "createTimeline(model.axis.ticks[" + index + "])"); + list.push({ id: "tick-" + index, label: formatClock(numericTick), minute: numericTick }); + return; + } + assert(tick && typeof tick === "object", "createTimeline(model.axis.ticks[" + index + "]) must be a number or object"); + assert(tick.minute != null, "createTimeline(model.axis.ticks[" + index + "].minute) is required"); + var minute2 = assertMinuteValue(tick.minute, "createTimeline(model.axis.ticks[" + index + "].minute)"); + list.push({ + id: normalizeId(tick.id, "tick-", index), + label: tick.label || formatClock(minute2), + minute: minute2 + }); + }); + return list; + } + for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { + list.push({ + id: "tick-" + minute, + label: formatClock(minute), + minute + }); + } + return list; +} +function makeDay(day, index) { + return { + endMinute: day.endMinute, + id: normalizeId(day.id, "day-", index), + isWeekend: !!day.isWeekend, + label: day.label || "Day " + (index + 1), + startMinute: day.startMinute, + subLabel: day.subLabel || "" + }; +} +function compareItems(left, right) { + if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; + if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; + if (left.label !== right.label) return left.label < right.label ? -1 : 1; + return left.originalIndex - right.originalIndex; +} +function normalizeOverviewSummary(summary, label) { + if (summary == null) return null; + assert(summary && typeof summary === "object", label + " must be an object"); + var normalized = { + count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + ".count"), + openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + ".openCount"), + primaryLabel: summary.primaryLabel == null ? "" : String(summary.primaryLabel), + secondaryLabel: summary.secondaryLabel == null ? "" : String(summary.secondaryLabel), + toneSegments: Array.isArray(summary.toneSegments) ? summary.toneSegments.map(function(segment, index) { + assert(segment && typeof segment === "object", label + ".toneSegments[" + index + "] must be an object"); + return { + count: assertNonNegativeInteger(segment.count, label + ".toneSegments[" + index + "].count"), + tone: resolveTone(segment.tone || segment.color || "slate") + }; + }).filter(function(segment) { + return segment.count > 0; + }) : [] + }; + if (normalized.count != null && normalized.openCount != null) { + assert(normalized.openCount <= normalized.count, label + ".openCount must not exceed count"); + } + return normalized; +} +function renderAxisDecor(track, axis, includeLabels) { + appendWeekendBands(track, axis); + appendDayDividers(track, axis); + appendTicks(track, axis, includeLabels); + if (includeLabels) appendDayBands(track, axis); +} +function appendDayBands(track, axis) { + axis.days.forEach(function(day) { + var band = el("div", { className: "sf-rail-timeline-day-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + band.appendChild(el("div", { className: "sf-rail-timeline-day-label" }, day.label)); + if (day.subLabel) { + band.appendChild(el("div", { className: "sf-rail-timeline-day-sub" }, day.subLabel)); + } + track.appendChild(band); + }); +} +function appendDayDividers(track, axis) { + axis.days.forEach(function(day, index) { + if (index === 0) return; + var divider = el("div", { className: "sf-rail-timeline-day-divider" }); + divider.style.left = positionPct(day.startMinute, axis) + "%"; + track.appendChild(divider); + }); +} +function appendTicks(track, axis, includeLabels) { + axis.ticks.forEach(function(tick) { + if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; + var tickEl = el("div", { className: "sf-rail-timeline-tick" }); + tickEl.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(tickEl); + if (!includeLabels) return; + var label = el("div", { className: "sf-rail-timeline-tick-label" }, tick.label); + label.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(label); + }); +} +function appendWeekendBands(track, axis) { + axis.days.forEach(function(day) { + if (!day.isWeekend) return; + var band = el("div", { className: "sf-rail-timeline-weekend-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + track.appendChild(band); + }); +} +function renderOverlays(track, overlays, axis) { + overlays.forEach(function(overlay) { + var band = el("div", { className: "sf-rail-timeline-overlay" }); + band.style.left = positionPct(overlay.startMinute, axis) + "%"; + band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + "%"; + band.style.background = overlay.tone.overlay; + band.style.borderColor = overlay.tone.border; + if (overlay.label) band.title = overlay.label; + track.appendChild(band); + }); +} +function groupOverviewItems(lane) { + var groups = []; + var current = null; + lane.items.forEach(function(item) { + if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { + if (current) groups.push(current); + current = { + clusterId: item.clusterId, + endMinute: item.endMinute, + items: [item], + lane, + startMinute: item.startMinute + }; + return; + } + current.items.push(item); + current.endMinute = Math.max(current.endMinute, item.endMinute); + if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; + }); + if (current) groups.push(current); + groups.forEach(function(group, groupIndex) { + finalizeGroup(group, lane, groupIndex); + }); + assertUniqueClusterKeys(lane, groups); + return groups; +} +function finalizeGroup(group, lane, index) { + var detailItems = []; + group.items.forEach(function(item) { + if (item.detailItems.length > 0) { + item.detailItems.forEach(function(detailItem) { + detailItems.push(detailItem); + }); + return; + } + detailItems.push(item); + }); + detailItems.sort(compareItems); + group.detailItems = detailItems; + group.isCluster = detailItems.length > 1 || group.items.some(function(item) { + return item.detailItems.length > 0; + }); + group.renderId = group.isCluster ? buildScopedId("cluster", lane.id + "-" + index + "-" + (group.items[0] ? group.items[0].id : "group")) : normalizeId(group.items[0] ? group.items[0].id : null, "group-", lane.id + "-" + index); + group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; + group.summary = deriveOverviewSummary(group); + group.count = group.summary.count; + group.label = group.summary.primaryLabel; + group.metaLabel = group.summary.secondaryLabel; + group.tone = group.summary.primaryTone || dominantTone(group.detailItems); +} +function assertUniqueClusterKeys(lane, groups) { + var seen = {}; + groups.forEach(function(group) { + if (!group.clusterKey) return; + assert( + !seen[group.clusterKey], + 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + ); + seen[group.clusterKey] = true; + }); +} +function dominantTone(items) { + var toneSegments = buildToneSegmentsFromItems(items); + if (!toneSegments.length) return resolveTone("slate"); + return toneSegments[0].tone; +} +function effectiveOverviewItems(item) { + return item.detailItems.length > 0 ? item.detailItems : [item]; +} +function deriveOverviewContribution(item) { + var items = effectiveOverviewItems(item); + var summary = item.summary; + var derivedCount = items.length; + var count = summary && summary.count != null ? summary.count : derivedCount; + var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; + var openCount = null; + var toneSegments = []; + if (summary && summary.openCount != null) openCount = summary.openCount; + else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); + if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; + else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); + return { + count, + openCount, + openCountKnown: openCount != null, + toneSegments, + toneSegmentsKnown: summary && summary.toneSegments.length > 0 ? true : canDeriveAggregateMetrics + }; +} +function deriveOverviewSummary(group) { + var contributions = group.items.map(deriveOverviewContribution); + var summaries = group.items.map(function(item) { + return item.summary; + }).filter(Boolean); + var count = contributions.reduce(function(sum, contribution) { + return sum + contribution.count; + }, 0); + var openCount = contributions.every(function(contribution) { + return contribution.openCountKnown; + }) ? contributions.reduce(function(sum, contribution) { + return sum + contribution.openCount; + }, 0) : null; + var toneSegments = contributions.every(function(contribution) { + return contribution.toneSegmentsKnown; + }) ? mergeToneSegments(contributions.reduce(function(segments, contribution) { + return segments.concat(contribution.toneSegments); + }, [])) : []; + var primarySummary = summaries.length === 1 ? summaries[0] : null; + return { + count, + openCount, + primaryLabel: primarySummary && primarySummary.primaryLabel ? primarySummary.primaryLabel : count > 1 ? count + " assignments" : group.items[0].label, + primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), + secondaryLabel: primarySummary && primarySummary.secondaryLabel ? primarySummary.secondaryLabel : count > 1 ? buildOverviewBlockMeta({ + count, + items: group.detailItems + }) : describeMeta(group.items[0].meta), + toneSegments + }; +} +function inferOpenCount(items) { + return items.reduce(function(count, item) { + if (!item) return count; + if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; + if (!item.meta || typeof item.meta !== "object" || Array.isArray(item.meta)) return count; + if (typeof item.meta.openCount === "number" && isFinite(item.meta.openCount)) return count + item.meta.openCount; + if (typeof item.meta.unassignedCount === "number" && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; + if (item.meta.open === true || item.meta.unassigned === true) return count + 1; + if (typeof item.meta.status === "string" && /open|unassigned/i.test(item.meta.status)) return count + 1; + return count; + }, 0); +} +function mergeToneSegments(segments) { + var byTone = {}; + segments.forEach(function(segment) { + if (!segment || !(segment.count > 0)) return; + var toneId = segment.tone.id || segment.tone.border || "slate"; + if (!byTone[toneId]) { + byTone[toneId] = { + count: 0, + tone: segment.tone + }; + } + byTone[toneId].count += segment.count; + }); + return Object.keys(byTone).map(function(toneId) { + return byTone[toneId]; + }).sort(compareToneSegments); +} +function buildToneSegmentsFromItems(items) { + return mergeToneSegments(items.map(function(item) { + return { + count: 1, + tone: item.tone + }; + })); +} +function compareToneSegments(left, right) { + if (left.count !== right.count) return right.count - left.count; + if (left.tone.id === right.tone.id) return 0; + return left.tone.id < right.tone.id ? -1 : 1; +} +function buildOverviewBlockSummary(group, expanded) { + var badges = []; + if (group.summary.count > 1) { + badges.push({ kind: "count", text: group.summary.count + " total" }); + } + if (group.summary.openCount > 0) { + badges.push({ kind: "open", text: group.summary.openCount + " open" }); + } + if (group.isCluster) { + badges.push({ kind: "action", text: expanded ? "Enter to collapse" : "Enter to inspect" }); + } + return { + badges, + toneSegments: group.summary.toneSegments + }; +} +function buildItemAriaLabel(item, lane) { + var parts = [ + lane.label, + item.label, + formatMinuteRange(item.startMinute, item.endMinute, lane.axis) + ]; + var meta = describeMeta(item.meta); + if (meta) parts.push(meta); + return parts.join(" \xB7 "); +} +function buildOverviewAriaLabel(group, lane, expanded) { + var parts = [ + lane.label, + group.summary.primaryLabel, + formatMinuteRange(group.startMinute, group.endMinute, lane.axis) + ]; + if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); + if (group.summary.count > 1) parts.push(group.summary.count + " assignments"); + if (group.summary.openCount > 0) parts.push(group.summary.openCount + " open"); + if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); + if (group.isCluster) parts.push(expanded ? "Expanded. Press Enter to collapse" : "Press Enter to expand"); + return parts.join(" \xB7 "); +} +function describeToneSegments(segments) { + return segments.map(function(segment) { + return segment.count + " " + segment.tone.id; + }).join(", "); +} +function buildOverviewTooltip(group, lane) { + if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { + return buildClusterTooltip(group, lane); + } + return buildItemTooltip(group.items[0], lane); +} +function packItems(items) { + var trackEnds = []; + var packed = []; + items.slice().sort(compareItems).forEach(function(item) { + var trackIndex = 0; + while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { + trackIndex += 1; + } + if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); + else trackEnds[trackIndex] = item.endMinute; + packed.push({ + item, + trackIndex + }); + }); + return { + items: packed, + trackCount: trackEnds.length + }; +} +function positionPct(minute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return (minute - axis.startMinute) / total * 100; +} +function spanPct(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0.25); +} +function spanPctExact(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0); +} +function formatClock(minute) { + var normalized = minute % DAY_MINUTES; + if (normalized < 0) normalized += DAY_MINUTES; + var hours = Math.floor(normalized / 60); + var minutes = normalized % 60; + return pad(hours) + ":" + pad(minutes); +} +function formatMinuteRange(startMinute, endMinute, axis) { + return formatMinute(startMinute, axis) + " \u2192 " + formatMinute(endMinute, axis); +} +function formatMinute(minute, axis) { + var dayLabel = ""; + axis.days.forEach(function(day) { + if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { + dayLabel = day.label; + } + }); + return (dayLabel ? dayLabel + " " : "") + formatClock(minute); +} +function pad(value) { + return value < 10 ? "0" + value : String(value); +} +function inferWeekend(label) { + return /sat|sun|weekend/i.test(String(label || "")); +} +function isColorString(value) { + return /^#|^rgb|^hsl/i.test(String(value || "")); +} +function resolveTone(tone) { + if (tone && typeof tone === "object") { + return { + id: tone.id || tone.name || tone.borderColor || tone.color || "custom", + background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, + border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, + overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, + text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text + }; + } + if (TONE_MAP[tone]) return TONE_MAP[tone]; + if (isColorString(tone)) { + return { + id: String(tone), + background: tone, + border: tone, + overlay: tone, + text: "#111827" + }; + } + return TONE_MAP.slate; +} +function measureLayout(bodyViewport, state) { + var viewportWidth = getMeasuredViewportWidth(bodyViewport); + if (!(viewportWidth > 0)) return null; + var preferredLabelWidth = state.labelWidth; + var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; + var effectiveLabelWidth = preferredLabelWidth; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; + var scale = totalDuration > 0 && visibleDuration > 0 ? totalDuration / visibleDuration : 1; + if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; + if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); + else effectiveLabelWidth = MIN_LABEL_WIDTH; + var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); + var contentTrackWidth = Math.max( + Math.round(visibleTrackWidth * scale), + visibleTrackWidth, + MIN_CONTENT_TRACK_WIDTH + ); + var contentWidth = effectiveLabelWidth + contentTrackWidth; + return { + contentWidth, + contentTrackWidth, + effectiveLabelWidth, + visibleTrackWidth, + viewportWidth + }; +} +function viewportToScrollLeft(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; + return Math.round((state.viewport.startMinute - axis.startMinute) / remainingDuration * maxScrollLeft); +} +function scrollLeftToViewport(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) { + return clampViewport(axis, { + startMinute: axis.startMinute, + endMinute: axis.startMinute + visibleDuration + }); + } + var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); + var startMinute = axis.startMinute + remainingDuration * ratio; + return clampViewport(axis, { + startMinute, + endMinute: startMinute + visibleDuration + }); +} +function getMaxScrollLeft(viewportEl) { + var scrollWidth = viewportEl.scrollWidth || 0; + var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; + return Math.max(scrollWidth - clientWidth, 0); +} +function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { + if (typeof ResizeObserver !== "function") return; + var resizeObserver = new ResizeObserver(function() { + if (state.destroyed) return; + syncLayoutFromViewport(); + }); + resizeObserver.observe(bodyViewport); + state.cleanup.push(function() { + resizeObserver.disconnect(); + }); +} +function bindWindowResize(state, syncLayoutFromViewport) { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") return; + function handleResize() { + if (state.destroyed) return; + syncLayoutFromViewport(); + } + window.addEventListener("resize", handleResize); + state.cleanup.push(function() { + if (typeof window.removeEventListener === "function") window.removeEventListener("resize", handleResize); + }); +} +function getMeasuredViewportWidth(bodyViewport) { + if (!bodyViewport) return 0; + if (typeof bodyViewport.clientWidth === "number" && bodyViewport.clientWidth > 0) { + return Math.round(bodyViewport.clientWidth); + } + if (typeof bodyViewport.offsetWidth === "number" && bodyViewport.offsetWidth > 0) { + return Math.round(bodyViewport.offsetWidth); + } + if (typeof bodyViewport.getBoundingClientRect === "function") { + var rect = bodyViewport.getBoundingClientRect(); + if (rect && typeof rect.width === "number" && rect.width > 0) { + return Math.round(rect.width); + } + } + return 0; +} +function applyLayout(root, headerRow, lanes, layout) { + setCustomProperty(root.style, "--sf-rail-label-width", layout ? layout.effectiveLabelWidth + "px" : ""); + setCustomProperty(root.style, "--sf-rail-content-width", layout ? layout.contentWidth + "px" : ""); + headerRow.style.width = layout ? layout.contentWidth + "px" : ""; + lanes.style.width = layout ? layout.contentWidth + "px" : ""; + root.dataset.supportedViewportWidth = layout ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) : ""; +} +function setCustomProperty(style, name, value) { + if (!style) return; + if (typeof style.setProperty === "function") { + style.setProperty(name, value); + return; + } + style[name] = value; +} +function queuePostMountSync(state, syncLayoutFromViewport) { + if (state.hasQueuedPostMountSync || typeof setTimeout !== "function") return; + state.hasQueuedPostMountSync = true; + var timerId = setTimeout(function() { + state.hasQueuedPostMountSync = false; + if (state.destroyed) return; + syncLayoutFromViewport(); + }, 0); + state.cleanup.push(function() { + if (typeof clearTimeout === "function") clearTimeout(timerId); + }); +} +function normalizeViewportInput(viewport, label) { + if (viewport == null) return null; + assert(typeof viewport === "object", label + " must be an object"); + return normalizeMinuteRange( + viewport.startMinute, + viewport.endMinute, + label + ".startMinute", + label + ".endMinute" + ); +} +function showTooltip(tooltip, root, payload, event) { + if (!payload) return; + tooltip.setAttribute("aria-hidden", "false"); + tooltip.innerHTML = ""; + tooltip.appendChild(el("div", { className: "sf-tooltip-title" }, payload.title)); + (payload.rows || []).forEach(function(row) { + var rowEl = el("div", { className: "sf-tooltip-row" }); + rowEl.appendChild(el("span", { className: "sf-tooltip-key" }, row.key)); + rowEl.appendChild(el("span", { className: "sf-tooltip-val" }, row.value)); + tooltip.appendChild(rowEl); + }); + var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; + var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; + var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; + tooltip.style.left = left + "px"; + tooltip.style.top = top + "px"; + tooltip.classList.add("visible"); +} +function showTooltipForElement(tooltip, root, payload, element) { + var rect = element && typeof element.getBoundingClientRect === "function" ? element.getBoundingClientRect() : null; + showTooltip(tooltip, root, payload, rect ? { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2 + } : null); +} +function hideTooltip(tooltip) { + tooltip.setAttribute("aria-hidden", "true"); + tooltip.classList.remove("visible"); +} +function updateViewportMetadata(root, state) { + var axis = state.model.axis; + var duration = state.viewport.endMinute - state.viewport.startMinute; + root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); + root.dataset.viewportDurationMinutes = String(Math.round(duration)); + root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); + root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); +} +function updateZoomButtons(buttons, state) { + var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); + var initial = state.model.axis.initialViewport; + buttons.forEach(function(button) { + var preset = button.dataset.zoom; + var active = false; + if (preset === "reset") { + active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); + } else if (preset === "1w") active = duration === WEEK_MINUTES; + else if (preset === "2w") active = duration === WEEK_MINUTES * 2; + else if (preset === "4w") active = duration === WEEK_MINUTES * 4; + button.classList.toggle("active", active); + }); +} +function normalizeZoomPresets(presets) { + if (presets == null) return ["1w", "2w", "4w", "reset"]; + assert(Array.isArray(presets), "rail.createTimeline(zoomPresets) must be an array"); + presets.forEach(function(preset, index) { + assert( + ["1w", "2w", "4w", "reset"].indexOf(preset) >= 0, + "rail.createTimeline(zoomPresets[" + index + "]) must be one of 1w, 2w, 4w, reset" + ); + }); + return presets.slice(); +} +function pruneExpandedClusters(state) { + Object.keys(state.expandedClusters).forEach(function(laneId) { + var exists = state.model.lanes.some(function(lane) { + return lane.id === laneId; + }); + if (!exists) delete state.expandedClusters[laneId]; + }); +} + +// ts-src/rail/index.ts +var rail = { + createHeader: createHeader2, + createCard, + createHeatmap, + createUnassignedRail, + addBlock, + addChangeover, + createTimeline +}; + +// ts-src/solver/backend.ts +function createBackend(config = {}) { + const resolvedConfig = config || {}; + const type = resolvedConfig.type ?? "axum"; + if (type === "tauri") { + return createTauriBackend(resolvedConfig); + } + return createHttpBackend(resolvedConfig); +} +function resolveJobId(raw) { + return normalizeCreateJobId(raw); +} +function resolveEventJobId(payload) { + if (!payload || typeof payload !== "object") return ""; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.id != null) return String(payload.data.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ""; +} +function withSnapshotRevision(path, snapshotRevision) { + if (snapshotRevision == null || snapshotRevision === "") return path; + return path + "?snapshot_revision=" + encodeURIComponent(String(snapshotRevision)); +} +function createHttpBackend(config) { + var baseUrl = config.baseUrl || ""; + var jobsPath = config.jobsPath || "/jobs"; + var demoDataPath = config.demoDataPath || "/demo-data"; + var extraHeaders = config.headers || {}; + function headers(extra = {}) { + return { + "Content-Type": "application/json", + ...extraHeaders, + ...extra + }; + } + function createRequestError(method, path, res) { + var err = new Error(res.status + " " + res.statusText); + err.status = res.status; + err.statusText = res.statusText; + err.method = method; + err.path = path; + err.url = baseUrl + path; + return err; + } + function request(method, path, body) { + const opts = { + method, + headers: headers() + }; + if (body !== void 0) opts.body = JSON.stringify(body); + return fetch(baseUrl + path, opts).then(function(res) { + if (!res.ok) throw createRequestError(method, path, res); + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("json")) { + return res.json(); + } + return res.text(); + }); + } + return { + createJob: function(data) { + return request("POST", jobsPath, data).then(resolveJobId); + }, + getJob: function(id) { + return request("GET", jobsPath + "/" + id); + }, + getJobStatus: function(id) { + return request("GET", jobsPath + "/" + id + "/status"); + }, + getSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/snapshot", snapshotRevision)); + }, + analyzeSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/analysis", snapshotRevision)); + }, + pauseJob: function(id) { + return request("POST", jobsPath + "/" + id + "/pause"); + }, + resumeJob: function(id) { + return request("POST", jobsPath + "/" + id + "/resume"); + }, + cancelJob: function(id) { + return request("POST", jobsPath + "/" + id + "/cancel"); + }, + deleteJob: function(id) { + return request("DELETE", jobsPath + "/" + id); + }, + getDemoData: function(name) { + return request("GET", demoDataPath + "/" + (name || "STANDARD")); + }, + listDemoData: function() { + return request("GET", demoDataPath); + }, + streamJobEvents: function(id, onMessage, onError) { + var url = baseUrl + jobsPath + "/" + id + "/events"; + var es = new EventSource(url); + var closed = false; + es.onmessage = function(e) { + try { + onMessage(JSON.parse(e.data)); + } catch { + } + }; + es.onerror = function() { + if (closed || !onError) return; + if (typeof EventSource !== "undefined" && es.readyState === EventSource.CLOSED) { + onError(createSseClosedError(url)); + } + }; + return function close() { + closed = true; + es.onmessage = null; + es.onerror = null; + es.close(); + }; + } + }; +} +function createTauriBackend(config) { + assert(typeof config === "object", "createBackend({}) is required for Tauri adapter"); + assert(typeof config.invoke === "function", "Tauri backend requires config.invoke"); + assert(typeof config.listen === "function", "Tauri backend requires config.listen"); + var invoke = config.invoke; + var listen = config.listen; + var commands = config.commands || {}; + var eventName = config.eventName || "solver-update"; + return { + createJob: function(data) { + return invoke(commands.createJob || "create_job", { request: data }).then(resolveJobId); + }, + getJob: function(id) { + return invoke(commands.getJob || "get_job", { id }); + }, + getJobStatus: function(id) { + return invoke(commands.getJobStatus || "get_job_status", { id }); + }, + getSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.getSnapshot || "get_snapshot", payload); + }, + analyzeSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.analyzeSnapshot || "analyze_snapshot", payload); + }, + pauseJob: function(id) { + return invoke(commands.pauseJob || "pause_job", { id }); + }, + resumeJob: function(id) { + return invoke(commands.resumeJob || "resume_job", { id }); + }, + cancelJob: function(id) { + return invoke(commands.cancelJob || "cancel_job", { id }); + }, + deleteJob: function(id) { + return invoke(commands.deleteJob || "delete_job", { id }); + }, + getDemoData: function(name) { + return invoke(commands.demoData || "demo_seed", { name }); + }, + listDemoData: function() { + return Promise.resolve([]); + }, + streamJobEvents: function(id, onMessage, _onError) { + var targetId = String(id); + var unlisten = null; + listen(eventName, function(event) { + var payload = event && event.payload || {}; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; + onMessage(payload); + }).then(function(fn) { + unlisten = fn; + }); + return function close() { + if (unlisten) unlisten(); + }; + } + }; +} +function createSseClosedError(url) { + var err = new Error("Event stream closed for " + url); + err.code = "SSE_CLOSED"; + err.transport = "sse"; + err.url = url; + return err; +} + +// ts-src/solver/solver.ts +var createSolver = function(config) { + assert(config, "createSolver(config) requires a configuration object"); + assert(config.backend, "createSolver(config.backend) is required"); + assert(hasFunction(config.backend, "createJob"), "createSolver(config.backend.createJob) must be a function"); + assert(hasFunction(config.backend, "getSnapshot"), "createSolver(config.backend.getSnapshot) must be a function"); + assert(hasFunction(config.backend, "analyzeSnapshot"), "createSolver(config.backend.analyzeSnapshot) must be a function"); + assert(hasFunction(config.backend, "pauseJob"), "createSolver(config.backend.pauseJob) must be a function"); + assert(hasFunction(config.backend, "resumeJob"), "createSolver(config.backend.resumeJob) must be a function"); + assert(hasFunction(config.backend, "cancelJob"), "createSolver(config.backend.cancelJob) must be a function"); + assert(hasFunction(config.backend, "deleteJob"), "createSolver(config.backend.deleteJob) must be a function"); + assert(hasFunction(config.backend, "streamJobEvents"), "createSolver(config.backend.streamJobEvents) must be a function"); + assert(!config.onProgress || typeof config.onProgress === "function", "createSolver(config.onProgress) must be a function"); + assert(!config.onSolution || typeof config.onSolution === "function", "createSolver(config.onSolution) must be a function"); + assert(!config.onPauseRequested || typeof config.onPauseRequested === "function", "createSolver(config.onPauseRequested) must be a function"); + assert(!config.onPaused || typeof config.onPaused === "function", "createSolver(config.onPaused) must be a function"); + assert(!config.onResumed || typeof config.onResumed === "function", "createSolver(config.onResumed) must be a function"); + assert(!config.onCancelled || typeof config.onCancelled === "function", "createSolver(config.onCancelled) must be a function"); + assert(!config.onComplete || typeof config.onComplete === "function", "createSolver(config.onComplete) must be a function"); + assert(!config.onFailure || typeof config.onFailure === "function", "createSolver(config.onFailure) must be a function"); + assert(!config.onAnalysis || typeof config.onAnalysis === "function", "createSolver(config.onAnalysis) must be a function"); + assert(!config.onError || typeof config.onError === "function", "createSolver(config.onError) must be a function"); + var backend = config.backend; + var statusBar = config.statusBar; + var closeStream = null; + var activeJobId = null; + var retainedJobId = null; + var lifecycleState = "IDLE"; + var phase = "idle"; + var runToken = 0; + var lastSnapshotRevision = null; + var lastMeta = null; + var lastNotifiedError = null; + var queuedAction = null; + var pendingPause = null; + var pendingResume = null; + var pendingCancel = null; + var terminalSync = null; + var api = { + /** + * Start a new solver job. + */ + start: function(data) { + if (retainedJobId) { + return Promise.reject( + new Error( + "Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first" + ) + ); + } + if (phase !== "idle") { + return Promise.resolve(); + } + resetForStart(); + phase = "starting"; + runToken += 1; + applyLifecycleState("STARTING"); + updateMoves(null); + var token = runToken; + return backend.createJob(data).then(function(id) { + if (token !== runToken) return; + var jobId = ensureJobId(id); + activeJobId = jobId; + retainedJobId = jobId; + phase = "solving"; + applyLifecycleState("SOLVING"); + attachStream(token, jobId); + if (queuedAction === "pause") { + queuedAction = null; + requestPause(token, jobId); + } else if (queuedAction === "cancel") { + queuedAction = null; + requestCancel(token, jobId); + } + }).catch(function(err) { + if (token !== runToken) return; + if (retainedJobId) { + failTransport(err); + } else { + failStartup(err); + } + throw err; + }); + }, + /** + * Request to pause the current solver job. + */ + pause: function() { + if (pendingPause) { + return pendingPause.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "pause"; + pendingPause = createDeferred(); + return pendingPause.promise; + } + var jobId = currentJobId(); + if (phase !== "solving" || !jobId) { + return Promise.resolve(); + } + pendingPause = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "pause")) { + return pendingPause.promise; + } + requestPause(runToken, jobId); + return pendingPause.promise; + }, + /** + * Resume a paused solver job. + */ + resume: function() { + if (pendingResume) { + return pendingResume.promise; + } + var jobId = currentJobId(); + if (phase !== "paused" || !jobId) { + return Promise.resolve(); + } + pendingResume = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "resume")) { + return pendingResume.promise; + } + requestResume(runToken, jobId); + return pendingResume.promise; + }, + /** + * Request to cancel the current solver job. + */ + cancel: function() { + if (pendingCancel) { + return pendingCancel.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "cancel"; + pendingCancel = createDeferred(); + return pendingCancel.promise; + } + var jobId = currentJobId(); + if (phase === "cancelling" && jobId) { + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + return pendingCancel.promise; + } + if (!jobId || !isCancelablePhase()) { + return Promise.resolve(); + } + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + requestCancel(runToken, jobId); + return pendingCancel.promise; + }, + /** + * Delete the retained job and its backend state. + */ + delete: function() { + if (!retainedJobId) { + return Promise.resolve(); + } + if (!isTerminalLifecycle(lifecycleState)) { + return Promise.reject( + new Error( + "Cannot delete a retained job before it reaches a terminal lifecycle state" + ) + ); + } + var jobId = retainedJobId; + return ensureTerminalSyncBeforeDelete(jobId).then(function() { + if (retainedJobId !== jobId) return; + return backend.deleteJob(jobId); + }).then(function() { + if (retainedJobId !== jobId) return; + resetAfterDelete(); + }).catch(function(err) { + notifyError(err); + throw err; + }); + }, + /** + * Get a snapshot for the current job. + */ + getSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.getSnapshot(jobId, revision).then(function(payload) { + return normalizeSnapshot(payload, lastMeta); + }); + }, + /** + * Get analysis for a snapshot of the current job. + */ + analyzeSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.analyzeSnapshot(jobId, revision).then(function(payload) { + return normalizeAnalysis(payload, lastMeta); + }); + }, + /** + * Check if the solver is currently running. + */ + isRunning: function() { + return phase !== "idle" && phase !== "paused"; + }, + /** + * Get the current job ID. + */ + getJobId: function() { + return activeJobId != null ? activeJobId : retainedJobId; + }, + /** + * Get the current lifecycle state. + */ + getLifecycleState: function() { + return lifecycleState; + }, + /** + * Get the current snapshot revision. + */ + getSnapshotRevision: function() { + return lastSnapshotRevision; + } + }; + return api; + function requestPause(token, id) { + phase = "pause-requested"; + backend.pauseJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "solving"; + rejectDeferred("pause", err); + notifyError(err); + }); + } + function attachStream(token, id) { + closeStream = backend.streamJobEvents(id, function(payload) { + if (token !== runToken) return; + handleEvent(token, id, payload); + }, function(err) { + if (token !== runToken) return; + failTransport(err); + }); + } + function ensureStreamAttached(token, id, pendingName) { + if (closeStream) return true; + try { + attachStream(token, id); + return true; + } catch (err) { + failTransport(err); + rejectDeferred(pendingName, err); + return false; + } + } + function requestResume(token, id) { + phase = "resuming"; + backend.resumeJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "paused"; + rejectDeferred("resume", err); + notifyError(err); + }); + } + function requestCancel(token, id) { + phase = "cancelling"; + backend.cancelJob(id).catch(function(err) { + if (token !== runToken) return; + phase = lifecycleState === "PAUSED" ? "paused" : "solving"; + rejectDeferred("cancel", err); + notifyError(err); + }); + } + function handleEvent(token, expectedId, payload) { + var event = normalizeJobEvent(payload, expectedId); + if (!event) return; + lastMeta = event.meta; + if (event.meta.snapshotRevision != null) { + lastSnapshotRevision = event.meta.snapshotRevision; + } + retainedJobId = event.meta.jobId; + activeJobId = event.meta.jobId; + if (event.eventType === "progress") { + if (!event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onProgress) config.onProgress(event.meta); + return; + } + if (event.eventType === "best_solution") { + if (!event.solution || !event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onSolution) { + config.onSolution(buildLiveSnapshot(event), event.meta); + } + return; + } + if (event.eventType === "pause_requested") { + phase = "pause-requested"; + applyEventMeta(event.meta); + if (config.onPauseRequested) config.onPauseRequested(event.meta); + return; + } + if (event.eventType === "paused") { + phase = "paused"; + applyEventMeta(event.meta); + syncSnapshotBundle(event.meta, true).then(function(bundle) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + applyBundle(bundle); + if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); + resolveDeferred("pause", bundle); + }).catch(function(err) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + rejectDeferred("pause", err); + notifyError(err); + }); + return; + } + if (event.eventType === "resumed") { + phase = "solving"; + applyEventMeta(event.meta); + if (config.onResumed) config.onResumed(event.meta); + resolveDeferred("resume", event.meta); + return; + } + if (event.eventType === "completed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, true); + return; + } + if (event.eventType === "cancelled") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + return; + } + if (event.eventType === "failed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + } + } + function syncSnapshotBundle(meta, requireSnapshot) { + var analysisRequired = !!config.onAnalysis; + var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; + return backend.getSnapshot(meta.jobId, snapshotRevision).then(function(snapshotPayload) { + var snapshot = normalizeSnapshot(snapshotPayload, meta); + if (!snapshot) throw new Error("Solver backend returned an invalid snapshot payload"); + var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); + var result = { + meta: mergedMeta, + snapshot, + analysis: null + }; + if (!analysisRequired) return result; + return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function(analysisPayload) { + result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); + return result; + }); + }).catch(function(err) { + if (requireSnapshot) throw err; + var fallback = { meta, snapshot: null, analysis: null }; + if (!analysisRequired || snapshotRevision == null) return fallback; + return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function(analysisPayload) { + fallback.analysis = normalizeAnalysis(analysisPayload, meta); + return fallback; + }).catch(function() { + return fallback; + }); + }); + } + function applyBundle(bundle) { + if (!bundle) return; + lastMeta = bundle.meta; + if (bundle.meta && bundle.meta.snapshotRevision != null) { + lastSnapshotRevision = bundle.meta.snapshotRevision; + } + applyEventMeta(bundle.meta, bundle.analysis); + if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + } + function finalizeTerminal(meta) { + closeCurrentStream(); + activeJobId = null; + queuedAction = null; + phase = "idle"; + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : "IDLE"); + updateMoves(null); + } + function failTransport(err) { + var jobId = activeJobId || retainedJobId; + retainedJobId = jobId; + closeCurrentStream(); + activeJobId = null; + phase = phaseForLifecycleState(lifecycleState); + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + notifyError(err); + } + function failStartup(err) { + closeCurrentStream(); + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + phase = "idle"; + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + applyLifecycleState("IDLE"); + updateMoves(null); + notifyError(err); + } + function applyEventMeta(meta, analysis) { + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); + updateScore(readDisplayScore(meta, analysis)); + updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); + if (analysis) { + var constraints = readAnalysisConstraints(analysis); + if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { + statusBar.colorDotsFromAnalysis(constraints); + } + } + } + function readDisplayScore(meta, analysis) { + if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; + if (analysis && analysis.score != null) return analysis.score; + return null; + } + function applyLifecycleState(state) { + lifecycleState = state || "IDLE"; + if (!statusBar) return; + if (typeof statusBar.setLifecycleState === "function") { + statusBar.setLifecycleState(lifecycleState); + return; + } + if (typeof statusBar.setSolving === "function") { + statusBar.setSolving(isActiveLifecycle2(lifecycleState)); + } + } + function updateScore(score2) { + if (statusBar && typeof statusBar.updateScore === "function") { + statusBar.updateScore(score2); + } + } + function updateMoves(value) { + if (statusBar && typeof statusBar.updateMoves === "function") { + statusBar.updateMoves(value); + } + } + function resetForStart() { + closeCurrentStream(); + activeJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + } + function resetAfterDelete() { + closeCurrentStream(); + rejectDeferred("pause", new Error("Solver job was deleted before pause settled")); + rejectDeferred("resume", new Error("Solver job was deleted before resume settled")); + rejectDeferred("cancel", new Error("Solver job was deleted before cancel settled")); + runToken += 1; + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + phase = "idle"; + applyLifecycleState("IDLE"); + updateScore(null); + updateMoves(null); + } + function closeCurrentStream() { + if (!closeStream) return; + closeStream(); + closeStream = null; + } + function currentJobId() { + return activeJobId != null ? activeJobId : retainedJobId; + } + function hasNewerEvent(meta) { + var currentSequence = lastMeta && typeof lastMeta.eventSequence === "number" ? lastMeta.eventSequence : null; + var candidateSequence = meta && typeof meta.eventSequence === "number" ? meta.eventSequence : null; + if (currentSequence == null || candidateSequence == null) return false; + return currentSequence > candidateSequence; + } + function resolveRequestedSnapshotRevision(snapshotRevision) { + if (snapshotRevision != null && snapshotRevision !== "") return snapshotRevision; + return lastSnapshotRevision; + } + function createTerminalSync(event) { + var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; + terminalSync = { + jobId: event.meta.jobId, + eventType: event.eventType, + meta: event.meta, + status: "pending", + promise: null, + error: null, + callbackDelivered: existing ? existing.callbackDelivered : false + }; + return terminalSync; + } + function runTerminalSync(record, token, event, requireSnapshot) { + record.status = "pending"; + record.error = null; + record.meta = event.meta; + record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function(bundle) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "synced"; + record.error = null; + record.meta = bundle.meta; + finalizeTerminal(bundle.meta); + applyBundle(bundle); + deliverTerminalCallback(record, event, bundle); + settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); + return record; + }).catch(function(err) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "failed"; + record.error = err; + finalizeTerminal(event.meta); + deliverTerminalFailureCallback(record, event); + settlePendingFromTerminal(event.eventType, null, err); + notifyError(err); + return record; + }); + return record.promise; + } + function ensureTerminalSyncBeforeDelete(jobId) { + var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; + if (!record) return Promise.resolve(); + return Promise.resolve(record.promise).then(function() { + if (!requiresSuccessfulTerminalSync(record)) return; + if (record.status === "synced") return; + return retryTerminalSync(record); + }); + } + function retryTerminalSync(record) { + var retryEvent = { + eventType: record.eventType, + meta: record.meta, + error: null + }; + return runTerminalSync(record, runToken, retryEvent, true).then(function() { + if (record.status !== "synced") { + throw record.error || new Error("Terminal snapshot synchronization failed"); + } + }); + } + function requiresSuccessfulTerminalSync(record) { + return record.eventType === "completed"; + } + function deliverTerminalCallback(record, event, bundle) { + if (record.callbackDelivered) return; + if (event.eventType === "completed") { + if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); + } else if (event.eventType === "cancelled") { + if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); + } else if (event.eventType === "failed") { + if (config.onFailure) config.onFailure(event.error || "Solver job failed", bundle.meta, bundle.snapshot, bundle.analysis); + } + record.callbackDelivered = true; + } + function deliverTerminalFailureCallback(record, event) { + if (record.callbackDelivered || event.eventType !== "failed") return; + if (config.onFailure) config.onFailure(event.error || "Solver job failed", event.meta, null, null); + record.callbackDelivered = true; + } + function terminalEventError(event) { + if (event.eventType !== "failed") return null; + return new Error(event.error || "Solver job failed"); + } + function isCancelablePhase() { + return phase === "solving" || phase === "pause-requested" || phase === "paused" || phase === "resuming"; + } + function phaseForLifecycleState(state) { + if (state === "STARTING") return "starting"; + if (state === "SOLVING") return "solving"; + if (state === "PAUSE_REQUESTED") return "pause-requested"; + if (state === "PAUSED") return "paused"; + if (state === "RESUMING") return "resuming"; + if (state === "CANCELLING") return "cancelling"; + return "idle"; + } + function isTerminalLifecycle(state) { + return state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; + } + function settlePendingFromTerminal(eventType, bundle, err) { + if (eventType === "cancelled") { + if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + } else if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + if (pendingPause) { + pendingPause.reject(err || new Error("Job terminated before pause settled")); + pendingPause = null; + } + if (pendingResume) { + pendingResume.reject(err || new Error("Job terminated before resume settled")); + pendingResume = null; + } + } + function resolveDeferred(name, value) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.resolve(value); + setDeferred(name, null); + } + function rejectDeferred(name, err) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.reject(err); + setDeferred(name, null); + } + function getDeferred(name) { + if (name === "pause") return pendingPause; + if (name === "resume") return pendingResume; + if (name === "cancel") return pendingCancel; + return null; + } + function setDeferred(name, value) { + if (name === "pause") pendingPause = value; + if (name === "resume") pendingResume = value; + if (name === "cancel") pendingCancel = value; + } + function notifyError(err) { + if (err && lastNotifiedError === err) return; + lastNotifiedError = err || null; + if (config.onError) config.onError(err && err.message ? err.message : String(err)); + } + function ensureJobId(id) { + var jobId = normalizeCreateJobId(id); + if (jobId) return jobId; + throw new Error("Invalid solver backend createJob response"); + } +}; +function hasFunction(object, key) { + return !!(object && typeof object[key] === "function"); +} +function createDeferred() { + var resolve; + var reject; + var promise = new Promise(function(res, rej) { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +function normalizeJobEvent(payload, expectedId) { + if (!payload || typeof payload !== "object") return null; + var eventType = normalizeEventType(readField(payload, ["eventType", "event_type", "type"])); + if (!eventType) return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); + if (jobId == null || jobId === "") jobId = expectedId; + if (jobId == null || jobId === "") return null; + if (String(jobId) !== String(expectedId)) return null; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + var meta = { + id: String(jobId), + jobId: String(jobId), + eventType, + eventSequence: readField(payload, ["eventSequence", "event_sequence"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state", "solverStatus", "solver_status"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) + }; + return { + eventType, + meta, + solution, + error: readField(payload, ["error"], [payload, payload.data]) || null + }; +} +function normalizeSnapshot(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + return { + id: jobId != null ? String(jobId) : null, + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || null, + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.data]), payload), + solution + }; +} +function normalizeAnalysis(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var analysisBody = payload.analysis || payload.data && payload.data.analysis || payload; + var constraints = readAnalysisConstraints(analysisBody); + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var snapshotRevision = readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]); + if (snapshotRevision == null || snapshotRevision === "") { + snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; + } + return { + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: snapshotRevision != null ? snapshotRevision : null, + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || fallbackMeta && fallbackMeta.terminalReason || null, + analysis: analysisBody, + score: analysisBody.score != null ? analysisBody.score : null, + constraints + }; +} +function buildLiveSnapshot(event) { + return { + id: event.meta.jobId, + jobId: event.meta.jobId, + snapshotRevision: event.meta.snapshotRevision, + lifecycleState: event.meta.lifecycleState, + terminalReason: event.meta.terminalReason, + currentScore: event.meta.currentScore, + bestScore: event.meta.bestScore, + telemetry: event.meta.telemetry, + solution: event.solution + }; +} +function mergeMeta(meta, snapshot, eventType) { + if (!snapshot) return meta; + return { + id: meta && meta.id != null ? meta.id : snapshot.id, + jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, + eventType: meta && meta.eventType ? meta.eventType : eventType, + eventSequence: meta ? meta.eventSequence : null, + lifecycleState: meta && meta.lifecycleState || snapshot.lifecycleState || normalizeLifecycleState2(null, eventType), + terminalReason: meta && meta.terminalReason || snapshot.terminalReason || null, + telemetry: snapshot.telemetry || meta && meta.telemetry || null, + currentScore: snapshot.currentScore || meta && meta.currentScore || null, + bestScore: snapshot.bestScore || meta && meta.bestScore || null, + snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : meta && meta.snapshotRevision + }; +} +function readField(payload, names, sources) { + var fields = Array.isArray(names) ? names : [names]; + var roots = sources || [payload]; + for (var i = 0; i < roots.length; i++) { + var source = roots[i]; + if (!source || typeof source !== "object") continue; + for (var j = 0; j < fields.length; j++) { + if (source[fields[j]] != null) return source[fields[j]]; + } + } + return null; +} +function normalizeEventType(value) { + if (typeof value !== "string") return null; + var normalized = value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase(); + if (!normalized) return null; + if (normalized === "finished") return "completed"; + return normalized; +} +function normalizeLifecycleState2(value, eventType) { + if (typeof value === "string" && value.trim()) { + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); + } + if (eventType === "progress" || eventType === "best_solution" || eventType === "resumed") return "SOLVING"; + if (eventType === "pause_requested") return "PAUSE_REQUESTED"; + if (eventType === "paused") return "PAUSED"; + if (eventType === "completed") return "COMPLETED"; + if (eventType === "cancelled") return "CANCELLED"; + if (eventType === "failed") return "FAILED"; + return "IDLE"; +} +function normalizeTelemetry(rawTelemetry, payload) { + if (rawTelemetry && typeof rawTelemetry === "object") return rawTelemetry; + var telemetry = {}; + var movesPerSecond = readField(payload, ["movesPerSecond", "moves_per_second"]); + var stepCount = readField(payload, ["stepCount", "step_count"]); + if (movesPerSecond != null) telemetry.movesPerSecond = Number(movesPerSecond); + if (stepCount != null) telemetry.stepCount = Number(stepCount); + return Object.keys(telemetry).length ? telemetry : null; +} +function readMovesPerSecond(telemetry) { + if (!telemetry || typeof telemetry !== "object") return null; + const value = telemetry.movesPerSecond ?? telemetry.moves_per_second; + if (value == null) return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} +function readAnalysisConstraints(analysis) { + if (!analysis || typeof analysis !== "object") return null; + const a = analysis; + if (Array.isArray(a.constraints)) return a.constraints; + const nested = a.analysis; + if (nested && Array.isArray(nested.constraints)) return nested.constraints; + return null; +} +function isActiveLifecycle2(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; +} + +// ts-src/index.ts +var colors = { + pick, + project, + reset +}; +var score = { + parseHard, + parseSoft, + parseMedium, + getComponents, + colorClass +}; +export { + assert, + bindActivation, + colorClass, + colors, + createApiGuide, + createBackend, + createButton, + createFooter, + createHeader, + createModal, + createSolver, + createStatusBar, + createTable, + createTabs, + el, + escHtml, + gantt, + getComponents, + normalizeCreateJobId, + parseHard, + parseMedium, + parseSoft, + pick, + project, + rail, + reset, + score, + showError, + showTab, + showToast, + uid, + version +}; diff --git a/static/sf/sf.js b/static/sf/sf.js index 947a273..558b5b6 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -1,151 +1,70 @@ -/* ============================================================================ - SolverForge UI — Core - ============================================================================ */ - -const SF = (function () { - 'use strict'; - - const sf = { version: '0.6.5' }; - var uidCounter = 0; - - /* ── Utilities ── */ - - sf.escHtml = function (str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }; - - sf.assert = function (cond, message) { - if (!cond) throw new Error('[SolverForge] ' + message); - }; - - sf.normalizeCreateJobId = function (raw) { - var value = raw; - if (value && typeof value === 'object') { - if (value.id != null) value = value.id; - else if (value.jobId != null) value = value.jobId; - else if (value.job_id != null) value = value.job_id; - else if (value.data && typeof value.data === 'object' && value.data.id != null) value = value.data.id; - else return ''; - } - - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim(); - return ''; - }; - - sf.el = function (tag, attrs) { - var children = Array.prototype.slice.call(arguments, 2); - var el = document.createElement(tag); - if (attrs) { - Object.keys(attrs).forEach(function (key) { - if (key === 'className') el.className = attrs[key]; - else if (key === 'style' && typeof attrs[key] === 'object') { - Object.assign(el.style, attrs[key]); - } - else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); - else if (key === 'dataset') Object.assign(el.dataset, attrs[key]); - else if (key === 'html') el.textContent = attrs[key]; - else if (key === 'unsafeHtml') el.innerHTML = attrs[key]; - else el.setAttribute(key, attrs[key]); - }); - } - children.forEach(function (child) { - if (child == null) return; - if (typeof child === 'string') el.appendChild(document.createTextNode(child)); - else if (child instanceof Node) el.appendChild(child); - }); - return el; - }; - - sf.uid = function (prefix) { - uidCounter += 1; - return (prefix || 'sf') + '-' + uidCounter; +var SF = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); }; - - sf.bindActivation = function (el, onActivate) { - if (!el || typeof onActivate !== 'function') return; - - function handleActivate(e) { - if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; - if (e.type === 'keydown') e.preventDefault(); - onActivate(e); + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } - - el.addEventListener('click', handleActivate); - el.addEventListener('keydown', handleActivate); - }; - - if (typeof window !== 'undefined') window.SF = sf; - return sf; -})(); -/* ============================================================================ - SolverForge UI — Score Parsing - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.score = {}; - - sf.score.parseHard = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)hard/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.parseSoft = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)soft/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.parseMedium = function (scoreStr) { - if (!scoreStr) return 0; - var m = scoreStr.match(/(-?\d+)medium/); - return m ? parseInt(m[1], 10) : 0; - }; - - sf.score.getComponents = function (scoreStr) { - return { - hard: sf.score.parseHard(scoreStr), - medium: sf.score.parseMedium(scoreStr), - soft: sf.score.parseSoft(scoreStr), - }; - }; - - sf.score.colorClass = function (scoreStr) { - var hard = sf.score.parseHard(scoreStr); - var soft = sf.score.parseSoft(scoreStr); - return hard < 0 ? 'score-red' : soft < 0 ? 'score-yellow' : 'score-green'; + return to; }; - -})(SF); -/* ============================================================================ - SolverForge UI — Color Factory - Tango palette + project color assignment. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8]; - var SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B]; - + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // ts-src/index.ts + var index_exports = {}; + __export(index_exports, { + assert: () => assert, + bindActivation: () => bindActivation, + colorClass: () => colorClass, + colors: () => colors, + createApiGuide: () => createApiGuide, + createBackend: () => createBackend, + createButton: () => createButton, + createFooter: () => createFooter, + createHeader: () => createHeader, + createModal: () => createModal, + createSolver: () => createSolver, + createStatusBar: () => createStatusBar, + createTable: () => createTable, + createTabs: () => createTabs, + el: () => el, + escHtml: () => escHtml, + gantt: () => gantt, + getComponents: () => getComponents, + normalizeCreateJobId: () => normalizeCreateJobId, + parseHard: () => parseHard, + parseMedium: () => parseMedium, + parseSoft: () => parseSoft, + pick: () => pick, + project: () => project, + rail: () => rail, + reset: () => reset, + score: () => score, + showError: () => showError, + showTab: () => showTab, + showToast: () => showToast, + uid: () => uid, + version: () => version + }); + + // ts-src/utils/colors.ts + var SEQUENCE_1 = [9101876, 16574799, 7512015, 15317358, 11370408]; + var SEQUENCE_2 = [7590422, 15586304, 3433892, 12680465, 7688315]; var colorMap = {}; var nextColorCount = 0; - function buildPercentageColor(floor, ceil, pct) { - var red = (floor & 0xFF0000) + Math.floor(pct * ((ceil & 0xFF0000) - (floor & 0xFF0000))) & 0xFF0000; - var green = (floor & 0x00FF00) + Math.floor(pct * ((ceil & 0x00FF00) - (floor & 0x00FF00))) & 0x00FF00; - var blue = (floor & 0x0000FF) + Math.floor(pct * ((ceil & 0x0000FF) - (floor & 0x0000FF))) & 0x0000FF; + var red = (floor & 16711680) + Math.floor(pct * ((ceil & 16711680) - (floor & 16711680))) & 16711680; + var green = (floor & 65280) + Math.floor(pct * ((ceil & 65280) - (floor & 65280))) & 65280; + var blue = (floor & 255) + Math.floor(pct * ((ceil & 255) - (floor & 255))) & 255; return red | green | blue; } - function nextColor() { var colorIndex = nextColorCount % SEQUENCE_1.length; var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); @@ -156,123 +75,230 @@ const SF = (function () { color = SEQUENCE_2[colorIndex]; } else { shadeIndex -= 3; - var base = Math.floor((shadeIndex / 2) + 1); + var base = Math.floor(shadeIndex / 2 + 1); var divisor = 2; while (base >= divisor) divisor *= 2; - base = (base * 2) - divisor + 1; + base = base * 2 - divisor + 1; color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); } nextColorCount++; - return '#' + color.toString(16).padStart(6, '0'); + return "#" + color.toString(16).padStart(6, "0"); } - - sf.colors = {}; - - sf.colors.pick = function (key) { - if (colorMap[key] !== undefined) return colorMap[key]; + var pick = function(key) { + if (colorMap[key] !== void 0) return colorMap[key]; var c = nextColor(); colorMap[key] = c; return c; }; - - sf.colors.reset = function () { + var reset = function() { colorMap = {}; nextColorCount = 0; }; - var PROJECT_COLORS = [ - { main: '#10b981', dark: '#047857', light: 'rgba(16,185,129,0.15)' }, - { main: '#3b82f6', dark: '#1d4ed8', light: 'rgba(59,130,246,0.15)' }, - { main: '#8b5cf6', dark: '#6d28d9', light: 'rgba(139,92,246,0.15)' }, - { main: '#f59e0b', dark: '#b45309', light: 'rgba(245,158,11,0.15)' }, - { main: '#ec4899', dark: '#be185d', light: 'rgba(236,72,153,0.15)' }, - { main: '#06b6d4', dark: '#0e7490', light: 'rgba(6,182,212,0.15)' }, - { main: '#f43f5e', dark: '#be123c', light: 'rgba(244,63,94,0.15)' }, - { main: '#84cc16', dark: '#4d7c0f', light: 'rgba(132,204,22,0.15)' }, + { main: "#10b981", dark: "#047857", light: "rgba(16,185,129,0.15)" }, + { main: "#3b82f6", dark: "#1d4ed8", light: "rgba(59,130,246,0.15)" }, + { main: "#8b5cf6", dark: "#6d28d9", light: "rgba(139,92,246,0.15)" }, + { main: "#f59e0b", dark: "#b45309", light: "rgba(245,158,11,0.15)" }, + { main: "#ec4899", dark: "#be185d", light: "rgba(236,72,153,0.15)" }, + { main: "#06b6d4", dark: "#0e7490", light: "rgba(6,182,212,0.15)" }, + { main: "#f43f5e", dark: "#be123c", light: "rgba(244,63,94,0.15)" }, + { main: "#84cc16", dark: "#4d7c0f", light: "rgba(132,204,22,0.15)" } ]; - - sf.colors.project = function (index) { + var project = function(index) { return PROJECT_COLORS[index % PROJECT_COLORS.length]; }; -})(SF); -/* ============================================================================ - SolverForge UI — Button Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createButton = function (config) { - sf.assert(config, 'createButton(config) requires a configuration object'); - - var classes = ['sf-btn']; + // ts-src/utils/score.ts + var parseHard = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)hard/); + return m ? parseInt(m[1], 10) : 0; + }; + var parseSoft = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)soft/); + return m ? parseInt(m[1], 10) : 0; + }; + var parseMedium = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)medium/); + return m ? parseInt(m[1], 10) : 0; + }; + var getComponents = function(scoreStr) { + return { + hard: parseHard(scoreStr), + medium: parseMedium(scoreStr), + soft: parseSoft(scoreStr) + }; + }; + var colorClass = function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + return hard < 0 ? "score-red" : soft < 0 ? "score-yellow" : "score-green"; + }; - if (config.variant) classes.push('sf-btn--' + config.variant); - if (config.size === 'small') classes.push('sf-btn--sm'); - if (config.size === 'large') classes.push('sf-btn--lg'); - if (config.pill) classes.push('sf-btn--pill'); - if (config.circle) classes.push('sf-btn--circle'); - if (config.outline) classes.push('sf-btn--outline'); - if (config.iconOnly) classes.push('sf-btn--icon'); + // ts-src/core/index.ts + var version = "0.6.5"; + var uidCounter = 0; + var escHtml = function(str) { + if (!str) return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + }; + var assert = function(cond, message) { + if (!cond) throw new Error("[SolverForge] " + message); + }; + var normalizeCreateJobId = function(raw) { + var value = raw; + if (value && typeof value === "object") { + if (value.id != null) value = value.id; + else if (value.jobId != null) value = value.jobId; + else if (value.job_id != null) value = value.job_id; + else if (value.data && typeof value.data === "object" && value.data.id != null) value = value.data.id; + else return ""; + } + if (typeof value === "string") return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value).trim(); + return ""; + }; + var el = function(tag, attrs = {}, ...children) { + var el2 = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function(key) { + var value = attrs[key]; + if (key === "className") el2.className = value; + else if (key === "style" && typeof value === "object") { + Object.assign(el2.style, value); + } else if (key.indexOf("on") === 0) { + el2.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === "dataset") Object.assign(el2.dataset, value); + else if (key === "html") el2.textContent = value; + else if (key === "unsafeHtml") el2.innerHTML = value; + else el2.setAttribute(key, value); + }); + } + children.forEach(function(child) { + if (child == null) return; + if (typeof child === "string") el2.appendChild(document.createTextNode(child)); + else if (child instanceof Node) el2.appendChild(child); + }); + return el2; + }; + var uid = function(prefix) { + uidCounter += 1; + return (prefix || "sf") + "-" + uidCounter; + }; + var bindActivation = function(el2, onActivate) { + if (!el2 || typeof onActivate !== "function") return; + function handleActivate(e) { + if (!e || e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return; + if (e.type === "keydown") e.preventDefault(); + onActivate(e); + } + el2.addEventListener("click", handleActivate); + el2.addEventListener("keydown", handleActivate); + }; - var btn = sf.el('button', { - className: classes.join(' '), - type: 'button', + // ts-src/components/api-guide.ts + var createApiGuide = function(config) { + assert(config, "createApiGuide(config) requires a configuration object"); + assert(Array.isArray(config.endpoints), "createApiGuide(config.endpoints) must be an array"); + var guide = el("div", { className: "sf-api-guide" }); + var endpoints = config.endpoints; + endpoints.forEach(function(ep) { + var section = el("div", { className: "sf-api-section" }); + section.appendChild(el("h3", null, (ep.method || "GET") + " " + ep.path)); + if (ep.description) { + section.appendChild(el("p", { style: { fontSize: "13px", color: "var(--sf-gray-600)", marginBottom: "8px" } }, ep.description)); + } + if (ep.curl) { + var block = el("div", { className: "sf-api-code-block" }); + block.appendChild(el("code", null, ep.curl)); + var copyBtn = el("button", { + className: "sf-copy-btn", + "aria-label": "Copy command", + onClick: function() { + navigator.clipboard.writeText(ep.curl).then(function() { + copyBtn.textContent = "Copied!"; + setTimeout(function() { + copyBtn.textContent = "Copy"; + }, 1500); + }); + } + }, "Copy"); + block.appendChild(copyBtn); + section.appendChild(block); + } + guide.appendChild(section); }); + return guide; + }; + // ts-src/components/buttons.ts + var createButton = function(config) { + assert(config, "createButton(config) requires a configuration object"); + var classes = ["sf-btn"]; + if (config.variant) classes.push("sf-btn--" + config.variant); + if (config.size === "small") classes.push("sf-btn--sm"); + if (config.size === "large") classes.push("sf-btn--lg"); + if (config.pill) classes.push("sf-btn--pill"); + if (config.circle) classes.push("sf-btn--circle"); + if (config.outline) classes.push("sf-btn--outline"); + if (config.iconOnly) classes.push("sf-btn--icon"); + var btn = el("button", { + className: classes.join(" "), + type: "button" + }); if (config.disabled) btn.disabled = true; - - sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); - + assert(!config.onClick || typeof config.onClick === "function", "createButton(onClick) must be a function"); if (config.icon) { - var icon = sf.el('i', { className: 'fa-solid ' + config.icon }); + var icon = el("i", { className: "fa-solid " + config.icon }); btn.appendChild(icon); } - if (config.text && !config.circle && !config.iconOnly) { btn.appendChild(document.createTextNode(config.text)); } - if (config.onClick) { - btn.addEventListener('click', config.onClick); + btn.addEventListener("click", config.onClick); } - if (config.tooltip) { btn.title = config.tooltip; } - if (config.ariaLabel) { - btn.setAttribute('aria-label', config.ariaLabel); + btn.setAttribute("aria-label", config.ariaLabel); } else if (config.iconOnly && config.text) { - btn.setAttribute('aria-label', config.text); + btn.setAttribute("aria-label", config.text); } else if (config.icon && !config.text) { - btn.setAttribute('aria-label', config.icon.replace(/fa-/, '').replace(/-/g, ' ')); + btn.setAttribute("aria-label", config.icon.replace(/fa-/, "").replace(/-/g, " ")); } - if (config.id) { btn.id = config.id; } - if (config.dataset) { Object.assign(btn.dataset, config.dataset); } - return btn; }; -})(SF); -/* ============================================================================ - SolverForge UI — Header Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createHeader = function (config) { - sf.assert(config, 'createHeader(config) requires a configuration object'); + // ts-src/components/footer.ts + var createFooter = function(config) { + assert(config, "createFooter(config) requires a configuration object"); + var footer = el("footer", { className: "sf-footer" }); + if (config.links) { + config.links.forEach(function(link, i) { + if (i > 0) footer.appendChild(el("span", { className: "sf-vr" })); + footer.appendChild(el("a", { href: link.url, target: "_blank" }, link.label)); + }); + } + if (config.version) { + footer.appendChild(el("span", { style: { marginLeft: "auto" } }, config.version)); + } + return footer; + }; - var header = sf.el('header', { className: 'sf-header' }); + // ts-src/components/header.ts + var createHeader = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + var header = el("header", { className: "sf-header" }); var controls = { actions: null, spinner: null, @@ -281,572 +307,397 @@ const SF = (function () { resumeBtn: null, cancelBtn: null, analyzeBtn: null, - nav: null, + nav: null }; - - // Logo - if (config.logo) { - var logo = sf.el('img', { - className: 'sf-header-logo', + if (config.logo) { + var logo = el("img", { + className: "sf-header-logo", src: config.logo, - alt: 'Logo', + alt: "Logo" }); header.appendChild(logo); } - - // Brand text - var brand = sf.el('div', { className: 'sf-header-brand' }); + var brand = el("div", { className: "sf-header-brand" }); if (config.title) { - brand.appendChild(sf.el('div', { className: 'sf-header-title' }, config.title)); + brand.appendChild(el("div", { className: "sf-header-title" }, config.title)); } if (config.subtitle) { - brand.appendChild(sf.el('div', { className: 'sf-header-subtitle' }, config.subtitle)); + brand.appendChild(el("div", { className: "sf-header-subtitle" }, config.subtitle)); } header.appendChild(brand); - - // Nav tabs if (config.tabs && config.tabs.length > 0) { - sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); - var nav = sf.el('nav', { className: 'sf-header-nav' }); + assert(Array.isArray(config.tabs), "createHeader(config.tabs) expects an array"); + var nav = el("nav", { className: "sf-header-nav" }); controls.nav = nav; - config.tabs.forEach(function (tab) { - sf.assert(tab && tab.id, 'createHeader tab entries require an id'); - sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); - var btn = sf.el('button', { - className: 'sf-nav-btn' + (tab.active ? ' active' : ''), - role: 'tab', - 'aria-selected': !!tab.active, + config.tabs.forEach(function(tab) { + assert(tab && tab.id, "createHeader tab entries require an id"); + assert(typeof tab.label === "string", "createHeader tab entries require a label"); + var btn = el("button", { + className: "sf-nav-btn" + (tab.active ? " active" : ""), + role: "tab", + "aria-selected": !!tab.active, tabIndex: 0, dataset: { tab: tab.id }, - onKeyDown: function (e) { - if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; - var buttons = nav.querySelectorAll('.sf-nav-btn'); + onKeyDown: function(e) { + if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; + var buttons = nav.querySelectorAll(".sf-nav-btn"); var list = Array.prototype.slice.call(buttons); - var nextIndex = e.key === 'ArrowRight' - ? (list.indexOf(btn) + 1) % list.length - : (list.length + list.indexOf(btn) - 1) % list.length; + var nextIndex = e.key === "ArrowRight" ? (list.indexOf(btn) + 1) % list.length : (list.length + list.indexOf(btn) - 1) % list.length; var next = list[nextIndex]; if (next && next.focus) next.focus(); }, - onClick: function () { - nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); - btn.classList.add('active'); - nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { - b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + onClick: function() { + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.classList.remove("active"); + }); + btn.classList.add("active"); + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.setAttribute("aria-selected", b === btn ? "true" : "false"); }); if (config.onTabChange) config.onTabChange(tab.id); - }, + } }); if (tab.icon) { - btn.appendChild(sf.el('i', { className: 'fa-solid ' + tab.icon })); + btn.appendChild(el("i", { className: "fa-solid " + tab.icon })); } btn.appendChild(document.createTextNode(tab.label)); nav.appendChild(btn); }); header.appendChild(nav); } - - // Action buttons if (config.actions) { - sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); - sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); - sf.assert(!config.actions.onPause || typeof config.actions.onPause === 'function', 'createHeader(config.actions.onPause) must be a function'); - sf.assert(!config.actions.onResume || typeof config.actions.onResume === 'function', 'createHeader(config.actions.onResume) must be a function'); - sf.assert(!config.actions.onCancel || typeof config.actions.onCancel === 'function', 'createHeader(config.actions.onCancel) must be a function'); - sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); - sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); - - var actions = sf.el('div', { className: 'sf-header-actions' }); + assert(typeof config.actions === "object", "createHeader(config.actions) expects an object"); + assert(!config.actions.onSolve || typeof config.actions.onSolve === "function", "createHeader(config.actions.onSolve) must be a function"); + assert(!config.actions.onPause || typeof config.actions.onPause === "function", "createHeader(config.actions.onPause) must be a function"); + assert(!config.actions.onResume || typeof config.actions.onResume === "function", "createHeader(config.actions.onResume) must be a function"); + assert(!config.actions.onCancel || typeof config.actions.onCancel === "function", "createHeader(config.actions.onCancel) must be a function"); + assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === "function", "createHeader(config.actions.onAnalyze) must be a function"); + assert(!config.onTabChange || typeof config.onTabChange === "function", "createHeader(config.onTabChange) must be a function"); + var actions = el("div", { className: "sf-header-actions" }); controls.actions = actions; - - // Spinner - var spinner = sf.el('div', { className: 'sf-solving-spinner' }); + var spinner = el("div", { className: "sf-solving-spinner" }); controls.spinner = spinner; actions.appendChild(spinner); - if (config.actions.onSolve) { - var solveBtn = sf.createButton({ - text: 'Solve', - variant: 'success', - icon: 'fa-play', - onClick: config.actions.onSolve, + var solveBtn = createButton({ + text: "Solve", + variant: "success", + icon: "fa-play", + onClick: config.actions.onSolve }); controls.solveBtn = solveBtn; actions.appendChild(solveBtn); } - if (config.actions.onPause) { - var pauseBtn = sf.createButton({ - text: 'Pause', - variant: 'default', - icon: 'fa-pause', - onClick: config.actions.onPause, + var pauseBtn = createButton({ + text: "Pause", + variant: "default", + icon: "fa-pause", + onClick: config.actions.onPause }); - pauseBtn.style.display = 'none'; + pauseBtn.style.display = "none"; controls.pauseBtn = pauseBtn; actions.appendChild(pauseBtn); } - if (config.actions.onResume) { - var resumeBtn = sf.createButton({ - text: 'Resume', - variant: 'primary', - icon: 'fa-play', - onClick: config.actions.onResume, + var resumeBtn = createButton({ + text: "Resume", + variant: "primary", + icon: "fa-play", + onClick: config.actions.onResume }); - resumeBtn.style.display = 'none'; + resumeBtn.style.display = "none"; controls.resumeBtn = resumeBtn; actions.appendChild(resumeBtn); } - if (config.actions.onCancel) { - var cancelBtn = sf.createButton({ - text: 'Stop', - variant: 'danger', - icon: 'fa-stop', - onClick: config.actions.onCancel, + var cancelBtn = createButton({ + text: "Stop", + variant: "danger", + icon: "fa-stop", + onClick: config.actions.onCancel }); - cancelBtn.style.display = 'none'; + cancelBtn.style.display = "none"; controls.cancelBtn = cancelBtn; actions.appendChild(cancelBtn); } - if (config.actions.onAnalyze) { - var analyzeBtn = sf.createButton({ - variant: 'ghost', - icon: 'fa-chart-bar', + var analyzeBtn = createButton({ + variant: "ghost", + icon: "fa-chart-bar", circle: true, - tooltip: 'Score Analysis', - onClick: config.actions.onAnalyze, + tooltip: "Score Analysis", + onClick: config.actions.onAnalyze }); controls.analyzeBtn = analyzeBtn; actions.appendChild(analyzeBtn); } - header.appendChild(actions); } - header.sfControls = controls; return header; }; -})(SF); -/* ============================================================================ - SolverForge UI — Status Bar Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; + // ts-src/components/modal.ts + var createModal = function(config) { + assert(config, "createModal(config) requires a configuration object"); + assert(!config.footer || Array.isArray(config.footer), "createModal(config.footer) must be an array"); + var overlay = el("div", { className: "sf-modal-overlay" }); + var dialogId = uid("sf-modal"); + var dialog = el("div", { + className: "sf-modal", + id: dialogId, + role: "dialog", + "aria-modal": "true", + "aria-labelledby": dialogId + "-title" + }); + var body = el("div", { className: "sf-modal-body" }); + var header = el("div", { className: "sf-modal-header" }); + var titleEl = el("div", { className: "sf-modal-title", id: dialogId + "-title" }, config.title || ""); + header.appendChild(titleEl); + var closeBtn = el("button", { + className: "sf-modal-close", + "aria-label": "Close modal", + onClick: function() { + api.close(); + } + }, "\xD7"); + header.appendChild(closeBtn); + dialog.appendChild(header); + setBodyContent(body, config.body, config.unsafeBody); + dialog.appendChild(body); + if (config.footer) { + var footer = el("div", { className: "sf-modal-footer" }); + config.footer.forEach(function(child) { + footer.appendChild(child); + }); + dialog.appendChild(footer); + } + overlay.appendChild(dialog); + var previousFocus = null; + overlay.addEventListener("click", function(e) { + if (e.target === overlay) api.close(); + }); + function onKeyDown(e) { + if (e.key === "Escape") api.close(); + } + var api = { el: overlay, body }; + api.open = function() { + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add("open"); + document.addEventListener("keydown", onKeyDown); + }; + api.close = function() { + overlay.classList.remove("open"); + document.removeEventListener("keydown", onKeyDown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); + if (config.onClose) config.onClose(); + }; + api.setBody = function(content) { + setBodyContent(body, content); + }; + if (config.width) { + dialog.style.maxWidth = config.width; + } + return api; + }; + function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ""; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === "string") { + target.textContent = content; + } else if (content && typeof content === "object" && "unsafeBody" in content) { + target.innerHTML = content.unsafeBody; + } else if (content && typeof content === "object" && "unsafeHtml" in content) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } + } - sf.createStatusBar = function (config) { - var bar = sf.el('div', { className: 'sf-statusbar' }); + // ts-src/components/statusbar.ts + var createStatusBar = function(config = {}) { + var bar = el("div", { className: "sf-statusbar" }); var lastScore = null; var controls = null; - - // Score display - var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); + var scoreEl = el("span", { className: "sf-statusbar-score", id: "sfScoreDisplay", "aria-live": "polite" }, "\u2014"); bar.appendChild(scoreEl); - - // Separator - bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); - - // Constraint dots container - var dotsContainer = sf.el('div', { className: 'sf-statusbar-constraints' }); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var dotsContainer = el("div", { className: "sf-statusbar-constraints" }); bar.appendChild(dotsContainer); - - // Separator + moves display - var movesSep = sf.el('span', { className: 'sf-statusbar-sep' }, '|'); - movesSep.style.display = 'none'; + var movesSep = el("span", { className: "sf-statusbar-sep" }, "|"); + movesSep.style.display = "none"; bar.appendChild(movesSep); - - var movesEl = sf.el('span'); - movesEl.style.display = 'none'; + var movesEl = el("span"); + movesEl.style.display = "none"; bar.appendChild(movesEl); - - // Separator + status text - bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); - var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var statusEl = el("span", { id: "sfStatusText", role: "status", "aria-live": "polite" }); bar.appendChild(statusEl); - - // Build initial constraint dots if (config && config.constraints) { buildDots(dotsContainer, config.constraints, config.onConstraintClick); } - - var api = { el: bar }; - - api.bindHeader = function (header) { - controls = header && header.sfControls ? header.sfControls : null; - return api; - }; - - api.updateScore = function (scoreStr) { - if (scoreStr && scoreStr !== lastScore) { - scoreEl.textContent = scoreStr; - var colorClass = sf.score.colorClass(scoreStr); - scoreEl.classList.remove('improved', 'score-green', 'score-red', 'score-yellow'); - scoreEl.classList.add(colorClass); - void scoreEl.offsetWidth; - scoreEl.classList.add('improved'); - lastScore = scoreStr; - } else if (!scoreStr) { - scoreEl.textContent = '\u2014'; - scoreEl.classList.remove('score-green', 'score-red', 'score-yellow', 'improved'); - lastScore = null; - } - }; - - api.setLifecycleState = function (state) { - var normalized = normalizeLifecycleState(state); - var solveBtn = controls && controls.solveBtn; - var pauseBtn = controls && controls.pauseBtn; - var resumeBtn = controls && controls.resumeBtn; - var cancelBtn = controls && controls.cancelBtn; - var spinner = controls && controls.spinner; - - if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? '' : 'none'; - if (pauseBtn) { - pauseBtn.style.display = shouldShowPause(normalized) ? '' : 'none'; - pauseBtn.disabled = normalized === 'PAUSE_REQUESTED'; - } - if (resumeBtn) { - resumeBtn.style.display = normalized === 'PAUSED' ? '' : 'none'; - resumeBtn.disabled = false; - } - if (cancelBtn) { - cancelBtn.style.display = shouldShowCancel(normalized) ? '' : 'none'; - cancelBtn.disabled = false; - } - if (spinner) spinner.classList.toggle('active', shouldSpin(normalized)); - - statusEl.textContent = lifecycleLabel(normalized); - statusEl.style.color = isActiveLifecycle(normalized) - ? 'var(--sf-emerald-600)' - : normalized === 'FAILED' - ? 'var(--sf-red-600)' - : normalized === 'CANCELLED' - ? 'var(--sf-amber-700)' - : 'var(--sf-gray-500)'; - }; - - api.setSolving = function (solving) { - api.setLifecycleState(solving ? 'SOLVING' : 'IDLE'); - }; - - api.updateMoves = function (mps) { - if (mps != null && mps > 0) { - movesEl.textContent = mps.toLocaleString() + ' moves/s'; - movesEl.style.display = ''; - movesSep.style.display = ''; - } else { - movesEl.style.display = 'none'; - movesSep.style.display = 'none'; + var api = { + el: bar, + bindHeader: function(header) { + controls = header && header.sfControls ? header.sfControls : null; + return api; + }, + updateScore: function(scoreStr) { + if (scoreStr && scoreStr !== lastScore) { + scoreEl.textContent = scoreStr; + var colorClassName = colorClass(scoreStr); + scoreEl.classList.remove("improved", "score-green", "score-red", "score-yellow"); + scoreEl.classList.add(colorClassName); + void scoreEl.offsetWidth; + scoreEl.classList.add("improved"); + lastScore = scoreStr; + } else if (!scoreStr) { + scoreEl.textContent = "\u2014"; + scoreEl.classList.remove("score-green", "score-red", "score-yellow", "improved"); + lastScore = null; + } + }, + setLifecycleState: function(state) { + var normalized = normalizeLifecycleState(state); + var solveBtn = controls && controls.solveBtn; + var pauseBtn = controls && controls.pauseBtn; + var resumeBtn = controls && controls.resumeBtn; + var cancelBtn = controls && controls.cancelBtn; + var spinner = controls && controls.spinner; + if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? "" : "none"; + if (pauseBtn) { + pauseBtn.style.display = shouldShowPause(normalized) ? "" : "none"; + pauseBtn.disabled = normalized === "PAUSE_REQUESTED"; + } + if (resumeBtn) { + resumeBtn.style.display = normalized === "PAUSED" ? "" : "none"; + resumeBtn.disabled = false; + } + if (cancelBtn) { + cancelBtn.style.display = shouldShowCancel(normalized) ? "" : "none"; + cancelBtn.disabled = false; + } + if (spinner) spinner.classList.toggle("active", shouldSpin(normalized)); + statusEl.textContent = lifecycleLabel(normalized); + statusEl.style.color = isActiveLifecycle(normalized) ? "var(--sf-emerald-600)" : normalized === "FAILED" ? "var(--sf-red-600)" : normalized === "CANCELLED" ? "var(--sf-amber-700)" : "var(--sf-gray-500)"; + }, + setSolving: function(solving) { + api.setLifecycleState(solving ? "SOLVING" : "IDLE"); + }, + updateMoves: function(mps) { + if (mps != null && mps > 0) { + movesEl.textContent = mps.toLocaleString() + " moves/s"; + movesEl.style.display = ""; + movesSep.style.display = ""; + } else { + movesEl.style.display = "none"; + movesSep.style.display = "none"; + } + }, + updateConstraintDots: function(constraints) { + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + }, + colorDotsByScore: function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot) { + var isHard = dot.dataset.type === "hard"; + dot.classList.toggle("violated", isHard && hard < 0); + dot.classList.toggle("violated-soft", !isHard && soft < 0); + }); + }, + colorDotsFromAnalysis: function(constraints) { + if (!constraints || constraints.length === 0) return; + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot, i) { + var c = constraints[i]; + if (!dot) return; + var isHardConstraint = c.type === "hard"; + var scoreVal = isHardConstraint ? parseHard(c.score) : parseSoft(c.score); + var violated = scoreVal < 0; + dot.classList.toggle("violated", isHardConstraint && violated); + dot.classList.toggle("violated-soft", !isHardConstraint && violated); + }); } }; - - api.updateConstraintDots = function (constraints) { - buildDots(dotsContainer, constraints, config && config.onConstraintClick); - }; - - api.colorDotsByScore = function (scoreStr) { - var hard = sf.score.parseHard(scoreStr); - var soft = sf.score.parseSoft(scoreStr); - dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot) { - var isHard = dot.dataset.type === 'hard'; - dot.classList.toggle('violated', isHard && hard < 0); - dot.classList.toggle('violated-soft', !isHard && soft < 0); - }); - }; - - api.colorDotsFromAnalysis = function (constraints) { - if (!constraints || constraints.length === 0) return; - buildDots(dotsContainer, constraints, config && config.onConstraintClick); - dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot, i) { - var c = constraints[i]; - if (!dot) return; - var isHard = c.type === 'hard'; - var scoreVal = isHard ? sf.score.parseHard(c.score) : sf.score.parseSoft(c.score); - var violated = scoreVal < 0; - dot.classList.toggle('violated', isHard && violated); - dot.classList.toggle('violated-soft', !isHard && violated); - }); - }; - if (config && config.header) { api.bindHeader(config.header); } - - api.setLifecycleState('IDLE'); - + api.setLifecycleState("IDLE"); return api; }; - - function buildDots(container, constraints, onClick) { - container.innerHTML = ''; + function buildDots(container2, constraints, onClick) { + container2.innerHTML = ""; if (!constraints) return; - constraints.forEach(function (c, i) { - var dot = sf.el('div', { - className: 'sf-constraint-dot', - id: 'sf-cdot-' + i, - title: c.name || ('Constraint ' + i), - role: onClick ? 'button' : null, - tabIndex: onClick ? '0' : null, - 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, - dataset: { type: c.type || 'hard', index: String(i) }, + constraints.forEach(function(c, i) { + var dot = el("div", { + className: "sf-constraint-dot", + id: "sf-cdot-" + i, + title: c.name || "Constraint " + i, + role: onClick ? "button" : null, + tabIndex: onClick ? "0" : null, + "aria-label": onClick ? "Open constraint " + (c.name || "Constraint " + i) : null, + dataset: { type: c.type || "hard", index: String(i) } }); if (onClick) { - dot.style.cursor = 'pointer'; - sf.bindActivation(dot, function () { onClick(i); }); + dot.style.cursor = "pointer"; + bindActivation(dot, function() { + onClick(i); + }); } - container.appendChild(dot); + container2.appendChild(dot); }); } - function normalizeLifecycleState(value) { - if (typeof value !== 'string' || !value.trim()) return 'IDLE'; - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toUpperCase(); + if (typeof value !== "string" || !value.trim()) return "IDLE"; + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); } - function shouldShowSolve(state) { - return state === 'IDLE' - || state === 'COMPLETED' - || state === 'CANCELLED' - || state === 'FAILED' - || state === 'TERMINATED_BY_CONFIG'; + return state === "IDLE" || state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; } - function shouldShowPause(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED'; + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED"; } - function shouldShowCancel(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'PAUSED' - || state === 'RESUMING' - || state === 'CANCELLING'; + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "PAUSED" || state === "RESUMING" || state === "CANCELLING"; } - function shouldSpin(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'RESUMING' - || state === 'CANCELLING'; + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; } - function isActiveLifecycle(state) { return shouldSpin(state); } - function lifecycleLabel(state) { - if (state === 'STARTING') return 'Starting...'; - if (state === 'SOLVING') return 'Solving...'; - if (state === 'PAUSE_REQUESTED') return 'Pause requested...'; - if (state === 'PAUSED') return 'Paused'; - if (state === 'RESUMING') return 'Resuming...'; - if (state === 'CANCELLING') return 'Cancelling...'; - if (state === 'COMPLETED') return 'Completed'; - if (state === 'CANCELLED') return 'Cancelled'; - if (state === 'FAILED') return 'Failed'; - if (state === 'TERMINATED_BY_CONFIG') return 'Completed'; - return 'Ready'; - } - -})(SF); -/* ============================================================================ - SolverForge UI — Modal Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createModal = function (config) { - sf.assert(config, 'createModal(config) requires a configuration object'); - sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); - - var overlay = sf.el('div', { className: 'sf-modal-overlay' }); - var dialogId = sf.uid('sf-modal'); - var dialog = sf.el('div', { - className: 'sf-modal', - id: dialogId, - role: 'dialog', - 'aria-modal': 'true', - 'aria-labelledby': dialogId + '-title', - }); - var body = sf.el('div', { className: 'sf-modal-body' }); - - // Header - var header = sf.el('div', { className: 'sf-modal-header' }); - var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); - header.appendChild(titleEl); - - var closeBtn = sf.el('button', { - className: 'sf-modal-close', - 'aria-label': 'Close modal', - onClick: function () { api.close(); }, - }, '×'); - header.appendChild(closeBtn); - - dialog.appendChild(header); - - // Body - setBodyContent(body, config.body, config.unsafeBody); - dialog.appendChild(body); - - // Footer - if (config.footer) { - var footer = sf.el('div', { className: 'sf-modal-footer' }); - config.footer.forEach(function (child) { - footer.appendChild(child); - }); - dialog.appendChild(footer); - } - - overlay.appendChild(dialog); - - var previousFocus = null; - - // Close on backdrop click - overlay.addEventListener('click', function (e) { - if (e.target === overlay) api.close(); - }); - - // Close on Escape - function onKeyDown(e) { - if (e.key === 'Escape') api.close(); - } - - var api = { el: overlay, body: body }; - - api.open = function () { - previousFocus = document.activeElement; - document.body.appendChild(overlay); - if (closeBtn.focus) closeBtn.focus(); - overlay.classList.add('open'); - document.addEventListener('keydown', onKeyDown); - }; - - api.close = function () { - overlay.classList.remove('open'); - document.removeEventListener('keydown', onKeyDown); - if (overlay.parentNode) overlay.parentNode.removeChild(overlay); - if (previousFocus && previousFocus.focus) previousFocus.focus(); - if (config.onClose) config.onClose(); - }; - - api.setBody = function (content) { - setBodyContent(body, content); - }; - - if (config.width) { - dialog.style.maxWidth = config.width; - } - - return api; - }; - - function setBodyContent(target, content, explicitUnsafeHtml) { - target.textContent = ''; - if (explicitUnsafeHtml != null) { - target.innerHTML = explicitUnsafeHtml; - } else if (typeof content === 'string') { - target.textContent = content; - } else if (content && content.unsafeBody) { - target.innerHTML = content.unsafeBody; - } else if (content && content.unsafeHtml) { - target.innerHTML = content.unsafeHtml; - } else if (content instanceof Node) { - target.appendChild(content); - } - } - -})(SF); -/* ============================================================================ - SolverForge UI — Tab Switching - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.showTab = function (tabId, root) { - if (root) { - activateTabInScope(root, tabId); - return; - } - - document.querySelectorAll('.sf-tabs-container').forEach(function (container) { - activateTabInScope(container, tabId); - }); - }; - - sf.createTabs = function (config) { - sf.assert(config, 'createTabs(config) requires a configuration object'); - sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); - - var container = sf.el('div', { className: 'sf-tabs-container' }); - var tabsId = sf.uid('sf-tabs'); - - config.tabs.forEach(function (tab) { - var panel = sf.el('div', { - className: 'sf-tab-panel' + (tab.active ? ' active' : ''), - id: tabsId + '-' + tab.id, - dataset: { tabId: tab.id }, - }); - if (tab.content) { - if (typeof tab.content === 'string') panel.textContent = tab.content; - else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; - else if (tab.content instanceof Node) panel.appendChild(tab.content); - } - container.appendChild(panel); - }); - - return { - el: container, - show: function (tabId) { - sf.showTab(tabId, container); - }, - }; - }; - - function activateTabInScope(scope, tabId) { - scope.querySelectorAll('.sf-tab-panel').forEach(function (p) { - p.classList.remove('active'); - }); - - var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); - if (panel) panel.classList.add('active'); - } - -})(SF); -/* ============================================================================ - SolverForge UI — Table Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createTable = function (config) { - sf.assert(config, 'createTable(config) requires a configuration object'); - sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); - sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); - - var wrapper = sf.el('div', { className: 'sf-table-container' }); - var table = sf.el('table', { className: 'sf-table' }); - - // Header + if (state === "STARTING") return "Starting..."; + if (state === "SOLVING") return "Solving..."; + if (state === "PAUSE_REQUESTED") return "Pause requested..."; + if (state === "PAUSED") return "Paused"; + if (state === "RESUMING") return "Resuming..."; + if (state === "CANCELLING") return "Cancelling..."; + if (state === "COMPLETED") return "Completed"; + if (state === "CANCELLED") return "Cancelled"; + if (state === "FAILED") return "Failed"; + if (state === "TERMINATED_BY_CONFIG") return "Completed"; + return "Ready"; + } + + // ts-src/components/table.ts + var createTable = function(config) { + assert(config, "createTable(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createTable(config.columns) must be an array"); + assert(!config.rows || Array.isArray(config.rows), "createTable(config.rows) must be an array"); + var wrapper = el("div", { className: "sf-table-container" }); + var table = el("table", { className: "sf-table" }); if (config.columns) { - var thead = sf.el('thead'); - var tr = sf.el('tr'); - config.columns.forEach(function (col) { - var th = sf.el('th', null, typeof col === 'string' ? col : col.label); + var thead = el("thead"); + var tr = el("tr"); + config.columns.forEach(function(col) { + var th = el("th", null, typeof col === "string" ? col : col.label); if (col.align) th.style.textAlign = col.align; if (col.width) th.style.width = col.width; tr.appendChild(th); @@ -854,16 +705,14 @@ const SF = (function () { thead.appendChild(tr); table.appendChild(thead); } - - // Body - var tbody = sf.el('tbody'); + var tbody = el("tbody"); if (config.rows) { - config.rows.forEach(function (row, rowIdx) { - var tr = sf.el('tr'); - row.forEach(function (cell, colIdx) { - var td = sf.el('td'); - if (typeof cell === 'string' || typeof cell === 'number') { - td.textContent = cell; + config.rows.forEach(function(row, rowIdx) { + var tr2 = el("tr"); + row.forEach(function(cell, colIdx) { + var td = el("td"); + if (typeof cell === "string" || typeof cell === "number") { + td.textContent = String(cell); } else if (cell instanceof Node) { td.appendChild(cell); } else if (cell && cell.unsafeHtml) { @@ -872,3694 +721,3292 @@ const SF = (function () { var col = config.columns && config.columns[colIdx]; if (col && col.align) td.style.textAlign = col.align; if (col && col.className) td.classList.add(col.className); - tr.appendChild(td); + tr2.appendChild(td); }); if (config.onRowClick) { - tr.style.cursor = 'pointer'; - tr.setAttribute('role', 'button'); - tr.tabIndex = 0; - sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); + tr2.style.cursor = "pointer"; + tr2.setAttribute("role", "button"); + tr2.tabIndex = 0; + bindActivation(tr2, function() { + config.onRowClick(rowIdx, row); + }); } - tbody.appendChild(tr); + tbody.appendChild(tr2); }); } table.appendChild(tbody); wrapper.appendChild(table); - return wrapper; }; -})(SF); -/* ============================================================================ - SolverForge UI — Toast Notifications - jQuery-free replacement for showError/showSimpleError. - ============================================================================ */ - -(function (sf) { - 'use strict'; + // ts-src/components/tabs.ts + var showTab = function(tabId, root) { + if (root) { + activateTabInScope(root, tabId); + return; + } + document.querySelectorAll(".sf-tabs-container").forEach(function(container2) { + activateTabInScope(container2, tabId); + }); + }; + var createTabs = function(config) { + assert(config, "createTabs(config) requires a configuration object"); + assert(Array.isArray(config.tabs), "createTabs(config.tabs) must be an array"); + var container2 = el("div", { className: "sf-tabs-container" }); + var tabsId = uid("sf-tabs"); + config.tabs.forEach(function(tab) { + var panel = el("div", { + className: "sf-tab-panel" + (tab.active ? " active" : ""), + id: tabsId + "-" + tab.id, + dataset: { tabId: tab.id } + }); + if (tab.content) { + if (typeof tab.content === "string") panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; + else if (tab.content instanceof Node) panel.appendChild(tab.content); + } + container2.appendChild(panel); + }); + return { + el: container2, + show: function(tabId) { + showTab(tabId, container2); + } + }; + }; + function activateTabInScope(scope, tabId) { + scope.querySelectorAll(".sf-tab-panel").forEach(function(p) { + p.classList.remove("active"); + }); + var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); + if (panel) panel.classList.add("active"); + } + // ts-src/components/toast.ts var container = null; - function ensureContainer() { if (container && document.body.contains(container)) return; - container = sf.el('div', { className: 'sf-toast-container' }); + container = el("div", { className: "sf-toast-container" }); document.body.appendChild(container); } - - sf.showToast = function (config) { - sf.assert(config, 'showToast(config) requires a configuration object'); - + var showToast = function(config) { + assert(config, "showToast(config) requires a configuration object"); ensureContainer(); - - var variant = config.variant || 'danger'; - var toast = sf.el('div', { - className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', - role: 'status', - 'aria-live': 'polite', + var variant = config.variant || "danger"; + var toast = el("div", { + className: "sf-toast sf-toast--" + variant + " sf-toast-enter", + role: "status", + "aria-live": "polite" }); - - var msg = sf.el('div', { className: 'sf-toast-message' }); + var msg = el("div", { className: "sf-toast-message" }); if (config.title) { - msg.appendChild(sf.el('div', { className: 'sf-toast-title' }, config.title)); + msg.appendChild(el("div", { className: "sf-toast-title" }, config.title)); } if (config.message) { - msg.appendChild(sf.el('div', null, config.message)); + msg.appendChild(el("div", null, config.message)); } if (config.detail) { - var pre = sf.el('pre', { style: { margin: '4px 0 0', fontSize: '11px', whiteSpace: 'pre-wrap' } }); - pre.appendChild(sf.el('code', null, config.detail)); + var pre = el("pre", { style: { margin: "4px 0 0", fontSize: "11px", whiteSpace: "pre-wrap" } }); + pre.appendChild(el("code", null, config.detail)); msg.appendChild(pre); } toast.appendChild(msg); - - var closeBtn = sf.el('button', { - className: 'sf-toast-close', - 'aria-label': 'Dismiss toast', - onClick: function () { dismiss(); }, - }, '×'); + var closeBtn = el("button", { + className: "sf-toast-close", + "aria-label": "Dismiss toast", + onClick: function() { + dismiss(); + } + }, "\xD7"); toast.appendChild(closeBtn); - container.appendChild(toast); - - var delay = config.delay || 10000; + var delay = config.delay || 1e4; var timer = setTimeout(dismiss, delay); - function dismiss() { clearTimeout(timer); - toast.classList.remove('sf-toast-enter'); - toast.classList.add('sf-toast-exit'); - setTimeout(function () { + toast.classList.remove("sf-toast-enter"); + toast.classList.add("sf-toast-exit"); + setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); } }; - - sf.showError = function (title, detail) { - sf.showToast({ title: 'Error', message: title, detail: detail, variant: 'danger', delay: 30000 }); + var showError = function(title, detail) { + showToast({ title: "Error", message: title, detail, variant: "danger", delay: 3e4 }); }; -})(SF); -/* ============================================================================ - SolverForge UI — Backend Adapters - Pluggable transport: Axum, Tauri IPC, generic fetch. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createBackend = function (config) { + // ts-src/gantt/gantt.ts + var create = function(config) { config = config || {}; - var type = config.type || 'axum'; - if (type === 'tauri') return createTauriBackend(config); - return createHttpBackend(config); - }; - - function resolveJobId(raw) { - return sf.normalizeCreateJobId(raw); - } - - function resolveEventJobId(payload) { - if (!payload || typeof payload !== 'object') return ''; - if (payload.jobId != null) return String(payload.jobId).trim(); - if (payload.job_id != null) return String(payload.job_id).trim(); - if (payload.id != null) return String(payload.id).trim(); - if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); - if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); - return ''; - } - - function withSnapshotRevision(path, snapshotRevision) { - if (snapshotRevision == null || snapshotRevision === '') return path; - return path + '?snapshot_revision=' + encodeURIComponent(String(snapshotRevision)); - } - - /* ── HTTP backend (Axum, Rails, anything) ── */ - - function createHttpBackend(config) { - var baseUrl = config.baseUrl || ''; - var jobsPath = config.jobsPath || '/jobs'; - var demoDataPath = config.demoDataPath || '/demo-data'; - var extraHeaders = config.headers || {}; - - function headers(extra) { - var h = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders, extra || {}); - return h; - } - - function createRequestError(method, path, res) { - var err = new Error(res.status + ' ' + res.statusText); - err.status = res.status; - err.statusText = res.statusText; - err.method = method; - err.path = path; - err.url = baseUrl + path; - return err; + var instanceId = uid("sf-gantt"); + var chartPaneId = config.chartPane || instanceId + "-chart-pane"; + var gridPaneId = config.gridPane || instanceId + "-grid-pane"; + var chartContainerId = config.chartContainer || instanceId + "-container"; + var svgId = config.svgId || instanceId + "-svg"; + var ganttChart = null; + var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; + var tasks = []; + var sortState = { key: null, direction: "asc" }; + var wrapper = el("div", { className: "sf-gantt-split" }); + var gridPane = el("div", { className: "sf-gantt-pane", id: gridPaneId }); + var gridHeader = el("div", { className: "sf-gantt-pane-header" }); + gridHeader.appendChild(el("h3", null, config.gridTitle || "Tasks")); + var gridControls = el("div", { className: "sf-gantt-pane-controls" }); + gridHeader.appendChild(gridControls); + gridPane.appendChild(gridHeader); + var gridContent = el("div", { className: "sf-gantt-pane-content" }); + var grid = el("div", { className: "sf-gantt-grid" }); + gridContent.appendChild(grid); + gridPane.appendChild(gridContent); + var chartPane = el("div", { className: "sf-gantt-pane", id: chartPaneId }); + var chartHeader = el("div", { className: "sf-gantt-pane-header" }); + chartHeader.appendChild(el("h3", null, config.chartTitle || "Timeline")); + var viewControls = el("div", { className: "sf-gantt-view-controls" }); + var viewSelect = el("select", { className: "sf-gantt-view-select" }); + var modes = [ + { value: "Quarter Day", label: "Quarter Day" }, + { value: "Half Day", label: "Half Day" }, + { value: "Day", label: "Day" }, + { value: "Week", label: "Week" }, + { value: "Month", label: "Month" } + ]; + modes.forEach(function(m) { + var opt = el("option", { value: m.value }, m.label); + if (m.value === (config.viewMode || "Quarter Day")) opt.selected = true; + viewSelect.appendChild(opt); + }); + viewSelect.addEventListener("change", function() { + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); + }); + viewControls.appendChild(viewSelect); + var chartControls = el("div", { className: "sf-gantt-pane-controls" }); + chartHeader.appendChild(viewControls); + chartHeader.appendChild(chartControls); + chartPane.appendChild(chartHeader); + var chartContent = el("div", { className: "sf-gantt-pane-content" }); + var chartContainer = el("div", { className: "sf-gantt-container", id: chartContainerId }); + chartContent.appendChild(chartContainer); + chartPane.appendChild(chartContent); + wrapper.appendChild(gridPane); + wrapper.appendChild(chartPane); + var ctrl = { el: wrapper }; + ctrl.mount = function(parent) { + assert(parent, "gantt.mount(parent) requires a mount target"); + var target = typeof parent === "string" ? document.getElementById(parent) : parent; + assert(target, "gantt.mount(parent) target not found: " + parent); + validateMountTarget(target); + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); + target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } + initSplit(); + bindResizeObserver2(); + }; + ctrl.setTasks = function(newTasks) { + assert(Array.isArray(newTasks), "gantt.setTasks(tasks) expects an array"); + tasks = newTasks; + renderGrid(newTasks); + renderChart(newTasks); + }; + ctrl.refresh = function() { + if (ganttChart && tasks.length > 0) { + ganttChart.refresh(tasksToFrappe(tasks)); + } + }; + ctrl.getChart = function() { + return ganttChart; + }; + ctrl.changeViewMode = function(mode) { + viewSelect.value = mode; + if (ganttChart) ganttChart.change_view_mode(mode); + }; + ctrl.highlightTask = function(taskId) { + grid.querySelectorAll(".sf-gantt-row").forEach(function(row) { + row.classList.toggle("selected", row.dataset.taskId === taskId); + }); + var svg = chartContainer.querySelector("svg"); + if (svg) { + svg.querySelectorAll(".bar-wrapper").forEach(function(bw) { + bw.classList.remove("highlighted"); + }); + var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); + if (bar) bar.classList.add("highlighted"); + } + }; + ctrl.destroy = function() { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + ganttChart = null; + mounted = false; + mountTarget = null; + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); + }; + return ctrl; + function initSplit() { + if (typeof Split !== "function") return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + splitInstance = Split(["#" + gridPaneId, "#" + chartPaneId], { + direction: "vertical", + sizes: splitSizes, + minSize: splitMinSize, + snapOffset: 30, + gutterSize: 4, + cursor: "col-resize", + onDragEnd: function() { + if (ganttChart) { + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 100); + } + } + }); } - - function request(method, path, body) { - var opts = { method: method, headers: headers() }; - if (body !== undefined) opts.body = JSON.stringify(body); - return fetch(baseUrl + path, opts).then(function (res) { - if (!res.ok) throw createRequestError(method, path, res); - var ct = res.headers.get('content-type') || ''; - return ct.indexOf('json') !== -1 ? res.json() : res.text(); + function bindResizeObserver2() { + if (typeof ResizeObserver !== "function") return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function() { + if (!ganttChart) return; + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 0); }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); } - - return { - createJob: function (data) { - return request('POST', jobsPath, data).then(resolveJobId); - }, - getJob: function (id) { - return request('GET', jobsPath + '/' + id); - }, - getJobStatus: function (id) { - return request('GET', jobsPath + '/' + id + '/status'); - }, - getSnapshot: function (id, snapshotRevision) { - return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/snapshot', snapshotRevision)); - }, - analyzeSnapshot: function (id, snapshotRevision) { - return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/analysis', snapshotRevision)); - }, - pauseJob: function (id) { - return request('POST', jobsPath + '/' + id + '/pause'); - }, - resumeJob: function (id) { - return request('POST', jobsPath + '/' + id + '/resume'); - }, - cancelJob: function (id) { - return request('POST', jobsPath + '/' + id + '/cancel'); - }, - deleteJob: function (id) { - return request('DELETE', jobsPath + '/' + id); - }, - getDemoData: function (name) { - return request('GET', demoDataPath + '/' + (name || 'STANDARD')); - }, - listDemoData: function () { - return request('GET', demoDataPath); - }, - streamJobEvents: function (id, onMessage, onError) { - var url = baseUrl + jobsPath + '/' + id + '/events'; - var es = new EventSource(url); - var closed = false; - es.onmessage = function (e) { - try { onMessage(JSON.parse(e.data)); } catch (_) {} + function normalizePair(value, fallback) { + if (typeof value === "number" && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + function validateMountTarget(target) { + assert(target && typeof target.appendChild === "function", "gantt.mount(parent) requires a valid DOM container"); + assert(getElementSize(target, "Width") > 0 && getElementSize(target, "Height") > 0, "gantt.mount(parent) target is not laid out yet"); + } + function getElementSize(target, axis) { + var clientKey = "client" + axis; + var offsetKey = "offset" + axis; + var rectKey = axis === "Width" ? "width" : "height"; + if (typeof target[clientKey] === "number") return target[clientKey]; + if (typeof target[offsetKey] === "number") return target[offsetKey]; + if (typeof target.getBoundingClientRect === "function") { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === "number") return rect[rectKey]; + } + return 0; + } + function tasksToFrappe(taskList) { + return taskList.filter(function(t) { + return t.start && t.end; + }).map(function(t) { + var customClass = t.custom_class || ""; + if (t.pinned) { + customClass = customClass ? customClass + " pinned" : "pinned"; + } + return { + id: t.id, + name: t.name || t.label || t.id, + start: t.start, + end: t.end, + custom_class: customClass, + dependencies: t.dependencies || "" }; - es.onerror = function () { - if (closed || !onError) return; - if (typeof EventSource !== 'undefined' && es.readyState === EventSource.CLOSED) { - onError(createSseClosedError(url)); + }); + } + function renderChart(taskList) { + var frappeTasks = tasksToFrappe(taskList); + if (frappeTasks.length === 0) { + chartContainer.textContent = ""; + chartContainer.appendChild(el("div", { + className: "sf-gantt-empty-state", + style: { + padding: "24px", + color: "var(--sf-gray-400)", + fontFamily: "var(--sf-font-mono)", + fontSize: "13px" } - }; - return function close() { - closed = true; - es.onmessage = null; - es.onerror = null; - es.close(); - }; - }, - }; - } - - /* ── Tauri IPC backend ── */ - - function createTauriBackend(config) { - sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); - sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); - sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); - - var invoke = config.invoke; - var listen = config.listen; - var commands = config.commands || {}; - var eventName = config.eventName || 'solver-update'; - - return { - createJob: function (data) { - return invoke(commands.createJob || 'create_job', { request: data }).then(resolveJobId); - }, - getJob: function (id) { - return invoke(commands.getJob || 'get_job', { id: id }); - }, - getJobStatus: function (id) { - return invoke(commands.getJobStatus || 'get_job_status', { id: id }); - }, - getSnapshot: function (id, snapshotRevision) { - var payload = { id: id }; - if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision; - return invoke(commands.getSnapshot || 'get_snapshot', payload); - }, - analyzeSnapshot: function (id, snapshotRevision) { - var payload = { id: id }; - if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision; - return invoke(commands.analyzeSnapshot || 'analyze_snapshot', payload); - }, - pauseJob: function (id) { - return invoke(commands.pauseJob || 'pause_job', { id: id }); - }, - resumeJob: function (id) { - return invoke(commands.resumeJob || 'resume_job', { id: id }); - }, - cancelJob: function (id) { - return invoke(commands.cancelJob || 'cancel_job', { id: id }); - }, - deleteJob: function (id) { - return invoke(commands.deleteJob || 'delete_job', { id: id }); - }, - getDemoData: function (name) { - return invoke(commands.demoData || 'demo_seed', { name: name }); - }, - listDemoData: function () { - return Promise.resolve([]); - }, - streamJobEvents: function (id, onMessage) { - var targetId = String(id); - var unlisten = null; - listen(eventName, function (event) { - var payload = event && event.payload ? event.payload : {}; - var payloadId = resolveEventJobId(payload); - if (payloadId && payloadId !== targetId) return; - onMessage(payload); - }).then(function (fn) { unlisten = fn; }); - return function close() { if (unlisten) unlisten(); }; - }, - }; - } - - function createSseClosedError(url) { - var err = new Error('Event stream closed for ' + url); - err.code = 'SSE_CLOSED'; - err.transport = 'sse'; - err.url = url; - return err; - } - -})(SF); -/* ============================================================================ - SolverForge UI — Solver Lifecycle - Shared job orchestration for start, pause, resume, cancel, and snapshots. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createSolver = function (config) { - sf.assert(config, 'createSolver(config) requires a configuration object'); - sf.assert(config.backend, 'createSolver(config.backend) is required'); - sf.assert(hasFunction(config.backend, 'createJob'), 'createSolver(config.backend.createJob) must be a function'); - sf.assert(hasFunction(config.backend, 'getSnapshot'), 'createSolver(config.backend.getSnapshot) must be a function'); - sf.assert(hasFunction(config.backend, 'analyzeSnapshot'), 'createSolver(config.backend.analyzeSnapshot) must be a function'); - sf.assert(hasFunction(config.backend, 'pauseJob'), 'createSolver(config.backend.pauseJob) must be a function'); - sf.assert(hasFunction(config.backend, 'resumeJob'), 'createSolver(config.backend.resumeJob) must be a function'); - sf.assert(hasFunction(config.backend, 'cancelJob'), 'createSolver(config.backend.cancelJob) must be a function'); - sf.assert(hasFunction(config.backend, 'deleteJob'), 'createSolver(config.backend.deleteJob) must be a function'); - sf.assert(hasFunction(config.backend, 'streamJobEvents'), 'createSolver(config.backend.streamJobEvents) must be a function'); - sf.assert(!config.onProgress || typeof config.onProgress === 'function', 'createSolver(config.onProgress) must be a function'); - sf.assert(!config.onSolution || typeof config.onSolution === 'function', 'createSolver(config.onSolution) must be a function'); - sf.assert(!config.onPauseRequested || typeof config.onPauseRequested === 'function', 'createSolver(config.onPauseRequested) must be a function'); - sf.assert(!config.onPaused || typeof config.onPaused === 'function', 'createSolver(config.onPaused) must be a function'); - sf.assert(!config.onResumed || typeof config.onResumed === 'function', 'createSolver(config.onResumed) must be a function'); - sf.assert(!config.onCancelled || typeof config.onCancelled === 'function', 'createSolver(config.onCancelled) must be a function'); - sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); - sf.assert(!config.onFailure || typeof config.onFailure === 'function', 'createSolver(config.onFailure) must be a function'); - sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); - sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); - - var backend = config.backend; - var statusBar = config.statusBar; - var closeStream = null; - var activeJobId = null; - var retainedJobId = null; - var lifecycleState = 'IDLE'; - var phase = 'idle'; - var runToken = 0; - var lastSnapshotRevision = null; - var lastMeta = null; - var lastNotifiedError = null; - var queuedAction = null; - var pendingPause = null; - var pendingResume = null; - var pendingCancel = null; - var terminalSync = null; - - var api = {}; - - api.start = function (data) { - if (retainedJobId) { - return Promise.reject(new Error('Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first')); + }, "No scheduled tasks to display.")); + ganttChart = null; + return; } - if (phase !== 'idle') return Promise.resolve(); - - resetForStart(); - phase = 'starting'; - runToken += 1; - applyLifecycleState('STARTING'); - updateMoves(null); - - var token = runToken; - return backend.createJob(data).then(function (id) { - if (token !== runToken) return; - var jobId = ensureJobId(id); - - activeJobId = jobId; - retainedJobId = jobId; - phase = 'solving'; - applyLifecycleState('SOLVING'); - - attachStream(token, jobId); - - if (queuedAction === 'pause') { - queuedAction = null; - requestPause(token, jobId); - } else if (queuedAction === 'cancel') { - queuedAction = null; - requestCancel(token, jobId); - } - }).catch(function (err) { - if (token !== runToken) return; - if (retainedJobId) { - failTransport(err); - } else { - failStartup(err); + chartContainer.textContent = ""; + chartContainer.appendChild(createSvgRoot(svgId)); + ganttChart = new Gantt("#" + svgId, frappeTasks, { + view_mode: viewSelect.value || "Quarter Day", + date_format: "YYYY-MM-DD HH:mm", + custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, + on_click: function(task) { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + on_date_change: function(task, start, end) { + if (config.onDateChange) config.onDateChange(task, start, end); } - throw err; }); - }; - - api.pause = function () { - if (pendingPause) return pendingPause.promise; - if (phase === 'starting' && !activeJobId) { - queuedAction = 'pause'; - pendingPause = createDeferred(); - return pendingPause.promise; - } - var jobId = currentJobId(); - if (phase !== 'solving' || !jobId) return Promise.resolve(); - - pendingPause = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'pause')) return pendingPause.promise; - requestPause(runToken, jobId); - return pendingPause.promise; - }; - - api.resume = function () { - if (pendingResume) return pendingResume.promise; - var jobId = currentJobId(); - if (phase !== 'paused' || !jobId) return Promise.resolve(); - - pendingResume = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'resume')) return pendingResume.promise; - requestResume(runToken, jobId); - return pendingResume.promise; - }; - - api.cancel = function () { - if (pendingCancel) return pendingCancel.promise; - if (phase === 'starting' && !activeJobId) { - queuedAction = 'cancel'; - pendingCancel = createDeferred(); - return pendingCancel.promise; - } - var jobId = currentJobId(); - if (phase === 'cancelling' && jobId) { - pendingCancel = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'cancel')) return pendingCancel.promise; - return pendingCancel.promise; - } - if (!jobId || !isCancelablePhase()) return Promise.resolve(); - - pendingCancel = createDeferred(); - if (!ensureStreamAttached(runToken, jobId, 'cancel')) return pendingCancel.promise; - requestCancel(runToken, jobId); - return pendingCancel.promise; - }; - - api.delete = function () { - if (!retainedJobId) return Promise.resolve(); - if (!isTerminalLifecycle(lifecycleState)) { - return Promise.reject(new Error('Cannot delete a retained job before it reaches a terminal lifecycle state')); - } - - var jobId = retainedJobId; - return ensureTerminalSyncBeforeDelete(jobId).then(function () { - if (retainedJobId !== jobId) return; - return backend.deleteJob(jobId); - }).then(function () { - if (retainedJobId !== jobId) return; - resetAfterDelete(); - }).catch(function (err) { - notifyError(err); - throw err; + } + function renderGrid(taskList) { + while (grid.firstChild) grid.removeChild(grid.firstChild); + var table = el("table", { className: "sf-gantt-table" }); + var columns = config.columns || [ + { key: "name", label: "Task" }, + { key: "start", label: "Start" }, + { key: "end", label: "End" } + ]; + var sortedTasks = sortTasks(taskList); + var thead = el("thead"); + var headerRow = el("tr"); + columns.forEach(function(col) { + headerRow.appendChild(buildHeaderCell(col)); }); - }; - - api.getSnapshot = function (snapshotRevision) { - var jobId = currentJobId(); - if (!jobId) return Promise.reject(new Error('No retained job is available')); - var revision = resolveRequestedSnapshotRevision(snapshotRevision); - return backend.getSnapshot(jobId, revision).then(function (payload) { - return normalizeSnapshot(payload, lastMeta); + thead.appendChild(headerRow); + table.appendChild(thead); + var tbody = el("tbody"); + sortedTasks.forEach(function(task) { + var rowClasses = ["sf-gantt-row"]; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push("sf-project-" + task.projectIndex); + var tr = el("tr", { + className: rowClasses.join(" "), + dataset: { taskId: task.id }, + onClick: function() { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + } + }); + columns.forEach(function(col) { + var td = el("td"); + if (col.key === "name") { + td.className = "sf-task-name"; + td.textContent = task.name || task.label || task.id; + } else if (col.render) { + var content = col.render(task); + if (typeof content === "string") td.textContent = content; + else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; + else if (content instanceof Node) td.appendChild(content); + } else { + td.textContent = task[col.key] || ""; + td.style.fontFamily = "var(--sf-font-mono)"; + td.style.fontSize = "12px"; + } + tr.appendChild(td); + }); + tbody.appendChild(tr); }); - }; - - api.analyzeSnapshot = function (snapshotRevision) { - var jobId = currentJobId(); - if (!jobId) return Promise.reject(new Error('No retained job is available')); - var revision = resolveRequestedSnapshotRevision(snapshotRevision); - return backend.analyzeSnapshot(jobId, revision).then(function (payload) { - return normalizeAnalysis(payload, lastMeta); + table.appendChild(tbody); + grid.appendChild(table); + } + function buildHeaderCell(col) { + if (!col.sortable) { + return el("th", null, col.label); + } + var isCurrent = sortState.key === col.key; + var th = el("th", { + className: "sortable" + (isCurrent ? " active" : ""), + role: "button", + tabIndex: 0, + "aria-sort": isCurrent ? sortState.direction === "asc" ? "ascending" : "descending" : "none" }); - }; - - api.isRunning = function () { - return phase !== 'idle' && phase !== 'paused'; - }; - - api.getJobId = function () { - return activeJobId != null ? activeJobId : retainedJobId; - }; - - api.getLifecycleState = function () { - return lifecycleState; - }; - - api.getSnapshotRevision = function () { - return lastSnapshotRevision; - }; - - return api; - - function requestPause(token, id) { - phase = 'pause-requested'; - backend.pauseJob(id).catch(function (err) { - if (token !== runToken) return; - phase = 'solving'; - rejectDeferred('pause', err); - notifyError(err); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(el("span", { className: "sort-icon" }, isCurrent ? sortState.direction === "asc" ? "\u25B2" : "\u25BC" : "")); + bindActivation(th, function() { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === "asc" ? "desc" : "asc"; + } else { + sortState.key = col.key; + sortState.direction = "asc"; + } + renderGrid(tasks); }); + return th; } - - function attachStream(token, id) { - closeStream = backend.streamJobEvents(id, function (payload) { - if (token !== runToken) return; - handleEvent(token, id, payload); - }, function (err) { - if (token !== runToken) return; - failTransport(err); + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function(a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === "asc") return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; }); + return sorted; } - - function ensureStreamAttached(token, id, pendingName) { - if (closeStream) return true; - try { - attachStream(token, id); - return true; - } catch (err) { - failTransport(err); - rejectDeferred(pendingName, err); - return false; + function sortValue(value, key) { + if (value == null) return ""; + if (key === "start" || key === "end") { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; } + if (typeof value === "number") return value; + return String(value).toLowerCase(); } - - function requestResume(token, id) { - phase = 'resuming'; - backend.resumeJob(id).catch(function (err) { - if (token !== runToken) return; - phase = 'paused'; - rejectDeferred('resume', err); - notifyError(err); + function defaultPopup(task) { + var t = tasks.find(function(x) { + return x.id === task.id; }); + if (!t) return ""; + return '

' + escHtml(t.name || t.id) + "

Start: " + escHtml(t.start) + "

End: " + escHtml(t.end) + "

" + (t.duration_minutes ? "

Duration: " + t.duration_minutes + " min

" : "") + (t.pinned ? '

Pinned

' : "") + "
"; } - - function requestCancel(token, id) { - phase = 'cancelling'; - backend.cancelJob(id).catch(function (err) { - if (token !== runToken) return; - phase = lifecycleState === 'PAUSED' ? 'paused' : 'solving'; - rejectDeferred('cancel', err); - notifyError(err); - }); + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = id; + return svg; + } + return el("svg", { id }); } + }; + var gantt = { create }; - function handleEvent(token, expectedId, payload) { - var event = normalizeJobEvent(payload, expectedId); - if (!event) return; - - lastMeta = event.meta; - if (event.meta.snapshotRevision != null) { - lastSnapshotRevision = event.meta.snapshotRevision; - } - retainedJobId = event.meta.jobId; - activeJobId = event.meta.jobId; - - if (event.eventType === 'progress') { - if (!event.meta.currentScore) return; - phase = phaseForLifecycleState(event.meta.lifecycleState); - applyEventMeta(event.meta); - if (config.onProgress) config.onProgress(event.meta); - return; - } - - if (event.eventType === 'best_solution') { - if (!event.solution || !event.meta.currentScore) return; - phase = phaseForLifecycleState(event.meta.lifecycleState); - applyEventMeta(event.meta); - if (config.onSolution) { - config.onSolution(buildLiveSnapshot(event), event.meta); + // ts-src/rail/card.ts + var createHeader2 = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createHeader(config.columns) expects an array"); + var labelWidth = config.labelWidth || 200; + var columns = config.columns || []; + var header = el("div", { className: "sf-timeline-header" }); + header.style.gridTemplateColumns = labelWidth + "px 1fr"; + var spacer = el("div", { className: "sf-timeline-label-spacer" }, config.label || ""); + header.appendChild(spacer); + var days = el("div", { className: "sf-timeline-days" }); + days.style.gridTemplateColumns = "repeat(" + columns.length + ", 1fr)"; + columns.forEach(function(col) { + var colEl = el("div", { className: "sf-timeline-day-col" }); + colEl.appendChild(el("span", null, typeof col === "string" ? col : col.label)); + days.appendChild(colEl); + }); + header.appendChild(days); + return header; + }; + var createCard = function(config) { + assert(config, "createCard(config) requires a configuration object"); + var labelWidth = config.labelWidth || 200; + var card = el("div", { className: "sf-resource-card" }); + var state = { + unassigned: [], + railConfig: config + }; + if (config.id) card.dataset.resourceId = config.id; + var resHeader = el("div", { className: "sf-resource-header" }); + resHeader.style.gridTemplateColumns = labelWidth + "px 1fr"; + var identity = el("div", { className: "sf-resource-identity" }); + if (config.name) { + identity.appendChild(el("div", { className: "sf-resource-name" }, config.name)); + } + if (config.badges || config.type) { + var meta = el("div", { className: "sf-resource-meta" }); + if (config.type) { + var badge = el("span", { className: "sf-resource-type-badge" }, config.type); + if (config.typeStyle) { + badge.style.background = config.typeStyle.bg || ""; + badge.style.color = config.typeStyle.color || ""; + badge.style.border = config.typeStyle.border || ""; } - return; - } - - if (event.eventType === 'pause_requested') { - phase = 'pause-requested'; - applyEventMeta(event.meta); - if (config.onPauseRequested) config.onPauseRequested(event.meta); - return; + meta.appendChild(badge); } - - if (event.eventType === 'paused') { - phase = 'paused'; - applyEventMeta(event.meta); - syncSnapshotBundle(event.meta, true).then(function (bundle) { - if (token !== runToken || hasNewerEvent(event.meta)) return; - applyBundle(bundle); - if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); - resolveDeferred('pause', bundle); - }).catch(function (err) { - if (token !== runToken || hasNewerEvent(event.meta)) return; - rejectDeferred('pause', err); - notifyError(err); + var badges = Array.isArray(config.badges) ? config.badges : config.badges ? [config.badges] : []; + if (badges.length) { + badges.forEach(function(entry) { + if (!entry) return; + if (typeof entry === "string") { + meta.appendChild(el("span", { className: "sf-resource-type-badge" }, entry)); + return; + } + var extraBadge = el("span", { className: "sf-resource-type-badge" }, entry.label || ""); + if (entry.style) { + extraBadge.style.background = entry.style.bg || ""; + extraBadge.style.color = entry.style.color || ""; + extraBadge.style.border = entry.style.border || ""; + } + meta.appendChild(extraBadge); }); - return; - } - - if (event.eventType === 'resumed') { - phase = 'solving'; - applyEventMeta(event.meta); - if (config.onResumed) config.onResumed(event.meta); - resolveDeferred('resume', event.meta); - return; - } - - if (event.eventType === 'completed') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, true); - return; - } - - if (event.eventType === 'cancelled') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, false); - return; - } - - if (event.eventType === 'failed') { - phase = 'idle'; - applyEventMeta(event.meta); - runTerminalSync(createTerminalSync(event), token, event, false); } + identity.appendChild(meta); } - - function syncSnapshotBundle(meta, requireSnapshot) { - var analysisRequired = !!config.onAnalysis; - var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; - - return backend.getSnapshot(meta.jobId, snapshotRevision).then(function (snapshotPayload) { - var snapshot = normalizeSnapshot(snapshotPayload, meta); - if (!snapshot) throw new Error('Solver backend returned an invalid snapshot payload'); - - var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); - var result = { - meta: mergedMeta, - snapshot: snapshot, - analysis: null, - }; - - if (!analysisRequired) return result; - - return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function (analysisPayload) { - result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); - return result; - }); - }).catch(function (err) { - if (requireSnapshot) throw err; - - var fallback = { meta: meta, snapshot: null, analysis: null }; - if (!analysisRequired || snapshotRevision == null) return fallback; - - return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function (analysisPayload) { - fallback.analysis = normalizeAnalysis(analysisPayload, meta); - return fallback; - }).catch(function () { - return fallback; + resHeader.appendChild(identity); + if (config.gauges && config.gauges.length > 0) { + var gauges = el("div", { className: "sf-gauges" }); + config.gauges.forEach(function(g) { + var row = el("div", { className: "sf-gauge-row" }); + row.appendChild(el("span", { className: "sf-gauge-label" }, g.label)); + var track = el("div", { className: "sf-gauge-track" }); + var fill = el("div", { + className: "sf-gauge-fill" + (g.style ? " sf-gauge-fill--" + g.style : "") }); + fill.style.width = Math.min(g.pct || 0, 100) + "%"; + track.appendChild(fill); + row.appendChild(track); + if (g.text) row.appendChild(el("span", { className: "sf-gauge-value" }, g.text)); + gauges.appendChild(row); }); + resHeader.appendChild(gauges); } - - function applyBundle(bundle) { - if (!bundle) return; - lastMeta = bundle.meta; - if (bundle.meta && bundle.meta.snapshotRevision != null) { - lastSnapshotRevision = bundle.meta.snapshotRevision; - } - applyEventMeta(bundle.meta, bundle.analysis); - if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + card.appendChild(resHeader); + var body = el("div", { className: "sf-resource-body" }); + body.style.gridTemplateColumns = labelWidth + "px 1fr"; + var stats = el("div", { className: "sf-resource-stats" }); + if (config.stats) { + config.stats.forEach(function(s) { + var row = el("div", { className: "sf-stat-row" }); + row.appendChild(el("span", { className: "sf-stat-label" }, s.label)); + row.appendChild(el("span", { className: "sf-stat-value" }, String(s.value))); + stats.appendChild(row); + }); } - - function finalizeTerminal(meta) { - closeCurrentStream(); - activeJobId = null; - queuedAction = null; - phase = 'idle'; - applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : 'IDLE'); - updateMoves(null); + body.appendChild(stats); + var railContainer = el("div", { className: "sf-rail-container" }); + var rail2 = el("div", { className: "sf-rail" }); + if (config.id) rail2.id = "sf-rail-" + config.id; + var numCols = config.columns || 5; + var dayGrid = el("div", { className: "sf-day-grid" }); + dayGrid.style.gridTemplateColumns = "repeat(" + numCols + ", 1fr)"; + for (var i = 0; i < numCols; i++) { + dayGrid.appendChild(el("div", { className: "sf-day-col" })); } - - function failTransport(err) { - var jobId = activeJobId || retainedJobId; - retainedJobId = jobId; - closeCurrentStream(); - activeJobId = null; - phase = phaseForLifecycleState(lifecycleState); - queuedAction = null; - rejectDeferred('pause', err); - rejectDeferred('resume', err); - rejectDeferred('cancel', err); - notifyError(err); + rail2.appendChild(dayGrid); + railContainer.appendChild(rail2); + body.appendChild(railContainer); + card.appendChild(body); + if (config.heatmap) { + var heatmapCfg = { + horizon: config.heatmap.horizon || 1, + label: config.heatmap.label, + segments: config.heatmap.segments, + labelWidth + }; + heatmapCfg.railConfig = config; + var heatmap = createHeatmap(heatmapCfg); + if (heatmap) card.appendChild(heatmap); } - - function failStartup(err) { - closeCurrentStream(); - activeJobId = null; - retainedJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - lastNotifiedError = null; - phase = 'idle'; - queuedAction = null; - rejectDeferred('pause', err); - rejectDeferred('resume', err); - rejectDeferred('cancel', err); - applyLifecycleState('IDLE'); - updateMoves(null); - notifyError(err); + var unassignedRail = el("div", { className: "sf-unassigned-rail" }); + if (config.unassigned) { + state.unassigned = config.unassigned; + renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); } - - function applyEventMeta(meta, analysis) { - applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); - updateScore(readDisplayScore(meta, analysis)); - updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); - if (analysis) { - var constraints = readAnalysisConstraints(analysis); - if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { - statusBar.colorDotsFromAnalysis(constraints); + if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); + var cardApi = { + el: card, + rail: rail2, + addBlock: function(blockConfig) { + return addBlock(rail2, blockConfig); + }, + setUnassigned: function(items) { + state.unassigned = Array.isArray(items) ? items : []; + if (state.unassigned.length === 0 && unassignedRail.parentNode) { + unassignedRail.innerHTML = ""; + unassignedRail.parentNode?.removeChild(unassignedRail); + return; } + if (state.unassigned.length > 0) { + renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); + } else { + unassignedRail.innerHTML = ""; + } + if (state.unassigned.length > 0 && !unassignedRail.parentNode) { + card.appendChild(unassignedRail); + } + }, + clearBlocks: function() { + rail2.querySelectorAll(".sf-block, .sf-changeover").forEach(function(el2) { + el2.remove(); + }); + }, + setSolving: function(solving) { + card.classList.toggle("solving", solving); } + }; + return cardApi; + }; + var createHeatmap = function(config) { + if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; + var heatmap = el("div", { className: "sf-heatmap" }); + heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + "px 1fr"; + var label = el("div", { className: "sf-heatmap-label" }, config.label || ""); + heatmap.appendChild(label); + var track = el("div", { className: "sf-heatmap-track" }); + var columns = config.railConfig && config.railConfig.columns || 1; + track.style.gridTemplateColumns = "repeat(" + columns + ", 1fr)"; + heatmap.appendChild(track); + var horizon = config.horizon || 1; + config.segments.forEach(function(segment) { + if (!segment || segment.end <= segment.start) return; + var band = el("div", { className: "sf-heatmap-segment" }); + var start = Math.max(0, segment.start); + var width = Math.max(0, segment.end - start); + band.style.left = start / horizon * 100 + "%"; + band.style.width = Math.max(width / horizon * 100, 0.25) + "%"; + if (segment.color) band.style.background = segment.color; + if (segment.opacity != null) band.style.opacity = segment.opacity; + if (segment.tooltip) band.title = segment.tooltip; + track.appendChild(band); + }); + return heatmap; + }; + var createUnassignedRail = function(tasks, onTaskClick) { + var rail2 = el("div", { className: "sf-unassigned-rail" }); + renderUnassigned(rail2, tasks, onTaskClick); + return rail2; + }; + var addBlock = function(rail2, config) { + assert(rail2, "addBlock(rail) requires a rail element"); + assert(config && config.horizon != null, "addBlock(config.horizon) is required"); + assert(config.start != null && config.end != null, "addBlock(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; + var block = el("div", { className: "sf-block" }); + block.style.left = startPct + "%"; + block.style.width = Math.max(widthPct, minWidthPct) + "%"; + if (config.color) { + block.style.background = config.color; + block.style.borderLeftColor = config.borderColor || config.color; } - - function readDisplayScore(meta, analysis) { - if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; - if (analysis && analysis.score != null) return analysis.score; - return null; + if (config.className) block.classList.add(config.className); + if (config.late) block.classList.add("late"); + if (config.id) block.dataset.blockId = config.id; + if (config.delay) block.style.animationDelay = config.delay; + if (config.label) { + block.appendChild(el("div", { className: "sf-block-label" }, config.label)); } - - function applyLifecycleState(state) { - lifecycleState = state || 'IDLE'; - if (!statusBar) return; - if (typeof statusBar.setLifecycleState === 'function') { - statusBar.setLifecycleState(lifecycleState); - return; - } - if (typeof statusBar.setSolving === 'function') { - statusBar.setSolving(isActiveLifecycle(lifecycleState)); - } + if (config.meta) { + block.appendChild(el("div", { className: "sf-block-meta" }, config.meta)); } - - function updateScore(score) { - if (statusBar && typeof statusBar.updateScore === 'function') { - statusBar.updateScore(score); - } + if (config.onHover) { + block.addEventListener("mouseenter", function(e) { + config.onHover(e, config); + }); } - - function updateMoves(value) { - if (statusBar && typeof statusBar.updateMoves === 'function') { - statusBar.updateMoves(value); - } + if (config.onLeave) { + block.addEventListener("mouseleave", function() { + config.onLeave(); + }); } - - function resetForStart() { - closeCurrentStream(); - activeJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - lastNotifiedError = null; - queuedAction = null; - pendingPause = null; - pendingResume = null; - pendingCancel = null; - terminalSync = null; + if (config.onClick) { + block.setAttribute("role", "button"); + block.tabIndex = 0; + bindActivation(block, function(e) { + config.onClick(e, config); + }); } + rail2.appendChild(block); + return block; + }; + var addChangeover = function(rail2, config) { + assert(rail2, "addChangeover(rail) requires a rail element"); + assert(config && config.horizon != null, "addChangeover(config.horizon) is required"); + assert(config.start != null && config.end != null, "addChangeover(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var co = el("div", { className: "sf-changeover" }); + co.style.left = startPct + "%"; + co.style.width = widthPct + "%"; + rail2.appendChild(co); + return co; + }; + function renderUnassigned(unassignedRail, items, onTaskClick) { + unassignedRail.innerHTML = ""; + (items || []).forEach(function(item) { + var label = typeof item === "string" ? item : item.label || item.id || ""; + if (!label) return; + var pill = el("button", { + className: "sf-unassigned-pill", + onClick: function() { + if (onTaskClick) onTaskClick(item); + } + }, label); + unassignedRail.appendChild(pill); + }); + } - function resetAfterDelete() { - closeCurrentStream(); - rejectDeferred('pause', new Error('Solver job was deleted before pause settled')); - rejectDeferred('resume', new Error('Solver job was deleted before resume settled')); - rejectDeferred('cancel', new Error('Solver job was deleted before cancel settled')); - runToken += 1; - activeJobId = null; - retainedJobId = null; - lastSnapshotRevision = null; - lastMeta = null; - queuedAction = null; - pendingPause = null; - pendingResume = null; - pendingCancel = null; - terminalSync = null; - phase = 'idle'; - applyLifecycleState('IDLE'); - updateScore(null); - updateMoves(null); - } - - function closeCurrentStream() { - if (!closeStream) return; - closeStream(); - closeStream = null; - } - - function currentJobId() { - return activeJobId != null ? activeJobId : retainedJobId; - } - - function hasNewerEvent(meta) { - var currentSequence = lastMeta && typeof lastMeta.eventSequence === 'number' ? lastMeta.eventSequence : null; - var candidateSequence = meta && typeof meta.eventSequence === 'number' ? meta.eventSequence : null; - if (currentSequence == null || candidateSequence == null) return false; - return currentSequence > candidateSequence; - } - - function resolveRequestedSnapshotRevision(snapshotRevision) { - if (snapshotRevision != null && snapshotRevision !== '') return snapshotRevision; - return lastSnapshotRevision; - } - - function createTerminalSync(event) { - var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; - terminalSync = { - jobId: event.meta.jobId, - eventType: event.eventType, - meta: event.meta, - status: 'pending', - promise: null, - error: null, - callbackDelivered: existing ? existing.callbackDelivered : false, - }; - return terminalSync; - } - - function runTerminalSync(record, token, event, requireSnapshot) { - record.status = 'pending'; - record.error = null; - record.meta = event.meta; - record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function (bundle) { - if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; - record.status = 'synced'; - record.error = null; - record.meta = bundle.meta; - finalizeTerminal(bundle.meta); - applyBundle(bundle); - deliverTerminalCallback(record, event, bundle); - settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); - return record; - }).catch(function (err) { - if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; - record.status = 'failed'; - record.error = err; - finalizeTerminal(event.meta); - deliverTerminalFailureCallback(record, event); - settlePendingFromTerminal(event.eventType, null, err); - notifyError(err); - return record; - }); - return record.promise; - } - - function ensureTerminalSyncBeforeDelete(jobId) { - var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; - if (!record) return Promise.resolve(); - - return Promise.resolve(record.promise).then(function () { - if (!requiresSuccessfulTerminalSync(record)) return; - if (record.status === 'synced') return; - return retryTerminalSync(record); - }); + // ts-src/rail/timeline.ts + var DAY_MINUTES = 24 * 60; + var SIX_HOUR_MINUTES = 6 * 60; + var WEEK_MINUTES = 7 * DAY_MINUTES; + var TRACK_HEIGHT = 34; + var TRACK_GAP = 8; + var TRACK_PADDING = 12; + var OVERVIEW_HEIGHT = 68; + var OVERVIEW_BLOCK_HEIGHT = 34; + var OVERVIEW_GROUP_GAP_MINUTES = 30; + var MIN_LABEL_WIDTH = 180; + var MIN_VISIBLE_TRACK_WIDTH = 320; + var MIN_CONTENT_TRACK_WIDTH = 480; + var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; + var TONE_MAP = { + emerald: { + id: "emerald", + background: "rgba(16, 185, 129, 0.22)", + border: "#059669", + text: "#064e3b", + overlay: "rgba(16, 185, 129, 0.10)" + }, + blue: { + id: "blue", + background: "rgba(59, 130, 246, 0.22)", + border: "#2563eb", + text: "#1e40af", + overlay: "rgba(59, 130, 246, 0.10)" + }, + amber: { + id: "amber", + background: "rgba(245, 158, 11, 0.24)", + border: "#d97706", + text: "#92400e", + overlay: "rgba(245, 158, 11, 0.10)" + }, + rose: { + id: "rose", + background: "rgba(244, 63, 94, 0.22)", + border: "#e11d48", + text: "#9f1239", + overlay: "rgba(244, 63, 94, 0.10)" + }, + violet: { + id: "violet", + background: "rgba(139, 92, 246, 0.22)", + border: "#7c3aed", + text: "#5b21b6", + overlay: "rgba(139, 92, 246, 0.10)" + }, + cyan: { + id: "cyan", + background: "rgba(6, 182, 212, 0.22)", + border: "#0891b2", + text: "#155e75", + overlay: "rgba(6, 182, 212, 0.10)" + }, + red: { + id: "red", + background: "rgba(239, 68, 68, 0.22)", + border: "#dc2626", + text: "#991b1b", + overlay: "rgba(239, 68, 68, 0.10)" + }, + slate: { + id: "slate", + background: "rgba(100, 116, 139, 0.20)", + border: "#475569", + text: "#1e293b", + overlay: "rgba(100, 116, 139, 0.08)" } - - function retryTerminalSync(record) { - var retryEvent = { - eventType: record.eventType, - meta: record.meta, - error: null, - }; - return runTerminalSync(record, runToken, retryEvent, true).then(function () { - if (record.status !== 'synced') { - throw record.error || new Error('Terminal snapshot synchronization failed'); + }; + var createTimeline = function(config) { + assert(config && config.model, "rail.createTimeline(config.model) requires a normalized model"); + var labelWidth = config.labelWidth == null ? 280 : assertFiniteNumber(config.labelWidth, "rail.createTimeline(labelWidth)"); + assert(labelWidth > 0, "rail.createTimeline(labelWidth) must be greater than zero"); + var state = { + cleanup: [], + config, + destroyed: false, + expandedClusters: {}, + hasQueuedPostMountSync: false, + instanceId: uid("sf-rail-timeline"), + labelWidth, + model: normalizeModel(config.model), + scrollSync: null, + viewport: null, + layout: null + }; + state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); + var root = el("section", { + className: "sf-rail-timeline", + dataset: { + labelWidth: String(labelWidth) + } + }); + root.setAttribute("role", "region"); + root.setAttribute("aria-label", config.title || "Scheduling timeline"); + var toolbar = el("div", { className: "sf-rail-timeline-toolbar" }); + var toolbarCopy = el("div", { className: "sf-rail-timeline-toolbar-copy" }); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-title" }, config.title || "Scheduling timeline")); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-subtitle" }, config.subtitle || "Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.")); + toolbar.appendChild(toolbarCopy); + var zoomControls = el("div", { className: "sf-rail-timeline-zoom-controls" }); + var zoomButtons = []; + normalizeZoomPresets(config.zoomPresets).forEach(function(preset) { + var button = el("button", { + className: "sf-rail-timeline-zoom-button", + type: "button", + dataset: { zoom: preset } + }, preset === "reset" ? "Reset" : preset.toUpperCase()); + button.addEventListener("click", function() { + if (preset === "reset") { + api.setViewport(state.model.axis.initialViewport); + return; } + api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); }); + zoomButtons.push(button); + zoomControls.appendChild(button); + }); + if (zoomButtons.length) { + toolbar.appendChild(zoomControls); } - - function requiresSuccessfulTerminalSync(record) { - return record.eventType === 'completed' - && (record.meta.lifecycleState === 'COMPLETED' || record.meta.lifecycleState === 'TERMINATED_BY_CONFIG'); + root.appendChild(toolbar); + var shell = el("div", { className: "sf-rail-timeline-shell" }); + var headerViewport = el("div", { className: "sf-rail-timeline-header-viewport" }); + var bodyViewport = el("div", { className: "sf-rail-timeline-body-viewport" }); + var headerRow = el("div", { className: "sf-rail-timeline-header-row" }); + var lanes = el("div", { className: "sf-rail-timeline-lanes" }); + headerViewport.appendChild(headerRow); + bodyViewport.appendChild(lanes); + shell.appendChild(headerViewport); + shell.appendChild(bodyViewport); + root.appendChild(shell); + var tooltip = el("div", { className: "sf-tooltip sf-rail-timeline-tooltip" }); + tooltip.id = uid("sf-rail-timeline-tooltip"); + tooltip.setAttribute("role", "tooltip"); + tooltip.setAttribute("aria-hidden", "true"); + root.appendChild(tooltip); + bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); + bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); + bindWindowResize(state, syncLayoutFromViewport); + function renderStructure() { + renderHeader(); + renderLanes(); } - - function deliverTerminalCallback(record, event, bundle) { - if (record.callbackDelivered) return; - if (event.eventType === 'completed') { - if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); - } else if (event.eventType === 'cancelled') { - if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); - } else if (event.eventType === 'failed') { - if (config.onFailure) config.onFailure(event.error || 'Solver job failed', bundle.meta, bundle.snapshot, bundle.analysis); - } - record.callbackDelivered = true; + function applyMeasuredLayout() { + state.layout = measureLayout(bodyViewport, state); + applyLayout(root, headerRow, lanes, state.layout); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); } - - function deliverTerminalFailureCallback(record, event) { - if (record.callbackDelivered || event.eventType !== 'failed') return; - if (config.onFailure) config.onFailure(event.error || 'Solver job failed', event.meta, null, null); - record.callbackDelivered = true; + function renderHeader() { + headerRow.innerHTML = ""; + var corner = el("div", { className: "sf-rail-timeline-label-corner" }, config.label || "Lane"); + headerRow.appendChild(corner); + var axis = el("div", { className: "sf-rail-timeline-axis sf-rail-timeline-axis--header" }); + axis.style.height = "82px"; + renderAxisDecor(axis, state.model.axis, true); + headerRow.appendChild(axis); } - - function terminalEventError(event) { - if (event.eventType !== 'failed') return null; - return new Error(event.error || 'Solver job failed'); + function renderLanes() { + lanes.innerHTML = ""; + state.model.lanes.forEach(function(lane, laneIndex) { + var laneRender = lane.mode === "overview" ? buildOverviewRender(lane, state, function() { + rerenderTimeline(); + }) : buildDetailedRender(lane, lane.items); + var row = el("div", { + className: "sf-rail-timeline-row sf-rail-timeline-row--" + lane.mode + (laneRender.expandedClusterId ? " sf-rail-timeline-row--expanded" : ""), + dataset: { + laneId: lane.id, + mode: lane.mode, + trackCount: String(laneRender.trackCount) + } + }); + if (laneRender.expandedClusterId) { + row.dataset.expandedClusterId = laneRender.expandedClusterId; + } + row.setAttribute("role", "group"); + var label = buildLaneLabel( + lane, + laneRender, + row, + buildScopedId(state.instanceId, "lane-title-" + laneIndex) + ); + row.appendChild(label); + var track = el("div", { className: "sf-rail-timeline-track" }); + track.style.height = laneRender.height + "px"; + renderAxisDecor(track, state.model.axis, false); + renderOverlays(track, lane.overlays, state.model.axis); + laneRender.blocks.forEach(function(blockConfig) { + appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); + }); + row.appendChild(track); + lanes.appendChild(row); + }); } - - function isCancelablePhase() { - return phase === 'solving' || phase === 'pause-requested' || phase === 'paused' || phase === 'resuming'; + function rerenderTimeline() { + renderStructure(); + syncLayoutFromViewport(); } - - function phaseForLifecycleState(state) { - if (state === 'STARTING') return 'starting'; - if (state === 'SOLVING') return 'solving'; - if (state === 'PAUSE_REQUESTED') return 'pause-requested'; - if (state === 'PAUSED') return 'paused'; - if (state === 'RESUMING') return 'resuming'; - if (state === 'CANCELLING') return 'cancelling'; - return 'idle'; + function syncLayoutFromViewport() { + applyMeasuredLayout(); + syncScrollToViewport(); } - - function isTerminalLifecycle(state) { - return state === 'COMPLETED' - || state === 'CANCELLED' - || state === 'FAILED' - || state === 'TERMINATED_BY_CONFIG'; + function syncScrollToViewport() { + if (!state.layout) return; + var scrollLeft = viewportToScrollLeft(state, bodyViewport); + state.scrollSync = bodyViewport; + bodyViewport.scrollLeft = scrollLeft; + headerViewport.scrollLeft = scrollLeft; + state.scrollSync = null; } - - function settlePendingFromTerminal(eventType, bundle, err) { - if (eventType === 'cancelled') { - resolveDeferred('cancel', bundle); - } else if (pendingCancel) { - if (bundle) pendingCancel.resolve(bundle); - else pendingCancel.reject(err || new Error('Cancel did not settle before the job terminated')); - pendingCancel = null; + var api = { + destroy: function() { + if (state.destroyed) return; + state.destroyed = true; + state.cleanup.forEach(function(cleanup) { + if (typeof cleanup === "function") cleanup(); + }); + root.innerHTML = ""; + }, + el: root, + expandCluster: function(laneId, clusterId) { + setExpandedCluster(state, laneId, clusterId); + rerenderTimeline(); + }, + setModel: function(nextModel) { + state.model = normalizeModel(nextModel); + state.viewport = clampViewport(state.model.axis, state.viewport); + pruneExpandedClusters(state); + rerenderTimeline(); + queuePostMountSync(state, syncLayoutFromViewport); + }, + setViewport: function(nextViewport) { + state.viewport = clampViewport( + state.model.axis, + normalizeViewportInput(nextViewport, "rail.createTimeline().setViewport(viewport)") + ); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); } - - rejectDeferred('pause', err || new Error('Job terminated before pause settled')); - rejectDeferred('resume', err || new Error('Job terminated before resume settled')); - } - - function resolveDeferred(name, value) { - var deferred = getDeferred(name); - if (!deferred) return; - deferred.resolve(value); - setDeferred(name, null); - } - - function rejectDeferred(name, err) { - var deferred = getDeferred(name); - if (!deferred) return; - deferred.reject(err); - setDeferred(name, null); - } - - function getDeferred(name) { - if (name === 'pause') return pendingPause; - if (name === 'resume') return pendingResume; - if (name === 'cancel') return pendingCancel; - return null; + }; + renderStructure(); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + return api; + }; + function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { + var tone = blockConfig.tone; + var relativeStart = blockConfig.startMinute - axis.startMinute; + var relativeEnd = blockConfig.endMinute - axis.startMinute; + var horizon = axis.endMinute - axis.startMinute; + var block = addBlock(track, { + start: relativeStart, + end: relativeEnd, + horizon, + label: blockConfig.label, + meta: blockConfig.metaLabel, + color: tone.background, + borderColor: tone.border, + minWidthPct: 0, + onClick: blockConfig.onClick, + onHover: function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }, + onLeave: function() { + hideTooltip(tooltip); + } + }); + block.classList.add("sf-rail-timeline-item"); + block.classList.add(blockConfig.kindClass); + block.style.left = positionPct(blockConfig.startMinute, axis) + "%"; + block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + "%"; + block.style.top = blockConfig.top + "px"; + block.style.height = blockConfig.height + "px"; + block.style.bottom = "auto"; + block.style.color = tone.text; + block.tabIndex = 0; + block.dataset.itemId = blockConfig.itemId; + block.dataset.laneId = lane.id; + block.dataset.startMinute = String(blockConfig.startMinute); + block.dataset.endMinute = String(blockConfig.endMinute); + if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); + if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; + if (blockConfig.onClick) { + block.setAttribute("role", "button"); + block.setAttribute("aria-expanded", blockConfig.expanded ? "true" : "false"); + } else { + block.setAttribute("role", "group"); } - - function setDeferred(name, value) { - if (name === 'pause') pendingPause = value; - if (name === 'resume') pendingResume = value; - if (name === 'cancel') pendingCancel = value; + if (blockConfig.ariaLabel) block.setAttribute("aria-label", blockConfig.ariaLabel); + block.setAttribute("aria-describedby", tooltip.id); + if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); + if (blockConfig.detailHint) { + block.appendChild(el("span", { className: "sf-rail-timeline-detail-hint" }, blockConfig.detailHint)); } - - function notifyError(err) { - if (err && lastNotifiedError === err) return; - lastNotifiedError = err || null; - if (config.onError) config.onError(err && err.message ? err.message : String(err)); + block.title = blockConfig.tooltip.title; + block.addEventListener("mousemove", function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }); + block.addEventListener("focus", function() { + showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + }); + block.addEventListener("blur", function() { + hideTooltip(tooltip); + }); + block.addEventListener("keydown", function(event) { + if (event && event.key === "Escape") hideTooltip(tooltip); + }); + } + function appendOverviewSummary(block, summary) { + var footer = el("div", { className: "sf-rail-timeline-summary-footer" }); + if (summary.badges.length > 0) { + var badgeRail = el("div", { className: "sf-rail-timeline-summary-badges" }); + summary.badges.forEach(function(badge) { + badgeRail.appendChild(el("span", { + className: "sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--" + badge.kind + }, badge.text)); + }); + footer.appendChild(badgeRail); } - - function ensureJobId(id) { - var jobId = sf.normalizeCreateJobId(id); - if (jobId) return jobId; - throw new Error('Invalid solver backend createJob response'); + if (summary.toneSegments.length > 0) { + var toneBar = el("div", { + className: "sf-rail-timeline-summary-tonebar", + "aria-hidden": "true" + }); + var total = summary.toneSegments.reduce(function(sum, segment) { + return sum + segment.count; + }, 0) || 1; + summary.toneSegments.forEach(function(segment) { + var toneSegment = el("span", { className: "sf-rail-timeline-summary-tone-segment" }); + toneSegment.style.background = segment.tone.border; + toneSegment.style.width = segment.count / total * 100 + "%"; + toneBar.appendChild(toneSegment); + }); + footer.appendChild(toneBar); } - }; - - function hasFunction(object, key) { - return !!(object && typeof object[key] === 'function'); + if (footer.children.length > 0) block.appendChild(footer); } - - function createDeferred() { - var resolve; - var reject; - var promise = new Promise(function (res, rej) { - resolve = res; - reject = rej; + function bindScrollSync(source, target, state, root, zoomButtons) { + source.addEventListener("scroll", function() { + handleScroll(source, target, state, root, zoomButtons); + }); + target.addEventListener("scroll", function() { + handleScroll(target, source, state, root, zoomButtons); }); - return { promise: promise, resolve: resolve, reject: reject }; } - - function normalizeJobEvent(payload, expectedId) { - if (!payload || typeof payload !== 'object') return null; - - var eventType = normalizeEventType(readField(payload, ['eventType', 'event_type', 'type'])); - if (!eventType) return null; - - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); - if (jobId == null || jobId === '') jobId = expectedId; - if (jobId == null || jobId === '') return null; - if (String(jobId) !== String(expectedId)) return null; - - var solution = payload.solution || (payload.data && payload.data.solution) || null; - var solutionScore = readField(solution, ['score'], [solution]); - var meta = { - id: String(jobId), - jobId: String(jobId), - eventType: eventType, - eventSequence: readField(payload, ['eventSequence', 'event_sequence'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state', 'solverStatus', 'solver_status'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, - telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), - currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || solutionScore || null, - bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || solutionScore || null, - snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), - }; - - return { - eventType: eventType, - meta: meta, - solution: solution, - error: readField(payload, ['error'], [payload, payload.data]) || null, + function bindDragPan(source, target, state, root, zoomButtons) { + var drag = { + active: false, + startClientX: 0, + startScrollLeft: 0 }; + source.addEventListener("mousedown", function(event) { + if (event.button != null && event.button !== 0) return; + drag.active = true; + drag.startClientX = event.clientX != null ? event.clientX : 0; + drag.startScrollLeft = source.scrollLeft || 0; + source.classList.add("is-dragging"); + if (event.preventDefault) event.preventDefault(); + }); + source.addEventListener("mousemove", function(event) { + if (!drag.active) return; + var clientX = event.clientX != null ? event.clientX : drag.startClientX; + var delta = clientX - drag.startClientX; + source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); + handleScroll(source, target, state, root, zoomButtons); + if (event.preventDefault) event.preventDefault(); + }); + function finishDrag() { + if (!drag.active) return; + drag.active = false; + source.classList.remove("is-dragging"); + } + source.addEventListener("mouseup", finishDrag); + source.addEventListener("mouseleave", finishDrag); } - - function normalizeSnapshot(payload, fallbackMeta) { - if (!payload || typeof payload !== 'object') return null; - - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]); - if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; - var solution = payload.solution || (payload.data && payload.data.solution) || null; - var solutionScore = readField(solution, ['score'], [solution]); - return { - id: jobId != null ? String(jobId) : null, - jobId: jobId != null ? String(jobId) : null, - snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]), - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || null, - currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.data]) || solutionScore || null, - bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.data]) || solutionScore || null, - telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.data]), payload), - solution: solution, - }; + function handleScroll(source, target, state, root, zoomButtons) { + if (state.destroyed) return; + if (!state.layout) return; + if (state.scrollSync === source) return; + state.scrollSync = source; + target.scrollLeft = source.scrollLeft; + state.viewport = scrollLeftToViewport(state, source); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + state.scrollSync = null; } - - function normalizeAnalysis(payload, fallbackMeta) { - if (!payload || typeof payload !== 'object') return null; - - var analysisBody = payload.analysis || (payload.data && payload.data.analysis) || payload; - var constraints = readAnalysisConstraints(analysisBody); - var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]); - if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; - var snapshotRevision = readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]); - if (snapshotRevision == null || snapshotRevision === '') { - snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; - } + function measurePackedHeight(packed) { + return packed.trackCount > 0 ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP : OVERVIEW_HEIGHT; + } + function buildDetailBlockConfig(item, lane, trackIndex, top, config = {}) { + const i = item; + const l = lane; return { - jobId: jobId != null ? String(jobId) : null, - snapshotRevision: snapshotRevision != null ? snapshotRevision : null, - lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), - terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.terminalReason) || null, - analysis: analysisBody, - score: analysisBody && analysisBody.score != null ? analysisBody.score : null, - constraints: constraints, + clusterId: config.clusterId || null, + detailHint: config.detailHint || "", + endMinute: i.endMinute, + height: TRACK_HEIGHT, + itemId: i.id, + kindClass: "sf-rail-timeline-item--detail", + label: i.label, + metaLabel: describeMeta(i.meta), + startMinute: i.startMinute, + top, + ariaLabel: buildItemAriaLabel(i, l), + tooltip: buildItemTooltip(i, l), + tone: i.tone, + trackIndex }; } - - function buildLiveSnapshot(event) { + function buildOverviewBlockConfig(group, height, options) { + var config = options || {}; return { - id: event.meta.jobId, - jobId: event.meta.jobId, - snapshotRevision: event.meta.snapshotRevision, - lifecycleState: event.meta.lifecycleState, - terminalReason: event.meta.terminalReason, - currentScore: event.meta.currentScore, - bestScore: event.meta.bestScore, - telemetry: event.meta.telemetry, - solution: event.solution, + clusterId: config.clusterId || null, + endMinute: group.endMinute, + height: OVERVIEW_BLOCK_HEIGHT, + itemId: config.itemId, + kindClass: config.kindClass, + label: group.summary.primaryLabel, + metaLabel: group.summary.secondaryLabel, + onClick: config.onClick || null, + startMinute: group.startMinute, + summary: buildOverviewBlockSummary(group, !!config.expanded), + top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), + ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), + expanded: !!config.expanded, + tooltip: config.tooltip, + tone: group.tone }; } - - function mergeMeta(meta, snapshot, eventType) { - if (!snapshot) return meta; + function buildDetailedRender(lane, items) { + var packed = packItems(items); + var height = measurePackedHeight(packed); + var blocks = packed.items.map(function(entry) { + return buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) + ); + }); return { - id: meta && meta.id != null ? meta.id : snapshot.id, - jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, - eventType: meta && meta.eventType ? meta.eventType : eventType, - eventSequence: meta ? meta.eventSequence : null, - lifecycleState: (meta && meta.lifecycleState) || snapshot.lifecycleState || normalizeLifecycleState(null, eventType), - terminalReason: (meta && meta.terminalReason) || snapshot.terminalReason || null, - telemetry: snapshot.telemetry || (meta && meta.telemetry) || null, - currentScore: snapshot.currentScore || (meta && meta.currentScore) || null, - bestScore: snapshot.bestScore || (meta && meta.bestScore) || null, - snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : (meta && meta.snapshotRevision), + blocks, + height, + trackCount: packed.trackCount || 1 }; } - - function readField(payload, names, sources) { - var fields = Array.isArray(names) ? names : [names]; - var roots = sources || [payload]; - for (var i = 0; i < roots.length; i++) { - var source = roots[i]; - if (!source || typeof source !== 'object') continue; - for (var j = 0; j < fields.length; j++) { - if (source[fields[j]] != null) return source[fields[j]]; + function buildOverviewRender(lane, state, rerender) { + var groups = groupOverviewItems(lane); + var expandedClusterId = state.expandedClusters[lane.id] || null; + var expandedGroup = null; + var packedExpanded = null; + var expandedDetailsTop = 0; + groups.forEach(function(group) { + if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { + expandedGroup = group; } + }); + if (expandedGroup) { + packedExpanded = packItems(expandedGroup.detailItems); + expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; } - return null; + var height = packedExpanded ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) : OVERVIEW_HEIGHT; + var blocks = []; + groups.forEach(function(group) { + if (group.isCluster) { + var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); + blocks.push(buildOverviewBlockConfig(group, height, { + clusterId: group.clusterKey, + itemId: group.renderId, + kindClass: "sf-rail-timeline-item--cluster", + expanded: isExpanded, + onClick: function() { + setExpandedCluster( + state, + lane.id, + state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey + ); + if (state.config && state.config.onClusterToggle) { + state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); + } + if (typeof rerender === "function") rerender(); + }, + top: isExpanded ? TRACK_PADDING : null, + tooltip: buildClusterTooltip(group, lane) + })); + if (isExpanded) { + packedExpanded.items.forEach(function(entry) { + blocks.push(buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), + { + clusterId: group.clusterKey, + detailHint: "Expanded" + } + )); + }); + } + return; + } + blocks.push(buildOverviewBlockConfig(group, height, { + itemId: group.items[0].id, + kindClass: "sf-rail-timeline-item--overview", + tooltip: buildOverviewTooltip(group, lane) + })); + }); + return { + blocks, + expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, + height, + trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1 + }; } - - function normalizeEventType(value) { - if (typeof value !== 'string') return null; - var normalized = value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toLowerCase(); - if (!normalized) return null; - if (normalized === 'finished') return 'completed'; - return normalized; + function buildLaneLabel(lane, laneRender, row, headingId) { + var label = el("div", { + className: "sf-rail-timeline-lane-label", + dataset: { laneId: lane.id } + }); + label.style.minHeight = laneRender.height + "px"; + var heading = el("div", { className: "sf-rail-timeline-lane-heading" }); + var title = el("div", { className: "sf-rail-timeline-lane-title" }, lane.label); + title.id = headingId; + heading.appendChild(title); + if (lane.mode) { + heading.appendChild(el("div", { className: "sf-rail-timeline-lane-mode" }, lane.mode)); + } + label.appendChild(heading); + if (row) row.setAttribute("aria-labelledby", title.id); + if (lane.badges.length > 0) { + var badges = el("div", { className: "sf-rail-timeline-lane-badges" }); + lane.badges.forEach(function(badge) { + var badgeEl = el("span", { className: "sf-rail-timeline-lane-badge" }, badge.label); + if (badge.style) { + badgeEl.style.background = badge.style.bg || ""; + badgeEl.style.border = badge.style.border || ""; + badgeEl.style.color = badge.style.color || ""; + } + badges.appendChild(badgeEl); + }); + label.appendChild(badges); + } + if (lane.stats.length > 0) { + var stats = el("div", { className: "sf-rail-timeline-lane-stats" }); + lane.stats.forEach(function(stat) { + var statRow = el("div", { className: "sf-rail-timeline-lane-stat" }); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-label" }, stat.label)); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-value" }, String(stat.value))); + stats.appendChild(statRow); + }); + label.appendChild(stats); + } + return label; } - - function normalizeLifecycleState(value, eventType) { - if (typeof value === 'string' && value.trim()) { - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[\s-]+/g, '_') - .toUpperCase(); + function buildClusterTooltip(group, lane) { + var first = group.detailItems[0] || group.items[0]; + var payload = { + rows: [ + { key: "Lane", value: lane.label }, + { key: "Window", value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, + { key: "Items", value: String(group.summary.count) } + ], + title: group.label + }; + if (group.summary.openCount > 0) { + payload.rows.push({ key: "Open", value: String(group.summary.openCount) }); } - - if (eventType === 'progress' || eventType === 'best_solution' || eventType === 'resumed') return 'SOLVING'; - if (eventType === 'pause_requested') return 'PAUSE_REQUESTED'; - if (eventType === 'paused') return 'PAUSED'; - if (eventType === 'completed') return 'COMPLETED'; - if (eventType === 'cancelled') return 'CANCELLED'; - if (eventType === 'failed') return 'FAILED'; - return 'IDLE'; + if (group.summary.toneSegments.length > 0) { + payload.rows.push({ key: "Mix", value: describeToneSegments(group.summary.toneSegments) }); + } + if (first && first.meta) { + payload.rows.push({ key: "Sample", value: describeMeta(first.meta) }); + } + return payload; } - - function normalizeTelemetry(rawTelemetry, payload) { - if (rawTelemetry && typeof rawTelemetry === 'object') return rawTelemetry; - - var telemetry = {}; - var movesPerSecond = readField(payload, ['movesPerSecond', 'moves_per_second']); - var stepCount = readField(payload, ['stepCount', 'step_count']); - if (movesPerSecond != null) telemetry.movesPerSecond = movesPerSecond; - if (stepCount != null) telemetry.stepCount = stepCount; - return Object.keys(telemetry).length ? telemetry : null; + function buildItemTooltip(item, lane) { + var rows = [ + { key: "Lane", value: lane.label }, + { key: "Time", value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) } + ]; + appendMetaRows(rows, item.meta); + return { + rows, + title: item.label + }; } - - function readMovesPerSecond(telemetry) { - if (!telemetry || typeof telemetry !== 'object') return null; - if (telemetry.movesPerSecond != null) return telemetry.movesPerSecond; - if (telemetry.moves_per_second != null) return telemetry.moves_per_second; - return null; + function buildOverviewBlockMeta(group) { + if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; + var labels = []; + group.items.slice(0, 2).forEach(function(item) { + labels.push(item.label); + }); + if (group.count > 2) labels.push("+" + (group.count - 2) + " more"); + return labels.join(" \u2022 "); } - - function readAnalysisConstraints(analysis) { - if (!analysis || typeof analysis !== 'object') return null; - if (Array.isArray(analysis.constraints)) return analysis.constraints; - if (analysis.analysis && Array.isArray(analysis.analysis.constraints)) return analysis.analysis.constraints; - return null; + function buildPresetViewport(axis, currentViewport, preset) { + var duration = preset === "1w" ? WEEK_MINUTES : preset === "2w" ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; + var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); + var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; + var start = Math.round(center - visibleDuration / 2); + return clampViewport(axis, { + startMinute: start, + endMinute: start + visibleDuration + }); } - - function isActiveLifecycle(state) { - return state === 'STARTING' - || state === 'SOLVING' - || state === 'PAUSE_REQUESTED' - || state === 'RESUMING' - || state === 'CANCELLING'; + function clampNumber(value, min, max) { + return Math.min(Math.max(value, min), max); } - -})(SF); -/* ============================================================================ - SolverForge UI — API Guide Panel - Generates REST API documentation from endpoint definitions. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createApiGuide = function (config) { - sf.assert(config, 'createApiGuide(config) requires a configuration object'); - sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); - - var guide = sf.el('div', { className: 'sf-api-guide' }); - var endpoints = config.endpoints; - - endpoints.forEach(function (ep) { - var section = sf.el('div', { className: 'sf-api-section' }); - section.appendChild(sf.el('h3', null, (ep.method || 'GET') + ' ' + ep.path)); - if (ep.description) { - section.appendChild(sf.el('p', { style: { fontSize: '13px', color: 'var(--sf-gray-600)', marginBottom: '8px' } }, ep.description)); - } - - if (ep.curl) { - var block = sf.el('div', { className: 'sf-api-code-block' }); - block.appendChild(sf.el('code', null, ep.curl)); - var copyBtn = sf.el('button', { - className: 'sf-copy-btn', - 'aria-label': 'Copy command', - onClick: function () { - navigator.clipboard.writeText(ep.curl).then(function () { - copyBtn.textContent = 'Copied!'; - setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1500); - }); - }, - }, 'Copy'); - block.appendChild(copyBtn); - section.appendChild(block); - } - - guide.appendChild(section); - }); - - return guide; - }; -})(SF); -/* ============================================================================ - SolverForge UI — Timeline Rail - Resource-lane timeline: header + cards with positioned blocks. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.rail = {}; - - sf.rail.createHeader = function (config) { - sf.assert(config, 'createHeader(config) requires a configuration object'); - sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); - - var labelWidth = config.labelWidth || 200; - var columns = config.columns || []; - - var header = sf.el('div', { className: 'sf-timeline-header' }); - header.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - var spacer = sf.el('div', { className: 'sf-timeline-label-spacer' }, config.label || ''); - header.appendChild(spacer); - - var days = sf.el('div', { className: 'sf-timeline-days' }); - days.style.gridTemplateColumns = 'repeat(' + columns.length + ', 1fr)'; - - columns.forEach(function (col) { - var colEl = sf.el('div', { className: 'sf-timeline-day-col' }); - colEl.appendChild(sf.el('span', null, typeof col === 'string' ? col : col.label)); - days.appendChild(colEl); - }); - - header.appendChild(days); - return header; - }; - - sf.rail.createCard = function (config) { - sf.assert(config, 'createCard(config) requires a configuration object'); - - var labelWidth = config.labelWidth || 200; - var card = sf.el('div', { className: 'sf-resource-card' }); - var state = { - unassigned: [], - railConfig: config, + function clampViewport(axis, viewport) { + var totalDuration = axis.endMinute - axis.startMinute; + var next = viewport || axis.initialViewport || { + startMinute: axis.startMinute, + endMinute: axis.endMinute }; - - if (config.id) card.dataset.resourceId = config.id; - - // Header row (identity + gauges) - var resHeader = sf.el('div', { className: 'sf-resource-header' }); - resHeader.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - var identity = sf.el('div', { className: 'sf-resource-identity' }); - if (config.name) { - identity.appendChild(sf.el('div', { className: 'sf-resource-name' }, config.name)); + var duration = next.endMinute - next.startMinute; + duration = Math.min(duration, totalDuration); + var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); + return { + endMinute: start + duration, + startMinute: start + }; + } + function assertFiniteNumber(value, label) { + assert(typeof value === "number" && isFinite(value), label + " must be a finite number"); + return value; + } + function assertMinuteValue(value, label) { + return assertInteger(value, label); + } + function assertInteger(value, label) { + var number = assertFiniteNumber(value, label); + assert(Math.floor(number) === number, label + " must be an integer"); + return number; + } + function assertNonNegativeInteger(value, label) { + var number = assertInteger(value, label); + assert(number >= 0, label + " must be greater than or equal to zero"); + return number; + } + function describeMeta(meta) { + if (meta == null) return ""; + if (typeof meta === "string") return meta; + if (typeof meta === "number") return String(meta); + if (Array.isArray(meta)) { + return meta.map(function(entry) { + if (entry && entry.label && entry.value != null) return entry.label + ": " + entry.value; + return String(entry || ""); + }).filter(Boolean).join(" \u2022 "); } - if (config.badges || config.type) { - var meta = sf.el('div', { className: 'sf-resource-meta' }); - if (config.type) { - var badge = sf.el('span', { className: 'sf-resource-type-badge' }, config.type); - if (config.typeStyle) { - badge.style.background = config.typeStyle.bg || ''; - badge.style.color = config.typeStyle.color || ''; - badge.style.border = config.typeStyle.border || ''; - } - meta.appendChild(badge); - } - var badges = Array.isArray(config.badges) - ? config.badges - : config.badges - ? [config.badges] - : []; - if (badges.length) { - badges.forEach(function (entry) { - if (!entry) return; - if (typeof entry === 'string') { - meta.appendChild(sf.el('span', { className: 'sf-resource-type-badge' }, entry)); - return; - } - var extraBadge = sf.el('span', { className: 'sf-resource-type-badge' }, entry.label || ''); - if (entry.style) { - extraBadge.style.background = entry.style.bg || ''; - extraBadge.style.color = entry.style.color || ''; - extraBadge.style.border = entry.style.border || ''; - } - meta.appendChild(extraBadge); - }); - } - identity.appendChild(meta); + if (typeof meta === "object") { + return Object.keys(meta).map(function(key) { + return key + ": " + meta[key]; + }).join(" \u2022 "); } - resHeader.appendChild(identity); - - // Gauges - if (config.gauges && config.gauges.length > 0) { - var gauges = sf.el('div', { className: 'sf-gauges' }); - config.gauges.forEach(function (g) { - var row = sf.el('div', { className: 'sf-gauge-row' }); - row.appendChild(sf.el('span', { className: 'sf-gauge-label' }, g.label)); - var track = sf.el('div', { className: 'sf-gauge-track' }); - var fill = sf.el('div', { - className: 'sf-gauge-fill' + (g.style ? ' sf-gauge-fill--' + g.style : ''), - }); - fill.style.width = Math.min(g.pct || 0, 100) + '%'; - track.appendChild(fill); - row.appendChild(track); - if (g.text) row.appendChild(sf.el('span', { className: 'sf-gauge-value' }, g.text)); - gauges.appendChild(row); - }); - resHeader.appendChild(gauges); + return String(meta); + } + function appendMetaRows(rows, meta) { + if (meta == null) return; + if (typeof meta === "string" || typeof meta === "number") { + rows.push({ key: "Meta", value: String(meta) }); + return; } - - card.appendChild(resHeader); - - // Body (stats + rail) - var body = sf.el('div', { className: 'sf-resource-body' }); - body.style.gridTemplateColumns = labelWidth + 'px 1fr'; - - // Stats panel - var stats = sf.el('div', { className: 'sf-resource-stats' }); - if (config.stats) { - config.stats.forEach(function (s) { - var row = sf.el('div', { className: 'sf-stat-row' }); - row.appendChild(sf.el('span', { className: 'sf-stat-label' }, s.label)); - row.appendChild(sf.el('span', { className: 'sf-stat-value' }, String(s.value))); - stats.appendChild(row); + if (Array.isArray(meta)) { + meta.forEach(function(entry, index) { + if (!entry) return; + if (entry.label && entry.value != null) { + rows.push({ key: entry.label, value: String(entry.value) }); + return; + } + rows.push({ key: "Meta " + (index + 1), value: String(entry) }); }); + return; } - body.appendChild(stats); - - // Rail - var railContainer = sf.el('div', { className: 'sf-rail-container' }); - var rail = sf.el('div', { className: 'sf-rail' }); - if (config.id) rail.id = 'sf-rail-' + config.id; - - // Day grid - var numCols = config.columns || 5; - var dayGrid = sf.el('div', { className: 'sf-day-grid' }); - dayGrid.style.gridTemplateColumns = 'repeat(' + numCols + ', 1fr)'; - for (var i = 0; i < numCols; i++) { - dayGrid.appendChild(sf.el('div', { className: 'sf-day-col' })); - } - rail.appendChild(dayGrid); - - railContainer.appendChild(rail); - body.appendChild(railContainer); - card.appendChild(body); - - // Optional heatmap strip - if (config.heatmap) { - var heatmapCfg = { - horizon: config.heatmap.horizon || 1, - label: config.heatmap.label, - segments: config.heatmap.segments, - labelWidth: labelWidth, - }; - heatmapCfg.railConfig = config; - var heatmap = sf.rail.createHeatmap(heatmapCfg); - if (heatmap) card.appendChild(heatmap); - } - - // Optional unassigned list - var unassignedRail = sf.el('div', { className: 'sf-unassigned-rail' }); - if (config.unassigned) { - state.unassigned = config.unassigned; - renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); + if (typeof meta === "object") { + Object.keys(meta).forEach(function(key) { + rows.push({ key, value: String(meta[key]) }); + }); } - if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); - - // API - var cardApi = { el: card, rail: rail }; - - cardApi.addBlock = function (blockConfig) { - return sf.rail.addBlock(rail, blockConfig); + } + function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { + var startMinute = assertMinuteValue(startValue, startLabel); + var endMinute = assertMinuteValue(endValue, endLabel); + assert(endMinute > startMinute, endLabel + " must be greater than startMinute"); + return { + endMinute, + startMinute }; - - cardApi.setUnassigned = function (items) { - state.unassigned = Array.isArray(items) ? items : []; - if (state.unassigned.length === 0 && unassignedRail.parentNode) { - unassignedRail.innerHTML = ''; - unassignedRail.parentNode && unassignedRail.parentNode.removeChild(unassignedRail); - return; - } - if (state.unassigned.length > 0) { - renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); - } else { - unassignedRail.innerHTML = ''; - } - if (state.unassigned.length > 0 && !unassignedRail.parentNode) { - card.appendChild(unassignedRail); - } + } + function normalizeId(value, prefix, suffix) { + return value != null ? String(value) : prefix + suffix; + } + function buildScopedId(scope, suffix) { + return scope + "-" + suffix; + } + function setExpandedCluster(state, laneId, clusterId) { + if (clusterId == null) delete state.expandedClusters[laneId]; + else state.expandedClusters[laneId] = String(clusterId); + } + function normalizeAxis(axis) { + assert(axis && axis.startMinute != null && axis.endMinute != null, "createTimeline(model.axis.startMinute/endMinute) are required"); + var axisRange = normalizeMinuteRange( + axis.startMinute, + axis.endMinute, + "createTimeline(model.axis.startMinute)", + "createTimeline(model.axis.endMinute)" + ); + var normalized = { + endMinute: axisRange.endMinute, + startMinute: axisRange.startMinute }; - - cardApi.clearBlocks = function () { - rail.querySelectorAll('.sf-block, .sf-changeover').forEach(function (el) { - el.remove(); - }); + normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); + normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); + normalized.initialViewport = clampViewport( + normalized, + normalizeViewportInput(axis.initialViewport, "createTimeline(model.axis.initialViewport)") || { + startMinute: normalized.startMinute, + endMinute: normalized.endMinute + } + ); + return normalized; + } + function normalizeBadge(badge) { + if (!badge) return null; + if (typeof badge === "string") return { label: badge }; + return { + label: badge.label || "", + style: badge.style || null }; - - cardApi.setSolving = function (solving) { - card.classList.toggle('solving', solving); - }; - - return cardApi; - }; - - sf.rail.createHeatmap = function (config) { - if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; - - var heatmap = sf.el('div', { className: 'sf-heatmap' }); - heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + 'px 1fr'; - var label = sf.el('div', { className: 'sf-heatmap-label' }, config.label || ''); - heatmap.appendChild(label); - - var track = sf.el('div', { className: 'sf-heatmap-track' }); - var columns = config.railConfig && config.railConfig.columns || 1; - track.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)'; - heatmap.appendChild(track); - - var horizon = config.horizon || 1; - config.segments.forEach(function (segment) { - if (!segment || segment.end <= segment.start) return; - var band = sf.el('div', { className: 'sf-heatmap-segment' }); - var start = Math.max(0, segment.start); - var width = Math.max(0, segment.end - start); - band.style.left = (start / horizon * 100) + '%'; - band.style.width = Math.max(width / horizon * 100, 0.25) + '%'; - if (segment.color) band.style.background = segment.color; - if (segment.opacity != null) band.style.opacity = segment.opacity; - if (segment.tooltip) band.title = segment.tooltip; - track.appendChild(band); - }); - - return heatmap; - }; - - sf.rail.createUnassignedRail = function (tasks, onTaskClick) { - var rail = sf.el('div', { className: 'sf-unassigned-rail' }); - renderUnassigned(rail, tasks, onTaskClick); - return rail; - }; - - sf.rail.addBlock = function (rail, config) { - sf.assert(rail, 'addBlock(rail) requires a rail element'); - sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); - sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); - - var horizon = config.horizon || 1; - var startPct = (config.start / horizon) * 100; - var widthPct = ((config.end - config.start) / horizon) * 100; - var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; - - var block = sf.el('div', { className: 'sf-block' }); - block.style.left = startPct + '%'; - block.style.width = Math.max(widthPct, minWidthPct) + '%'; - - if (config.color) { - block.style.background = config.color; - block.style.borderLeftColor = config.borderColor || config.color; - } - if (config.className) block.classList.add(config.className); - if (config.late) block.classList.add('late'); - if (config.id) block.dataset.blockId = config.id; - if (config.delay) block.style.animationDelay = config.delay; - - if (config.label) { - block.appendChild(sf.el('div', { className: 'sf-block-label' }, config.label)); - } - if (config.meta) { - block.appendChild(sf.el('div', { className: 'sf-block-meta' }, config.meta)); - } - - if (config.onHover) { - block.addEventListener('mouseenter', function (e) { config.onHover(e, config); }); - } - if (config.onLeave) { - block.addEventListener('mouseleave', function () { config.onLeave(); }); - } - if (config.onClick) { - block.setAttribute('role', 'button'); - block.tabIndex = 0; - sf.bindActivation(block, function (e) { config.onClick(e, config); }); + } + function normalizeDays(days, startMinute, endMinute) { + var list = []; + var source = Array.isArray(days) && days.length > 0 ? days : null; + var cursor = startMinute; + var index = 0; + if (!source) { + while (cursor < endMinute) { + list.push(makeDay({ + endMinute: Math.min(cursor + DAY_MINUTES, endMinute), + isWeekend: false, + label: "Day " + (index + 1), + startMinute: cursor + }, index)); + cursor += DAY_MINUTES; + index += 1; + } + return list; } - - rail.appendChild(block); - return block; - }; - - sf.rail.addChangeover = function (rail, config) { - sf.assert(rail, 'addChangeover(rail) requires a rail element'); - sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); - sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); - - var horizon = config.horizon || 1; - var startPct = (config.start / horizon) * 100; - var widthPct = ((config.end - config.start) / horizon) * 100; - - var co = sf.el('div', { className: 'sf-changeover' }); - co.style.left = startPct + '%'; - co.style.width = widthPct + '%'; - rail.appendChild(co); - return co; - }; - - function renderUnassigned(unassignedRail, items, onTaskClick) { - unassignedRail.innerHTML = ''; - (items || []).forEach(function (item) { - var label = typeof item === 'string' ? item : item.label || item.id || ''; - if (!label) return; - var pill = sf.el('button', { - className: 'sf-unassigned-pill', - onClick: function () { - if (onTaskClick) onTaskClick(item); - }, - }, label); - unassignedRail.appendChild(pill); + source.forEach(function(day, dayIndex) { + if (cursor >= endMinute) return; + if (typeof day === "string") { + var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); + list.push(makeDay({ + endMinute: generatedEnd, + isWeekend: inferWeekend(day), + label: day, + startMinute: cursor + }, dayIndex)); + cursor = generatedEnd; + return; + } + var nextStart = day.startMinute != null ? day.startMinute : cursor; + var nextEnd = day.endMinute != null ? day.endMinute : Math.min(nextStart + DAY_MINUTES, endMinute); + var dayRange = normalizeMinuteRange( + nextStart, + nextEnd, + "createTimeline(model.axis.days[" + dayIndex + "].startMinute)", + "createTimeline(model.axis.days[" + dayIndex + "].endMinute)" + ); + list.push(makeDay({ + endMinute: dayRange.endMinute, + isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), + label: day.label || "Day " + (dayIndex + 1), + startMinute: dayRange.startMinute, + subLabel: day.subLabel || day.meta || "" + }, dayIndex)); + cursor = dayRange.endMinute; }); + return list; } - -})(SF); -/* ============================================================================ - SolverForge UI — Rail Timeline - Canonical dense scheduling surface for resource-lane timelines. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - var DAY_MINUTES = 24 * 60; - var SIX_HOUR_MINUTES = 6 * 60; - var WEEK_MINUTES = 7 * DAY_MINUTES; - var TRACK_HEIGHT = 34; - var TRACK_GAP = 8; - var TRACK_PADDING = 12; - var OVERVIEW_HEIGHT = 68; - var OVERVIEW_BLOCK_HEIGHT = 34; - var OVERVIEW_GROUP_GAP_MINUTES = 30; - var MIN_LABEL_WIDTH = 180; - var MIN_VISIBLE_TRACK_WIDTH = 320; - var MIN_CONTENT_TRACK_WIDTH = 480; - var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; - - var TONE_MAP = { - emerald: { - id: 'emerald', - background: 'rgba(16, 185, 129, 0.22)', - border: '#059669', - text: '#064e3b', - overlay: 'rgba(16, 185, 129, 0.10)', - }, - blue: { - id: 'blue', - background: 'rgba(59, 130, 246, 0.22)', - border: '#2563eb', - text: '#1e40af', - overlay: 'rgba(59, 130, 246, 0.10)', - }, - amber: { - id: 'amber', - background: 'rgba(245, 158, 11, 0.24)', - border: '#d97706', - text: '#92400e', - overlay: 'rgba(245, 158, 11, 0.10)', - }, - rose: { - id: 'rose', - background: 'rgba(244, 63, 94, 0.22)', - border: '#e11d48', - text: '#9f1239', - overlay: 'rgba(244, 63, 94, 0.10)', - }, - violet: { - id: 'violet', - background: 'rgba(139, 92, 246, 0.22)', - border: '#7c3aed', - text: '#5b21b6', - overlay: 'rgba(139, 92, 246, 0.10)', - }, - cyan: { - id: 'cyan', - background: 'rgba(6, 182, 212, 0.22)', - border: '#0891b2', - text: '#155e75', - overlay: 'rgba(6, 182, 212, 0.10)', - }, - red: { - id: 'red', - background: 'rgba(239, 68, 68, 0.22)', - border: '#dc2626', - text: '#991b1b', - overlay: 'rgba(239, 68, 68, 0.10)', - }, - slate: { - id: 'slate', - background: 'rgba(100, 116, 139, 0.20)', - border: '#475569', - text: '#1e293b', - overlay: 'rgba(100, 116, 139, 0.08)', - }, - }; - - sf.rail = sf.rail || {}; - - sf.rail.createTimeline = function (config) { - sf.assert(config && config.model, 'rail.createTimeline(config.model) requires a normalized model'); - - var labelWidth = config.labelWidth == null - ? 280 - : assertFiniteNumber(config.labelWidth, 'rail.createTimeline(labelWidth)'); - sf.assert(labelWidth > 0, 'rail.createTimeline(labelWidth) must be greater than zero'); - var state = { - cleanup: [], - config: config, - destroyed: false, - expandedClusters: {}, - hasQueuedPostMountSync: false, - instanceId: sf.uid('sf-rail-timeline'), - labelWidth: labelWidth, - model: normalizeModel(config.model), - scrollSync: null, - viewport: null, - layout: null, + function normalizeItem(item, pathKey, ordinal) { + assert(item && item.startMinute != null && item.endMinute != null, "timeline items require startMinute/endMinute"); + var itemRange = normalizeMinuteRange( + item.startMinute, + item.endMinute, + "createTimeline(model.lanes[].items[].startMinute)", + "createTimeline(model.lanes[].items[].endMinute)" + ); + return { + clusterId: item.clusterId != null ? String(item.clusterId) : null, + detailItems: Array.isArray(item.detailItems) ? item.detailItems.map(function(detailItem, detailIndex) { + return normalizeItem(detailItem, pathKey + "-" + detailIndex, detailIndex); + }) : [], + endMinute: itemRange.endMinute, + id: normalizeId(item.id, "item-", pathKey), + label: item.label || "Item " + (ordinal + 1), + meta: item.meta != null ? item.meta : "", + originalIndex: ordinal, + summary: normalizeOverviewSummary(item.summary, "createTimeline(model.lanes[].items[].summary)"), + startMinute: itemRange.startMinute, + tone: resolveTone(item.tone || item.color || "slate") }; - - state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); - - var root = sf.el('section', { - className: 'sf-rail-timeline', - dataset: { - labelWidth: String(labelWidth), - }, - }); - root.setAttribute('role', 'region'); - root.setAttribute('aria-label', config.title || 'Scheduling timeline'); - - var toolbar = sf.el('div', { className: 'sf-rail-timeline-toolbar' }); - var toolbarCopy = sf.el('div', { className: 'sf-rail-timeline-toolbar-copy' }); - toolbarCopy.appendChild(sf.el('div', { className: 'sf-rail-timeline-toolbar-title' }, config.title || 'Scheduling timeline')); - toolbarCopy.appendChild(sf.el('div', { className: 'sf-rail-timeline-toolbar-subtitle' }, config.subtitle || 'Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.')); - toolbar.appendChild(toolbarCopy); - - var zoomControls = sf.el('div', { className: 'sf-rail-timeline-zoom-controls' }); - var zoomButtons = []; - normalizeZoomPresets(config.zoomPresets).forEach(function (preset) { - var button = sf.el('button', { - className: 'sf-rail-timeline-zoom-button', - type: 'button', - dataset: { zoom: preset }, - }, preset === 'reset' ? 'Reset' : preset.toUpperCase()); - button.addEventListener('click', function () { - if (preset === 'reset') { - api.setViewport(state.model.axis.initialViewport); + } + function normalizeLane(lane, index, axis) { + assert(lane && Array.isArray(lane.items), "timeline lanes require an items array"); + var normalizedLane = { + axis, + badges: [], + id: normalizeId(lane.id, "lane-", index), + items: lane.items.map(function(item, itemIndex) { + return normalizeItem(item, index + "-" + itemIndex, itemIndex); + }), + label: lane.label || "Lane " + (index + 1), + mode: lane.mode === "overview" ? "overview" : "detailed", + overlays: Array.isArray(lane.overlays) ? lane.overlays.map(function(overlay, overlayIndex) { + return normalizeOverlay(overlay, overlayIndex, axis); + }).filter(Boolean) : [], + stats: Array.isArray(lane.stats) ? lane.stats : [] + }; + normalizedLane.items.sort(compareItems); + if (Array.isArray(lane.badges)) { + lane.badges.forEach(function(badge) { + var normalizedBadge = normalizeBadge(badge); + if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); + }); + } else { + var singleBadge = normalizeBadge(lane.badges); + if (singleBadge) normalizedLane.badges.push(singleBadge); + } + return normalizedLane; + } + function normalizeModel(model) { + assert(model && model.axis && Array.isArray(model.lanes), "createTimeline(model.axis/model.lanes) are required"); + var axis = normalizeAxis(model.axis); + return { + axis, + lanes: model.lanes.map(function(lane, index) { + return normalizeLane(lane, index, axis); + }) + }; + } + function normalizeOverlay(overlay, index, axis) { + var label = "createTimeline(model.lanes[].overlays[" + index + "])"; + assert(overlay && typeof overlay === "object", label + " must be an object"); + var startMinute = overlay.startMinute; + var endMinute = overlay.endMinute; + if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { + var dayIndex = assertInteger(overlay.dayIndex, label + ".dayIndex"); + var day = axis.days[dayIndex]; + assert(day, label + ".dayIndex must reference an existing day"); + var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + ".dayCount"); + assert(dayCount > 0, label + ".dayCount must be greater than zero"); + var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; + startMinute = day.startMinute; + endMinute = lastDay.endMinute; + } + assert( + startMinute != null && endMinute != null, + label + " requires startMinute/endMinute or dayIndex/dayCount" + ); + var overlayRange = normalizeMinuteRange( + startMinute, + endMinute, + label + ".startMinute", + label + ".endMinute" + ); + return { + endMinute: overlayRange.endMinute, + id: normalizeId(overlay.id, "overlay-", index), + label: overlay.label || "", + meta: overlay.meta || "", + startMinute: overlayRange.startMinute, + tone: resolveTone(overlay.tone || overlay.color || "slate") + }; + } + function normalizeTicks(ticks, startMinute, endMinute) { + var list = []; + if (Array.isArray(ticks) && ticks.length > 0) { + ticks.forEach(function(tick, index) { + if (typeof tick === "number") { + var numericTick = assertMinuteValue(tick, "createTimeline(model.axis.ticks[" + index + "])"); + list.push({ id: "tick-" + index, label: formatClock(numericTick), minute: numericTick }); return; } - api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); + assert(tick && typeof tick === "object", "createTimeline(model.axis.ticks[" + index + "]) must be a number or object"); + assert(tick.minute != null, "createTimeline(model.axis.ticks[" + index + "].minute) is required"); + var minute2 = assertMinuteValue(tick.minute, "createTimeline(model.axis.ticks[" + index + "].minute)"); + list.push({ + id: normalizeId(tick.id, "tick-", index), + label: tick.label || formatClock(minute2), + minute: minute2 + }); }); - zoomButtons.push(button); - zoomControls.appendChild(button); - }); - if (zoomButtons.length) { - toolbar.appendChild(zoomControls); + return list; } - root.appendChild(toolbar); - - var shell = sf.el('div', { className: 'sf-rail-timeline-shell' }); - var headerViewport = sf.el('div', { className: 'sf-rail-timeline-header-viewport' }); - var bodyViewport = sf.el('div', { className: 'sf-rail-timeline-body-viewport' }); - var headerRow = sf.el('div', { className: 'sf-rail-timeline-header-row' }); - var lanes = sf.el('div', { className: 'sf-rail-timeline-lanes' }); - headerViewport.appendChild(headerRow); - bodyViewport.appendChild(lanes); - shell.appendChild(headerViewport); - shell.appendChild(bodyViewport); - root.appendChild(shell); - - var tooltip = sf.el('div', { className: 'sf-tooltip sf-rail-timeline-tooltip' }); - tooltip.id = sf.uid('sf-rail-timeline-tooltip'); - tooltip.setAttribute('role', 'tooltip'); - tooltip.setAttribute('aria-hidden', 'true'); - root.appendChild(tooltip); - - bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); - bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); - bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); - bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); - bindWindowResize(state, syncLayoutFromViewport); - - function renderStructure() { - renderHeader(); - renderLanes(); + for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { + list.push({ + id: "tick-" + minute, + label: formatClock(minute), + minute + }); } - - function applyMeasuredLayout() { - state.layout = measureLayout(bodyViewport, state); - applyLayout(root, headerRow, lanes, state.layout); - updateViewportMetadata(root, state); - updateZoomButtons(zoomButtons, state); - } - - function renderHeader() { - headerRow.innerHTML = ''; - - var corner = sf.el('div', { className: 'sf-rail-timeline-label-corner' }, config.label || 'Lane'); - headerRow.appendChild(corner); - - var axis = sf.el('div', { className: 'sf-rail-timeline-axis sf-rail-timeline-axis--header' }); - axis.style.height = '82px'; - renderAxisDecor(axis, state.model.axis, true); - headerRow.appendChild(axis); - } - - function renderLanes() { - lanes.innerHTML = ''; - - state.model.lanes.forEach(function (lane, laneIndex) { - var laneRender = lane.mode === 'overview' - ? buildOverviewRender(lane, state, function () { - rerenderTimeline(); - }) - : buildDetailedRender(lane, lane.items); - - var row = sf.el('div', { - className: 'sf-rail-timeline-row sf-rail-timeline-row--' + lane.mode + (laneRender.expandedClusterId ? ' sf-rail-timeline-row--expanded' : ''), - dataset: { - laneId: lane.id, - mode: lane.mode, - trackCount: String(laneRender.trackCount), - }, - }); - if (laneRender.expandedClusterId) { - row.dataset.expandedClusterId = laneRender.expandedClusterId; - } - row.setAttribute('role', 'group'); - - var label = buildLaneLabel( - lane, - laneRender, - row, - buildScopedId(state.instanceId, 'lane-title-' + laneIndex) - ); - row.appendChild(label); - - var track = sf.el('div', { className: 'sf-rail-timeline-track' }); - track.style.height = laneRender.height + 'px'; - renderAxisDecor(track, state.model.axis, false); - renderOverlays(track, lane.overlays, state.model.axis); - laneRender.blocks.forEach(function (blockConfig) { - appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); - }); - row.appendChild(track); - lanes.appendChild(row); - }); - } - - function rerenderTimeline() { - renderStructure(); - syncLayoutFromViewport(); - } - - function syncLayoutFromViewport() { - applyMeasuredLayout(); - syncScrollToViewport(); - } - - function syncScrollToViewport() { - if (!state.layout) return; - var scrollLeft = viewportToScrollLeft(state, bodyViewport); - state.scrollSync = bodyViewport; - bodyViewport.scrollLeft = scrollLeft; - headerViewport.scrollLeft = scrollLeft; - state.scrollSync = null; - } - - var api = { - destroy: function () { - if (state.destroyed) return; - state.destroyed = true; - state.cleanup.forEach(function (cleanup) { - if (typeof cleanup === 'function') cleanup(); - }); - root.innerHTML = ''; - }, - el: root, - expandCluster: function (laneId, clusterId) { - setExpandedCluster(state, laneId, clusterId); - rerenderTimeline(); - }, - setModel: function (nextModel) { - state.model = normalizeModel(nextModel); - state.viewport = clampViewport(state.model.axis, state.viewport); - pruneExpandedClusters(state); - rerenderTimeline(); - queuePostMountSync(state, syncLayoutFromViewport); - }, - setViewport: function (nextViewport) { - state.viewport = clampViewport( - state.model.axis, - normalizeViewportInput(nextViewport, 'rail.createTimeline().setViewport(viewport)') - ); - syncLayoutFromViewport(); - queuePostMountSync(state, syncLayoutFromViewport); - }, + return list; + } + function makeDay(day, index) { + return { + endMinute: day.endMinute, + id: normalizeId(day.id, "day-", index), + isWeekend: !!day.isWeekend, + label: day.label || "Day " + (index + 1), + startMinute: day.startMinute, + subLabel: day.subLabel || "" }; - - renderStructure(); - syncLayoutFromViewport(); - queuePostMountSync(state, syncLayoutFromViewport); - - return api; - }; - - function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { - var tone = blockConfig.tone; - var relativeStart = blockConfig.startMinute - axis.startMinute; - var relativeEnd = blockConfig.endMinute - axis.startMinute; - var horizon = axis.endMinute - axis.startMinute; - var block = sf.rail.addBlock(track, { - start: relativeStart, - end: relativeEnd, - horizon: horizon, - label: blockConfig.label, - meta: blockConfig.metaLabel, - color: tone.background, - borderColor: tone.border, - minWidthPct: 0, - onClick: blockConfig.onClick, - onHover: function (event) { - showTooltip(tooltip, root, blockConfig.tooltip, event); - }, - onLeave: function () { - hideTooltip(tooltip); - }, - }); - - block.classList.add('sf-rail-timeline-item'); - block.classList.add(blockConfig.kindClass); - block.style.left = positionPct(blockConfig.startMinute, axis) + '%'; - block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + '%'; - block.style.top = blockConfig.top + 'px'; - block.style.height = blockConfig.height + 'px'; - block.style.bottom = 'auto'; - block.style.color = tone.text; - block.tabIndex = 0; - block.dataset.itemId = blockConfig.itemId; - block.dataset.laneId = lane.id; - block.dataset.startMinute = String(blockConfig.startMinute); - block.dataset.endMinute = String(blockConfig.endMinute); - if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); - if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; - if (blockConfig.onClick) { - block.setAttribute('role', 'button'); - block.setAttribute('aria-expanded', blockConfig.expanded ? 'true' : 'false'); - } else { - block.setAttribute('role', 'group'); - } - if (blockConfig.ariaLabel) block.setAttribute('aria-label', blockConfig.ariaLabel); - block.setAttribute('aria-describedby', tooltip.id); - if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); - if (blockConfig.detailHint) { - block.appendChild(sf.el('span', { className: 'sf-rail-timeline-detail-hint' }, blockConfig.detailHint)); + } + function compareItems(left, right) { + if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; + if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; + if (left.label !== right.label) return left.label < right.label ? -1 : 1; + return left.originalIndex - right.originalIndex; + } + function normalizeOverviewSummary(summary, label) { + if (summary == null) return null; + assert(summary && typeof summary === "object", label + " must be an object"); + var normalized = { + count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + ".count"), + openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + ".openCount"), + primaryLabel: summary.primaryLabel == null ? "" : String(summary.primaryLabel), + secondaryLabel: summary.secondaryLabel == null ? "" : String(summary.secondaryLabel), + toneSegments: Array.isArray(summary.toneSegments) ? summary.toneSegments.map(function(segment, index) { + assert(segment && typeof segment === "object", label + ".toneSegments[" + index + "] must be an object"); + return { + count: assertNonNegativeInteger(segment.count, label + ".toneSegments[" + index + "].count"), + tone: resolveTone(segment.tone || segment.color || "slate") + }; + }).filter(function(segment) { + return segment.count > 0; + }) : [] + }; + if (normalized.count != null && normalized.openCount != null) { + assert(normalized.openCount <= normalized.count, label + ".openCount must not exceed count"); } - block.title = blockConfig.tooltip.title; - block.addEventListener('mousemove', function (event) { - showTooltip(tooltip, root, blockConfig.tooltip, event); + return normalized; + } + function renderAxisDecor(track, axis, includeLabels) { + appendWeekendBands(track, axis); + appendDayDividers(track, axis); + appendTicks(track, axis, includeLabels); + if (includeLabels) appendDayBands(track, axis); + } + function appendDayBands(track, axis) { + axis.days.forEach(function(day) { + var band = el("div", { className: "sf-rail-timeline-day-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + band.appendChild(el("div", { className: "sf-rail-timeline-day-label" }, day.label)); + if (day.subLabel) { + band.appendChild(el("div", { className: "sf-rail-timeline-day-sub" }, day.subLabel)); + } + track.appendChild(band); }); - block.addEventListener('focus', function () { - showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + } + function appendDayDividers(track, axis) { + axis.days.forEach(function(day, index) { + if (index === 0) return; + var divider = el("div", { className: "sf-rail-timeline-day-divider" }); + divider.style.left = positionPct(day.startMinute, axis) + "%"; + track.appendChild(divider); }); - block.addEventListener('blur', function () { - hideTooltip(tooltip); + } + function appendTicks(track, axis, includeLabels) { + axis.ticks.forEach(function(tick) { + if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; + var tickEl = el("div", { className: "sf-rail-timeline-tick" }); + tickEl.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(tickEl); + if (!includeLabels) return; + var label = el("div", { className: "sf-rail-timeline-tick-label" }, tick.label); + label.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(label); }); - block.addEventListener('keydown', function (event) { - if (event && event.key === 'Escape') hideTooltip(tooltip); + } + function appendWeekendBands(track, axis) { + axis.days.forEach(function(day) { + if (!day.isWeekend) return; + var band = el("div", { className: "sf-rail-timeline-weekend-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + track.appendChild(band); }); } - - function appendOverviewSummary(block, summary) { - var footer = sf.el('div', { className: 'sf-rail-timeline-summary-footer' }); - if (summary.badges.length > 0) { - var badgeRail = sf.el('div', { className: 'sf-rail-timeline-summary-badges' }); - summary.badges.forEach(function (badge) { - badgeRail.appendChild(sf.el('span', { - className: 'sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--' + badge.kind, - }, badge.text)); - }); - footer.appendChild(badgeRail); - } - if (summary.toneSegments.length > 0) { - var toneBar = sf.el('div', { - className: 'sf-rail-timeline-summary-tonebar', - 'aria-hidden': 'true', - }); - var total = summary.toneSegments.reduce(function (sum, segment) { - return sum + segment.count; - }, 0) || 1; - summary.toneSegments.forEach(function (segment) { - var toneSegment = sf.el('span', { className: 'sf-rail-timeline-summary-tone-segment' }); - toneSegment.style.background = segment.tone.border; - toneSegment.style.width = ((segment.count / total) * 100) + '%'; - toneBar.appendChild(toneSegment); - }); - footer.appendChild(toneBar); - } - if (footer.children.length > 0) block.appendChild(footer); + function renderOverlays(track, overlays, axis) { + overlays.forEach(function(overlay) { + var band = el("div", { className: "sf-rail-timeline-overlay" }); + band.style.left = positionPct(overlay.startMinute, axis) + "%"; + band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + "%"; + band.style.background = overlay.tone.overlay; + band.style.borderColor = overlay.tone.border; + if (overlay.label) band.title = overlay.label; + track.appendChild(band); + }); } - - function bindScrollSync(source, target, state, root, zoomButtons) { - source.addEventListener('scroll', function () { - handleScroll(source, target, state, root, zoomButtons); + function groupOverviewItems(lane) { + var groups = []; + var current = null; + lane.items.forEach(function(item) { + if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { + if (current) groups.push(current); + current = { + clusterId: item.clusterId, + endMinute: item.endMinute, + items: [item], + lane, + startMinute: item.startMinute + }; + return; + } + current.items.push(item); + current.endMinute = Math.max(current.endMinute, item.endMinute); + if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; }); - target.addEventListener('scroll', function () { - handleScroll(target, source, state, root, zoomButtons); + if (current) groups.push(current); + groups.forEach(function(group, groupIndex) { + finalizeGroup(group, lane, groupIndex); }); + assertUniqueClusterKeys(lane, groups); + return groups; } - - function bindDragPan(source, target, state, root, zoomButtons) { - var drag = { - active: false, - startClientX: 0, - startScrollLeft: 0, - }; - - source.addEventListener('mousedown', function (event) { - if (event.button != null && event.button !== 0) return; - drag.active = true; - drag.startClientX = event.clientX != null ? event.clientX : 0; - drag.startScrollLeft = source.scrollLeft || 0; - source.classList.add('is-dragging'); - if (event.preventDefault) event.preventDefault(); + function finalizeGroup(group, lane, index) { + var detailItems = []; + group.items.forEach(function(item) { + if (item.detailItems.length > 0) { + item.detailItems.forEach(function(detailItem) { + detailItems.push(detailItem); + }); + return; + } + detailItems.push(item); }); - - source.addEventListener('mousemove', function (event) { - if (!drag.active) return; - var clientX = event.clientX != null ? event.clientX : drag.startClientX; - var delta = clientX - drag.startClientX; - source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); - handleScroll(source, target, state, root, zoomButtons); - if (event.preventDefault) event.preventDefault(); + detailItems.sort(compareItems); + group.detailItems = detailItems; + group.isCluster = detailItems.length > 1 || group.items.some(function(item) { + return item.detailItems.length > 0; }); - - function finishDrag() { - if (!drag.active) return; - drag.active = false; - source.classList.remove('is-dragging'); - } - - source.addEventListener('mouseup', finishDrag); - source.addEventListener('mouseleave', finishDrag); - } - - function handleScroll(source, target, state, root, zoomButtons) { - if (state.destroyed) return; - if (!state.layout) return; - if (state.scrollSync === source) return; - - state.scrollSync = source; - target.scrollLeft = source.scrollLeft; - state.viewport = scrollLeftToViewport(state, source); - updateViewportMetadata(root, state); - updateZoomButtons(zoomButtons, state); - state.scrollSync = null; + group.renderId = group.isCluster ? buildScopedId("cluster", lane.id + "-" + index + "-" + (group.items[0] ? group.items[0].id : "group")) : normalizeId(group.items[0] ? group.items[0].id : null, "group-", lane.id + "-" + index); + group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; + group.summary = deriveOverviewSummary(group); + group.count = group.summary.count; + group.label = group.summary.primaryLabel; + group.metaLabel = group.summary.secondaryLabel; + group.tone = group.summary.primaryTone || dominantTone(group.detailItems); } - - function measurePackedHeight(packed) { - return packed.trackCount > 0 - ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP - : OVERVIEW_HEIGHT; + function assertUniqueClusterKeys(lane, groups) { + var seen = {}; + groups.forEach(function(group) { + if (!group.clusterKey) return; + assert( + !seen[group.clusterKey], + 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + ); + seen[group.clusterKey] = true; + }); } - - function buildDetailBlockConfig(item, lane, trackIndex, top, options) { - var config = options || {}; - return { - clusterId: config.clusterId || null, - detailHint: config.detailHint || '', - endMinute: item.endMinute, - height: TRACK_HEIGHT, - itemId: item.id, - kindClass: 'sf-rail-timeline-item--detail', - label: item.label, - metaLabel: describeMeta(item.meta), - startMinute: item.startMinute, - top: top, - ariaLabel: buildItemAriaLabel(item, lane), - tooltip: buildItemTooltip(item, lane), - tone: item.tone, - trackIndex: trackIndex, - }; + function dominantTone(items) { + var toneSegments = buildToneSegmentsFromItems(items); + if (!toneSegments.length) return resolveTone("slate"); + return toneSegments[0].tone; } - - function buildOverviewBlockConfig(group, height, options) { - var config = options || {}; + function effectiveOverviewItems(item) { + return item.detailItems.length > 0 ? item.detailItems : [item]; + } + function deriveOverviewContribution(item) { + var items = effectiveOverviewItems(item); + var summary = item.summary; + var derivedCount = items.length; + var count = summary && summary.count != null ? summary.count : derivedCount; + var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; + var openCount = null; + var toneSegments = []; + if (summary && summary.openCount != null) openCount = summary.openCount; + else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); + if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; + else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); return { - clusterId: config.clusterId || null, - endMinute: group.endMinute, - height: OVERVIEW_BLOCK_HEIGHT, - itemId: config.itemId, - kindClass: config.kindClass, - label: group.summary.primaryLabel, - metaLabel: group.summary.secondaryLabel, - onClick: config.onClick || null, - startMinute: group.startMinute, - summary: buildOverviewBlockSummary(group, !!config.expanded), - top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), - ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), - expanded: !!config.expanded, - tooltip: config.tooltip, - tone: group.tone, + count, + openCount, + openCountKnown: openCount != null, + toneSegments, + toneSegmentsKnown: summary && summary.toneSegments.length > 0 ? true : canDeriveAggregateMetrics }; } - - function buildDetailedRender(lane, items) { - var packed = packItems(items); - var height = measurePackedHeight(packed); - - var blocks = packed.items.map(function (entry) { - return buildDetailBlockConfig( - entry.item, - lane, - entry.trackIndex, - TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) - ); - }); - + function deriveOverviewSummary(group) { + var contributions = group.items.map(deriveOverviewContribution); + var summaries = group.items.map(function(item) { + return item.summary; + }).filter(Boolean); + var count = contributions.reduce(function(sum, contribution) { + return sum + contribution.count; + }, 0); + var openCount = contributions.every(function(contribution) { + return contribution.openCountKnown; + }) ? contributions.reduce(function(sum, contribution) { + return sum + contribution.openCount; + }, 0) : null; + var toneSegments = contributions.every(function(contribution) { + return contribution.toneSegmentsKnown; + }) ? mergeToneSegments(contributions.reduce(function(segments, contribution) { + return segments.concat(contribution.toneSegments); + }, [])) : []; + var primarySummary = summaries.length === 1 ? summaries[0] : null; return { - blocks: blocks, - height: height, - trackCount: packed.trackCount || 1, + count, + openCount, + primaryLabel: primarySummary && primarySummary.primaryLabel ? primarySummary.primaryLabel : count > 1 ? count + " assignments" : group.items[0].label, + primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), + secondaryLabel: primarySummary && primarySummary.secondaryLabel ? primarySummary.secondaryLabel : count > 1 ? buildOverviewBlockMeta({ + count, + items: group.detailItems + }) : describeMeta(group.items[0].meta), + toneSegments }; } - - function buildOverviewRender(lane, state, rerender) { - var groups = groupOverviewItems(lane); - var expandedClusterId = state.expandedClusters[lane.id] || null; - var expandedGroup = null; - var packedExpanded = null; - var expandedDetailsTop = 0; - - groups.forEach(function (group) { - if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { - expandedGroup = group; - } - }); - - if (expandedGroup) { - packedExpanded = packItems(expandedGroup.detailItems); - expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; - } - - var height = packedExpanded - ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) - : OVERVIEW_HEIGHT; - - var blocks = []; - groups.forEach(function (group) { - if (group.isCluster) { - var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); - blocks.push(buildOverviewBlockConfig(group, height, { - clusterId: group.clusterKey, - itemId: group.renderId, - kindClass: 'sf-rail-timeline-item--cluster', - expanded: isExpanded, - onClick: function () { - setExpandedCluster( - state, - lane.id, - state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey - ); - if (state.config && state.config.onClusterToggle) { - state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); - } - if (typeof rerender === 'function') rerender(); - }, - top: isExpanded ? TRACK_PADDING : null, - tooltip: buildClusterTooltip(group, lane), - })); - if (isExpanded) { - packedExpanded.items.forEach(function (entry) { - blocks.push(buildDetailBlockConfig( - entry.item, - lane, - entry.trackIndex, - expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), - { - clusterId: group.clusterKey, - detailHint: 'Expanded', - } - )); - }); - } - return; + function inferOpenCount(items) { + return items.reduce(function(count, item) { + if (!item) return count; + if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; + if (!item.meta || typeof item.meta !== "object" || Array.isArray(item.meta)) return count; + if (typeof item.meta.openCount === "number" && isFinite(item.meta.openCount)) return count + item.meta.openCount; + if (typeof item.meta.unassignedCount === "number" && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; + if (item.meta.open === true || item.meta.unassigned === true) return count + 1; + if (typeof item.meta.status === "string" && /open|unassigned/i.test(item.meta.status)) return count + 1; + return count; + }, 0); + } + function mergeToneSegments(segments) { + var byTone = {}; + segments.forEach(function(segment) { + if (!segment || !(segment.count > 0)) return; + var toneId = segment.tone.id || segment.tone.border || "slate"; + if (!byTone[toneId]) { + byTone[toneId] = { + count: 0, + tone: segment.tone + }; } - - blocks.push(buildOverviewBlockConfig(group, height, { - itemId: group.items[0].id, - kindClass: 'sf-rail-timeline-item--overview', - tooltip: buildOverviewTooltip(group, lane), - })); + byTone[toneId].count += segment.count; }); - - return { - blocks: blocks, - expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, - height: height, - trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1, - }; + return Object.keys(byTone).map(function(toneId) { + return byTone[toneId]; + }).sort(compareToneSegments); } - - function buildLaneLabel(lane, laneRender, row, headingId) { - var label = sf.el('div', { - className: 'sf-rail-timeline-lane-label', - dataset: { laneId: lane.id }, - }); - label.style.minHeight = laneRender.height + 'px'; - - var heading = sf.el('div', { className: 'sf-rail-timeline-lane-heading' }); - var title = sf.el('div', { className: 'sf-rail-timeline-lane-title' }, lane.label); - title.id = headingId; - heading.appendChild(title); - if (lane.mode) { - heading.appendChild(sf.el('div', { className: 'sf-rail-timeline-lane-mode' }, lane.mode)); - } - label.appendChild(heading); - if (row) row.setAttribute('aria-labelledby', title.id); - - if (lane.badges.length > 0) { - var badges = sf.el('div', { className: 'sf-rail-timeline-lane-badges' }); - lane.badges.forEach(function (badge) { - var badgeEl = sf.el('span', { className: 'sf-rail-timeline-lane-badge' }, badge.label); - if (badge.style) { - badgeEl.style.background = badge.style.bg || ''; - badgeEl.style.border = badge.style.border || ''; - badgeEl.style.color = badge.style.color || ''; - } - badges.appendChild(badgeEl); - }); - label.appendChild(badges); - } - - if (lane.stats.length > 0) { - var stats = sf.el('div', { className: 'sf-rail-timeline-lane-stats' }); - lane.stats.forEach(function (stat) { - var statRow = sf.el('div', { className: 'sf-rail-timeline-lane-stat' }); - statRow.appendChild(sf.el('span', { className: 'sf-rail-timeline-lane-stat-label' }, stat.label)); - statRow.appendChild(sf.el('span', { className: 'sf-rail-timeline-lane-stat-value' }, String(stat.value))); - stats.appendChild(statRow); - }); - label.appendChild(stats); - } - - return label; + function buildToneSegmentsFromItems(items) { + return mergeToneSegments(items.map(function(item) { + return { + count: 1, + tone: item.tone + }; + })); } - - function buildClusterTooltip(group, lane) { - var first = group.detailItems[0] || group.items[0]; - var payload = { - rows: [ - { key: 'Lane', value: lane.label }, - { key: 'Window', value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, - { key: 'Items', value: String(group.summary.count) }, - ], - title: group.label, - }; - - if (group.summary.openCount > 0) { - payload.rows.push({ key: 'Open', value: String(group.summary.openCount) }); + function compareToneSegments(left, right) { + if (left.count !== right.count) return right.count - left.count; + if (left.tone.id === right.tone.id) return 0; + return left.tone.id < right.tone.id ? -1 : 1; + } + function buildOverviewBlockSummary(group, expanded) { + var badges = []; + if (group.summary.count > 1) { + badges.push({ kind: "count", text: group.summary.count + " total" }); } - if (group.summary.toneSegments.length > 0) { - payload.rows.push({ key: 'Mix', value: describeToneSegments(group.summary.toneSegments) }); + if (group.summary.openCount > 0) { + badges.push({ kind: "open", text: group.summary.openCount + " open" }); } - - if (first && first.meta) { - payload.rows.push({ key: 'Sample', value: describeMeta(first.meta) }); + if (group.isCluster) { + badges.push({ kind: "action", text: expanded ? "Enter to collapse" : "Enter to inspect" }); } - - return payload; - } - - function buildItemTooltip(item, lane) { - var rows = [ - { key: 'Lane', value: lane.label }, - { key: 'Time', value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) }, - ]; - - appendMetaRows(rows, item.meta); - return { - rows: rows, - title: item.label, + badges, + toneSegments: group.summary.toneSegments }; } - - function buildOverviewBlockMeta(group) { - if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; - var labels = []; - group.items.slice(0, 2).forEach(function (item) { - labels.push(item.label); - }); - if (group.count > 2) labels.push('+' + (group.count - 2) + ' more'); - return labels.join(' • '); + function buildItemAriaLabel(item, lane) { + var parts = [ + lane.label, + item.label, + formatMinuteRange(item.startMinute, item.endMinute, lane.axis) + ]; + var meta = describeMeta(item.meta); + if (meta) parts.push(meta); + return parts.join(" \xB7 "); } - - function buildPresetViewport(axis, currentViewport, preset) { - var duration = preset === '1w' ? WEEK_MINUTES : preset === '2w' ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; - var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); - var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; - var start = Math.round(center - visibleDuration / 2); - return clampViewport(axis, { - startMinute: start, - endMinute: start + visibleDuration, - }); + function buildOverviewAriaLabel(group, lane, expanded) { + var parts = [ + lane.label, + group.summary.primaryLabel, + formatMinuteRange(group.startMinute, group.endMinute, lane.axis) + ]; + if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); + if (group.summary.count > 1) parts.push(group.summary.count + " assignments"); + if (group.summary.openCount > 0) parts.push(group.summary.openCount + " open"); + if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); + if (group.isCluster) parts.push(expanded ? "Expanded. Press Enter to collapse" : "Press Enter to expand"); + return parts.join(" \xB7 "); } - - function clampNumber(value, min, max) { - return Math.min(Math.max(value, min), max); + function describeToneSegments(segments) { + return segments.map(function(segment) { + return segment.count + " " + segment.tone.id; + }).join(", "); } - - function clampViewport(axis, viewport) { - var totalDuration = axis.endMinute - axis.startMinute; - var next = viewport || axis.initialViewport || { - startMinute: axis.startMinute, - endMinute: axis.endMinute, - }; - var duration = next.endMinute - next.startMinute; - duration = Math.min(duration, totalDuration); - - var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); - + function buildOverviewTooltip(group, lane) { + if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { + return buildClusterTooltip(group, lane); + } + return buildItemTooltip(group.items[0], lane); + } + function packItems(items) { + var trackEnds = []; + var packed = []; + items.slice().sort(compareItems).forEach(function(item) { + var trackIndex = 0; + while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { + trackIndex += 1; + } + if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); + else trackEnds[trackIndex] = item.endMinute; + packed.push({ + item, + trackIndex + }); + }); return { - endMinute: start + duration, - startMinute: start, + items: packed, + trackCount: trackEnds.length }; } - - function assertFiniteNumber(value, label) { - sf.assert(typeof value === 'number' && isFinite(value), label + ' must be a finite number'); - return value; + function positionPct(minute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return (minute - axis.startMinute) / total * 100; } - - function assertMinuteValue(value, label) { - return assertInteger(value, label); + function spanPct(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0.25); } - - function assertInteger(value, label) { - var number = assertFiniteNumber(value, label); - sf.assert(Math.floor(number) === number, label + ' must be an integer'); - return number; + function spanPctExact(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0); } - - function assertNonNegativeInteger(value, label) { - var number = assertInteger(value, label); - sf.assert(number >= 0, label + ' must be greater than or equal to zero'); - return number; + function formatClock(minute) { + var normalized = minute % DAY_MINUTES; + if (normalized < 0) normalized += DAY_MINUTES; + var hours = Math.floor(normalized / 60); + var minutes = normalized % 60; + return pad(hours) + ":" + pad(minutes); } - - function describeMeta(meta) { - if (meta == null) return ''; - if (typeof meta === 'string') return meta; - if (typeof meta === 'number') return String(meta); - if (Array.isArray(meta)) { - return meta.map(function (entry) { - if (entry && entry.label && entry.value != null) return entry.label + ': ' + entry.value; - return String(entry || ''); - }).filter(Boolean).join(' • '); - } - if (typeof meta === 'object') { - return Object.keys(meta).map(function (key) { - return key + ': ' + meta[key]; - }).join(' • '); - } - return String(meta); + function formatMinuteRange(startMinute, endMinute, axis) { + return formatMinute(startMinute, axis) + " \u2192 " + formatMinute(endMinute, axis); } - - function appendMetaRows(rows, meta) { - if (meta == null) return; - if (typeof meta === 'string' || typeof meta === 'number') { - rows.push({ key: 'Meta', value: String(meta) }); - return; - } - if (Array.isArray(meta)) { - meta.forEach(function (entry, index) { - if (!entry) return; - if (entry.label && entry.value != null) { - rows.push({ key: entry.label, value: String(entry.value) }); - return; - } - rows.push({ key: 'Meta ' + (index + 1), value: String(entry) }); - }); - return; - } - if (typeof meta === 'object') { - Object.keys(meta).forEach(function (key) { - rows.push({ key: key, value: String(meta[key]) }); - }); - } + function formatMinute(minute, axis) { + var dayLabel = ""; + axis.days.forEach(function(day) { + if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { + dayLabel = day.label; + } + }); + return (dayLabel ? dayLabel + " " : "") + formatClock(minute); } - - function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { - var startMinute = assertMinuteValue(startValue, startLabel); - var endMinute = assertMinuteValue(endValue, endLabel); - sf.assert(endMinute > startMinute, endLabel + ' must be greater than startMinute'); - return { - endMinute: endMinute, - startMinute: startMinute, - }; + function pad(value) { + return value < 10 ? "0" + value : String(value); } - - function normalizeId(value, prefix, suffix) { - return value != null ? String(value) : prefix + suffix; + function inferWeekend(label) { + return /sat|sun|weekend/i.test(String(label || "")); } - - function buildScopedId(scope, suffix) { - return scope + '-' + suffix; + function isColorString(value) { + return /^#|^rgb|^hsl/i.test(String(value || "")); } - - function setExpandedCluster(state, laneId, clusterId) { - if (clusterId == null) delete state.expandedClusters[laneId]; - else state.expandedClusters[laneId] = String(clusterId); + function resolveTone(tone) { + if (tone && typeof tone === "object") { + return { + id: tone.id || tone.name || tone.borderColor || tone.color || "custom", + background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, + border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, + overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, + text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text + }; + } + if (TONE_MAP[tone]) return TONE_MAP[tone]; + if (isColorString(tone)) { + return { + id: String(tone), + background: tone, + border: tone, + overlay: tone, + text: "#111827" + }; + } + return TONE_MAP.slate; } - - function normalizeAxis(axis) { - sf.assert(axis && axis.startMinute != null && axis.endMinute != null, 'createTimeline(model.axis.startMinute/endMinute) are required'); - var axisRange = normalizeMinuteRange( - axis.startMinute, - axis.endMinute, - 'createTimeline(model.axis.startMinute)', - 'createTimeline(model.axis.endMinute)' - ); - - var normalized = { - endMinute: axisRange.endMinute, - startMinute: axisRange.startMinute, - }; - - normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); - normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); - normalized.initialViewport = clampViewport( - normalized, - normalizeViewportInput(axis.initialViewport, 'createTimeline(model.axis.initialViewport)') || { - startMinute: normalized.startMinute, - endMinute: normalized.endMinute, - } + function measureLayout(bodyViewport, state) { + var viewportWidth = getMeasuredViewportWidth(bodyViewport); + if (!(viewportWidth > 0)) return null; + var preferredLabelWidth = state.labelWidth; + var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; + var effectiveLabelWidth = preferredLabelWidth; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; + var scale = totalDuration > 0 && visibleDuration > 0 ? totalDuration / visibleDuration : 1; + if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; + if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); + else effectiveLabelWidth = MIN_LABEL_WIDTH; + var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); + var contentTrackWidth = Math.max( + Math.round(visibleTrackWidth * scale), + visibleTrackWidth, + MIN_CONTENT_TRACK_WIDTH ); - - return normalized; - } - - function normalizeBadge(badge) { - if (!badge) return null; - if (typeof badge === 'string') return { label: badge }; + var contentWidth = effectiveLabelWidth + contentTrackWidth; return { - label: badge.label || '', - style: badge.style || null, + contentWidth, + contentTrackWidth, + effectiveLabelWidth, + visibleTrackWidth, + viewportWidth }; } - - function normalizeDays(days, startMinute, endMinute) { - var list = []; - var source = Array.isArray(days) && days.length > 0 ? days : null; - var cursor = startMinute; - var index = 0; - - if (!source) { - while (cursor < endMinute) { - list.push(makeDay({ - endMinute: Math.min(cursor + DAY_MINUTES, endMinute), - isWeekend: false, - label: 'Day ' + (index + 1), - startMinute: cursor, - }, index)); - cursor += DAY_MINUTES; - index += 1; - } - return list; + function viewportToScrollLeft(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; + return Math.round((state.viewport.startMinute - axis.startMinute) / remainingDuration * maxScrollLeft); + } + function scrollLeftToViewport(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) { + return clampViewport(axis, { + startMinute: axis.startMinute, + endMinute: axis.startMinute + visibleDuration + }); } - - source.forEach(function (day, dayIndex) { - if (cursor >= endMinute) return; - if (typeof day === 'string') { - var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); - list.push(makeDay({ - endMinute: generatedEnd, - isWeekend: inferWeekend(day), - label: day, - startMinute: cursor, - }, dayIndex)); - cursor = generatedEnd; - return; - } - - var nextStart = day.startMinute != null - ? day.startMinute - : cursor; - var nextEnd = day.endMinute != null - ? day.endMinute - : Math.min(nextStart + DAY_MINUTES, endMinute); - var dayRange = normalizeMinuteRange( - nextStart, - nextEnd, - 'createTimeline(model.axis.days[' + dayIndex + '].startMinute)', - 'createTimeline(model.axis.days[' + dayIndex + '].endMinute)' - ); - list.push(makeDay({ - endMinute: dayRange.endMinute, - isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), - label: day.label || 'Day ' + (dayIndex + 1), - startMinute: dayRange.startMinute, - subLabel: day.subLabel || day.meta || '', - }, dayIndex)); - cursor = dayRange.endMinute; + var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); + var startMinute = axis.startMinute + remainingDuration * ratio; + return clampViewport(axis, { + startMinute, + endMinute: startMinute + visibleDuration }); - - return list; } - - function normalizeItem(item, pathKey, ordinal) { - sf.assert(item && item.startMinute != null && item.endMinute != null, 'timeline items require startMinute/endMinute'); - var itemRange = normalizeMinuteRange( - item.startMinute, - item.endMinute, - 'createTimeline(model.lanes[].items[].startMinute)', - 'createTimeline(model.lanes[].items[].endMinute)' - ); - - return { - clusterId: item.clusterId != null ? String(item.clusterId) : null, - detailItems: Array.isArray(item.detailItems) - ? item.detailItems.map(function (detailItem, detailIndex) { - return normalizeItem(detailItem, pathKey + '-' + detailIndex, detailIndex); - }) - : [], - endMinute: itemRange.endMinute, - id: normalizeId(item.id, 'item-', pathKey), - label: item.label || 'Item ' + (ordinal + 1), - meta: item.meta != null ? item.meta : '', - originalIndex: ordinal, - summary: normalizeOverviewSummary(item.summary, 'createTimeline(model.lanes[].items[].summary)'), - startMinute: itemRange.startMinute, - tone: resolveTone(item.tone || item.color || 'slate'), - }; + function getMaxScrollLeft(viewportEl) { + var scrollWidth = viewportEl.scrollWidth || 0; + var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; + return Math.max(scrollWidth - clientWidth, 0); } - - function normalizeLane(lane, index, axis) { - sf.assert(lane && Array.isArray(lane.items), 'timeline lanes require an items array'); - - var normalizedLane = { - axis: axis, - badges: [], - id: normalizeId(lane.id, 'lane-', index), - items: lane.items.map(function (item, itemIndex) { - return normalizeItem(item, index + '-' + itemIndex, itemIndex); - }), - label: lane.label || 'Lane ' + (index + 1), - mode: lane.mode === 'overview' ? 'overview' : 'detailed', - overlays: Array.isArray(lane.overlays) - ? lane.overlays.map(function (overlay, overlayIndex) { - return normalizeOverlay(overlay, overlayIndex, axis); - }).filter(Boolean) - : [], - stats: Array.isArray(lane.stats) ? lane.stats : [], - }; - - normalizedLane.items.sort(compareItems); - - if (Array.isArray(lane.badges)) { - lane.badges.forEach(function (badge) { - var normalizedBadge = normalizeBadge(badge); - if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); - }); - } else { - var singleBadge = normalizeBadge(lane.badges); - if (singleBadge) normalizedLane.badges.push(singleBadge); - } - - return normalizedLane; - } - - function normalizeModel(model) { - sf.assert(model && model.axis && Array.isArray(model.lanes), 'createTimeline(model.axis/model.lanes) are required'); - var axis = normalizeAxis(model.axis); - - return { - axis: axis, - lanes: model.lanes.map(function (lane, index) { - return normalizeLane(lane, index, axis); - }), - }; + function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { + if (typeof ResizeObserver !== "function") return; + var resizeObserver = new ResizeObserver(function() { + if (state.destroyed) return; + syncLayoutFromViewport(); + }); + resizeObserver.observe(bodyViewport); + state.cleanup.push(function() { + resizeObserver.disconnect(); + }); } - - function normalizeOverlay(overlay, index, axis) { - var label = 'createTimeline(model.lanes[].overlays[' + index + '])'; - sf.assert(overlay && typeof overlay === 'object', label + ' must be an object'); - - var startMinute = overlay.startMinute; - var endMinute = overlay.endMinute; - - if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { - var dayIndex = assertInteger(overlay.dayIndex, label + '.dayIndex'); - var day = axis.days[dayIndex]; - sf.assert(day, label + '.dayIndex must reference an existing day'); - var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + '.dayCount'); - sf.assert(dayCount > 0, label + '.dayCount must be greater than zero'); - var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; - startMinute = day.startMinute; - endMinute = lastDay.endMinute; + function bindWindowResize(state, syncLayoutFromViewport) { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") return; + function handleResize() { + if (state.destroyed) return; + syncLayoutFromViewport(); } - - sf.assert( - startMinute != null && endMinute != null, - label + ' requires startMinute/endMinute or dayIndex/dayCount' - ); - var overlayRange = normalizeMinuteRange( - startMinute, - endMinute, - label + '.startMinute', - label + '.endMinute' - ); - - return { - endMinute: overlayRange.endMinute, - id: normalizeId(overlay.id, 'overlay-', index), - label: overlay.label || '', - meta: overlay.meta || '', - startMinute: overlayRange.startMinute, - tone: resolveTone(overlay.tone || overlay.color || 'slate'), - }; + window.addEventListener("resize", handleResize); + state.cleanup.push(function() { + if (typeof window.removeEventListener === "function") window.removeEventListener("resize", handleResize); + }); } - - function normalizeTicks(ticks, startMinute, endMinute) { - var list = []; - - if (Array.isArray(ticks) && ticks.length > 0) { - ticks.forEach(function (tick, index) { - if (typeof tick === 'number') { - var numericTick = assertMinuteValue(tick, 'createTimeline(model.axis.ticks[' + index + '])'); - list.push({ id: 'tick-' + index, label: formatClock(numericTick), minute: numericTick }); - return; - } - sf.assert(tick && typeof tick === 'object', 'createTimeline(model.axis.ticks[' + index + ']) must be a number or object'); - sf.assert(tick.minute != null, 'createTimeline(model.axis.ticks[' + index + '].minute) is required'); - var minute = assertMinuteValue(tick.minute, 'createTimeline(model.axis.ticks[' + index + '].minute)'); - list.push({ - id: normalizeId(tick.id, 'tick-', index), - label: tick.label || formatClock(minute), - minute: minute, - }); - }); - return list; + function getMeasuredViewportWidth(bodyViewport) { + if (!bodyViewport) return 0; + if (typeof bodyViewport.clientWidth === "number" && bodyViewport.clientWidth > 0) { + return Math.round(bodyViewport.clientWidth); } - - for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { - list.push({ - id: 'tick-' + minute, - label: formatClock(minute), - minute: minute, - }); + if (typeof bodyViewport.offsetWidth === "number" && bodyViewport.offsetWidth > 0) { + return Math.round(bodyViewport.offsetWidth); } - - return list; - } - - function makeDay(day, index) { - return { - endMinute: day.endMinute, - id: normalizeId(day.id, 'day-', index), - isWeekend: !!day.isWeekend, - label: day.label || 'Day ' + (index + 1), - startMinute: day.startMinute, - subLabel: day.subLabel || '', - }; + if (typeof bodyViewport.getBoundingClientRect === "function") { + var rect = bodyViewport.getBoundingClientRect(); + if (rect && typeof rect.width === "number" && rect.width > 0) { + return Math.round(rect.width); + } + } + return 0; } - - function compareItems(left, right) { - if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; - if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; - if (left.label !== right.label) return left.label < right.label ? -1 : 1; - return left.originalIndex - right.originalIndex; + function applyLayout(root, headerRow, lanes, layout) { + setCustomProperty(root.style, "--sf-rail-label-width", layout ? layout.effectiveLabelWidth + "px" : ""); + setCustomProperty(root.style, "--sf-rail-content-width", layout ? layout.contentWidth + "px" : ""); + headerRow.style.width = layout ? layout.contentWidth + "px" : ""; + lanes.style.width = layout ? layout.contentWidth + "px" : ""; + root.dataset.supportedViewportWidth = layout ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) : ""; } - - function normalizeOverviewSummary(summary, label) { - if (summary == null) return null; - sf.assert(summary && typeof summary === 'object', label + ' must be an object'); - - var normalized = { - count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + '.count'), - openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + '.openCount'), - primaryLabel: summary.primaryLabel == null ? '' : String(summary.primaryLabel), - secondaryLabel: summary.secondaryLabel == null ? '' : String(summary.secondaryLabel), - toneSegments: Array.isArray(summary.toneSegments) - ? summary.toneSegments.map(function (segment, index) { - sf.assert(segment && typeof segment === 'object', label + '.toneSegments[' + index + '] must be an object'); - return { - count: assertNonNegativeInteger(segment.count, label + '.toneSegments[' + index + '].count'), - tone: resolveTone(segment.tone || segment.color || 'slate'), - }; - }).filter(function (segment) { - return segment.count > 0; - }) - : [], - }; - - if (normalized.count != null && normalized.openCount != null) { - sf.assert(normalized.openCount <= normalized.count, label + '.openCount must not exceed count'); + function setCustomProperty(style, name, value) { + if (!style) return; + if (typeof style.setProperty === "function") { + style.setProperty(name, value); + return; } - - return normalized; - } - - function renderAxisDecor(track, axis, includeLabels) { - appendWeekendBands(track, axis); - appendDayDividers(track, axis); - appendTicks(track, axis, includeLabels); - if (includeLabels) appendDayBands(track, axis); + style[name] = value; } - - function appendDayBands(track, axis) { - axis.days.forEach(function (day) { - var band = sf.el('div', { className: 'sf-rail-timeline-day-band' }); - band.style.left = positionPct(day.startMinute, axis) + '%'; - band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; - band.appendChild(sf.el('div', { className: 'sf-rail-timeline-day-label' }, day.label)); - if (day.subLabel) { - band.appendChild(sf.el('div', { className: 'sf-rail-timeline-day-sub' }, day.subLabel)); - } - track.appendChild(band); + function queuePostMountSync(state, syncLayoutFromViewport) { + if (state.hasQueuedPostMountSync || typeof setTimeout !== "function") return; + state.hasQueuedPostMountSync = true; + var timerId = setTimeout(function() { + state.hasQueuedPostMountSync = false; + if (state.destroyed) return; + syncLayoutFromViewport(); + }, 0); + state.cleanup.push(function() { + if (typeof clearTimeout === "function") clearTimeout(timerId); }); } - - function appendDayDividers(track, axis) { - axis.days.forEach(function (day, index) { - if (index === 0) return; - var divider = sf.el('div', { className: 'sf-rail-timeline-day-divider' }); - divider.style.left = positionPct(day.startMinute, axis) + '%'; - track.appendChild(divider); - }); + function normalizeViewportInput(viewport, label) { + if (viewport == null) return null; + assert(typeof viewport === "object", label + " must be an object"); + return normalizeMinuteRange( + viewport.startMinute, + viewport.endMinute, + label + ".startMinute", + label + ".endMinute" + ); } - - function appendTicks(track, axis, includeLabels) { - axis.ticks.forEach(function (tick) { - if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; - var tickEl = sf.el('div', { className: 'sf-rail-timeline-tick' }); - tickEl.style.left = positionPct(tick.minute, axis) + '%'; - track.appendChild(tickEl); - - if (!includeLabels) return; - var label = sf.el('div', { className: 'sf-rail-timeline-tick-label' }, tick.label); - label.style.left = positionPct(tick.minute, axis) + '%'; - track.appendChild(label); + function showTooltip(tooltip, root, payload, event) { + if (!payload) return; + tooltip.setAttribute("aria-hidden", "false"); + tooltip.innerHTML = ""; + tooltip.appendChild(el("div", { className: "sf-tooltip-title" }, payload.title)); + (payload.rows || []).forEach(function(row) { + var rowEl = el("div", { className: "sf-tooltip-row" }); + rowEl.appendChild(el("span", { className: "sf-tooltip-key" }, row.key)); + rowEl.appendChild(el("span", { className: "sf-tooltip-val" }, row.value)); + tooltip.appendChild(rowEl); }); + var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; + var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; + var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; + tooltip.style.left = left + "px"; + tooltip.style.top = top + "px"; + tooltip.classList.add("visible"); } - - function appendWeekendBands(track, axis) { - axis.days.forEach(function (day) { - if (!day.isWeekend) return; - var band = sf.el('div', { className: 'sf-rail-timeline-weekend-band' }); - band.style.left = positionPct(day.startMinute, axis) + '%'; - band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; - track.appendChild(band); - }); + function showTooltipForElement(tooltip, root, payload, element) { + var rect = element && typeof element.getBoundingClientRect === "function" ? element.getBoundingClientRect() : null; + showTooltip(tooltip, root, payload, rect ? { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2 + } : null); } - - function renderOverlays(track, overlays, axis) { - overlays.forEach(function (overlay) { - var band = sf.el('div', { className: 'sf-rail-timeline-overlay' }); - band.style.left = positionPct(overlay.startMinute, axis) + '%'; - band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + '%'; - band.style.background = overlay.tone.overlay; - band.style.borderColor = overlay.tone.border; - if (overlay.label) band.title = overlay.label; - track.appendChild(band); - }); + function hideTooltip(tooltip) { + tooltip.setAttribute("aria-hidden", "true"); + tooltip.classList.remove("visible"); } - - function groupOverviewItems(lane) { - var groups = []; - var current = null; - - lane.items.forEach(function (item) { - if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { - if (current) groups.push(current); - current = { - clusterId: item.clusterId, - endMinute: item.endMinute, - items: [item], - lane: lane, - startMinute: item.startMinute, - }; - return; - } - current.items.push(item); - current.endMinute = Math.max(current.endMinute, item.endMinute); - if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; - }); - if (current) groups.push(current); - - groups.forEach(function (group, groupIndex) { - finalizeGroup(group, lane, groupIndex); - }); - assertUniqueClusterKeys(lane, groups); - - return groups; + function updateViewportMetadata(root, state) { + var axis = state.model.axis; + var duration = state.viewport.endMinute - state.viewport.startMinute; + root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); + root.dataset.viewportDurationMinutes = String(Math.round(duration)); + root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); + root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); } - - function finalizeGroup(group, lane, index) { - var detailItems = []; - - group.items.forEach(function (item) { - if (item.detailItems.length > 0) { - item.detailItems.forEach(function (detailItem) { - detailItems.push(detailItem); - }); - return; - } - detailItems.push(item); - }); - - detailItems.sort(compareItems); - group.detailItems = detailItems; - group.isCluster = detailItems.length > 1 || group.items.some(function (item) { - return item.detailItems.length > 0; + function updateZoomButtons(buttons, state) { + var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); + var initial = state.model.axis.initialViewport; + buttons.forEach(function(button) { + var preset = button.dataset.zoom; + var active = false; + if (preset === "reset") { + active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); + } else if (preset === "1w") active = duration === WEEK_MINUTES; + else if (preset === "2w") active = duration === WEEK_MINUTES * 2; + else if (preset === "4w") active = duration === WEEK_MINUTES * 4; + button.classList.toggle("active", active); }); - group.renderId = group.isCluster - ? buildScopedId('cluster', lane.id + '-' + index + '-' + (group.items[0] ? group.items[0].id : 'group')) - : normalizeId(group.items[0] ? group.items[0].id : null, 'group-', lane.id + '-' + index); - group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; - group.summary = deriveOverviewSummary(group); - group.count = group.summary.count; - group.label = group.summary.primaryLabel; - group.metaLabel = group.summary.secondaryLabel; - group.tone = group.summary.primaryTone || dominantTone(group.detailItems); } - - function assertUniqueClusterKeys(lane, groups) { - var seen = {}; - - groups.forEach(function (group) { - if (!group.clusterKey) return; - sf.assert( - !seen[group.clusterKey], - 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + function normalizeZoomPresets(presets) { + if (presets == null) return ["1w", "2w", "4w", "reset"]; + assert(Array.isArray(presets), "rail.createTimeline(zoomPresets) must be an array"); + presets.forEach(function(preset, index) { + assert( + ["1w", "2w", "4w", "reset"].indexOf(preset) >= 0, + "rail.createTimeline(zoomPresets[" + index + "]) must be one of 1w, 2w, 4w, reset" ); - seen[group.clusterKey] = true; }); + return presets.slice(); } - - function dominantTone(items) { - var toneSegments = buildToneSegmentsFromItems(items); - if (!toneSegments.length) return resolveTone('slate'); - return toneSegments[0].tone; + function pruneExpandedClusters(state) { + Object.keys(state.expandedClusters).forEach(function(laneId) { + var exists = state.model.lanes.some(function(lane) { + return lane.id === laneId; + }); + if (!exists) delete state.expandedClusters[laneId]; + }); } - function effectiveOverviewItems(item) { - return item.detailItems.length > 0 ? item.detailItems : [item]; - } + // ts-src/rail/index.ts + var rail = { + createHeader: createHeader2, + createCard, + createHeatmap, + createUnassignedRail, + addBlock, + addChangeover, + createTimeline + }; - function deriveOverviewContribution(item) { - var items = effectiveOverviewItems(item); - var summary = item.summary; - var derivedCount = items.length; - var count = summary && summary.count != null ? summary.count : derivedCount; - var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; - var openCount = null; - var toneSegments = []; - - if (summary && summary.openCount != null) openCount = summary.openCount; - else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); - - if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; - else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); - - return { - count: count, - openCount: openCount, - openCountKnown: openCount != null, - toneSegments: toneSegments, - toneSegmentsKnown: summary && summary.toneSegments.length > 0 - ? true - : canDeriveAggregateMetrics, - }; + // ts-src/solver/backend.ts + function createBackend(config = {}) { + const resolvedConfig = config || {}; + const type = resolvedConfig.type ?? "axum"; + if (type === "tauri") { + return createTauriBackend(resolvedConfig); + } + return createHttpBackend(resolvedConfig); } - - function deriveOverviewSummary(group) { - var contributions = group.items.map(deriveOverviewContribution); - var summaries = group.items.map(function (item) { - return item.summary; - }).filter(Boolean); - var count = contributions.reduce(function (sum, contribution) { - return sum + contribution.count; - }, 0); - var openCount = contributions.every(function (contribution) { - return contribution.openCountKnown; - }) - ? contributions.reduce(function (sum, contribution) { - return sum + contribution.openCount; - }, 0) - : null; - var toneSegments = contributions.every(function (contribution) { - return contribution.toneSegmentsKnown; - }) - ? mergeToneSegments(contributions.reduce(function (segments, contribution) { - return segments.concat(contribution.toneSegments); - }, [])) - : []; - var primarySummary = summaries.length === 1 ? summaries[0] : null; - - return { - count: count, - openCount: openCount, - primaryLabel: primarySummary && primarySummary.primaryLabel - ? primarySummary.primaryLabel - : count > 1 - ? count + ' assignments' - : group.items[0].label, - primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), - secondaryLabel: primarySummary && primarySummary.secondaryLabel - ? primarySummary.secondaryLabel - : count > 1 - ? buildOverviewBlockMeta({ - count: count, - items: group.detailItems, - }) - : describeMeta(group.items[0].meta), - toneSegments: toneSegments, - }; + function resolveJobId(raw) { + return normalizeCreateJobId(raw); } - - function inferOpenCount(items) { - return items.reduce(function (count, item) { - if (!item) return count; - if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; - if (!item.meta || typeof item.meta !== 'object' || Array.isArray(item.meta)) return count; - if (typeof item.meta.openCount === 'number' && isFinite(item.meta.openCount)) return count + item.meta.openCount; - if (typeof item.meta.unassignedCount === 'number' && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; - if (item.meta.open === true || item.meta.unassigned === true) return count + 1; - if (typeof item.meta.status === 'string' && /open|unassigned/i.test(item.meta.status)) return count + 1; - return count; - }, 0); + function resolveEventJobId(payload) { + if (!payload || typeof payload !== "object") return ""; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.id != null) return String(payload.data.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ""; } - - function mergeToneSegments(segments) { - var byTone = {}; - segments.forEach(function (segment) { - if (!segment || !(segment.count > 0)) return; - var toneId = segment.tone.id || segment.tone.border || 'slate'; - if (!byTone[toneId]) { - byTone[toneId] = { - count: 0, - tone: segment.tone, - }; - } - byTone[toneId].count += segment.count; - }); - return Object.keys(byTone).map(function (toneId) { - return byTone[toneId]; - }).sort(compareToneSegments); + function withSnapshotRevision(path, snapshotRevision) { + if (snapshotRevision == null || snapshotRevision === "") return path; + return path + "?snapshot_revision=" + encodeURIComponent(String(snapshotRevision)); } - - function buildToneSegmentsFromItems(items) { - return mergeToneSegments(items.map(function (item) { + function createHttpBackend(config) { + var baseUrl = config.baseUrl || ""; + var jobsPath = config.jobsPath || "/jobs"; + var demoDataPath = config.demoDataPath || "/demo-data"; + var extraHeaders = config.headers || {}; + function headers(extra = {}) { return { - count: 1, - tone: item.tone, + "Content-Type": "application/json", + ...extraHeaders, + ...extra }; - })); - } - - function compareToneSegments(left, right) { - if (left.count !== right.count) return right.count - left.count; - if (left.tone.id === right.tone.id) return 0; - return left.tone.id < right.tone.id ? -1 : 1; - } - - function buildOverviewBlockSummary(group, expanded) { - var badges = []; - if (group.summary.count > 1) { - badges.push({ kind: 'count', text: group.summary.count + ' total' }); } - if (group.summary.openCount > 0) { - badges.push({ kind: 'open', text: group.summary.openCount + ' open' }); + function createRequestError(method, path, res) { + var err = new Error(res.status + " " + res.statusText); + err.status = res.status; + err.statusText = res.statusText; + err.method = method; + err.path = path; + err.url = baseUrl + path; + return err; } - if (group.isCluster) { - badges.push({ kind: 'action', text: expanded ? 'Enter to collapse' : 'Enter to inspect' }); + function request(method, path, body) { + const opts = { + method, + headers: headers() + }; + if (body !== void 0) opts.body = JSON.stringify(body); + return fetch(baseUrl + path, opts).then(function(res) { + if (!res.ok) throw createRequestError(method, path, res); + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("json")) { + return res.json(); + } + return res.text(); + }); } return { - badges: badges, - toneSegments: group.summary.toneSegments, - }; - } - - function buildItemAriaLabel(item, lane) { - var parts = [ - lane.label, - item.label, - formatMinuteRange(item.startMinute, item.endMinute, lane.axis), - ]; - var meta = describeMeta(item.meta); - if (meta) parts.push(meta); - return parts.join(' · '); - } - - function buildOverviewAriaLabel(group, lane, expanded) { - var parts = [ - lane.label, - group.summary.primaryLabel, - formatMinuteRange(group.startMinute, group.endMinute, lane.axis), - ]; - if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); - if (group.summary.count > 1) parts.push(group.summary.count + ' assignments'); - if (group.summary.openCount > 0) parts.push(group.summary.openCount + ' open'); - if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); - if (group.isCluster) parts.push(expanded ? 'Expanded. Press Enter to collapse' : 'Press Enter to expand'); - return parts.join(' · '); - } - - function describeToneSegments(segments) { - return segments.map(function (segment) { - return segment.count + ' ' + segment.tone.id; - }).join(', '); - } - - function buildOverviewTooltip(group, lane) { - if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { - return buildClusterTooltip(group, lane); - } - return buildItemTooltip(group.items[0], lane); - } - - function packItems(items) { - var trackEnds = []; - var packed = []; - - items.slice().sort(compareItems).forEach(function (item) { - var trackIndex = 0; - while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { - trackIndex += 1; + createJob: function(data) { + return request("POST", jobsPath, data).then(resolveJobId); + }, + getJob: function(id) { + return request("GET", jobsPath + "/" + id); + }, + getJobStatus: function(id) { + return request("GET", jobsPath + "/" + id + "/status"); + }, + getSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/snapshot", snapshotRevision)); + }, + analyzeSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/analysis", snapshotRevision)); + }, + pauseJob: function(id) { + return request("POST", jobsPath + "/" + id + "/pause"); + }, + resumeJob: function(id) { + return request("POST", jobsPath + "/" + id + "/resume"); + }, + cancelJob: function(id) { + return request("POST", jobsPath + "/" + id + "/cancel"); + }, + deleteJob: function(id) { + return request("DELETE", jobsPath + "/" + id); + }, + getDemoData: function(name) { + return request("GET", demoDataPath + "/" + (name || "STANDARD")); + }, + listDemoData: function() { + return request("GET", demoDataPath); + }, + streamJobEvents: function(id, onMessage, onError) { + var url = baseUrl + jobsPath + "/" + id + "/events"; + var es = new EventSource(url); + var closed = false; + es.onmessage = function(e) { + try { + onMessage(JSON.parse(e.data)); + } catch { + } + }; + es.onerror = function() { + if (closed || !onError) return; + if (typeof EventSource !== "undefined" && es.readyState === EventSource.CLOSED) { + onError(createSseClosedError(url)); + } + }; + return function close() { + closed = true; + es.onmessage = null; + es.onerror = null; + es.close(); + }; } - if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); - else trackEnds[trackIndex] = item.endMinute; - packed.push({ - item: item, - trackIndex: trackIndex, - }); - }); - - return { - items: packed, - trackCount: trackEnds.length, }; } - - function positionPct(minute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return ((minute - axis.startMinute) / total) * 100; - } - - function spanPct(startMinute, endMinute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return Math.max(((endMinute - startMinute) / total) * 100, 0.25); - } - - function spanPctExact(startMinute, endMinute, axis) { - var total = axis.endMinute - axis.startMinute; - if (total <= 0) return 0; - return Math.max(((endMinute - startMinute) / total) * 100, 0); - } - - function formatClock(minute) { - var normalized = minute % DAY_MINUTES; - if (normalized < 0) normalized += DAY_MINUTES; - var hours = Math.floor(normalized / 60); - var minutes = normalized % 60; - return pad(hours) + ':' + pad(minutes); - } - - function formatMinuteRange(startMinute, endMinute, axis) { - return formatMinute(startMinute, axis) + ' → ' + formatMinute(endMinute, axis); - } - - function formatMinute(minute, axis) { - var dayLabel = ''; - axis.days.forEach(function (day) { - if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { - dayLabel = day.label; + function createTauriBackend(config) { + assert(typeof config === "object", "createBackend({}) is required for Tauri adapter"); + assert(typeof config.invoke === "function", "Tauri backend requires config.invoke"); + assert(typeof config.listen === "function", "Tauri backend requires config.listen"); + var invoke = config.invoke; + var listen = config.listen; + var commands = config.commands || {}; + var eventName = config.eventName || "solver-update"; + return { + createJob: function(data) { + return invoke(commands.createJob || "create_job", { request: data }).then(resolveJobId); + }, + getJob: function(id) { + return invoke(commands.getJob || "get_job", { id }); + }, + getJobStatus: function(id) { + return invoke(commands.getJobStatus || "get_job_status", { id }); + }, + getSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.getSnapshot || "get_snapshot", payload); + }, + analyzeSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.analyzeSnapshot || "analyze_snapshot", payload); + }, + pauseJob: function(id) { + return invoke(commands.pauseJob || "pause_job", { id }); + }, + resumeJob: function(id) { + return invoke(commands.resumeJob || "resume_job", { id }); + }, + cancelJob: function(id) { + return invoke(commands.cancelJob || "cancel_job", { id }); + }, + deleteJob: function(id) { + return invoke(commands.deleteJob || "delete_job", { id }); + }, + getDemoData: function(name) { + return invoke(commands.demoData || "demo_seed", { name }); + }, + listDemoData: function() { + return Promise.resolve([]); + }, + streamJobEvents: function(id, onMessage, _onError) { + var targetId = String(id); + var unlisten = null; + listen(eventName, function(event) { + var payload = event && event.payload || {}; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; + onMessage(payload); + }).then(function(fn) { + unlisten = fn; + }); + return function close() { + if (unlisten) unlisten(); + }; } - }); - return (dayLabel ? dayLabel + ' ' : '') + formatClock(minute); - } - - function pad(value) { - return value < 10 ? '0' + value : String(value); - } - - function inferWeekend(label) { - return /sat|sun|weekend/i.test(String(label || '')); - } - - function isColorString(value) { - return /^#|^rgb|^hsl/i.test(String(value || '')); + }; } - - function resolveTone(tone) { - if (tone && typeof tone === 'object') { - return { - id: tone.id || tone.name || tone.borderColor || tone.color || 'custom', - background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, - border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, - overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, - text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text, - }; - } - if (TONE_MAP[tone]) return TONE_MAP[tone]; - if (isColorString(tone)) { - return { - id: String(tone), - background: tone, - border: tone, - overlay: tone, - text: '#111827', - }; - } - return TONE_MAP.slate; + function createSseClosedError(url) { + var err = new Error("Event stream closed for " + url); + err.code = "SSE_CLOSED"; + err.transport = "sse"; + err.url = url; + return err; } - function measureLayout(bodyViewport, state) { - var viewportWidth = getMeasuredViewportWidth(bodyViewport); - if (!(viewportWidth > 0)) return null; - - var preferredLabelWidth = state.labelWidth; - var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; - var effectiveLabelWidth = preferredLabelWidth; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; - var scale = totalDuration > 0 && visibleDuration > 0 - ? totalDuration / visibleDuration - : 1; - if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; - if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); - else effectiveLabelWidth = MIN_LABEL_WIDTH; - - var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); - var contentTrackWidth = Math.max( - Math.round(visibleTrackWidth * scale), - visibleTrackWidth, - MIN_CONTENT_TRACK_WIDTH - ); - var contentWidth = effectiveLabelWidth + contentTrackWidth; - - return { - contentWidth: contentWidth, - contentTrackWidth: contentTrackWidth, - effectiveLabelWidth: effectiveLabelWidth, - visibleTrackWidth: visibleTrackWidth, - viewportWidth: viewportWidth, + // ts-src/solver/solver.ts + var createSolver = function(config) { + assert(config, "createSolver(config) requires a configuration object"); + assert(config.backend, "createSolver(config.backend) is required"); + assert(hasFunction(config.backend, "createJob"), "createSolver(config.backend.createJob) must be a function"); + assert(hasFunction(config.backend, "getSnapshot"), "createSolver(config.backend.getSnapshot) must be a function"); + assert(hasFunction(config.backend, "analyzeSnapshot"), "createSolver(config.backend.analyzeSnapshot) must be a function"); + assert(hasFunction(config.backend, "pauseJob"), "createSolver(config.backend.pauseJob) must be a function"); + assert(hasFunction(config.backend, "resumeJob"), "createSolver(config.backend.resumeJob) must be a function"); + assert(hasFunction(config.backend, "cancelJob"), "createSolver(config.backend.cancelJob) must be a function"); + assert(hasFunction(config.backend, "deleteJob"), "createSolver(config.backend.deleteJob) must be a function"); + assert(hasFunction(config.backend, "streamJobEvents"), "createSolver(config.backend.streamJobEvents) must be a function"); + assert(!config.onProgress || typeof config.onProgress === "function", "createSolver(config.onProgress) must be a function"); + assert(!config.onSolution || typeof config.onSolution === "function", "createSolver(config.onSolution) must be a function"); + assert(!config.onPauseRequested || typeof config.onPauseRequested === "function", "createSolver(config.onPauseRequested) must be a function"); + assert(!config.onPaused || typeof config.onPaused === "function", "createSolver(config.onPaused) must be a function"); + assert(!config.onResumed || typeof config.onResumed === "function", "createSolver(config.onResumed) must be a function"); + assert(!config.onCancelled || typeof config.onCancelled === "function", "createSolver(config.onCancelled) must be a function"); + assert(!config.onComplete || typeof config.onComplete === "function", "createSolver(config.onComplete) must be a function"); + assert(!config.onFailure || typeof config.onFailure === "function", "createSolver(config.onFailure) must be a function"); + assert(!config.onAnalysis || typeof config.onAnalysis === "function", "createSolver(config.onAnalysis) must be a function"); + assert(!config.onError || typeof config.onError === "function", "createSolver(config.onError) must be a function"); + var backend = config.backend; + var statusBar = config.statusBar; + var closeStream = null; + var activeJobId = null; + var retainedJobId = null; + var lifecycleState = "IDLE"; + var phase = "idle"; + var runToken = 0; + var lastSnapshotRevision = null; + var lastMeta = null; + var lastNotifiedError = null; + var queuedAction = null; + var pendingPause = null; + var pendingResume = null; + var pendingCancel = null; + var terminalSync = null; + var api = { + /** + * Start a new solver job. + */ + start: function(data) { + if (retainedJobId) { + return Promise.reject( + new Error( + "Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first" + ) + ); + } + if (phase !== "idle") { + return Promise.resolve(); + } + resetForStart(); + phase = "starting"; + runToken += 1; + applyLifecycleState("STARTING"); + updateMoves(null); + var token = runToken; + return backend.createJob(data).then(function(id) { + if (token !== runToken) return; + var jobId = ensureJobId(id); + activeJobId = jobId; + retainedJobId = jobId; + phase = "solving"; + applyLifecycleState("SOLVING"); + attachStream(token, jobId); + if (queuedAction === "pause") { + queuedAction = null; + requestPause(token, jobId); + } else if (queuedAction === "cancel") { + queuedAction = null; + requestCancel(token, jobId); + } + }).catch(function(err) { + if (token !== runToken) return; + if (retainedJobId) { + failTransport(err); + } else { + failStartup(err); + } + throw err; + }); + }, + /** + * Request to pause the current solver job. + */ + pause: function() { + if (pendingPause) { + return pendingPause.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "pause"; + pendingPause = createDeferred(); + return pendingPause.promise; + } + var jobId = currentJobId(); + if (phase !== "solving" || !jobId) { + return Promise.resolve(); + } + pendingPause = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "pause")) { + return pendingPause.promise; + } + requestPause(runToken, jobId); + return pendingPause.promise; + }, + /** + * Resume a paused solver job. + */ + resume: function() { + if (pendingResume) { + return pendingResume.promise; + } + var jobId = currentJobId(); + if (phase !== "paused" || !jobId) { + return Promise.resolve(); + } + pendingResume = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "resume")) { + return pendingResume.promise; + } + requestResume(runToken, jobId); + return pendingResume.promise; + }, + /** + * Request to cancel the current solver job. + */ + cancel: function() { + if (pendingCancel) { + return pendingCancel.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "cancel"; + pendingCancel = createDeferred(); + return pendingCancel.promise; + } + var jobId = currentJobId(); + if (phase === "cancelling" && jobId) { + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + return pendingCancel.promise; + } + if (!jobId || !isCancelablePhase()) { + return Promise.resolve(); + } + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + requestCancel(runToken, jobId); + return pendingCancel.promise; + }, + /** + * Delete the retained job and its backend state. + */ + delete: function() { + if (!retainedJobId) { + return Promise.resolve(); + } + if (!isTerminalLifecycle(lifecycleState)) { + return Promise.reject( + new Error( + "Cannot delete a retained job before it reaches a terminal lifecycle state" + ) + ); + } + var jobId = retainedJobId; + return ensureTerminalSyncBeforeDelete(jobId).then(function() { + if (retainedJobId !== jobId) return; + return backend.deleteJob(jobId); + }).then(function() { + if (retainedJobId !== jobId) return; + resetAfterDelete(); + }).catch(function(err) { + notifyError(err); + throw err; + }); + }, + /** + * Get a snapshot for the current job. + */ + getSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.getSnapshot(jobId, revision).then(function(payload) { + return normalizeSnapshot(payload, lastMeta); + }); + }, + /** + * Get analysis for a snapshot of the current job. + */ + analyzeSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.analyzeSnapshot(jobId, revision).then(function(payload) { + return normalizeAnalysis(payload, lastMeta); + }); + }, + /** + * Check if the solver is currently running. + */ + isRunning: function() { + return phase !== "idle" && phase !== "paused"; + }, + /** + * Get the current job ID. + */ + getJobId: function() { + return activeJobId != null ? activeJobId : retainedJobId; + }, + /** + * Get the current lifecycle state. + */ + getLifecycleState: function() { + return lifecycleState; + }, + /** + * Get the current snapshot revision. + */ + getSnapshotRevision: function() { + return lastSnapshotRevision; + } }; - } - - function viewportToScrollLeft(state, viewportEl) { - var axis = state.model.axis; - var totalDuration = axis.endMinute - axis.startMinute; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var remainingDuration = Math.max(totalDuration - visibleDuration, 0); - var maxScrollLeft = getMaxScrollLeft(viewportEl); - if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; - return Math.round(((state.viewport.startMinute - axis.startMinute) / remainingDuration) * maxScrollLeft); - } - - function scrollLeftToViewport(state, viewportEl) { - var axis = state.model.axis; - var totalDuration = axis.endMinute - axis.startMinute; - var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; - var remainingDuration = Math.max(totalDuration - visibleDuration, 0); - var maxScrollLeft = getMaxScrollLeft(viewportEl); - if (remainingDuration <= 0 || maxScrollLeft <= 0) { - return clampViewport(axis, { - startMinute: axis.startMinute, - endMinute: axis.startMinute + visibleDuration, + return api; + function requestPause(token, id) { + phase = "pause-requested"; + backend.pauseJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "solving"; + rejectDeferred("pause", err); + notifyError(err); }); } - var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); - var startMinute = axis.startMinute + remainingDuration * ratio; - return clampViewport(axis, { - startMinute: startMinute, - endMinute: startMinute + visibleDuration, - }); - } - - function getMaxScrollLeft(viewportEl) { - var scrollWidth = viewportEl.scrollWidth || 0; - var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; - return Math.max(scrollWidth - clientWidth, 0); - } - - function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { - if (typeof ResizeObserver !== 'function') return; - - var resizeObserver = new ResizeObserver(function () { - if (state.destroyed) return; - syncLayoutFromViewport(); - }); - resizeObserver.observe(bodyViewport); - state.cleanup.push(function () { - resizeObserver.disconnect(); - }); - } - - function bindWindowResize(state, syncLayoutFromViewport) { - if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return; - - function handleResize() { - if (state.destroyed) return; - syncLayoutFromViewport(); + function attachStream(token, id) { + closeStream = backend.streamJobEvents(id, function(payload) { + if (token !== runToken) return; + handleEvent(token, id, payload); + }, function(err) { + if (token !== runToken) return; + failTransport(err); + }); } - - window.addEventListener('resize', handleResize); - state.cleanup.push(function () { - if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', handleResize); - }); - } - - function getMeasuredViewportWidth(bodyViewport) { - if (!bodyViewport) return 0; - if (typeof bodyViewport.clientWidth === 'number' && bodyViewport.clientWidth > 0) { - return Math.round(bodyViewport.clientWidth); + function ensureStreamAttached(token, id, pendingName) { + if (closeStream) return true; + try { + attachStream(token, id); + return true; + } catch (err) { + failTransport(err); + rejectDeferred(pendingName, err); + return false; + } } - if (typeof bodyViewport.offsetWidth === 'number' && bodyViewport.offsetWidth > 0) { - return Math.round(bodyViewport.offsetWidth); + function requestResume(token, id) { + phase = "resuming"; + backend.resumeJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "paused"; + rejectDeferred("resume", err); + notifyError(err); + }); } - if (typeof bodyViewport.getBoundingClientRect === 'function') { - var rect = bodyViewport.getBoundingClientRect(); - if (rect && typeof rect.width === 'number' && rect.width > 0) { - return Math.round(rect.width); + function requestCancel(token, id) { + phase = "cancelling"; + backend.cancelJob(id).catch(function(err) { + if (token !== runToken) return; + phase = lifecycleState === "PAUSED" ? "paused" : "solving"; + rejectDeferred("cancel", err); + notifyError(err); + }); + } + function handleEvent(token, expectedId, payload) { + var event = normalizeJobEvent(payload, expectedId); + if (!event) return; + lastMeta = event.meta; + if (event.meta.snapshotRevision != null) { + lastSnapshotRevision = event.meta.snapshotRevision; + } + retainedJobId = event.meta.jobId; + activeJobId = event.meta.jobId; + if (event.eventType === "progress") { + if (!event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onProgress) config.onProgress(event.meta); + return; + } + if (event.eventType === "best_solution") { + if (!event.solution || !event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onSolution) { + config.onSolution(buildLiveSnapshot(event), event.meta); + } + return; + } + if (event.eventType === "pause_requested") { + phase = "pause-requested"; + applyEventMeta(event.meta); + if (config.onPauseRequested) config.onPauseRequested(event.meta); + return; + } + if (event.eventType === "paused") { + phase = "paused"; + applyEventMeta(event.meta); + syncSnapshotBundle(event.meta, true).then(function(bundle) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + applyBundle(bundle); + if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); + resolveDeferred("pause", bundle); + }).catch(function(err) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + rejectDeferred("pause", err); + notifyError(err); + }); + return; + } + if (event.eventType === "resumed") { + phase = "solving"; + applyEventMeta(event.meta); + if (config.onResumed) config.onResumed(event.meta); + resolveDeferred("resume", event.meta); + return; + } + if (event.eventType === "completed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, true); + return; + } + if (event.eventType === "cancelled") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + return; + } + if (event.eventType === "failed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); } } - return 0; - } - - function applyLayout(root, headerRow, lanes, layout) { - setCustomProperty(root.style, '--sf-rail-label-width', layout ? layout.effectiveLabelWidth + 'px' : ''); - setCustomProperty(root.style, '--sf-rail-content-width', layout ? layout.contentWidth + 'px' : ''); - headerRow.style.width = layout ? layout.contentWidth + 'px' : ''; - lanes.style.width = layout ? layout.contentWidth + 'px' : ''; - root.dataset.supportedViewportWidth = layout - ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) - : ''; - } - - function setCustomProperty(style, name, value) { - if (!style) return; - if (typeof style.setProperty === 'function') { - style.setProperty(name, value); - return; + function syncSnapshotBundle(meta, requireSnapshot) { + var analysisRequired = !!config.onAnalysis; + var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; + return backend.getSnapshot(meta.jobId, snapshotRevision).then(function(snapshotPayload) { + var snapshot = normalizeSnapshot(snapshotPayload, meta); + if (!snapshot) throw new Error("Solver backend returned an invalid snapshot payload"); + var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); + var result = { + meta: mergedMeta, + snapshot, + analysis: null + }; + if (!analysisRequired) return result; + return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function(analysisPayload) { + result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); + return result; + }); + }).catch(function(err) { + if (requireSnapshot) throw err; + var fallback = { meta, snapshot: null, analysis: null }; + if (!analysisRequired || snapshotRevision == null) return fallback; + return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function(analysisPayload) { + fallback.analysis = normalizeAnalysis(analysisPayload, meta); + return fallback; + }).catch(function() { + return fallback; + }); + }); } - style[name] = value; - } - - function queuePostMountSync(state, syncLayoutFromViewport) { - if (state.hasQueuedPostMountSync || typeof setTimeout !== 'function') return; - state.hasQueuedPostMountSync = true; - - var timerId = setTimeout(function () { - state.hasQueuedPostMountSync = false; - if (state.destroyed) return; - syncLayoutFromViewport(); - }, 0); - - state.cleanup.push(function () { - if (typeof clearTimeout === 'function') clearTimeout(timerId); - }); - } - - function normalizeViewportInput(viewport, label) { - if (viewport == null) return null; - sf.assert(typeof viewport === 'object', label + ' must be an object'); - - return normalizeMinuteRange( - viewport.startMinute, - viewport.endMinute, - label + '.startMinute', - label + '.endMinute' - ); - } - - function showTooltip(tooltip, root, payload, event) { - if (!payload) return; - tooltip.setAttribute('aria-hidden', 'false'); - tooltip.innerHTML = ''; - tooltip.appendChild(sf.el('div', { className: 'sf-tooltip-title' }, payload.title)); - (payload.rows || []).forEach(function (row) { - var rowEl = sf.el('div', { className: 'sf-tooltip-row' }); - rowEl.appendChild(sf.el('span', { className: 'sf-tooltip-key' }, row.key)); - rowEl.appendChild(sf.el('span', { className: 'sf-tooltip-val' }, row.value)); - tooltip.appendChild(rowEl); - }); - - var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; - var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; - var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - tooltip.classList.add('visible'); - } - - function showTooltipForElement(tooltip, root, payload, element) { - var rect = element && typeof element.getBoundingClientRect === 'function' - ? element.getBoundingClientRect() - : null; - showTooltip(tooltip, root, payload, rect ? { - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - } : null); - } - - function hideTooltip(tooltip) { - tooltip.setAttribute('aria-hidden', 'true'); - tooltip.classList.remove('visible'); - } - - function updateViewportMetadata(root, state) { - var axis = state.model.axis; - var duration = state.viewport.endMinute - state.viewport.startMinute; - root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); - root.dataset.viewportDurationMinutes = String(Math.round(duration)); - root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); - root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); - } - - function updateZoomButtons(buttons, state) { - var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); - var initial = state.model.axis.initialViewport; - buttons.forEach(function (button) { - var preset = button.dataset.zoom; - var active = false; - if (preset === 'reset') { - active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) - && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); - } else if (preset === '1w') active = duration === WEEK_MINUTES; - else if (preset === '2w') active = duration === WEEK_MINUTES * 2; - else if (preset === '4w') active = duration === WEEK_MINUTES * 4; - button.classList.toggle('active', active); - }); - } - - function normalizeZoomPresets(presets) { - if (presets == null) return ['1w', '2w', '4w', 'reset']; - sf.assert(Array.isArray(presets), 'rail.createTimeline(zoomPresets) must be an array'); - presets.forEach(function (preset, index) { - sf.assert( - ['1w', '2w', '4w', 'reset'].indexOf(preset) >= 0, - 'rail.createTimeline(zoomPresets[' + index + ']) must be one of 1w, 2w, 4w, reset' - ); - }); - return presets.slice(); - } - - function pruneExpandedClusters(state) { - Object.keys(state.expandedClusters).forEach(function (laneId) { - var exists = state.model.lanes.some(function (lane) { - return lane.id === laneId; - }); - if (!exists) delete state.expandedClusters[laneId]; - }); - } - -})(SF); -/* ============================================================================ - SolverForge UI — Gantt (Frappe Gantt + Split.js wrapper) - Requires: Frappe Gantt (Gantt) and Split (Split) loaded globally. - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.gantt = {}; - - sf.gantt.create = function (config) { - config = config || {}; - var instanceId = sf.uid('sf-gantt'); - var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); - var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); - var chartContainerId = config.chartContainer || (instanceId + '-container'); - var svgId = config.svgId || (instanceId + '-svg'); - var ganttChart = null; - var splitInstance = null; - var mounted = false; - var mountTarget = null; - var resizeObserver = null; - var tasks = []; - var sortState = { key: null, direction: 'asc' }; - - // ── Build DOM ── - var wrapper = sf.el('div', { className: 'sf-gantt-split' }); - - // Grid pane - var gridPane = sf.el('div', { className: 'sf-gantt-pane', id: gridPaneId }); - var gridHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); - gridHeader.appendChild(sf.el('h3', null, config.gridTitle || 'Tasks')); - var gridControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); - gridHeader.appendChild(gridControls); - gridPane.appendChild(gridHeader); - - var gridContent = sf.el('div', { className: 'sf-gantt-pane-content' }); - var grid = sf.el('div', { className: 'sf-gantt-grid' }); - gridContent.appendChild(grid); - gridPane.appendChild(gridContent); - - // Chart pane - var chartPane = sf.el('div', { className: 'sf-gantt-pane', id: chartPaneId }); - var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); - chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline')); - - var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' }); - var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' }); - var modes = [ - { value: 'Quarter Day', label: 'Quarter Day' }, - { value: 'Half Day', label: 'Half Day' }, - { value: 'Day', label: 'Day' }, - { value: 'Week', label: 'Week' }, - { value: 'Month', label: 'Month' }, - ]; - modes.forEach(function (m) { - var opt = sf.el('option', { value: m.value }, m.label); - if (m.value === (config.viewMode || 'Quarter Day')) opt.selected = true; - viewSelect.appendChild(opt); - }); - viewSelect.addEventListener('change', function () { - if (ganttChart) ganttChart.change_view_mode(viewSelect.value); - }); - viewControls.appendChild(viewSelect); - - var chartControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); - chartHeader.appendChild(viewControls); - chartHeader.appendChild(chartControls); - chartPane.appendChild(chartHeader); - - var chartContent = sf.el('div', { className: 'sf-gantt-pane-content' }); - var chartContainer = sf.el('div', { className: 'sf-gantt-container', id: chartContainerId }); - chartContent.appendChild(chartContainer); - chartPane.appendChild(chartContent); - - wrapper.appendChild(gridPane); - wrapper.appendChild(chartPane); - - // ── API ── - var ctrl = { el: wrapper }; - - ctrl.mount = function (parent) { - sf.assert(parent, 'gantt.mount(parent) requires a mount target'); - var target = typeof parent === 'string' ? document.getElementById(parent) : parent; - sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); - validateMountTarget(target); - - if (mounted && mountTarget === target && wrapper.parentNode === target) { - return; + function applyBundle(bundle) { + if (!bundle) return; + lastMeta = bundle.meta; + if (bundle.meta && bundle.meta.snapshotRevision != null) { + lastSnapshotRevision = bundle.meta.snapshotRevision; } - if (mounted) ctrl.destroy(); - target.appendChild(wrapper); - mounted = true; - mountTarget = target; - if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { - renderGrid(tasks); - renderChart(tasks); + applyEventMeta(bundle.meta, bundle.analysis); + if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + } + function finalizeTerminal(meta) { + closeCurrentStream(); + activeJobId = null; + queuedAction = null; + phase = "idle"; + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : "IDLE"); + updateMoves(null); + } + function failTransport(err) { + var jobId = activeJobId || retainedJobId; + retainedJobId = jobId; + closeCurrentStream(); + activeJobId = null; + phase = phaseForLifecycleState(lifecycleState); + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + notifyError(err); + } + function failStartup(err) { + closeCurrentStream(); + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + phase = "idle"; + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + applyLifecycleState("IDLE"); + updateMoves(null); + notifyError(err); + } + function applyEventMeta(meta, analysis) { + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); + updateScore(readDisplayScore(meta, analysis)); + updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); + if (analysis) { + var constraints = readAnalysisConstraints(analysis); + if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { + statusBar.colorDotsFromAnalysis(constraints); + } } - initSplit(); - bindResizeObserver(); - }; - - ctrl.setTasks = function (newTasks) { - sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); - tasks = newTasks; - renderGrid(newTasks); - renderChart(newTasks); - }; - - ctrl.refresh = function () { - if (ganttChart && tasks.length > 0) { - ganttChart.refresh(tasksToFrappe(tasks)); + } + function readDisplayScore(meta, analysis) { + if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; + if (analysis && analysis.score != null) return analysis.score; + return null; + } + function applyLifecycleState(state) { + lifecycleState = state || "IDLE"; + if (!statusBar) return; + if (typeof statusBar.setLifecycleState === "function") { + statusBar.setLifecycleState(lifecycleState); + return; } - }; - - ctrl.getChart = function () { return ganttChart; }; - - ctrl.changeViewMode = function (mode) { - viewSelect.value = mode; - if (ganttChart) ganttChart.change_view_mode(mode); - }; - - ctrl.highlightTask = function (taskId) { - grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { - row.classList.toggle('selected', row.dataset.taskId === taskId); - }); - var svg = chartContainer.querySelector('svg'); - if (svg) { - svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { - bw.classList.remove('highlighted'); - }); - var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); - if (bar) bar.classList.add('highlighted'); + if (typeof statusBar.setSolving === "function") { + statusBar.setSolving(isActiveLifecycle2(lifecycleState)); } - }; - - ctrl.destroy = function () { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = null; + } + function updateScore(score2) { + if (statusBar && typeof statusBar.updateScore === "function") { + statusBar.updateScore(score2); } - if (splitInstance) { splitInstance.destroy(); splitInstance = null; } - ganttChart = null; - mounted = false; - mountTarget = null; - if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); - }; - - return ctrl; - - function initSplit() { - if (typeof Split !== 'function') return; - if (splitInstance) { - splitInstance.destroy(); - splitInstance = null; + } + function updateMoves(value) { + if (statusBar && typeof statusBar.updateMoves === "function") { + statusBar.updateMoves(value); } - - var splitSizes = normalizePair(config.splitSizes, [40, 60]); - var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); - - splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { - direction: 'vertical', - sizes: splitSizes, - minSize: splitMinSize, - snapOffset: 30, - gutterSize: 4, - cursor: 'col-resize', - onDragEnd: function () { - if (ganttChart) { - setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 100); - } - }, + } + function resetForStart() { + closeCurrentStream(); + activeJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + } + function resetAfterDelete() { + closeCurrentStream(); + rejectDeferred("pause", new Error("Solver job was deleted before pause settled")); + rejectDeferred("resume", new Error("Solver job was deleted before resume settled")); + rejectDeferred("cancel", new Error("Solver job was deleted before cancel settled")); + runToken += 1; + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + phase = "idle"; + applyLifecycleState("IDLE"); + updateScore(null); + updateMoves(null); + } + function closeCurrentStream() { + if (!closeStream) return; + closeStream(); + closeStream = null; + } + function currentJobId() { + return activeJobId != null ? activeJobId : retainedJobId; + } + function hasNewerEvent(meta) { + var currentSequence = lastMeta && typeof lastMeta.eventSequence === "number" ? lastMeta.eventSequence : null; + var candidateSequence = meta && typeof meta.eventSequence === "number" ? meta.eventSequence : null; + if (currentSequence == null || candidateSequence == null) return false; + return currentSequence > candidateSequence; + } + function resolveRequestedSnapshotRevision(snapshotRevision) { + if (snapshotRevision != null && snapshotRevision !== "") return snapshotRevision; + return lastSnapshotRevision; + } + function createTerminalSync(event) { + var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; + terminalSync = { + jobId: event.meta.jobId, + eventType: event.eventType, + meta: event.meta, + status: "pending", + promise: null, + error: null, + callbackDelivered: existing ? existing.callbackDelivered : false + }; + return terminalSync; + } + function runTerminalSync(record, token, event, requireSnapshot) { + record.status = "pending"; + record.error = null; + record.meta = event.meta; + record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function(bundle) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "synced"; + record.error = null; + record.meta = bundle.meta; + finalizeTerminal(bundle.meta); + applyBundle(bundle); + deliverTerminalCallback(record, event, bundle); + settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); + return record; + }).catch(function(err) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "failed"; + record.error = err; + finalizeTerminal(event.meta); + deliverTerminalFailureCallback(record, event); + settlePendingFromTerminal(event.eventType, null, err); + notifyError(err); + return record; + }); + return record.promise; + } + function ensureTerminalSyncBeforeDelete(jobId) { + var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; + if (!record) return Promise.resolve(); + return Promise.resolve(record.promise).then(function() { + if (!requiresSuccessfulTerminalSync(record)) return; + if (record.status === "synced") return; + return retryTerminalSync(record); + }); + } + function retryTerminalSync(record) { + var retryEvent = { + eventType: record.eventType, + meta: record.meta, + error: null + }; + return runTerminalSync(record, runToken, retryEvent, true).then(function() { + if (record.status !== "synced") { + throw record.error || new Error("Terminal snapshot synchronization failed"); + } }); } - - function bindResizeObserver() { - if (typeof ResizeObserver !== 'function') return; - if (resizeObserver) { - resizeObserver.disconnect(); + function requiresSuccessfulTerminalSync(record) { + return record.eventType === "completed"; + } + function deliverTerminalCallback(record, event, bundle) { + if (record.callbackDelivered) return; + if (event.eventType === "completed") { + if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); + } else if (event.eventType === "cancelled") { + if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); + } else if (event.eventType === "failed") { + if (config.onFailure) config.onFailure(event.error || "Solver job failed", bundle.meta, bundle.snapshot, bundle.analysis); } - resizeObserver = new ResizeObserver(function () { - if (!ganttChart) return; - setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); - }); - if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + record.callbackDelivered = true; } - - function normalizePair(value, fallback) { - if (typeof value === 'number' && isFinite(value)) return [value, value]; - if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); - var n0 = Number(value[0]); - var n1 = Number(value[1]); - if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); - return [n0, n1]; + function deliverTerminalFailureCallback(record, event) { + if (record.callbackDelivered || event.eventType !== "failed") return; + if (config.onFailure) config.onFailure(event.error || "Solver job failed", event.meta, null, null); + record.callbackDelivered = true; } - - function validateMountTarget(target) { - sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); - sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); + function terminalEventError(event) { + if (event.eventType !== "failed") return null; + return new Error(event.error || "Solver job failed"); } - - function getElementSize(target, axis) { - var clientKey = 'client' + axis; - var offsetKey = 'offset' + axis; - var rectKey = axis === 'Width' ? 'width' : 'height'; - - if (typeof target[clientKey] === 'number') return target[clientKey]; - if (typeof target[offsetKey] === 'number') return target[offsetKey]; - if (typeof target.getBoundingClientRect === 'function') { - var rect = target.getBoundingClientRect(); - if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; - } - return 0; + function isCancelablePhase() { + return phase === "solving" || phase === "pause-requested" || phase === "paused" || phase === "resuming"; } - - function tasksToFrappe(taskList) { - return taskList - .filter(function (t) { return t.start && t.end; }) - .map(function (t) { - var customClass = t.custom_class || ''; - if (t.pinned) { - customClass = customClass ? customClass + ' pinned' : 'pinned'; - } - return { - id: t.id, - name: t.name || t.label || t.id, - start: t.start, - end: t.end, - custom_class: customClass, - dependencies: t.dependencies || '', - }; - }); + function phaseForLifecycleState(state) { + if (state === "STARTING") return "starting"; + if (state === "SOLVING") return "solving"; + if (state === "PAUSE_REQUESTED") return "pause-requested"; + if (state === "PAUSED") return "paused"; + if (state === "RESUMING") return "resuming"; + if (state === "CANCELLING") return "cancelling"; + return "idle"; } - - function renderChart(taskList) { - var frappeTasks = tasksToFrappe(taskList); - - if (frappeTasks.length === 0) { - chartContainer.textContent = ''; - chartContainer.appendChild(sf.el('div', { - className: 'sf-gantt-empty-state', - style: { - padding: '24px', - color: 'var(--sf-gray-400)', - fontFamily: 'var(--sf-font-mono)', - fontSize: '13px', - }, - }, 'No scheduled tasks to display.')); - ganttChart = null; - return; + function isTerminalLifecycle(state) { + return state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; + } + function settlePendingFromTerminal(eventType, bundle, err) { + if (eventType === "cancelled") { + if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + } else if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + if (pendingPause) { + pendingPause.reject(err || new Error("Job terminated before pause settled")); + pendingPause = null; + } + if (pendingResume) { + pendingResume.reject(err || new Error("Job terminated before resume settled")); + pendingResume = null; } - - chartContainer.textContent = ''; - chartContainer.appendChild(createSvgRoot(svgId)); - - ganttChart = new Gantt('#' + svgId, frappeTasks, { - view_mode: viewSelect.value || 'Quarter Day', - date_format: 'YYYY-MM-DD HH:mm', - custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, - on_click: function (task) { - ctrl.highlightTask(task.id); - if (config.onTaskClick) config.onTaskClick(task); - }, - on_date_change: function (task, start, end) { - if (config.onDateChange) config.onDateChange(task, start, end); - }, - }); } - - function renderGrid(taskList) { - while (grid.firstChild) grid.removeChild(grid.firstChild); - var table = sf.el('table', { className: 'sf-gantt-table' }); - var columns = config.columns || [ - { key: 'name', label: 'Task' }, - { key: 'start', label: 'Start' }, - { key: 'end', label: 'End' }, - ]; - var sortedTasks = sortTasks(taskList); - - var thead = sf.el('thead'); - var headerRow = sf.el('tr'); - columns.forEach(function (col) { - headerRow.appendChild(buildHeaderCell(col)); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - var tbody = sf.el('tbody'); - sortedTasks.forEach(function (task) { - var rowClasses = ['sf-gantt-row']; - if (task.custom_class) rowClasses.push(task.custom_class); - if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); - - var tr = sf.el('tr', { - className: rowClasses.join(' '), - dataset: { taskId: task.id }, - onClick: function () { - ctrl.highlightTask(task.id); - if (config.onTaskClick) config.onTaskClick(task); - }, - }); - - columns.forEach(function (col) { - var td = sf.el('td'); - if (col.key === 'name') { - td.className = 'sf-task-name'; - td.textContent = task.name || task.label || task.id; - } else if (col.render) { - var content = col.render(task); - if (typeof content === 'string') td.textContent = content; - else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; - else if (content instanceof Node) td.appendChild(content); - } else { - td.textContent = task[col.key] || ''; - td.style.fontFamily = 'var(--sf-font-mono)'; - td.style.fontSize = '12px'; - } - tr.appendChild(td); - }); - - tbody.appendChild(tr); - }); - table.appendChild(tbody); - grid.appendChild(table); + function resolveDeferred(name, value) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.resolve(value); + setDeferred(name, null); } - - function buildHeaderCell(col) { - if (!col.sortable) { - return sf.el('th', null, col.label); - } - - var isCurrent = sortState.key === col.key; - var th = sf.el('th', { - className: 'sortable' + (isCurrent ? ' active' : ''), - role: 'button', - tabIndex: 0, - 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', - }); - th.appendChild(document.createTextNode(col.label)); - th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : '')); - - sf.bindActivation(th, function () { - if (sortState.key === col.key) { - sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; - } else { - sortState.key = col.key; - sortState.direction = 'asc'; - } - renderGrid(tasks); - }); - - return th; + function rejectDeferred(name, err) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.reject(err); + setDeferred(name, null); } - - function sortTasks(taskList) { - if (!sortState.key) return taskList.slice(); - var sorted = taskList.slice(); - sorted.sort(function (a, b) { - var aVal = sortValue(a[sortState.key], sortState.key); - var bVal = sortValue(b[sortState.key], sortState.key); - if (aVal === bVal) return 0; - if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; - return aVal > bVal ? -1 : 1; - }); - return sorted; + function getDeferred(name) { + if (name === "pause") return pendingPause; + if (name === "resume") return pendingResume; + if (name === "cancel") return pendingCancel; + return null; } - - function sortValue(value, key) { - if (value == null) return ''; - if (key === 'start' || key === 'end') { - var parsed = Date.parse(value); - return isNaN(parsed) ? String(value).toLowerCase() : parsed; - } - if (typeof value === 'number') return value; - return String(value).toLowerCase(); + function setDeferred(name, value) { + if (name === "pause") pendingPause = value; + if (name === "resume") pendingResume = value; + if (name === "cancel") pendingCancel = value; } - - function defaultPopup(task) { - var t = tasks.find(function (x) { return x.id === task.id; }); - if (!t) return ''; - return '
' + - '

' + sf.escHtml(t.name || t.id) + '

' + - '

Start: ' + sf.escHtml(t.start) + '

' + - '

End: ' + sf.escHtml(t.end) + '

' + - (t.duration_minutes ? '

Duration: ' + t.duration_minutes + ' min

' : '') + - (t.pinned ? '

Pinned

' : '') + - '
'; + function notifyError(err) { + if (err && lastNotifiedError === err) return; + lastNotifiedError = err || null; + if (config.onError) config.onError(err && err.message ? err.message : String(err)); } - - function createSvgRoot(id) { - if (document.createElementNS) { - var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.id = id; - return svg; - } - return sf.el('svg', { id: id }); + function ensureJobId(id) { + var jobId = normalizeCreateJobId(id); + if (jobId) return jobId; + throw new Error("Invalid solver backend createJob response"); } }; - -})(SF); -/* ============================================================================ - SolverForge UI — Footer Factory - ============================================================================ */ - -(function (sf) { - 'use strict'; - - sf.createFooter = function (config) { - sf.assert(config, 'createFooter(config) requires a configuration object'); - - var footer = sf.el('footer', { className: 'sf-footer' }); - if (config.links) { - config.links.forEach(function (link, i) { - if (i > 0) footer.appendChild(sf.el('span', { className: 'sf-vr' })); - footer.appendChild(sf.el('a', { href: link.url, target: '_blank' }, link.label)); - }); + function hasFunction(object, key) { + return !!(object && typeof object[key] === "function"); + } + function createDeferred() { + var resolve; + var reject; + var promise = new Promise(function(res, rej) { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + function normalizeJobEvent(payload, expectedId) { + if (!payload || typeof payload !== "object") return null; + var eventType = normalizeEventType(readField(payload, ["eventType", "event_type", "type"])); + if (!eventType) return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); + if (jobId == null || jobId === "") jobId = expectedId; + if (jobId == null || jobId === "") return null; + if (String(jobId) !== String(expectedId)) return null; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + var meta = { + id: String(jobId), + jobId: String(jobId), + eventType, + eventSequence: readField(payload, ["eventSequence", "event_sequence"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state", "solverStatus", "solver_status"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) + }; + return { + eventType, + meta, + solution, + error: readField(payload, ["error"], [payload, payload.data]) || null + }; + } + function normalizeSnapshot(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + return { + id: jobId != null ? String(jobId) : null, + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || null, + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.data]), payload), + solution + }; + } + function normalizeAnalysis(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var analysisBody = payload.analysis || payload.data && payload.data.analysis || payload; + var constraints = readAnalysisConstraints(analysisBody); + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var snapshotRevision = readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]); + if (snapshotRevision == null || snapshotRevision === "") { + snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; } - if (config.version) { - footer.appendChild(sf.el('span', { style: { marginLeft: 'auto' } }, config.version)); + return { + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: snapshotRevision != null ? snapshotRevision : null, + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || fallbackMeta && fallbackMeta.terminalReason || null, + analysis: analysisBody, + score: analysisBody.score != null ? analysisBody.score : null, + constraints + }; + } + function buildLiveSnapshot(event) { + return { + id: event.meta.jobId, + jobId: event.meta.jobId, + snapshotRevision: event.meta.snapshotRevision, + lifecycleState: event.meta.lifecycleState, + terminalReason: event.meta.terminalReason, + currentScore: event.meta.currentScore, + bestScore: event.meta.bestScore, + telemetry: event.meta.telemetry, + solution: event.solution + }; + } + function mergeMeta(meta, snapshot, eventType) { + if (!snapshot) return meta; + return { + id: meta && meta.id != null ? meta.id : snapshot.id, + jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, + eventType: meta && meta.eventType ? meta.eventType : eventType, + eventSequence: meta ? meta.eventSequence : null, + lifecycleState: meta && meta.lifecycleState || snapshot.lifecycleState || normalizeLifecycleState2(null, eventType), + terminalReason: meta && meta.terminalReason || snapshot.terminalReason || null, + telemetry: snapshot.telemetry || meta && meta.telemetry || null, + currentScore: snapshot.currentScore || meta && meta.currentScore || null, + bestScore: snapshot.bestScore || meta && meta.bestScore || null, + snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : meta && meta.snapshotRevision + }; + } + function readField(payload, names, sources) { + var fields = Array.isArray(names) ? names : [names]; + var roots = sources || [payload]; + for (var i = 0; i < roots.length; i++) { + var source = roots[i]; + if (!source || typeof source !== "object") continue; + for (var j = 0; j < fields.length; j++) { + if (source[fields[j]] != null) return source[fields[j]]; + } } - return footer; - }; + return null; + } + function normalizeEventType(value) { + if (typeof value !== "string") return null; + var normalized = value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase(); + if (!normalized) return null; + if (normalized === "finished") return "completed"; + return normalized; + } + function normalizeLifecycleState2(value, eventType) { + if (typeof value === "string" && value.trim()) { + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); + } + if (eventType === "progress" || eventType === "best_solution" || eventType === "resumed") return "SOLVING"; + if (eventType === "pause_requested") return "PAUSE_REQUESTED"; + if (eventType === "paused") return "PAUSED"; + if (eventType === "completed") return "COMPLETED"; + if (eventType === "cancelled") return "CANCELLED"; + if (eventType === "failed") return "FAILED"; + return "IDLE"; + } + function normalizeTelemetry(rawTelemetry, payload) { + if (rawTelemetry && typeof rawTelemetry === "object") return rawTelemetry; + var telemetry = {}; + var movesPerSecond = readField(payload, ["movesPerSecond", "moves_per_second"]); + var stepCount = readField(payload, ["stepCount", "step_count"]); + if (movesPerSecond != null) telemetry.movesPerSecond = Number(movesPerSecond); + if (stepCount != null) telemetry.stepCount = Number(stepCount); + return Object.keys(telemetry).length ? telemetry : null; + } + function readMovesPerSecond(telemetry) { + if (!telemetry || typeof telemetry !== "object") return null; + const value = telemetry.movesPerSecond ?? telemetry.moves_per_second; + if (value == null) return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; + } + function readAnalysisConstraints(analysis) { + if (!analysis || typeof analysis !== "object") return null; + const a = analysis; + if (Array.isArray(a.constraints)) return a.constraints; + const nested = a.analysis; + if (nested && Array.isArray(nested.constraints)) return nested.constraints; + return null; + } + function isActiveLifecycle2(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; + } -})(SF); + // ts-src/index.ts + var colors = { + pick, + project, + reset + }; + var score = { + parseHard, + parseSoft, + parseMedium, + getComponents, + colorClass + }; + return __toCommonJS(index_exports); +})(); +if (typeof window !== 'undefined') window.SF = SF; diff --git a/static/sf/sf.mjs b/static/sf/sf.mjs new file mode 100644 index 0000000..7e13c37 --- /dev/null +++ b/static/sf/sf.mjs @@ -0,0 +1,3987 @@ +// ts-src/utils/colors.ts +var SEQUENCE_1 = [9101876, 16574799, 7512015, 15317358, 11370408]; +var SEQUENCE_2 = [7590422, 15586304, 3433892, 12680465, 7688315]; +var colorMap = {}; +var nextColorCount = 0; +function buildPercentageColor(floor, ceil, pct) { + var red = (floor & 16711680) + Math.floor(pct * ((ceil & 16711680) - (floor & 16711680))) & 16711680; + var green = (floor & 65280) + Math.floor(pct * ((ceil & 65280) - (floor & 65280))) & 65280; + var blue = (floor & 255) + Math.floor(pct * ((ceil & 255) - (floor & 255))) & 255; + return red | green | blue; +} +function nextColor() { + var colorIndex = nextColorCount % SEQUENCE_1.length; + var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); + var color; + if (shadeIndex === 0) { + color = SEQUENCE_1[colorIndex]; + } else if (shadeIndex === 1) { + color = SEQUENCE_2[colorIndex]; + } else { + shadeIndex -= 3; + var base = Math.floor(shadeIndex / 2 + 1); + var divisor = 2; + while (base >= divisor) divisor *= 2; + base = base * 2 - divisor + 1; + color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); + } + nextColorCount++; + return "#" + color.toString(16).padStart(6, "0"); +} +var pick = function(key) { + if (colorMap[key] !== void 0) return colorMap[key]; + var c = nextColor(); + colorMap[key] = c; + return c; +}; +var reset = function() { + colorMap = {}; + nextColorCount = 0; +}; +var PROJECT_COLORS = [ + { main: "#10b981", dark: "#047857", light: "rgba(16,185,129,0.15)" }, + { main: "#3b82f6", dark: "#1d4ed8", light: "rgba(59,130,246,0.15)" }, + { main: "#8b5cf6", dark: "#6d28d9", light: "rgba(139,92,246,0.15)" }, + { main: "#f59e0b", dark: "#b45309", light: "rgba(245,158,11,0.15)" }, + { main: "#ec4899", dark: "#be185d", light: "rgba(236,72,153,0.15)" }, + { main: "#06b6d4", dark: "#0e7490", light: "rgba(6,182,212,0.15)" }, + { main: "#f43f5e", dark: "#be123c", light: "rgba(244,63,94,0.15)" }, + { main: "#84cc16", dark: "#4d7c0f", light: "rgba(132,204,22,0.15)" } +]; +var project = function(index) { + return PROJECT_COLORS[index % PROJECT_COLORS.length]; +}; + +// ts-src/utils/score.ts +var parseHard = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)hard/); + return m ? parseInt(m[1], 10) : 0; +}; +var parseSoft = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)soft/); + return m ? parseInt(m[1], 10) : 0; +}; +var parseMedium = function(scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)medium/); + return m ? parseInt(m[1], 10) : 0; +}; +var getComponents = function(scoreStr) { + return { + hard: parseHard(scoreStr), + medium: parseMedium(scoreStr), + soft: parseSoft(scoreStr) + }; +}; +var colorClass = function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + return hard < 0 ? "score-red" : soft < 0 ? "score-yellow" : "score-green"; +}; + +// ts-src/core/index.ts +var version = "0.6.5"; +var uidCounter = 0; +var escHtml = function(str) { + if (!str) return ""; + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +}; +var assert = function(cond, message) { + if (!cond) throw new Error("[SolverForge] " + message); +}; +var normalizeCreateJobId = function(raw) { + var value = raw; + if (value && typeof value === "object") { + if (value.id != null) value = value.id; + else if (value.jobId != null) value = value.jobId; + else if (value.job_id != null) value = value.job_id; + else if (value.data && typeof value.data === "object" && value.data.id != null) value = value.data.id; + else return ""; + } + if (typeof value === "string") return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value).trim(); + return ""; +}; +var el = function(tag, attrs = {}, ...children) { + var el2 = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function(key) { + var value = attrs[key]; + if (key === "className") el2.className = value; + else if (key === "style" && typeof value === "object") { + Object.assign(el2.style, value); + } else if (key.indexOf("on") === 0) { + el2.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === "dataset") Object.assign(el2.dataset, value); + else if (key === "html") el2.textContent = value; + else if (key === "unsafeHtml") el2.innerHTML = value; + else el2.setAttribute(key, value); + }); + } + children.forEach(function(child) { + if (child == null) return; + if (typeof child === "string") el2.appendChild(document.createTextNode(child)); + else if (child instanceof Node) el2.appendChild(child); + }); + return el2; +}; +var uid = function(prefix) { + uidCounter += 1; + return (prefix || "sf") + "-" + uidCounter; +}; +var bindActivation = function(el2, onActivate) { + if (!el2 || typeof onActivate !== "function") return; + function handleActivate(e) { + if (!e || e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return; + if (e.type === "keydown") e.preventDefault(); + onActivate(e); + } + el2.addEventListener("click", handleActivate); + el2.addEventListener("keydown", handleActivate); +}; + +// ts-src/components/api-guide.ts +var createApiGuide = function(config) { + assert(config, "createApiGuide(config) requires a configuration object"); + assert(Array.isArray(config.endpoints), "createApiGuide(config.endpoints) must be an array"); + var guide = el("div", { className: "sf-api-guide" }); + var endpoints = config.endpoints; + endpoints.forEach(function(ep) { + var section = el("div", { className: "sf-api-section" }); + section.appendChild(el("h3", null, (ep.method || "GET") + " " + ep.path)); + if (ep.description) { + section.appendChild(el("p", { style: { fontSize: "13px", color: "var(--sf-gray-600)", marginBottom: "8px" } }, ep.description)); + } + if (ep.curl) { + var block = el("div", { className: "sf-api-code-block" }); + block.appendChild(el("code", null, ep.curl)); + var copyBtn = el("button", { + className: "sf-copy-btn", + "aria-label": "Copy command", + onClick: function() { + navigator.clipboard.writeText(ep.curl).then(function() { + copyBtn.textContent = "Copied!"; + setTimeout(function() { + copyBtn.textContent = "Copy"; + }, 1500); + }); + } + }, "Copy"); + block.appendChild(copyBtn); + section.appendChild(block); + } + guide.appendChild(section); + }); + return guide; +}; + +// ts-src/components/buttons.ts +var createButton = function(config) { + assert(config, "createButton(config) requires a configuration object"); + var classes = ["sf-btn"]; + if (config.variant) classes.push("sf-btn--" + config.variant); + if (config.size === "small") classes.push("sf-btn--sm"); + if (config.size === "large") classes.push("sf-btn--lg"); + if (config.pill) classes.push("sf-btn--pill"); + if (config.circle) classes.push("sf-btn--circle"); + if (config.outline) classes.push("sf-btn--outline"); + if (config.iconOnly) classes.push("sf-btn--icon"); + var btn = el("button", { + className: classes.join(" "), + type: "button" + }); + if (config.disabled) btn.disabled = true; + assert(!config.onClick || typeof config.onClick === "function", "createButton(onClick) must be a function"); + if (config.icon) { + var icon = el("i", { className: "fa-solid " + config.icon }); + btn.appendChild(icon); + } + if (config.text && !config.circle && !config.iconOnly) { + btn.appendChild(document.createTextNode(config.text)); + } + if (config.onClick) { + btn.addEventListener("click", config.onClick); + } + if (config.tooltip) { + btn.title = config.tooltip; + } + if (config.ariaLabel) { + btn.setAttribute("aria-label", config.ariaLabel); + } else if (config.iconOnly && config.text) { + btn.setAttribute("aria-label", config.text); + } else if (config.icon && !config.text) { + btn.setAttribute("aria-label", config.icon.replace(/fa-/, "").replace(/-/g, " ")); + } + if (config.id) { + btn.id = config.id; + } + if (config.dataset) { + Object.assign(btn.dataset, config.dataset); + } + return btn; +}; + +// ts-src/components/footer.ts +var createFooter = function(config) { + assert(config, "createFooter(config) requires a configuration object"); + var footer = el("footer", { className: "sf-footer" }); + if (config.links) { + config.links.forEach(function(link, i) { + if (i > 0) footer.appendChild(el("span", { className: "sf-vr" })); + footer.appendChild(el("a", { href: link.url, target: "_blank" }, link.label)); + }); + } + if (config.version) { + footer.appendChild(el("span", { style: { marginLeft: "auto" } }, config.version)); + } + return footer; +}; + +// ts-src/components/header.ts +var createHeader = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + var header = el("header", { className: "sf-header" }); + var controls = { + actions: null, + spinner: null, + solveBtn: null, + pauseBtn: null, + resumeBtn: null, + cancelBtn: null, + analyzeBtn: null, + nav: null + }; + if (config.logo) { + var logo = el("img", { + className: "sf-header-logo", + src: config.logo, + alt: "Logo" + }); + header.appendChild(logo); + } + var brand = el("div", { className: "sf-header-brand" }); + if (config.title) { + brand.appendChild(el("div", { className: "sf-header-title" }, config.title)); + } + if (config.subtitle) { + brand.appendChild(el("div", { className: "sf-header-subtitle" }, config.subtitle)); + } + header.appendChild(brand); + if (config.tabs && config.tabs.length > 0) { + assert(Array.isArray(config.tabs), "createHeader(config.tabs) expects an array"); + var nav = el("nav", { className: "sf-header-nav" }); + controls.nav = nav; + config.tabs.forEach(function(tab) { + assert(tab && tab.id, "createHeader tab entries require an id"); + assert(typeof tab.label === "string", "createHeader tab entries require a label"); + var btn = el("button", { + className: "sf-nav-btn" + (tab.active ? " active" : ""), + role: "tab", + "aria-selected": !!tab.active, + tabIndex: 0, + dataset: { tab: tab.id }, + onKeyDown: function(e) { + if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; + var buttons = nav.querySelectorAll(".sf-nav-btn"); + var list = Array.prototype.slice.call(buttons); + var nextIndex = e.key === "ArrowRight" ? (list.indexOf(btn) + 1) % list.length : (list.length + list.indexOf(btn) - 1) % list.length; + var next = list[nextIndex]; + if (next && next.focus) next.focus(); + }, + onClick: function() { + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.classList.remove("active"); + }); + btn.classList.add("active"); + nav.querySelectorAll(".sf-nav-btn").forEach(function(b) { + b.setAttribute("aria-selected", b === btn ? "true" : "false"); + }); + if (config.onTabChange) config.onTabChange(tab.id); + } + }); + if (tab.icon) { + btn.appendChild(el("i", { className: "fa-solid " + tab.icon })); + } + btn.appendChild(document.createTextNode(tab.label)); + nav.appendChild(btn); + }); + header.appendChild(nav); + } + if (config.actions) { + assert(typeof config.actions === "object", "createHeader(config.actions) expects an object"); + assert(!config.actions.onSolve || typeof config.actions.onSolve === "function", "createHeader(config.actions.onSolve) must be a function"); + assert(!config.actions.onPause || typeof config.actions.onPause === "function", "createHeader(config.actions.onPause) must be a function"); + assert(!config.actions.onResume || typeof config.actions.onResume === "function", "createHeader(config.actions.onResume) must be a function"); + assert(!config.actions.onCancel || typeof config.actions.onCancel === "function", "createHeader(config.actions.onCancel) must be a function"); + assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === "function", "createHeader(config.actions.onAnalyze) must be a function"); + assert(!config.onTabChange || typeof config.onTabChange === "function", "createHeader(config.onTabChange) must be a function"); + var actions = el("div", { className: "sf-header-actions" }); + controls.actions = actions; + var spinner = el("div", { className: "sf-solving-spinner" }); + controls.spinner = spinner; + actions.appendChild(spinner); + if (config.actions.onSolve) { + var solveBtn = createButton({ + text: "Solve", + variant: "success", + icon: "fa-play", + onClick: config.actions.onSolve + }); + controls.solveBtn = solveBtn; + actions.appendChild(solveBtn); + } + if (config.actions.onPause) { + var pauseBtn = createButton({ + text: "Pause", + variant: "default", + icon: "fa-pause", + onClick: config.actions.onPause + }); + pauseBtn.style.display = "none"; + controls.pauseBtn = pauseBtn; + actions.appendChild(pauseBtn); + } + if (config.actions.onResume) { + var resumeBtn = createButton({ + text: "Resume", + variant: "primary", + icon: "fa-play", + onClick: config.actions.onResume + }); + resumeBtn.style.display = "none"; + controls.resumeBtn = resumeBtn; + actions.appendChild(resumeBtn); + } + if (config.actions.onCancel) { + var cancelBtn = createButton({ + text: "Stop", + variant: "danger", + icon: "fa-stop", + onClick: config.actions.onCancel + }); + cancelBtn.style.display = "none"; + controls.cancelBtn = cancelBtn; + actions.appendChild(cancelBtn); + } + if (config.actions.onAnalyze) { + var analyzeBtn = createButton({ + variant: "ghost", + icon: "fa-chart-bar", + circle: true, + tooltip: "Score Analysis", + onClick: config.actions.onAnalyze + }); + controls.analyzeBtn = analyzeBtn; + actions.appendChild(analyzeBtn); + } + header.appendChild(actions); + } + header.sfControls = controls; + return header; +}; + +// ts-src/components/modal.ts +var createModal = function(config) { + assert(config, "createModal(config) requires a configuration object"); + assert(!config.footer || Array.isArray(config.footer), "createModal(config.footer) must be an array"); + var overlay = el("div", { className: "sf-modal-overlay" }); + var dialogId = uid("sf-modal"); + var dialog = el("div", { + className: "sf-modal", + id: dialogId, + role: "dialog", + "aria-modal": "true", + "aria-labelledby": dialogId + "-title" + }); + var body = el("div", { className: "sf-modal-body" }); + var header = el("div", { className: "sf-modal-header" }); + var titleEl = el("div", { className: "sf-modal-title", id: dialogId + "-title" }, config.title || ""); + header.appendChild(titleEl); + var closeBtn = el("button", { + className: "sf-modal-close", + "aria-label": "Close modal", + onClick: function() { + api.close(); + } + }, "\xD7"); + header.appendChild(closeBtn); + dialog.appendChild(header); + setBodyContent(body, config.body, config.unsafeBody); + dialog.appendChild(body); + if (config.footer) { + var footer = el("div", { className: "sf-modal-footer" }); + config.footer.forEach(function(child) { + footer.appendChild(child); + }); + dialog.appendChild(footer); + } + overlay.appendChild(dialog); + var previousFocus = null; + overlay.addEventListener("click", function(e) { + if (e.target === overlay) api.close(); + }); + function onKeyDown(e) { + if (e.key === "Escape") api.close(); + } + var api = { el: overlay, body }; + api.open = function() { + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add("open"); + document.addEventListener("keydown", onKeyDown); + }; + api.close = function() { + overlay.classList.remove("open"); + document.removeEventListener("keydown", onKeyDown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); + if (config.onClose) config.onClose(); + }; + api.setBody = function(content) { + setBodyContent(body, content); + }; + if (config.width) { + dialog.style.maxWidth = config.width; + } + return api; +}; +function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ""; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === "string") { + target.textContent = content; + } else if (content && typeof content === "object" && "unsafeBody" in content) { + target.innerHTML = content.unsafeBody; + } else if (content && typeof content === "object" && "unsafeHtml" in content) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } +} + +// ts-src/components/statusbar.ts +var createStatusBar = function(config = {}) { + var bar = el("div", { className: "sf-statusbar" }); + var lastScore = null; + var controls = null; + var scoreEl = el("span", { className: "sf-statusbar-score", id: "sfScoreDisplay", "aria-live": "polite" }, "\u2014"); + bar.appendChild(scoreEl); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var dotsContainer = el("div", { className: "sf-statusbar-constraints" }); + bar.appendChild(dotsContainer); + var movesSep = el("span", { className: "sf-statusbar-sep" }, "|"); + movesSep.style.display = "none"; + bar.appendChild(movesSep); + var movesEl = el("span"); + movesEl.style.display = "none"; + bar.appendChild(movesEl); + bar.appendChild(el("span", { className: "sf-statusbar-sep" }, "|")); + var statusEl = el("span", { id: "sfStatusText", role: "status", "aria-live": "polite" }); + bar.appendChild(statusEl); + if (config && config.constraints) { + buildDots(dotsContainer, config.constraints, config.onConstraintClick); + } + var api = { + el: bar, + bindHeader: function(header) { + controls = header && header.sfControls ? header.sfControls : null; + return api; + }, + updateScore: function(scoreStr) { + if (scoreStr && scoreStr !== lastScore) { + scoreEl.textContent = scoreStr; + var colorClassName = colorClass(scoreStr); + scoreEl.classList.remove("improved", "score-green", "score-red", "score-yellow"); + scoreEl.classList.add(colorClassName); + void scoreEl.offsetWidth; + scoreEl.classList.add("improved"); + lastScore = scoreStr; + } else if (!scoreStr) { + scoreEl.textContent = "\u2014"; + scoreEl.classList.remove("score-green", "score-red", "score-yellow", "improved"); + lastScore = null; + } + }, + setLifecycleState: function(state) { + var normalized = normalizeLifecycleState(state); + var solveBtn = controls && controls.solveBtn; + var pauseBtn = controls && controls.pauseBtn; + var resumeBtn = controls && controls.resumeBtn; + var cancelBtn = controls && controls.cancelBtn; + var spinner = controls && controls.spinner; + if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? "" : "none"; + if (pauseBtn) { + pauseBtn.style.display = shouldShowPause(normalized) ? "" : "none"; + pauseBtn.disabled = normalized === "PAUSE_REQUESTED"; + } + if (resumeBtn) { + resumeBtn.style.display = normalized === "PAUSED" ? "" : "none"; + resumeBtn.disabled = false; + } + if (cancelBtn) { + cancelBtn.style.display = shouldShowCancel(normalized) ? "" : "none"; + cancelBtn.disabled = false; + } + if (spinner) spinner.classList.toggle("active", shouldSpin(normalized)); + statusEl.textContent = lifecycleLabel(normalized); + statusEl.style.color = isActiveLifecycle(normalized) ? "var(--sf-emerald-600)" : normalized === "FAILED" ? "var(--sf-red-600)" : normalized === "CANCELLED" ? "var(--sf-amber-700)" : "var(--sf-gray-500)"; + }, + setSolving: function(solving) { + api.setLifecycleState(solving ? "SOLVING" : "IDLE"); + }, + updateMoves: function(mps) { + if (mps != null && mps > 0) { + movesEl.textContent = mps.toLocaleString() + " moves/s"; + movesEl.style.display = ""; + movesSep.style.display = ""; + } else { + movesEl.style.display = "none"; + movesSep.style.display = "none"; + } + }, + updateConstraintDots: function(constraints) { + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + }, + colorDotsByScore: function(scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot) { + var isHard = dot.dataset.type === "hard"; + dot.classList.toggle("violated", isHard && hard < 0); + dot.classList.toggle("violated-soft", !isHard && soft < 0); + }); + }, + colorDotsFromAnalysis: function(constraints) { + if (!constraints || constraints.length === 0) return; + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + dotsContainer.querySelectorAll(".sf-constraint-dot").forEach(function(dot, i) { + var c = constraints[i]; + if (!dot) return; + var isHardConstraint = c.type === "hard"; + var scoreVal = isHardConstraint ? parseHard(c.score) : parseSoft(c.score); + var violated = scoreVal < 0; + dot.classList.toggle("violated", isHardConstraint && violated); + dot.classList.toggle("violated-soft", !isHardConstraint && violated); + }); + } + }; + if (config && config.header) { + api.bindHeader(config.header); + } + api.setLifecycleState("IDLE"); + return api; +}; +function buildDots(container2, constraints, onClick) { + container2.innerHTML = ""; + if (!constraints) return; + constraints.forEach(function(c, i) { + var dot = el("div", { + className: "sf-constraint-dot", + id: "sf-cdot-" + i, + title: c.name || "Constraint " + i, + role: onClick ? "button" : null, + tabIndex: onClick ? "0" : null, + "aria-label": onClick ? "Open constraint " + (c.name || "Constraint " + i) : null, + dataset: { type: c.type || "hard", index: String(i) } + }); + if (onClick) { + dot.style.cursor = "pointer"; + bindActivation(dot, function() { + onClick(i); + }); + } + container2.appendChild(dot); + }); +} +function normalizeLifecycleState(value) { + if (typeof value !== "string" || !value.trim()) return "IDLE"; + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); +} +function shouldShowSolve(state) { + return state === "IDLE" || state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; +} +function shouldShowPause(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED"; +} +function shouldShowCancel(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "PAUSED" || state === "RESUMING" || state === "CANCELLING"; +} +function shouldSpin(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; +} +function isActiveLifecycle(state) { + return shouldSpin(state); +} +function lifecycleLabel(state) { + if (state === "STARTING") return "Starting..."; + if (state === "SOLVING") return "Solving..."; + if (state === "PAUSE_REQUESTED") return "Pause requested..."; + if (state === "PAUSED") return "Paused"; + if (state === "RESUMING") return "Resuming..."; + if (state === "CANCELLING") return "Cancelling..."; + if (state === "COMPLETED") return "Completed"; + if (state === "CANCELLED") return "Cancelled"; + if (state === "FAILED") return "Failed"; + if (state === "TERMINATED_BY_CONFIG") return "Completed"; + return "Ready"; +} + +// ts-src/components/table.ts +var createTable = function(config) { + assert(config, "createTable(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createTable(config.columns) must be an array"); + assert(!config.rows || Array.isArray(config.rows), "createTable(config.rows) must be an array"); + var wrapper = el("div", { className: "sf-table-container" }); + var table = el("table", { className: "sf-table" }); + if (config.columns) { + var thead = el("thead"); + var tr = el("tr"); + config.columns.forEach(function(col) { + var th = el("th", null, typeof col === "string" ? col : col.label); + if (col.align) th.style.textAlign = col.align; + if (col.width) th.style.width = col.width; + tr.appendChild(th); + }); + thead.appendChild(tr); + table.appendChild(thead); + } + var tbody = el("tbody"); + if (config.rows) { + config.rows.forEach(function(row, rowIdx) { + var tr2 = el("tr"); + row.forEach(function(cell, colIdx) { + var td = el("td"); + if (typeof cell === "string" || typeof cell === "number") { + td.textContent = String(cell); + } else if (cell instanceof Node) { + td.appendChild(cell); + } else if (cell && cell.unsafeHtml) { + td.innerHTML = cell.unsafeHtml; + } + var col = config.columns && config.columns[colIdx]; + if (col && col.align) td.style.textAlign = col.align; + if (col && col.className) td.classList.add(col.className); + tr2.appendChild(td); + }); + if (config.onRowClick) { + tr2.style.cursor = "pointer"; + tr2.setAttribute("role", "button"); + tr2.tabIndex = 0; + bindActivation(tr2, function() { + config.onRowClick(rowIdx, row); + }); + } + tbody.appendChild(tr2); + }); + } + table.appendChild(tbody); + wrapper.appendChild(table); + return wrapper; +}; + +// ts-src/components/tabs.ts +var showTab = function(tabId, root) { + if (root) { + activateTabInScope(root, tabId); + return; + } + document.querySelectorAll(".sf-tabs-container").forEach(function(container2) { + activateTabInScope(container2, tabId); + }); +}; +var createTabs = function(config) { + assert(config, "createTabs(config) requires a configuration object"); + assert(Array.isArray(config.tabs), "createTabs(config.tabs) must be an array"); + var container2 = el("div", { className: "sf-tabs-container" }); + var tabsId = uid("sf-tabs"); + config.tabs.forEach(function(tab) { + var panel = el("div", { + className: "sf-tab-panel" + (tab.active ? " active" : ""), + id: tabsId + "-" + tab.id, + dataset: { tabId: tab.id } + }); + if (tab.content) { + if (typeof tab.content === "string") panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; + else if (tab.content instanceof Node) panel.appendChild(tab.content); + } + container2.appendChild(panel); + }); + return { + el: container2, + show: function(tabId) { + showTab(tabId, container2); + } + }; +}; +function activateTabInScope(scope, tabId) { + scope.querySelectorAll(".sf-tab-panel").forEach(function(p) { + p.classList.remove("active"); + }); + var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); + if (panel) panel.classList.add("active"); +} + +// ts-src/components/toast.ts +var container = null; +function ensureContainer() { + if (container && document.body.contains(container)) return; + container = el("div", { className: "sf-toast-container" }); + document.body.appendChild(container); +} +var showToast = function(config) { + assert(config, "showToast(config) requires a configuration object"); + ensureContainer(); + var variant = config.variant || "danger"; + var toast = el("div", { + className: "sf-toast sf-toast--" + variant + " sf-toast-enter", + role: "status", + "aria-live": "polite" + }); + var msg = el("div", { className: "sf-toast-message" }); + if (config.title) { + msg.appendChild(el("div", { className: "sf-toast-title" }, config.title)); + } + if (config.message) { + msg.appendChild(el("div", null, config.message)); + } + if (config.detail) { + var pre = el("pre", { style: { margin: "4px 0 0", fontSize: "11px", whiteSpace: "pre-wrap" } }); + pre.appendChild(el("code", null, config.detail)); + msg.appendChild(pre); + } + toast.appendChild(msg); + var closeBtn = el("button", { + className: "sf-toast-close", + "aria-label": "Dismiss toast", + onClick: function() { + dismiss(); + } + }, "\xD7"); + toast.appendChild(closeBtn); + container.appendChild(toast); + var delay = config.delay || 1e4; + var timer = setTimeout(dismiss, delay); + function dismiss() { + clearTimeout(timer); + toast.classList.remove("sf-toast-enter"); + toast.classList.add("sf-toast-exit"); + setTimeout(function() { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 200); + } +}; +var showError = function(title, detail) { + showToast({ title: "Error", message: title, detail, variant: "danger", delay: 3e4 }); +}; + +// ts-src/gantt/gantt.ts +var create = function(config) { + config = config || {}; + var instanceId = uid("sf-gantt"); + var chartPaneId = config.chartPane || instanceId + "-chart-pane"; + var gridPaneId = config.gridPane || instanceId + "-grid-pane"; + var chartContainerId = config.chartContainer || instanceId + "-container"; + var svgId = config.svgId || instanceId + "-svg"; + var ganttChart = null; + var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; + var tasks = []; + var sortState = { key: null, direction: "asc" }; + var wrapper = el("div", { className: "sf-gantt-split" }); + var gridPane = el("div", { className: "sf-gantt-pane", id: gridPaneId }); + var gridHeader = el("div", { className: "sf-gantt-pane-header" }); + gridHeader.appendChild(el("h3", null, config.gridTitle || "Tasks")); + var gridControls = el("div", { className: "sf-gantt-pane-controls" }); + gridHeader.appendChild(gridControls); + gridPane.appendChild(gridHeader); + var gridContent = el("div", { className: "sf-gantt-pane-content" }); + var grid = el("div", { className: "sf-gantt-grid" }); + gridContent.appendChild(grid); + gridPane.appendChild(gridContent); + var chartPane = el("div", { className: "sf-gantt-pane", id: chartPaneId }); + var chartHeader = el("div", { className: "sf-gantt-pane-header" }); + chartHeader.appendChild(el("h3", null, config.chartTitle || "Timeline")); + var viewControls = el("div", { className: "sf-gantt-view-controls" }); + var viewSelect = el("select", { className: "sf-gantt-view-select" }); + var modes = [ + { value: "Quarter Day", label: "Quarter Day" }, + { value: "Half Day", label: "Half Day" }, + { value: "Day", label: "Day" }, + { value: "Week", label: "Week" }, + { value: "Month", label: "Month" } + ]; + modes.forEach(function(m) { + var opt = el("option", { value: m.value }, m.label); + if (m.value === (config.viewMode || "Quarter Day")) opt.selected = true; + viewSelect.appendChild(opt); + }); + viewSelect.addEventListener("change", function() { + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); + }); + viewControls.appendChild(viewSelect); + var chartControls = el("div", { className: "sf-gantt-pane-controls" }); + chartHeader.appendChild(viewControls); + chartHeader.appendChild(chartControls); + chartPane.appendChild(chartHeader); + var chartContent = el("div", { className: "sf-gantt-pane-content" }); + var chartContainer = el("div", { className: "sf-gantt-container", id: chartContainerId }); + chartContent.appendChild(chartContainer); + chartPane.appendChild(chartContent); + wrapper.appendChild(gridPane); + wrapper.appendChild(chartPane); + var ctrl = { el: wrapper }; + ctrl.mount = function(parent) { + assert(parent, "gantt.mount(parent) requires a mount target"); + var target = typeof parent === "string" ? document.getElementById(parent) : parent; + assert(target, "gantt.mount(parent) target not found: " + parent); + validateMountTarget(target); + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); + target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } + initSplit(); + bindResizeObserver2(); + }; + ctrl.setTasks = function(newTasks) { + assert(Array.isArray(newTasks), "gantt.setTasks(tasks) expects an array"); + tasks = newTasks; + renderGrid(newTasks); + renderChart(newTasks); + }; + ctrl.refresh = function() { + if (ganttChart && tasks.length > 0) { + ganttChart.refresh(tasksToFrappe(tasks)); + } + }; + ctrl.getChart = function() { + return ganttChart; + }; + ctrl.changeViewMode = function(mode) { + viewSelect.value = mode; + if (ganttChart) ganttChart.change_view_mode(mode); + }; + ctrl.highlightTask = function(taskId) { + grid.querySelectorAll(".sf-gantt-row").forEach(function(row) { + row.classList.toggle("selected", row.dataset.taskId === taskId); + }); + var svg = chartContainer.querySelector("svg"); + if (svg) { + svg.querySelectorAll(".bar-wrapper").forEach(function(bw) { + bw.classList.remove("highlighted"); + }); + var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); + if (bar) bar.classList.add("highlighted"); + } + }; + ctrl.destroy = function() { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + ganttChart = null; + mounted = false; + mountTarget = null; + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); + }; + return ctrl; + function initSplit() { + if (typeof Split !== "function") return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + splitInstance = Split(["#" + gridPaneId, "#" + chartPaneId], { + direction: "vertical", + sizes: splitSizes, + minSize: splitMinSize, + snapOffset: 30, + gutterSize: 4, + cursor: "col-resize", + onDragEnd: function() { + if (ganttChart) { + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 100); + } + } + }); + } + function bindResizeObserver2() { + if (typeof ResizeObserver !== "function") return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function() { + if (!ganttChart) return; + setTimeout(function() { + ganttChart.refresh(tasksToFrappe(tasks)); + }, 0); + }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + } + function normalizePair(value, fallback) { + if (typeof value === "number" && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + function validateMountTarget(target) { + assert(target && typeof target.appendChild === "function", "gantt.mount(parent) requires a valid DOM container"); + assert(getElementSize(target, "Width") > 0 && getElementSize(target, "Height") > 0, "gantt.mount(parent) target is not laid out yet"); + } + function getElementSize(target, axis) { + var clientKey = "client" + axis; + var offsetKey = "offset" + axis; + var rectKey = axis === "Width" ? "width" : "height"; + if (typeof target[clientKey] === "number") return target[clientKey]; + if (typeof target[offsetKey] === "number") return target[offsetKey]; + if (typeof target.getBoundingClientRect === "function") { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === "number") return rect[rectKey]; + } + return 0; + } + function tasksToFrappe(taskList) { + return taskList.filter(function(t) { + return t.start && t.end; + }).map(function(t) { + var customClass = t.custom_class || ""; + if (t.pinned) { + customClass = customClass ? customClass + " pinned" : "pinned"; + } + return { + id: t.id, + name: t.name || t.label || t.id, + start: t.start, + end: t.end, + custom_class: customClass, + dependencies: t.dependencies || "" + }; + }); + } + function renderChart(taskList) { + var frappeTasks = tasksToFrappe(taskList); + if (frappeTasks.length === 0) { + chartContainer.textContent = ""; + chartContainer.appendChild(el("div", { + className: "sf-gantt-empty-state", + style: { + padding: "24px", + color: "var(--sf-gray-400)", + fontFamily: "var(--sf-font-mono)", + fontSize: "13px" + } + }, "No scheduled tasks to display.")); + ganttChart = null; + return; + } + chartContainer.textContent = ""; + chartContainer.appendChild(createSvgRoot(svgId)); + ganttChart = new Gantt("#" + svgId, frappeTasks, { + view_mode: viewSelect.value || "Quarter Day", + date_format: "YYYY-MM-DD HH:mm", + custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, + on_click: function(task) { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + on_date_change: function(task, start, end) { + if (config.onDateChange) config.onDateChange(task, start, end); + } + }); + } + function renderGrid(taskList) { + while (grid.firstChild) grid.removeChild(grid.firstChild); + var table = el("table", { className: "sf-gantt-table" }); + var columns = config.columns || [ + { key: "name", label: "Task" }, + { key: "start", label: "Start" }, + { key: "end", label: "End" } + ]; + var sortedTasks = sortTasks(taskList); + var thead = el("thead"); + var headerRow = el("tr"); + columns.forEach(function(col) { + headerRow.appendChild(buildHeaderCell(col)); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + var tbody = el("tbody"); + sortedTasks.forEach(function(task) { + var rowClasses = ["sf-gantt-row"]; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push("sf-project-" + task.projectIndex); + var tr = el("tr", { + className: rowClasses.join(" "), + dataset: { taskId: task.id }, + onClick: function() { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + } + }); + columns.forEach(function(col) { + var td = el("td"); + if (col.key === "name") { + td.className = "sf-task-name"; + td.textContent = task.name || task.label || task.id; + } else if (col.render) { + var content = col.render(task); + if (typeof content === "string") td.textContent = content; + else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; + else if (content instanceof Node) td.appendChild(content); + } else { + td.textContent = task[col.key] || ""; + td.style.fontFamily = "var(--sf-font-mono)"; + td.style.fontSize = "12px"; + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + grid.appendChild(table); + } + function buildHeaderCell(col) { + if (!col.sortable) { + return el("th", null, col.label); + } + var isCurrent = sortState.key === col.key; + var th = el("th", { + className: "sortable" + (isCurrent ? " active" : ""), + role: "button", + tabIndex: 0, + "aria-sort": isCurrent ? sortState.direction === "asc" ? "ascending" : "descending" : "none" + }); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(el("span", { className: "sort-icon" }, isCurrent ? sortState.direction === "asc" ? "\u25B2" : "\u25BC" : "")); + bindActivation(th, function() { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === "asc" ? "desc" : "asc"; + } else { + sortState.key = col.key; + sortState.direction = "asc"; + } + renderGrid(tasks); + }); + return th; + } + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function(a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === "asc") return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; + }); + return sorted; + } + function sortValue(value, key) { + if (value == null) return ""; + if (key === "start" || key === "end") { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; + } + if (typeof value === "number") return value; + return String(value).toLowerCase(); + } + function defaultPopup(task) { + var t = tasks.find(function(x) { + return x.id === task.id; + }); + if (!t) return ""; + return '

' + escHtml(t.name || t.id) + "

Start: " + escHtml(t.start) + "

End: " + escHtml(t.end) + "

" + (t.duration_minutes ? "

Duration: " + t.duration_minutes + " min

" : "") + (t.pinned ? '

Pinned

' : "") + "
"; + } + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = id; + return svg; + } + return el("svg", { id }); + } +}; +var gantt = { create }; + +// ts-src/rail/card.ts +var createHeader2 = function(config) { + assert(config, "createHeader(config) requires a configuration object"); + assert(!config.columns || Array.isArray(config.columns), "createHeader(config.columns) expects an array"); + var labelWidth = config.labelWidth || 200; + var columns = config.columns || []; + var header = el("div", { className: "sf-timeline-header" }); + header.style.gridTemplateColumns = labelWidth + "px 1fr"; + var spacer = el("div", { className: "sf-timeline-label-spacer" }, config.label || ""); + header.appendChild(spacer); + var days = el("div", { className: "sf-timeline-days" }); + days.style.gridTemplateColumns = "repeat(" + columns.length + ", 1fr)"; + columns.forEach(function(col) { + var colEl = el("div", { className: "sf-timeline-day-col" }); + colEl.appendChild(el("span", null, typeof col === "string" ? col : col.label)); + days.appendChild(colEl); + }); + header.appendChild(days); + return header; +}; +var createCard = function(config) { + assert(config, "createCard(config) requires a configuration object"); + var labelWidth = config.labelWidth || 200; + var card = el("div", { className: "sf-resource-card" }); + var state = { + unassigned: [], + railConfig: config + }; + if (config.id) card.dataset.resourceId = config.id; + var resHeader = el("div", { className: "sf-resource-header" }); + resHeader.style.gridTemplateColumns = labelWidth + "px 1fr"; + var identity = el("div", { className: "sf-resource-identity" }); + if (config.name) { + identity.appendChild(el("div", { className: "sf-resource-name" }, config.name)); + } + if (config.badges || config.type) { + var meta = el("div", { className: "sf-resource-meta" }); + if (config.type) { + var badge = el("span", { className: "sf-resource-type-badge" }, config.type); + if (config.typeStyle) { + badge.style.background = config.typeStyle.bg || ""; + badge.style.color = config.typeStyle.color || ""; + badge.style.border = config.typeStyle.border || ""; + } + meta.appendChild(badge); + } + var badges = Array.isArray(config.badges) ? config.badges : config.badges ? [config.badges] : []; + if (badges.length) { + badges.forEach(function(entry) { + if (!entry) return; + if (typeof entry === "string") { + meta.appendChild(el("span", { className: "sf-resource-type-badge" }, entry)); + return; + } + var extraBadge = el("span", { className: "sf-resource-type-badge" }, entry.label || ""); + if (entry.style) { + extraBadge.style.background = entry.style.bg || ""; + extraBadge.style.color = entry.style.color || ""; + extraBadge.style.border = entry.style.border || ""; + } + meta.appendChild(extraBadge); + }); + } + identity.appendChild(meta); + } + resHeader.appendChild(identity); + if (config.gauges && config.gauges.length > 0) { + var gauges = el("div", { className: "sf-gauges" }); + config.gauges.forEach(function(g) { + var row = el("div", { className: "sf-gauge-row" }); + row.appendChild(el("span", { className: "sf-gauge-label" }, g.label)); + var track = el("div", { className: "sf-gauge-track" }); + var fill = el("div", { + className: "sf-gauge-fill" + (g.style ? " sf-gauge-fill--" + g.style : "") + }); + fill.style.width = Math.min(g.pct || 0, 100) + "%"; + track.appendChild(fill); + row.appendChild(track); + if (g.text) row.appendChild(el("span", { className: "sf-gauge-value" }, g.text)); + gauges.appendChild(row); + }); + resHeader.appendChild(gauges); + } + card.appendChild(resHeader); + var body = el("div", { className: "sf-resource-body" }); + body.style.gridTemplateColumns = labelWidth + "px 1fr"; + var stats = el("div", { className: "sf-resource-stats" }); + if (config.stats) { + config.stats.forEach(function(s) { + var row = el("div", { className: "sf-stat-row" }); + row.appendChild(el("span", { className: "sf-stat-label" }, s.label)); + row.appendChild(el("span", { className: "sf-stat-value" }, String(s.value))); + stats.appendChild(row); + }); + } + body.appendChild(stats); + var railContainer = el("div", { className: "sf-rail-container" }); + var rail2 = el("div", { className: "sf-rail" }); + if (config.id) rail2.id = "sf-rail-" + config.id; + var numCols = config.columns || 5; + var dayGrid = el("div", { className: "sf-day-grid" }); + dayGrid.style.gridTemplateColumns = "repeat(" + numCols + ", 1fr)"; + for (var i = 0; i < numCols; i++) { + dayGrid.appendChild(el("div", { className: "sf-day-col" })); + } + rail2.appendChild(dayGrid); + railContainer.appendChild(rail2); + body.appendChild(railContainer); + card.appendChild(body); + if (config.heatmap) { + var heatmapCfg = { + horizon: config.heatmap.horizon || 1, + label: config.heatmap.label, + segments: config.heatmap.segments, + labelWidth + }; + heatmapCfg.railConfig = config; + var heatmap = createHeatmap(heatmapCfg); + if (heatmap) card.appendChild(heatmap); + } + var unassignedRail = el("div", { className: "sf-unassigned-rail" }); + if (config.unassigned) { + state.unassigned = config.unassigned; + renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); + } + if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); + var cardApi = { + el: card, + rail: rail2, + addBlock: function(blockConfig) { + return addBlock(rail2, blockConfig); + }, + setUnassigned: function(items) { + state.unassigned = Array.isArray(items) ? items : []; + if (state.unassigned.length === 0 && unassignedRail.parentNode) { + unassignedRail.innerHTML = ""; + unassignedRail.parentNode?.removeChild(unassignedRail); + return; + } + if (state.unassigned.length > 0) { + renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); + } else { + unassignedRail.innerHTML = ""; + } + if (state.unassigned.length > 0 && !unassignedRail.parentNode) { + card.appendChild(unassignedRail); + } + }, + clearBlocks: function() { + rail2.querySelectorAll(".sf-block, .sf-changeover").forEach(function(el2) { + el2.remove(); + }); + }, + setSolving: function(solving) { + card.classList.toggle("solving", solving); + } + }; + return cardApi; +}; +var createHeatmap = function(config) { + if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; + var heatmap = el("div", { className: "sf-heatmap" }); + heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + "px 1fr"; + var label = el("div", { className: "sf-heatmap-label" }, config.label || ""); + heatmap.appendChild(label); + var track = el("div", { className: "sf-heatmap-track" }); + var columns = config.railConfig && config.railConfig.columns || 1; + track.style.gridTemplateColumns = "repeat(" + columns + ", 1fr)"; + heatmap.appendChild(track); + var horizon = config.horizon || 1; + config.segments.forEach(function(segment) { + if (!segment || segment.end <= segment.start) return; + var band = el("div", { className: "sf-heatmap-segment" }); + var start = Math.max(0, segment.start); + var width = Math.max(0, segment.end - start); + band.style.left = start / horizon * 100 + "%"; + band.style.width = Math.max(width / horizon * 100, 0.25) + "%"; + if (segment.color) band.style.background = segment.color; + if (segment.opacity != null) band.style.opacity = segment.opacity; + if (segment.tooltip) band.title = segment.tooltip; + track.appendChild(band); + }); + return heatmap; +}; +var createUnassignedRail = function(tasks, onTaskClick) { + var rail2 = el("div", { className: "sf-unassigned-rail" }); + renderUnassigned(rail2, tasks, onTaskClick); + return rail2; +}; +var addBlock = function(rail2, config) { + assert(rail2, "addBlock(rail) requires a rail element"); + assert(config && config.horizon != null, "addBlock(config.horizon) is required"); + assert(config.start != null && config.end != null, "addBlock(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; + var block = el("div", { className: "sf-block" }); + block.style.left = startPct + "%"; + block.style.width = Math.max(widthPct, minWidthPct) + "%"; + if (config.color) { + block.style.background = config.color; + block.style.borderLeftColor = config.borderColor || config.color; + } + if (config.className) block.classList.add(config.className); + if (config.late) block.classList.add("late"); + if (config.id) block.dataset.blockId = config.id; + if (config.delay) block.style.animationDelay = config.delay; + if (config.label) { + block.appendChild(el("div", { className: "sf-block-label" }, config.label)); + } + if (config.meta) { + block.appendChild(el("div", { className: "sf-block-meta" }, config.meta)); + } + if (config.onHover) { + block.addEventListener("mouseenter", function(e) { + config.onHover(e, config); + }); + } + if (config.onLeave) { + block.addEventListener("mouseleave", function() { + config.onLeave(); + }); + } + if (config.onClick) { + block.setAttribute("role", "button"); + block.tabIndex = 0; + bindActivation(block, function(e) { + config.onClick(e, config); + }); + } + rail2.appendChild(block); + return block; +}; +var addChangeover = function(rail2, config) { + assert(rail2, "addChangeover(rail) requires a rail element"); + assert(config && config.horizon != null, "addChangeover(config.horizon) is required"); + assert(config.start != null && config.end != null, "addChangeover(config.start/config.end) are required"); + var horizon = config.horizon || 1; + var startPct = config.start / horizon * 100; + var widthPct = (config.end - config.start) / horizon * 100; + var co = el("div", { className: "sf-changeover" }); + co.style.left = startPct + "%"; + co.style.width = widthPct + "%"; + rail2.appendChild(co); + return co; +}; +function renderUnassigned(unassignedRail, items, onTaskClick) { + unassignedRail.innerHTML = ""; + (items || []).forEach(function(item) { + var label = typeof item === "string" ? item : item.label || item.id || ""; + if (!label) return; + var pill = el("button", { + className: "sf-unassigned-pill", + onClick: function() { + if (onTaskClick) onTaskClick(item); + } + }, label); + unassignedRail.appendChild(pill); + }); +} + +// ts-src/rail/timeline.ts +var DAY_MINUTES = 24 * 60; +var SIX_HOUR_MINUTES = 6 * 60; +var WEEK_MINUTES = 7 * DAY_MINUTES; +var TRACK_HEIGHT = 34; +var TRACK_GAP = 8; +var TRACK_PADDING = 12; +var OVERVIEW_HEIGHT = 68; +var OVERVIEW_BLOCK_HEIGHT = 34; +var OVERVIEW_GROUP_GAP_MINUTES = 30; +var MIN_LABEL_WIDTH = 180; +var MIN_VISIBLE_TRACK_WIDTH = 320; +var MIN_CONTENT_TRACK_WIDTH = 480; +var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; +var TONE_MAP = { + emerald: { + id: "emerald", + background: "rgba(16, 185, 129, 0.22)", + border: "#059669", + text: "#064e3b", + overlay: "rgba(16, 185, 129, 0.10)" + }, + blue: { + id: "blue", + background: "rgba(59, 130, 246, 0.22)", + border: "#2563eb", + text: "#1e40af", + overlay: "rgba(59, 130, 246, 0.10)" + }, + amber: { + id: "amber", + background: "rgba(245, 158, 11, 0.24)", + border: "#d97706", + text: "#92400e", + overlay: "rgba(245, 158, 11, 0.10)" + }, + rose: { + id: "rose", + background: "rgba(244, 63, 94, 0.22)", + border: "#e11d48", + text: "#9f1239", + overlay: "rgba(244, 63, 94, 0.10)" + }, + violet: { + id: "violet", + background: "rgba(139, 92, 246, 0.22)", + border: "#7c3aed", + text: "#5b21b6", + overlay: "rgba(139, 92, 246, 0.10)" + }, + cyan: { + id: "cyan", + background: "rgba(6, 182, 212, 0.22)", + border: "#0891b2", + text: "#155e75", + overlay: "rgba(6, 182, 212, 0.10)" + }, + red: { + id: "red", + background: "rgba(239, 68, 68, 0.22)", + border: "#dc2626", + text: "#991b1b", + overlay: "rgba(239, 68, 68, 0.10)" + }, + slate: { + id: "slate", + background: "rgba(100, 116, 139, 0.20)", + border: "#475569", + text: "#1e293b", + overlay: "rgba(100, 116, 139, 0.08)" + } +}; +var createTimeline = function(config) { + assert(config && config.model, "rail.createTimeline(config.model) requires a normalized model"); + var labelWidth = config.labelWidth == null ? 280 : assertFiniteNumber(config.labelWidth, "rail.createTimeline(labelWidth)"); + assert(labelWidth > 0, "rail.createTimeline(labelWidth) must be greater than zero"); + var state = { + cleanup: [], + config, + destroyed: false, + expandedClusters: {}, + hasQueuedPostMountSync: false, + instanceId: uid("sf-rail-timeline"), + labelWidth, + model: normalizeModel(config.model), + scrollSync: null, + viewport: null, + layout: null + }; + state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); + var root = el("section", { + className: "sf-rail-timeline", + dataset: { + labelWidth: String(labelWidth) + } + }); + root.setAttribute("role", "region"); + root.setAttribute("aria-label", config.title || "Scheduling timeline"); + var toolbar = el("div", { className: "sf-rail-timeline-toolbar" }); + var toolbarCopy = el("div", { className: "sf-rail-timeline-toolbar-copy" }); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-title" }, config.title || "Scheduling timeline")); + toolbarCopy.appendChild(el("div", { className: "sf-rail-timeline-toolbar-subtitle" }, config.subtitle || "Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.")); + toolbar.appendChild(toolbarCopy); + var zoomControls = el("div", { className: "sf-rail-timeline-zoom-controls" }); + var zoomButtons = []; + normalizeZoomPresets(config.zoomPresets).forEach(function(preset) { + var button = el("button", { + className: "sf-rail-timeline-zoom-button", + type: "button", + dataset: { zoom: preset } + }, preset === "reset" ? "Reset" : preset.toUpperCase()); + button.addEventListener("click", function() { + if (preset === "reset") { + api.setViewport(state.model.axis.initialViewport); + return; + } + api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); + }); + zoomButtons.push(button); + zoomControls.appendChild(button); + }); + if (zoomButtons.length) { + toolbar.appendChild(zoomControls); + } + root.appendChild(toolbar); + var shell = el("div", { className: "sf-rail-timeline-shell" }); + var headerViewport = el("div", { className: "sf-rail-timeline-header-viewport" }); + var bodyViewport = el("div", { className: "sf-rail-timeline-body-viewport" }); + var headerRow = el("div", { className: "sf-rail-timeline-header-row" }); + var lanes = el("div", { className: "sf-rail-timeline-lanes" }); + headerViewport.appendChild(headerRow); + bodyViewport.appendChild(lanes); + shell.appendChild(headerViewport); + shell.appendChild(bodyViewport); + root.appendChild(shell); + var tooltip = el("div", { className: "sf-tooltip sf-rail-timeline-tooltip" }); + tooltip.id = uid("sf-rail-timeline-tooltip"); + tooltip.setAttribute("role", "tooltip"); + tooltip.setAttribute("aria-hidden", "true"); + root.appendChild(tooltip); + bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); + bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); + bindWindowResize(state, syncLayoutFromViewport); + function renderStructure() { + renderHeader(); + renderLanes(); + } + function applyMeasuredLayout() { + state.layout = measureLayout(bodyViewport, state); + applyLayout(root, headerRow, lanes, state.layout); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + } + function renderHeader() { + headerRow.innerHTML = ""; + var corner = el("div", { className: "sf-rail-timeline-label-corner" }, config.label || "Lane"); + headerRow.appendChild(corner); + var axis = el("div", { className: "sf-rail-timeline-axis sf-rail-timeline-axis--header" }); + axis.style.height = "82px"; + renderAxisDecor(axis, state.model.axis, true); + headerRow.appendChild(axis); + } + function renderLanes() { + lanes.innerHTML = ""; + state.model.lanes.forEach(function(lane, laneIndex) { + var laneRender = lane.mode === "overview" ? buildOverviewRender(lane, state, function() { + rerenderTimeline(); + }) : buildDetailedRender(lane, lane.items); + var row = el("div", { + className: "sf-rail-timeline-row sf-rail-timeline-row--" + lane.mode + (laneRender.expandedClusterId ? " sf-rail-timeline-row--expanded" : ""), + dataset: { + laneId: lane.id, + mode: lane.mode, + trackCount: String(laneRender.trackCount) + } + }); + if (laneRender.expandedClusterId) { + row.dataset.expandedClusterId = laneRender.expandedClusterId; + } + row.setAttribute("role", "group"); + var label = buildLaneLabel( + lane, + laneRender, + row, + buildScopedId(state.instanceId, "lane-title-" + laneIndex) + ); + row.appendChild(label); + var track = el("div", { className: "sf-rail-timeline-track" }); + track.style.height = laneRender.height + "px"; + renderAxisDecor(track, state.model.axis, false); + renderOverlays(track, lane.overlays, state.model.axis); + laneRender.blocks.forEach(function(blockConfig) { + appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); + }); + row.appendChild(track); + lanes.appendChild(row); + }); + } + function rerenderTimeline() { + renderStructure(); + syncLayoutFromViewport(); + } + function syncLayoutFromViewport() { + applyMeasuredLayout(); + syncScrollToViewport(); + } + function syncScrollToViewport() { + if (!state.layout) return; + var scrollLeft = viewportToScrollLeft(state, bodyViewport); + state.scrollSync = bodyViewport; + bodyViewport.scrollLeft = scrollLeft; + headerViewport.scrollLeft = scrollLeft; + state.scrollSync = null; + } + var api = { + destroy: function() { + if (state.destroyed) return; + state.destroyed = true; + state.cleanup.forEach(function(cleanup) { + if (typeof cleanup === "function") cleanup(); + }); + root.innerHTML = ""; + }, + el: root, + expandCluster: function(laneId, clusterId) { + setExpandedCluster(state, laneId, clusterId); + rerenderTimeline(); + }, + setModel: function(nextModel) { + state.model = normalizeModel(nextModel); + state.viewport = clampViewport(state.model.axis, state.viewport); + pruneExpandedClusters(state); + rerenderTimeline(); + queuePostMountSync(state, syncLayoutFromViewport); + }, + setViewport: function(nextViewport) { + state.viewport = clampViewport( + state.model.axis, + normalizeViewportInput(nextViewport, "rail.createTimeline().setViewport(viewport)") + ); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + } + }; + renderStructure(); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + return api; +}; +function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { + var tone = blockConfig.tone; + var relativeStart = blockConfig.startMinute - axis.startMinute; + var relativeEnd = blockConfig.endMinute - axis.startMinute; + var horizon = axis.endMinute - axis.startMinute; + var block = addBlock(track, { + start: relativeStart, + end: relativeEnd, + horizon, + label: blockConfig.label, + meta: blockConfig.metaLabel, + color: tone.background, + borderColor: tone.border, + minWidthPct: 0, + onClick: blockConfig.onClick, + onHover: function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }, + onLeave: function() { + hideTooltip(tooltip); + } + }); + block.classList.add("sf-rail-timeline-item"); + block.classList.add(blockConfig.kindClass); + block.style.left = positionPct(blockConfig.startMinute, axis) + "%"; + block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + "%"; + block.style.top = blockConfig.top + "px"; + block.style.height = blockConfig.height + "px"; + block.style.bottom = "auto"; + block.style.color = tone.text; + block.tabIndex = 0; + block.dataset.itemId = blockConfig.itemId; + block.dataset.laneId = lane.id; + block.dataset.startMinute = String(blockConfig.startMinute); + block.dataset.endMinute = String(blockConfig.endMinute); + if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); + if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; + if (blockConfig.onClick) { + block.setAttribute("role", "button"); + block.setAttribute("aria-expanded", blockConfig.expanded ? "true" : "false"); + } else { + block.setAttribute("role", "group"); + } + if (blockConfig.ariaLabel) block.setAttribute("aria-label", blockConfig.ariaLabel); + block.setAttribute("aria-describedby", tooltip.id); + if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); + if (blockConfig.detailHint) { + block.appendChild(el("span", { className: "sf-rail-timeline-detail-hint" }, blockConfig.detailHint)); + } + block.title = blockConfig.tooltip.title; + block.addEventListener("mousemove", function(event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }); + block.addEventListener("focus", function() { + showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + }); + block.addEventListener("blur", function() { + hideTooltip(tooltip); + }); + block.addEventListener("keydown", function(event) { + if (event && event.key === "Escape") hideTooltip(tooltip); + }); +} +function appendOverviewSummary(block, summary) { + var footer = el("div", { className: "sf-rail-timeline-summary-footer" }); + if (summary.badges.length > 0) { + var badgeRail = el("div", { className: "sf-rail-timeline-summary-badges" }); + summary.badges.forEach(function(badge) { + badgeRail.appendChild(el("span", { + className: "sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--" + badge.kind + }, badge.text)); + }); + footer.appendChild(badgeRail); + } + if (summary.toneSegments.length > 0) { + var toneBar = el("div", { + className: "sf-rail-timeline-summary-tonebar", + "aria-hidden": "true" + }); + var total = summary.toneSegments.reduce(function(sum, segment) { + return sum + segment.count; + }, 0) || 1; + summary.toneSegments.forEach(function(segment) { + var toneSegment = el("span", { className: "sf-rail-timeline-summary-tone-segment" }); + toneSegment.style.background = segment.tone.border; + toneSegment.style.width = segment.count / total * 100 + "%"; + toneBar.appendChild(toneSegment); + }); + footer.appendChild(toneBar); + } + if (footer.children.length > 0) block.appendChild(footer); +} +function bindScrollSync(source, target, state, root, zoomButtons) { + source.addEventListener("scroll", function() { + handleScroll(source, target, state, root, zoomButtons); + }); + target.addEventListener("scroll", function() { + handleScroll(target, source, state, root, zoomButtons); + }); +} +function bindDragPan(source, target, state, root, zoomButtons) { + var drag = { + active: false, + startClientX: 0, + startScrollLeft: 0 + }; + source.addEventListener("mousedown", function(event) { + if (event.button != null && event.button !== 0) return; + drag.active = true; + drag.startClientX = event.clientX != null ? event.clientX : 0; + drag.startScrollLeft = source.scrollLeft || 0; + source.classList.add("is-dragging"); + if (event.preventDefault) event.preventDefault(); + }); + source.addEventListener("mousemove", function(event) { + if (!drag.active) return; + var clientX = event.clientX != null ? event.clientX : drag.startClientX; + var delta = clientX - drag.startClientX; + source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); + handleScroll(source, target, state, root, zoomButtons); + if (event.preventDefault) event.preventDefault(); + }); + function finishDrag() { + if (!drag.active) return; + drag.active = false; + source.classList.remove("is-dragging"); + } + source.addEventListener("mouseup", finishDrag); + source.addEventListener("mouseleave", finishDrag); +} +function handleScroll(source, target, state, root, zoomButtons) { + if (state.destroyed) return; + if (!state.layout) return; + if (state.scrollSync === source) return; + state.scrollSync = source; + target.scrollLeft = source.scrollLeft; + state.viewport = scrollLeftToViewport(state, source); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + state.scrollSync = null; +} +function measurePackedHeight(packed) { + return packed.trackCount > 0 ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP : OVERVIEW_HEIGHT; +} +function buildDetailBlockConfig(item, lane, trackIndex, top, config = {}) { + const i = item; + const l = lane; + return { + clusterId: config.clusterId || null, + detailHint: config.detailHint || "", + endMinute: i.endMinute, + height: TRACK_HEIGHT, + itemId: i.id, + kindClass: "sf-rail-timeline-item--detail", + label: i.label, + metaLabel: describeMeta(i.meta), + startMinute: i.startMinute, + top, + ariaLabel: buildItemAriaLabel(i, l), + tooltip: buildItemTooltip(i, l), + tone: i.tone, + trackIndex + }; +} +function buildOverviewBlockConfig(group, height, options) { + var config = options || {}; + return { + clusterId: config.clusterId || null, + endMinute: group.endMinute, + height: OVERVIEW_BLOCK_HEIGHT, + itemId: config.itemId, + kindClass: config.kindClass, + label: group.summary.primaryLabel, + metaLabel: group.summary.secondaryLabel, + onClick: config.onClick || null, + startMinute: group.startMinute, + summary: buildOverviewBlockSummary(group, !!config.expanded), + top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), + ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), + expanded: !!config.expanded, + tooltip: config.tooltip, + tone: group.tone + }; +} +function buildDetailedRender(lane, items) { + var packed = packItems(items); + var height = measurePackedHeight(packed); + var blocks = packed.items.map(function(entry) { + return buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) + ); + }); + return { + blocks, + height, + trackCount: packed.trackCount || 1 + }; +} +function buildOverviewRender(lane, state, rerender) { + var groups = groupOverviewItems(lane); + var expandedClusterId = state.expandedClusters[lane.id] || null; + var expandedGroup = null; + var packedExpanded = null; + var expandedDetailsTop = 0; + groups.forEach(function(group) { + if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { + expandedGroup = group; + } + }); + if (expandedGroup) { + packedExpanded = packItems(expandedGroup.detailItems); + expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; + } + var height = packedExpanded ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) : OVERVIEW_HEIGHT; + var blocks = []; + groups.forEach(function(group) { + if (group.isCluster) { + var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); + blocks.push(buildOverviewBlockConfig(group, height, { + clusterId: group.clusterKey, + itemId: group.renderId, + kindClass: "sf-rail-timeline-item--cluster", + expanded: isExpanded, + onClick: function() { + setExpandedCluster( + state, + lane.id, + state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey + ); + if (state.config && state.config.onClusterToggle) { + state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); + } + if (typeof rerender === "function") rerender(); + }, + top: isExpanded ? TRACK_PADDING : null, + tooltip: buildClusterTooltip(group, lane) + })); + if (isExpanded) { + packedExpanded.items.forEach(function(entry) { + blocks.push(buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), + { + clusterId: group.clusterKey, + detailHint: "Expanded" + } + )); + }); + } + return; + } + blocks.push(buildOverviewBlockConfig(group, height, { + itemId: group.items[0].id, + kindClass: "sf-rail-timeline-item--overview", + tooltip: buildOverviewTooltip(group, lane) + })); + }); + return { + blocks, + expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, + height, + trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1 + }; +} +function buildLaneLabel(lane, laneRender, row, headingId) { + var label = el("div", { + className: "sf-rail-timeline-lane-label", + dataset: { laneId: lane.id } + }); + label.style.minHeight = laneRender.height + "px"; + var heading = el("div", { className: "sf-rail-timeline-lane-heading" }); + var title = el("div", { className: "sf-rail-timeline-lane-title" }, lane.label); + title.id = headingId; + heading.appendChild(title); + if (lane.mode) { + heading.appendChild(el("div", { className: "sf-rail-timeline-lane-mode" }, lane.mode)); + } + label.appendChild(heading); + if (row) row.setAttribute("aria-labelledby", title.id); + if (lane.badges.length > 0) { + var badges = el("div", { className: "sf-rail-timeline-lane-badges" }); + lane.badges.forEach(function(badge) { + var badgeEl = el("span", { className: "sf-rail-timeline-lane-badge" }, badge.label); + if (badge.style) { + badgeEl.style.background = badge.style.bg || ""; + badgeEl.style.border = badge.style.border || ""; + badgeEl.style.color = badge.style.color || ""; + } + badges.appendChild(badgeEl); + }); + label.appendChild(badges); + } + if (lane.stats.length > 0) { + var stats = el("div", { className: "sf-rail-timeline-lane-stats" }); + lane.stats.forEach(function(stat) { + var statRow = el("div", { className: "sf-rail-timeline-lane-stat" }); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-label" }, stat.label)); + statRow.appendChild(el("span", { className: "sf-rail-timeline-lane-stat-value" }, String(stat.value))); + stats.appendChild(statRow); + }); + label.appendChild(stats); + } + return label; +} +function buildClusterTooltip(group, lane) { + var first = group.detailItems[0] || group.items[0]; + var payload = { + rows: [ + { key: "Lane", value: lane.label }, + { key: "Window", value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, + { key: "Items", value: String(group.summary.count) } + ], + title: group.label + }; + if (group.summary.openCount > 0) { + payload.rows.push({ key: "Open", value: String(group.summary.openCount) }); + } + if (group.summary.toneSegments.length > 0) { + payload.rows.push({ key: "Mix", value: describeToneSegments(group.summary.toneSegments) }); + } + if (first && first.meta) { + payload.rows.push({ key: "Sample", value: describeMeta(first.meta) }); + } + return payload; +} +function buildItemTooltip(item, lane) { + var rows = [ + { key: "Lane", value: lane.label }, + { key: "Time", value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) } + ]; + appendMetaRows(rows, item.meta); + return { + rows, + title: item.label + }; +} +function buildOverviewBlockMeta(group) { + if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; + var labels = []; + group.items.slice(0, 2).forEach(function(item) { + labels.push(item.label); + }); + if (group.count > 2) labels.push("+" + (group.count - 2) + " more"); + return labels.join(" \u2022 "); +} +function buildPresetViewport(axis, currentViewport, preset) { + var duration = preset === "1w" ? WEEK_MINUTES : preset === "2w" ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; + var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); + var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; + var start = Math.round(center - visibleDuration / 2); + return clampViewport(axis, { + startMinute: start, + endMinute: start + visibleDuration + }); +} +function clampNumber(value, min, max) { + return Math.min(Math.max(value, min), max); +} +function clampViewport(axis, viewport) { + var totalDuration = axis.endMinute - axis.startMinute; + var next = viewport || axis.initialViewport || { + startMinute: axis.startMinute, + endMinute: axis.endMinute + }; + var duration = next.endMinute - next.startMinute; + duration = Math.min(duration, totalDuration); + var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); + return { + endMinute: start + duration, + startMinute: start + }; +} +function assertFiniteNumber(value, label) { + assert(typeof value === "number" && isFinite(value), label + " must be a finite number"); + return value; +} +function assertMinuteValue(value, label) { + return assertInteger(value, label); +} +function assertInteger(value, label) { + var number = assertFiniteNumber(value, label); + assert(Math.floor(number) === number, label + " must be an integer"); + return number; +} +function assertNonNegativeInteger(value, label) { + var number = assertInteger(value, label); + assert(number >= 0, label + " must be greater than or equal to zero"); + return number; +} +function describeMeta(meta) { + if (meta == null) return ""; + if (typeof meta === "string") return meta; + if (typeof meta === "number") return String(meta); + if (Array.isArray(meta)) { + return meta.map(function(entry) { + if (entry && entry.label && entry.value != null) return entry.label + ": " + entry.value; + return String(entry || ""); + }).filter(Boolean).join(" \u2022 "); + } + if (typeof meta === "object") { + return Object.keys(meta).map(function(key) { + return key + ": " + meta[key]; + }).join(" \u2022 "); + } + return String(meta); +} +function appendMetaRows(rows, meta) { + if (meta == null) return; + if (typeof meta === "string" || typeof meta === "number") { + rows.push({ key: "Meta", value: String(meta) }); + return; + } + if (Array.isArray(meta)) { + meta.forEach(function(entry, index) { + if (!entry) return; + if (entry.label && entry.value != null) { + rows.push({ key: entry.label, value: String(entry.value) }); + return; + } + rows.push({ key: "Meta " + (index + 1), value: String(entry) }); + }); + return; + } + if (typeof meta === "object") { + Object.keys(meta).forEach(function(key) { + rows.push({ key, value: String(meta[key]) }); + }); + } +} +function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { + var startMinute = assertMinuteValue(startValue, startLabel); + var endMinute = assertMinuteValue(endValue, endLabel); + assert(endMinute > startMinute, endLabel + " must be greater than startMinute"); + return { + endMinute, + startMinute + }; +} +function normalizeId(value, prefix, suffix) { + return value != null ? String(value) : prefix + suffix; +} +function buildScopedId(scope, suffix) { + return scope + "-" + suffix; +} +function setExpandedCluster(state, laneId, clusterId) { + if (clusterId == null) delete state.expandedClusters[laneId]; + else state.expandedClusters[laneId] = String(clusterId); +} +function normalizeAxis(axis) { + assert(axis && axis.startMinute != null && axis.endMinute != null, "createTimeline(model.axis.startMinute/endMinute) are required"); + var axisRange = normalizeMinuteRange( + axis.startMinute, + axis.endMinute, + "createTimeline(model.axis.startMinute)", + "createTimeline(model.axis.endMinute)" + ); + var normalized = { + endMinute: axisRange.endMinute, + startMinute: axisRange.startMinute + }; + normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); + normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); + normalized.initialViewport = clampViewport( + normalized, + normalizeViewportInput(axis.initialViewport, "createTimeline(model.axis.initialViewport)") || { + startMinute: normalized.startMinute, + endMinute: normalized.endMinute + } + ); + return normalized; +} +function normalizeBadge(badge) { + if (!badge) return null; + if (typeof badge === "string") return { label: badge }; + return { + label: badge.label || "", + style: badge.style || null + }; +} +function normalizeDays(days, startMinute, endMinute) { + var list = []; + var source = Array.isArray(days) && days.length > 0 ? days : null; + var cursor = startMinute; + var index = 0; + if (!source) { + while (cursor < endMinute) { + list.push(makeDay({ + endMinute: Math.min(cursor + DAY_MINUTES, endMinute), + isWeekend: false, + label: "Day " + (index + 1), + startMinute: cursor + }, index)); + cursor += DAY_MINUTES; + index += 1; + } + return list; + } + source.forEach(function(day, dayIndex) { + if (cursor >= endMinute) return; + if (typeof day === "string") { + var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); + list.push(makeDay({ + endMinute: generatedEnd, + isWeekend: inferWeekend(day), + label: day, + startMinute: cursor + }, dayIndex)); + cursor = generatedEnd; + return; + } + var nextStart = day.startMinute != null ? day.startMinute : cursor; + var nextEnd = day.endMinute != null ? day.endMinute : Math.min(nextStart + DAY_MINUTES, endMinute); + var dayRange = normalizeMinuteRange( + nextStart, + nextEnd, + "createTimeline(model.axis.days[" + dayIndex + "].startMinute)", + "createTimeline(model.axis.days[" + dayIndex + "].endMinute)" + ); + list.push(makeDay({ + endMinute: dayRange.endMinute, + isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), + label: day.label || "Day " + (dayIndex + 1), + startMinute: dayRange.startMinute, + subLabel: day.subLabel || day.meta || "" + }, dayIndex)); + cursor = dayRange.endMinute; + }); + return list; +} +function normalizeItem(item, pathKey, ordinal) { + assert(item && item.startMinute != null && item.endMinute != null, "timeline items require startMinute/endMinute"); + var itemRange = normalizeMinuteRange( + item.startMinute, + item.endMinute, + "createTimeline(model.lanes[].items[].startMinute)", + "createTimeline(model.lanes[].items[].endMinute)" + ); + return { + clusterId: item.clusterId != null ? String(item.clusterId) : null, + detailItems: Array.isArray(item.detailItems) ? item.detailItems.map(function(detailItem, detailIndex) { + return normalizeItem(detailItem, pathKey + "-" + detailIndex, detailIndex); + }) : [], + endMinute: itemRange.endMinute, + id: normalizeId(item.id, "item-", pathKey), + label: item.label || "Item " + (ordinal + 1), + meta: item.meta != null ? item.meta : "", + originalIndex: ordinal, + summary: normalizeOverviewSummary(item.summary, "createTimeline(model.lanes[].items[].summary)"), + startMinute: itemRange.startMinute, + tone: resolveTone(item.tone || item.color || "slate") + }; +} +function normalizeLane(lane, index, axis) { + assert(lane && Array.isArray(lane.items), "timeline lanes require an items array"); + var normalizedLane = { + axis, + badges: [], + id: normalizeId(lane.id, "lane-", index), + items: lane.items.map(function(item, itemIndex) { + return normalizeItem(item, index + "-" + itemIndex, itemIndex); + }), + label: lane.label || "Lane " + (index + 1), + mode: lane.mode === "overview" ? "overview" : "detailed", + overlays: Array.isArray(lane.overlays) ? lane.overlays.map(function(overlay, overlayIndex) { + return normalizeOverlay(overlay, overlayIndex, axis); + }).filter(Boolean) : [], + stats: Array.isArray(lane.stats) ? lane.stats : [] + }; + normalizedLane.items.sort(compareItems); + if (Array.isArray(lane.badges)) { + lane.badges.forEach(function(badge) { + var normalizedBadge = normalizeBadge(badge); + if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); + }); + } else { + var singleBadge = normalizeBadge(lane.badges); + if (singleBadge) normalizedLane.badges.push(singleBadge); + } + return normalizedLane; +} +function normalizeModel(model) { + assert(model && model.axis && Array.isArray(model.lanes), "createTimeline(model.axis/model.lanes) are required"); + var axis = normalizeAxis(model.axis); + return { + axis, + lanes: model.lanes.map(function(lane, index) { + return normalizeLane(lane, index, axis); + }) + }; +} +function normalizeOverlay(overlay, index, axis) { + var label = "createTimeline(model.lanes[].overlays[" + index + "])"; + assert(overlay && typeof overlay === "object", label + " must be an object"); + var startMinute = overlay.startMinute; + var endMinute = overlay.endMinute; + if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { + var dayIndex = assertInteger(overlay.dayIndex, label + ".dayIndex"); + var day = axis.days[dayIndex]; + assert(day, label + ".dayIndex must reference an existing day"); + var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + ".dayCount"); + assert(dayCount > 0, label + ".dayCount must be greater than zero"); + var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; + startMinute = day.startMinute; + endMinute = lastDay.endMinute; + } + assert( + startMinute != null && endMinute != null, + label + " requires startMinute/endMinute or dayIndex/dayCount" + ); + var overlayRange = normalizeMinuteRange( + startMinute, + endMinute, + label + ".startMinute", + label + ".endMinute" + ); + return { + endMinute: overlayRange.endMinute, + id: normalizeId(overlay.id, "overlay-", index), + label: overlay.label || "", + meta: overlay.meta || "", + startMinute: overlayRange.startMinute, + tone: resolveTone(overlay.tone || overlay.color || "slate") + }; +} +function normalizeTicks(ticks, startMinute, endMinute) { + var list = []; + if (Array.isArray(ticks) && ticks.length > 0) { + ticks.forEach(function(tick, index) { + if (typeof tick === "number") { + var numericTick = assertMinuteValue(tick, "createTimeline(model.axis.ticks[" + index + "])"); + list.push({ id: "tick-" + index, label: formatClock(numericTick), minute: numericTick }); + return; + } + assert(tick && typeof tick === "object", "createTimeline(model.axis.ticks[" + index + "]) must be a number or object"); + assert(tick.minute != null, "createTimeline(model.axis.ticks[" + index + "].minute) is required"); + var minute2 = assertMinuteValue(tick.minute, "createTimeline(model.axis.ticks[" + index + "].minute)"); + list.push({ + id: normalizeId(tick.id, "tick-", index), + label: tick.label || formatClock(minute2), + minute: minute2 + }); + }); + return list; + } + for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { + list.push({ + id: "tick-" + minute, + label: formatClock(minute), + minute + }); + } + return list; +} +function makeDay(day, index) { + return { + endMinute: day.endMinute, + id: normalizeId(day.id, "day-", index), + isWeekend: !!day.isWeekend, + label: day.label || "Day " + (index + 1), + startMinute: day.startMinute, + subLabel: day.subLabel || "" + }; +} +function compareItems(left, right) { + if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; + if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; + if (left.label !== right.label) return left.label < right.label ? -1 : 1; + return left.originalIndex - right.originalIndex; +} +function normalizeOverviewSummary(summary, label) { + if (summary == null) return null; + assert(summary && typeof summary === "object", label + " must be an object"); + var normalized = { + count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + ".count"), + openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + ".openCount"), + primaryLabel: summary.primaryLabel == null ? "" : String(summary.primaryLabel), + secondaryLabel: summary.secondaryLabel == null ? "" : String(summary.secondaryLabel), + toneSegments: Array.isArray(summary.toneSegments) ? summary.toneSegments.map(function(segment, index) { + assert(segment && typeof segment === "object", label + ".toneSegments[" + index + "] must be an object"); + return { + count: assertNonNegativeInteger(segment.count, label + ".toneSegments[" + index + "].count"), + tone: resolveTone(segment.tone || segment.color || "slate") + }; + }).filter(function(segment) { + return segment.count > 0; + }) : [] + }; + if (normalized.count != null && normalized.openCount != null) { + assert(normalized.openCount <= normalized.count, label + ".openCount must not exceed count"); + } + return normalized; +} +function renderAxisDecor(track, axis, includeLabels) { + appendWeekendBands(track, axis); + appendDayDividers(track, axis); + appendTicks(track, axis, includeLabels); + if (includeLabels) appendDayBands(track, axis); +} +function appendDayBands(track, axis) { + axis.days.forEach(function(day) { + var band = el("div", { className: "sf-rail-timeline-day-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + band.appendChild(el("div", { className: "sf-rail-timeline-day-label" }, day.label)); + if (day.subLabel) { + band.appendChild(el("div", { className: "sf-rail-timeline-day-sub" }, day.subLabel)); + } + track.appendChild(band); + }); +} +function appendDayDividers(track, axis) { + axis.days.forEach(function(day, index) { + if (index === 0) return; + var divider = el("div", { className: "sf-rail-timeline-day-divider" }); + divider.style.left = positionPct(day.startMinute, axis) + "%"; + track.appendChild(divider); + }); +} +function appendTicks(track, axis, includeLabels) { + axis.ticks.forEach(function(tick) { + if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; + var tickEl = el("div", { className: "sf-rail-timeline-tick" }); + tickEl.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(tickEl); + if (!includeLabels) return; + var label = el("div", { className: "sf-rail-timeline-tick-label" }, tick.label); + label.style.left = positionPct(tick.minute, axis) + "%"; + track.appendChild(label); + }); +} +function appendWeekendBands(track, axis) { + axis.days.forEach(function(day) { + if (!day.isWeekend) return; + var band = el("div", { className: "sf-rail-timeline-weekend-band" }); + band.style.left = positionPct(day.startMinute, axis) + "%"; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + "%"; + track.appendChild(band); + }); +} +function renderOverlays(track, overlays, axis) { + overlays.forEach(function(overlay) { + var band = el("div", { className: "sf-rail-timeline-overlay" }); + band.style.left = positionPct(overlay.startMinute, axis) + "%"; + band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + "%"; + band.style.background = overlay.tone.overlay; + band.style.borderColor = overlay.tone.border; + if (overlay.label) band.title = overlay.label; + track.appendChild(band); + }); +} +function groupOverviewItems(lane) { + var groups = []; + var current = null; + lane.items.forEach(function(item) { + if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { + if (current) groups.push(current); + current = { + clusterId: item.clusterId, + endMinute: item.endMinute, + items: [item], + lane, + startMinute: item.startMinute + }; + return; + } + current.items.push(item); + current.endMinute = Math.max(current.endMinute, item.endMinute); + if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; + }); + if (current) groups.push(current); + groups.forEach(function(group, groupIndex) { + finalizeGroup(group, lane, groupIndex); + }); + assertUniqueClusterKeys(lane, groups); + return groups; +} +function finalizeGroup(group, lane, index) { + var detailItems = []; + group.items.forEach(function(item) { + if (item.detailItems.length > 0) { + item.detailItems.forEach(function(detailItem) { + detailItems.push(detailItem); + }); + return; + } + detailItems.push(item); + }); + detailItems.sort(compareItems); + group.detailItems = detailItems; + group.isCluster = detailItems.length > 1 || group.items.some(function(item) { + return item.detailItems.length > 0; + }); + group.renderId = group.isCluster ? buildScopedId("cluster", lane.id + "-" + index + "-" + (group.items[0] ? group.items[0].id : "group")) : normalizeId(group.items[0] ? group.items[0].id : null, "group-", lane.id + "-" + index); + group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; + group.summary = deriveOverviewSummary(group); + group.count = group.summary.count; + group.label = group.summary.primaryLabel; + group.metaLabel = group.summary.secondaryLabel; + group.tone = group.summary.primaryTone || dominantTone(group.detailItems); +} +function assertUniqueClusterKeys(lane, groups) { + var seen = {}; + groups.forEach(function(group) { + if (!group.clusterKey) return; + assert( + !seen[group.clusterKey], + 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + ); + seen[group.clusterKey] = true; + }); +} +function dominantTone(items) { + var toneSegments = buildToneSegmentsFromItems(items); + if (!toneSegments.length) return resolveTone("slate"); + return toneSegments[0].tone; +} +function effectiveOverviewItems(item) { + return item.detailItems.length > 0 ? item.detailItems : [item]; +} +function deriveOverviewContribution(item) { + var items = effectiveOverviewItems(item); + var summary = item.summary; + var derivedCount = items.length; + var count = summary && summary.count != null ? summary.count : derivedCount; + var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; + var openCount = null; + var toneSegments = []; + if (summary && summary.openCount != null) openCount = summary.openCount; + else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); + if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; + else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); + return { + count, + openCount, + openCountKnown: openCount != null, + toneSegments, + toneSegmentsKnown: summary && summary.toneSegments.length > 0 ? true : canDeriveAggregateMetrics + }; +} +function deriveOverviewSummary(group) { + var contributions = group.items.map(deriveOverviewContribution); + var summaries = group.items.map(function(item) { + return item.summary; + }).filter(Boolean); + var count = contributions.reduce(function(sum, contribution) { + return sum + contribution.count; + }, 0); + var openCount = contributions.every(function(contribution) { + return contribution.openCountKnown; + }) ? contributions.reduce(function(sum, contribution) { + return sum + contribution.openCount; + }, 0) : null; + var toneSegments = contributions.every(function(contribution) { + return contribution.toneSegmentsKnown; + }) ? mergeToneSegments(contributions.reduce(function(segments, contribution) { + return segments.concat(contribution.toneSegments); + }, [])) : []; + var primarySummary = summaries.length === 1 ? summaries[0] : null; + return { + count, + openCount, + primaryLabel: primarySummary && primarySummary.primaryLabel ? primarySummary.primaryLabel : count > 1 ? count + " assignments" : group.items[0].label, + primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), + secondaryLabel: primarySummary && primarySummary.secondaryLabel ? primarySummary.secondaryLabel : count > 1 ? buildOverviewBlockMeta({ + count, + items: group.detailItems + }) : describeMeta(group.items[0].meta), + toneSegments + }; +} +function inferOpenCount(items) { + return items.reduce(function(count, item) { + if (!item) return count; + if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; + if (!item.meta || typeof item.meta !== "object" || Array.isArray(item.meta)) return count; + if (typeof item.meta.openCount === "number" && isFinite(item.meta.openCount)) return count + item.meta.openCount; + if (typeof item.meta.unassignedCount === "number" && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; + if (item.meta.open === true || item.meta.unassigned === true) return count + 1; + if (typeof item.meta.status === "string" && /open|unassigned/i.test(item.meta.status)) return count + 1; + return count; + }, 0); +} +function mergeToneSegments(segments) { + var byTone = {}; + segments.forEach(function(segment) { + if (!segment || !(segment.count > 0)) return; + var toneId = segment.tone.id || segment.tone.border || "slate"; + if (!byTone[toneId]) { + byTone[toneId] = { + count: 0, + tone: segment.tone + }; + } + byTone[toneId].count += segment.count; + }); + return Object.keys(byTone).map(function(toneId) { + return byTone[toneId]; + }).sort(compareToneSegments); +} +function buildToneSegmentsFromItems(items) { + return mergeToneSegments(items.map(function(item) { + return { + count: 1, + tone: item.tone + }; + })); +} +function compareToneSegments(left, right) { + if (left.count !== right.count) return right.count - left.count; + if (left.tone.id === right.tone.id) return 0; + return left.tone.id < right.tone.id ? -1 : 1; +} +function buildOverviewBlockSummary(group, expanded) { + var badges = []; + if (group.summary.count > 1) { + badges.push({ kind: "count", text: group.summary.count + " total" }); + } + if (group.summary.openCount > 0) { + badges.push({ kind: "open", text: group.summary.openCount + " open" }); + } + if (group.isCluster) { + badges.push({ kind: "action", text: expanded ? "Enter to collapse" : "Enter to inspect" }); + } + return { + badges, + toneSegments: group.summary.toneSegments + }; +} +function buildItemAriaLabel(item, lane) { + var parts = [ + lane.label, + item.label, + formatMinuteRange(item.startMinute, item.endMinute, lane.axis) + ]; + var meta = describeMeta(item.meta); + if (meta) parts.push(meta); + return parts.join(" \xB7 "); +} +function buildOverviewAriaLabel(group, lane, expanded) { + var parts = [ + lane.label, + group.summary.primaryLabel, + formatMinuteRange(group.startMinute, group.endMinute, lane.axis) + ]; + if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); + if (group.summary.count > 1) parts.push(group.summary.count + " assignments"); + if (group.summary.openCount > 0) parts.push(group.summary.openCount + " open"); + if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); + if (group.isCluster) parts.push(expanded ? "Expanded. Press Enter to collapse" : "Press Enter to expand"); + return parts.join(" \xB7 "); +} +function describeToneSegments(segments) { + return segments.map(function(segment) { + return segment.count + " " + segment.tone.id; + }).join(", "); +} +function buildOverviewTooltip(group, lane) { + if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { + return buildClusterTooltip(group, lane); + } + return buildItemTooltip(group.items[0], lane); +} +function packItems(items) { + var trackEnds = []; + var packed = []; + items.slice().sort(compareItems).forEach(function(item) { + var trackIndex = 0; + while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { + trackIndex += 1; + } + if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); + else trackEnds[trackIndex] = item.endMinute; + packed.push({ + item, + trackIndex + }); + }); + return { + items: packed, + trackCount: trackEnds.length + }; +} +function positionPct(minute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return (minute - axis.startMinute) / total * 100; +} +function spanPct(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0.25); +} +function spanPctExact(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max((endMinute - startMinute) / total * 100, 0); +} +function formatClock(minute) { + var normalized = minute % DAY_MINUTES; + if (normalized < 0) normalized += DAY_MINUTES; + var hours = Math.floor(normalized / 60); + var minutes = normalized % 60; + return pad(hours) + ":" + pad(minutes); +} +function formatMinuteRange(startMinute, endMinute, axis) { + return formatMinute(startMinute, axis) + " \u2192 " + formatMinute(endMinute, axis); +} +function formatMinute(minute, axis) { + var dayLabel = ""; + axis.days.forEach(function(day) { + if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { + dayLabel = day.label; + } + }); + return (dayLabel ? dayLabel + " " : "") + formatClock(minute); +} +function pad(value) { + return value < 10 ? "0" + value : String(value); +} +function inferWeekend(label) { + return /sat|sun|weekend/i.test(String(label || "")); +} +function isColorString(value) { + return /^#|^rgb|^hsl/i.test(String(value || "")); +} +function resolveTone(tone) { + if (tone && typeof tone === "object") { + return { + id: tone.id || tone.name || tone.borderColor || tone.color || "custom", + background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, + border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, + overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, + text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text + }; + } + if (TONE_MAP[tone]) return TONE_MAP[tone]; + if (isColorString(tone)) { + return { + id: String(tone), + background: tone, + border: tone, + overlay: tone, + text: "#111827" + }; + } + return TONE_MAP.slate; +} +function measureLayout(bodyViewport, state) { + var viewportWidth = getMeasuredViewportWidth(bodyViewport); + if (!(viewportWidth > 0)) return null; + var preferredLabelWidth = state.labelWidth; + var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; + var effectiveLabelWidth = preferredLabelWidth; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; + var scale = totalDuration > 0 && visibleDuration > 0 ? totalDuration / visibleDuration : 1; + if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; + if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); + else effectiveLabelWidth = MIN_LABEL_WIDTH; + var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); + var contentTrackWidth = Math.max( + Math.round(visibleTrackWidth * scale), + visibleTrackWidth, + MIN_CONTENT_TRACK_WIDTH + ); + var contentWidth = effectiveLabelWidth + contentTrackWidth; + return { + contentWidth, + contentTrackWidth, + effectiveLabelWidth, + visibleTrackWidth, + viewportWidth + }; +} +function viewportToScrollLeft(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; + return Math.round((state.viewport.startMinute - axis.startMinute) / remainingDuration * maxScrollLeft); +} +function scrollLeftToViewport(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) { + return clampViewport(axis, { + startMinute: axis.startMinute, + endMinute: axis.startMinute + visibleDuration + }); + } + var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); + var startMinute = axis.startMinute + remainingDuration * ratio; + return clampViewport(axis, { + startMinute, + endMinute: startMinute + visibleDuration + }); +} +function getMaxScrollLeft(viewportEl) { + var scrollWidth = viewportEl.scrollWidth || 0; + var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; + return Math.max(scrollWidth - clientWidth, 0); +} +function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { + if (typeof ResizeObserver !== "function") return; + var resizeObserver = new ResizeObserver(function() { + if (state.destroyed) return; + syncLayoutFromViewport(); + }); + resizeObserver.observe(bodyViewport); + state.cleanup.push(function() { + resizeObserver.disconnect(); + }); +} +function bindWindowResize(state, syncLayoutFromViewport) { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") return; + function handleResize() { + if (state.destroyed) return; + syncLayoutFromViewport(); + } + window.addEventListener("resize", handleResize); + state.cleanup.push(function() { + if (typeof window.removeEventListener === "function") window.removeEventListener("resize", handleResize); + }); +} +function getMeasuredViewportWidth(bodyViewport) { + if (!bodyViewport) return 0; + if (typeof bodyViewport.clientWidth === "number" && bodyViewport.clientWidth > 0) { + return Math.round(bodyViewport.clientWidth); + } + if (typeof bodyViewport.offsetWidth === "number" && bodyViewport.offsetWidth > 0) { + return Math.round(bodyViewport.offsetWidth); + } + if (typeof bodyViewport.getBoundingClientRect === "function") { + var rect = bodyViewport.getBoundingClientRect(); + if (rect && typeof rect.width === "number" && rect.width > 0) { + return Math.round(rect.width); + } + } + return 0; +} +function applyLayout(root, headerRow, lanes, layout) { + setCustomProperty(root.style, "--sf-rail-label-width", layout ? layout.effectiveLabelWidth + "px" : ""); + setCustomProperty(root.style, "--sf-rail-content-width", layout ? layout.contentWidth + "px" : ""); + headerRow.style.width = layout ? layout.contentWidth + "px" : ""; + lanes.style.width = layout ? layout.contentWidth + "px" : ""; + root.dataset.supportedViewportWidth = layout ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) : ""; +} +function setCustomProperty(style, name, value) { + if (!style) return; + if (typeof style.setProperty === "function") { + style.setProperty(name, value); + return; + } + style[name] = value; +} +function queuePostMountSync(state, syncLayoutFromViewport) { + if (state.hasQueuedPostMountSync || typeof setTimeout !== "function") return; + state.hasQueuedPostMountSync = true; + var timerId = setTimeout(function() { + state.hasQueuedPostMountSync = false; + if (state.destroyed) return; + syncLayoutFromViewport(); + }, 0); + state.cleanup.push(function() { + if (typeof clearTimeout === "function") clearTimeout(timerId); + }); +} +function normalizeViewportInput(viewport, label) { + if (viewport == null) return null; + assert(typeof viewport === "object", label + " must be an object"); + return normalizeMinuteRange( + viewport.startMinute, + viewport.endMinute, + label + ".startMinute", + label + ".endMinute" + ); +} +function showTooltip(tooltip, root, payload, event) { + if (!payload) return; + tooltip.setAttribute("aria-hidden", "false"); + tooltip.innerHTML = ""; + tooltip.appendChild(el("div", { className: "sf-tooltip-title" }, payload.title)); + (payload.rows || []).forEach(function(row) { + var rowEl = el("div", { className: "sf-tooltip-row" }); + rowEl.appendChild(el("span", { className: "sf-tooltip-key" }, row.key)); + rowEl.appendChild(el("span", { className: "sf-tooltip-val" }, row.value)); + tooltip.appendChild(rowEl); + }); + var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; + var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; + var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; + tooltip.style.left = left + "px"; + tooltip.style.top = top + "px"; + tooltip.classList.add("visible"); +} +function showTooltipForElement(tooltip, root, payload, element) { + var rect = element && typeof element.getBoundingClientRect === "function" ? element.getBoundingClientRect() : null; + showTooltip(tooltip, root, payload, rect ? { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2 + } : null); +} +function hideTooltip(tooltip) { + tooltip.setAttribute("aria-hidden", "true"); + tooltip.classList.remove("visible"); +} +function updateViewportMetadata(root, state) { + var axis = state.model.axis; + var duration = state.viewport.endMinute - state.viewport.startMinute; + root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); + root.dataset.viewportDurationMinutes = String(Math.round(duration)); + root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); + root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); +} +function updateZoomButtons(buttons, state) { + var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); + var initial = state.model.axis.initialViewport; + buttons.forEach(function(button) { + var preset = button.dataset.zoom; + var active = false; + if (preset === "reset") { + active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); + } else if (preset === "1w") active = duration === WEEK_MINUTES; + else if (preset === "2w") active = duration === WEEK_MINUTES * 2; + else if (preset === "4w") active = duration === WEEK_MINUTES * 4; + button.classList.toggle("active", active); + }); +} +function normalizeZoomPresets(presets) { + if (presets == null) return ["1w", "2w", "4w", "reset"]; + assert(Array.isArray(presets), "rail.createTimeline(zoomPresets) must be an array"); + presets.forEach(function(preset, index) { + assert( + ["1w", "2w", "4w", "reset"].indexOf(preset) >= 0, + "rail.createTimeline(zoomPresets[" + index + "]) must be one of 1w, 2w, 4w, reset" + ); + }); + return presets.slice(); +} +function pruneExpandedClusters(state) { + Object.keys(state.expandedClusters).forEach(function(laneId) { + var exists = state.model.lanes.some(function(lane) { + return lane.id === laneId; + }); + if (!exists) delete state.expandedClusters[laneId]; + }); +} + +// ts-src/rail/index.ts +var rail = { + createHeader: createHeader2, + createCard, + createHeatmap, + createUnassignedRail, + addBlock, + addChangeover, + createTimeline +}; + +// ts-src/solver/backend.ts +function createBackend(config = {}) { + const resolvedConfig = config || {}; + const type = resolvedConfig.type ?? "axum"; + if (type === "tauri") { + return createTauriBackend(resolvedConfig); + } + return createHttpBackend(resolvedConfig); +} +function resolveJobId(raw) { + return normalizeCreateJobId(raw); +} +function resolveEventJobId(payload) { + if (!payload || typeof payload !== "object") return ""; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.id != null) return String(payload.data.id).trim(); + if (payload.data && typeof payload.data === "object" && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ""; +} +function withSnapshotRevision(path, snapshotRevision) { + if (snapshotRevision == null || snapshotRevision === "") return path; + return path + "?snapshot_revision=" + encodeURIComponent(String(snapshotRevision)); +} +function createHttpBackend(config) { + var baseUrl = config.baseUrl || ""; + var jobsPath = config.jobsPath || "/jobs"; + var demoDataPath = config.demoDataPath || "/demo-data"; + var extraHeaders = config.headers || {}; + function headers(extra = {}) { + return { + "Content-Type": "application/json", + ...extraHeaders, + ...extra + }; + } + function createRequestError(method, path, res) { + var err = new Error(res.status + " " + res.statusText); + err.status = res.status; + err.statusText = res.statusText; + err.method = method; + err.path = path; + err.url = baseUrl + path; + return err; + } + function request(method, path, body) { + const opts = { + method, + headers: headers() + }; + if (body !== void 0) opts.body = JSON.stringify(body); + return fetch(baseUrl + path, opts).then(function(res) { + if (!res.ok) throw createRequestError(method, path, res); + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("json")) { + return res.json(); + } + return res.text(); + }); + } + return { + createJob: function(data) { + return request("POST", jobsPath, data).then(resolveJobId); + }, + getJob: function(id) { + return request("GET", jobsPath + "/" + id); + }, + getJobStatus: function(id) { + return request("GET", jobsPath + "/" + id + "/status"); + }, + getSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/snapshot", snapshotRevision)); + }, + analyzeSnapshot: function(id, snapshotRevision) { + return request("GET", withSnapshotRevision(jobsPath + "/" + id + "/analysis", snapshotRevision)); + }, + pauseJob: function(id) { + return request("POST", jobsPath + "/" + id + "/pause"); + }, + resumeJob: function(id) { + return request("POST", jobsPath + "/" + id + "/resume"); + }, + cancelJob: function(id) { + return request("POST", jobsPath + "/" + id + "/cancel"); + }, + deleteJob: function(id) { + return request("DELETE", jobsPath + "/" + id); + }, + getDemoData: function(name) { + return request("GET", demoDataPath + "/" + (name || "STANDARD")); + }, + listDemoData: function() { + return request("GET", demoDataPath); + }, + streamJobEvents: function(id, onMessage, onError) { + var url = baseUrl + jobsPath + "/" + id + "/events"; + var es = new EventSource(url); + var closed = false; + es.onmessage = function(e) { + try { + onMessage(JSON.parse(e.data)); + } catch { + } + }; + es.onerror = function() { + if (closed || !onError) return; + if (typeof EventSource !== "undefined" && es.readyState === EventSource.CLOSED) { + onError(createSseClosedError(url)); + } + }; + return function close() { + closed = true; + es.onmessage = null; + es.onerror = null; + es.close(); + }; + } + }; +} +function createTauriBackend(config) { + assert(typeof config === "object", "createBackend({}) is required for Tauri adapter"); + assert(typeof config.invoke === "function", "Tauri backend requires config.invoke"); + assert(typeof config.listen === "function", "Tauri backend requires config.listen"); + var invoke = config.invoke; + var listen = config.listen; + var commands = config.commands || {}; + var eventName = config.eventName || "solver-update"; + return { + createJob: function(data) { + return invoke(commands.createJob || "create_job", { request: data }).then(resolveJobId); + }, + getJob: function(id) { + return invoke(commands.getJob || "get_job", { id }); + }, + getJobStatus: function(id) { + return invoke(commands.getJobStatus || "get_job_status", { id }); + }, + getSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.getSnapshot || "get_snapshot", payload); + }, + analyzeSnapshot: function(id, snapshotRevision) { + var payload = { + id, + ...snapshotRevision != null && snapshotRevision !== "" ? { snapshotRevision } : {} + }; + return invoke(commands.analyzeSnapshot || "analyze_snapshot", payload); + }, + pauseJob: function(id) { + return invoke(commands.pauseJob || "pause_job", { id }); + }, + resumeJob: function(id) { + return invoke(commands.resumeJob || "resume_job", { id }); + }, + cancelJob: function(id) { + return invoke(commands.cancelJob || "cancel_job", { id }); + }, + deleteJob: function(id) { + return invoke(commands.deleteJob || "delete_job", { id }); + }, + getDemoData: function(name) { + return invoke(commands.demoData || "demo_seed", { name }); + }, + listDemoData: function() { + return Promise.resolve([]); + }, + streamJobEvents: function(id, onMessage, _onError) { + var targetId = String(id); + var unlisten = null; + listen(eventName, function(event) { + var payload = event && event.payload || {}; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; + onMessage(payload); + }).then(function(fn) { + unlisten = fn; + }); + return function close() { + if (unlisten) unlisten(); + }; + } + }; +} +function createSseClosedError(url) { + var err = new Error("Event stream closed for " + url); + err.code = "SSE_CLOSED"; + err.transport = "sse"; + err.url = url; + return err; +} + +// ts-src/solver/solver.ts +var createSolver = function(config) { + assert(config, "createSolver(config) requires a configuration object"); + assert(config.backend, "createSolver(config.backend) is required"); + assert(hasFunction(config.backend, "createJob"), "createSolver(config.backend.createJob) must be a function"); + assert(hasFunction(config.backend, "getSnapshot"), "createSolver(config.backend.getSnapshot) must be a function"); + assert(hasFunction(config.backend, "analyzeSnapshot"), "createSolver(config.backend.analyzeSnapshot) must be a function"); + assert(hasFunction(config.backend, "pauseJob"), "createSolver(config.backend.pauseJob) must be a function"); + assert(hasFunction(config.backend, "resumeJob"), "createSolver(config.backend.resumeJob) must be a function"); + assert(hasFunction(config.backend, "cancelJob"), "createSolver(config.backend.cancelJob) must be a function"); + assert(hasFunction(config.backend, "deleteJob"), "createSolver(config.backend.deleteJob) must be a function"); + assert(hasFunction(config.backend, "streamJobEvents"), "createSolver(config.backend.streamJobEvents) must be a function"); + assert(!config.onProgress || typeof config.onProgress === "function", "createSolver(config.onProgress) must be a function"); + assert(!config.onSolution || typeof config.onSolution === "function", "createSolver(config.onSolution) must be a function"); + assert(!config.onPauseRequested || typeof config.onPauseRequested === "function", "createSolver(config.onPauseRequested) must be a function"); + assert(!config.onPaused || typeof config.onPaused === "function", "createSolver(config.onPaused) must be a function"); + assert(!config.onResumed || typeof config.onResumed === "function", "createSolver(config.onResumed) must be a function"); + assert(!config.onCancelled || typeof config.onCancelled === "function", "createSolver(config.onCancelled) must be a function"); + assert(!config.onComplete || typeof config.onComplete === "function", "createSolver(config.onComplete) must be a function"); + assert(!config.onFailure || typeof config.onFailure === "function", "createSolver(config.onFailure) must be a function"); + assert(!config.onAnalysis || typeof config.onAnalysis === "function", "createSolver(config.onAnalysis) must be a function"); + assert(!config.onError || typeof config.onError === "function", "createSolver(config.onError) must be a function"); + var backend = config.backend; + var statusBar = config.statusBar; + var closeStream = null; + var activeJobId = null; + var retainedJobId = null; + var lifecycleState = "IDLE"; + var phase = "idle"; + var runToken = 0; + var lastSnapshotRevision = null; + var lastMeta = null; + var lastNotifiedError = null; + var queuedAction = null; + var pendingPause = null; + var pendingResume = null; + var pendingCancel = null; + var terminalSync = null; + var api = { + /** + * Start a new solver job. + */ + start: function(data) { + if (retainedJobId) { + return Promise.reject( + new Error( + "Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first" + ) + ); + } + if (phase !== "idle") { + return Promise.resolve(); + } + resetForStart(); + phase = "starting"; + runToken += 1; + applyLifecycleState("STARTING"); + updateMoves(null); + var token = runToken; + return backend.createJob(data).then(function(id) { + if (token !== runToken) return; + var jobId = ensureJobId(id); + activeJobId = jobId; + retainedJobId = jobId; + phase = "solving"; + applyLifecycleState("SOLVING"); + attachStream(token, jobId); + if (queuedAction === "pause") { + queuedAction = null; + requestPause(token, jobId); + } else if (queuedAction === "cancel") { + queuedAction = null; + requestCancel(token, jobId); + } + }).catch(function(err) { + if (token !== runToken) return; + if (retainedJobId) { + failTransport(err); + } else { + failStartup(err); + } + throw err; + }); + }, + /** + * Request to pause the current solver job. + */ + pause: function() { + if (pendingPause) { + return pendingPause.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "pause"; + pendingPause = createDeferred(); + return pendingPause.promise; + } + var jobId = currentJobId(); + if (phase !== "solving" || !jobId) { + return Promise.resolve(); + } + pendingPause = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "pause")) { + return pendingPause.promise; + } + requestPause(runToken, jobId); + return pendingPause.promise; + }, + /** + * Resume a paused solver job. + */ + resume: function() { + if (pendingResume) { + return pendingResume.promise; + } + var jobId = currentJobId(); + if (phase !== "paused" || !jobId) { + return Promise.resolve(); + } + pendingResume = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "resume")) { + return pendingResume.promise; + } + requestResume(runToken, jobId); + return pendingResume.promise; + }, + /** + * Request to cancel the current solver job. + */ + cancel: function() { + if (pendingCancel) { + return pendingCancel.promise; + } + if (phase === "starting" && !activeJobId) { + queuedAction = "cancel"; + pendingCancel = createDeferred(); + return pendingCancel.promise; + } + var jobId = currentJobId(); + if (phase === "cancelling" && jobId) { + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + return pendingCancel.promise; + } + if (!jobId || !isCancelablePhase()) { + return Promise.resolve(); + } + pendingCancel = createDeferred(); + if (!ensureStreamAttached(runToken, jobId, "cancel")) { + return pendingCancel.promise; + } + requestCancel(runToken, jobId); + return pendingCancel.promise; + }, + /** + * Delete the retained job and its backend state. + */ + delete: function() { + if (!retainedJobId) { + return Promise.resolve(); + } + if (!isTerminalLifecycle(lifecycleState)) { + return Promise.reject( + new Error( + "Cannot delete a retained job before it reaches a terminal lifecycle state" + ) + ); + } + var jobId = retainedJobId; + return ensureTerminalSyncBeforeDelete(jobId).then(function() { + if (retainedJobId !== jobId) return; + return backend.deleteJob(jobId); + }).then(function() { + if (retainedJobId !== jobId) return; + resetAfterDelete(); + }).catch(function(err) { + notifyError(err); + throw err; + }); + }, + /** + * Get a snapshot for the current job. + */ + getSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.getSnapshot(jobId, revision).then(function(payload) { + return normalizeSnapshot(payload, lastMeta); + }); + }, + /** + * Get analysis for a snapshot of the current job. + */ + analyzeSnapshot: function(snapshotRevision) { + var jobId = currentJobId(); + if (!jobId) { + return Promise.reject( + new Error("No retained job is available") + ); + } + var revision = resolveRequestedSnapshotRevision(snapshotRevision); + return backend.analyzeSnapshot(jobId, revision).then(function(payload) { + return normalizeAnalysis(payload, lastMeta); + }); + }, + /** + * Check if the solver is currently running. + */ + isRunning: function() { + return phase !== "idle" && phase !== "paused"; + }, + /** + * Get the current job ID. + */ + getJobId: function() { + return activeJobId != null ? activeJobId : retainedJobId; + }, + /** + * Get the current lifecycle state. + */ + getLifecycleState: function() { + return lifecycleState; + }, + /** + * Get the current snapshot revision. + */ + getSnapshotRevision: function() { + return lastSnapshotRevision; + } + }; + return api; + function requestPause(token, id) { + phase = "pause-requested"; + backend.pauseJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "solving"; + rejectDeferred("pause", err); + notifyError(err); + }); + } + function attachStream(token, id) { + closeStream = backend.streamJobEvents(id, function(payload) { + if (token !== runToken) return; + handleEvent(token, id, payload); + }, function(err) { + if (token !== runToken) return; + failTransport(err); + }); + } + function ensureStreamAttached(token, id, pendingName) { + if (closeStream) return true; + try { + attachStream(token, id); + return true; + } catch (err) { + failTransport(err); + rejectDeferred(pendingName, err); + return false; + } + } + function requestResume(token, id) { + phase = "resuming"; + backend.resumeJob(id).catch(function(err) { + if (token !== runToken) return; + phase = "paused"; + rejectDeferred("resume", err); + notifyError(err); + }); + } + function requestCancel(token, id) { + phase = "cancelling"; + backend.cancelJob(id).catch(function(err) { + if (token !== runToken) return; + phase = lifecycleState === "PAUSED" ? "paused" : "solving"; + rejectDeferred("cancel", err); + notifyError(err); + }); + } + function handleEvent(token, expectedId, payload) { + var event = normalizeJobEvent(payload, expectedId); + if (!event) return; + lastMeta = event.meta; + if (event.meta.snapshotRevision != null) { + lastSnapshotRevision = event.meta.snapshotRevision; + } + retainedJobId = event.meta.jobId; + activeJobId = event.meta.jobId; + if (event.eventType === "progress") { + if (!event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onProgress) config.onProgress(event.meta); + return; + } + if (event.eventType === "best_solution") { + if (!event.solution || !event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onSolution) { + config.onSolution(buildLiveSnapshot(event), event.meta); + } + return; + } + if (event.eventType === "pause_requested") { + phase = "pause-requested"; + applyEventMeta(event.meta); + if (config.onPauseRequested) config.onPauseRequested(event.meta); + return; + } + if (event.eventType === "paused") { + phase = "paused"; + applyEventMeta(event.meta); + syncSnapshotBundle(event.meta, true).then(function(bundle) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + applyBundle(bundle); + if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); + resolveDeferred("pause", bundle); + }).catch(function(err) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + rejectDeferred("pause", err); + notifyError(err); + }); + return; + } + if (event.eventType === "resumed") { + phase = "solving"; + applyEventMeta(event.meta); + if (config.onResumed) config.onResumed(event.meta); + resolveDeferred("resume", event.meta); + return; + } + if (event.eventType === "completed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, true); + return; + } + if (event.eventType === "cancelled") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + return; + } + if (event.eventType === "failed") { + phase = "idle"; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + } + } + function syncSnapshotBundle(meta, requireSnapshot) { + var analysisRequired = !!config.onAnalysis; + var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; + return backend.getSnapshot(meta.jobId, snapshotRevision).then(function(snapshotPayload) { + var snapshot = normalizeSnapshot(snapshotPayload, meta); + if (!snapshot) throw new Error("Solver backend returned an invalid snapshot payload"); + var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); + var result = { + meta: mergedMeta, + snapshot, + analysis: null + }; + if (!analysisRequired) return result; + return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function(analysisPayload) { + result.analysis = normalizeAnalysis(analysisPayload, mergedMeta); + return result; + }); + }).catch(function(err) { + if (requireSnapshot) throw err; + var fallback = { meta, snapshot: null, analysis: null }; + if (!analysisRequired || snapshotRevision == null) return fallback; + return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function(analysisPayload) { + fallback.analysis = normalizeAnalysis(analysisPayload, meta); + return fallback; + }).catch(function() { + return fallback; + }); + }); + } + function applyBundle(bundle) { + if (!bundle) return; + lastMeta = bundle.meta; + if (bundle.meta && bundle.meta.snapshotRevision != null) { + lastSnapshotRevision = bundle.meta.snapshotRevision; + } + applyEventMeta(bundle.meta, bundle.analysis); + if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + } + function finalizeTerminal(meta) { + closeCurrentStream(); + activeJobId = null; + queuedAction = null; + phase = "idle"; + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : "IDLE"); + updateMoves(null); + } + function failTransport(err) { + var jobId = activeJobId || retainedJobId; + retainedJobId = jobId; + closeCurrentStream(); + activeJobId = null; + phase = phaseForLifecycleState(lifecycleState); + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + notifyError(err); + } + function failStartup(err) { + closeCurrentStream(); + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + phase = "idle"; + queuedAction = null; + rejectDeferred("pause", err); + rejectDeferred("resume", err); + rejectDeferred("cancel", err); + applyLifecycleState("IDLE"); + updateMoves(null); + notifyError(err); + } + function applyEventMeta(meta, analysis) { + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); + updateScore(readDisplayScore(meta, analysis)); + updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); + if (analysis) { + var constraints = readAnalysisConstraints(analysis); + if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { + statusBar.colorDotsFromAnalysis(constraints); + } + } + } + function readDisplayScore(meta, analysis) { + if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; + if (analysis && analysis.score != null) return analysis.score; + return null; + } + function applyLifecycleState(state) { + lifecycleState = state || "IDLE"; + if (!statusBar) return; + if (typeof statusBar.setLifecycleState === "function") { + statusBar.setLifecycleState(lifecycleState); + return; + } + if (typeof statusBar.setSolving === "function") { + statusBar.setSolving(isActiveLifecycle2(lifecycleState)); + } + } + function updateScore(score2) { + if (statusBar && typeof statusBar.updateScore === "function") { + statusBar.updateScore(score2); + } + } + function updateMoves(value) { + if (statusBar && typeof statusBar.updateMoves === "function") { + statusBar.updateMoves(value); + } + } + function resetForStart() { + closeCurrentStream(); + activeJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + } + function resetAfterDelete() { + closeCurrentStream(); + rejectDeferred("pause", new Error("Solver job was deleted before pause settled")); + rejectDeferred("resume", new Error("Solver job was deleted before resume settled")); + rejectDeferred("cancel", new Error("Solver job was deleted before cancel settled")); + runToken += 1; + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + phase = "idle"; + applyLifecycleState("IDLE"); + updateScore(null); + updateMoves(null); + } + function closeCurrentStream() { + if (!closeStream) return; + closeStream(); + closeStream = null; + } + function currentJobId() { + return activeJobId != null ? activeJobId : retainedJobId; + } + function hasNewerEvent(meta) { + var currentSequence = lastMeta && typeof lastMeta.eventSequence === "number" ? lastMeta.eventSequence : null; + var candidateSequence = meta && typeof meta.eventSequence === "number" ? meta.eventSequence : null; + if (currentSequence == null || candidateSequence == null) return false; + return currentSequence > candidateSequence; + } + function resolveRequestedSnapshotRevision(snapshotRevision) { + if (snapshotRevision != null && snapshotRevision !== "") return snapshotRevision; + return lastSnapshotRevision; + } + function createTerminalSync(event) { + var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; + terminalSync = { + jobId: event.meta.jobId, + eventType: event.eventType, + meta: event.meta, + status: "pending", + promise: null, + error: null, + callbackDelivered: existing ? existing.callbackDelivered : false + }; + return terminalSync; + } + function runTerminalSync(record, token, event, requireSnapshot) { + record.status = "pending"; + record.error = null; + record.meta = event.meta; + record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function(bundle) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "synced"; + record.error = null; + record.meta = bundle.meta; + finalizeTerminal(bundle.meta); + applyBundle(bundle); + deliverTerminalCallback(record, event, bundle); + settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); + return record; + }).catch(function(err) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = "failed"; + record.error = err; + finalizeTerminal(event.meta); + deliverTerminalFailureCallback(record, event); + settlePendingFromTerminal(event.eventType, null, err); + notifyError(err); + return record; + }); + return record.promise; + } + function ensureTerminalSyncBeforeDelete(jobId) { + var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; + if (!record) return Promise.resolve(); + return Promise.resolve(record.promise).then(function() { + if (!requiresSuccessfulTerminalSync(record)) return; + if (record.status === "synced") return; + return retryTerminalSync(record); + }); + } + function retryTerminalSync(record) { + var retryEvent = { + eventType: record.eventType, + meta: record.meta, + error: null + }; + return runTerminalSync(record, runToken, retryEvent, true).then(function() { + if (record.status !== "synced") { + throw record.error || new Error("Terminal snapshot synchronization failed"); + } + }); + } + function requiresSuccessfulTerminalSync(record) { + return record.eventType === "completed"; + } + function deliverTerminalCallback(record, event, bundle) { + if (record.callbackDelivered) return; + if (event.eventType === "completed") { + if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); + } else if (event.eventType === "cancelled") { + if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); + } else if (event.eventType === "failed") { + if (config.onFailure) config.onFailure(event.error || "Solver job failed", bundle.meta, bundle.snapshot, bundle.analysis); + } + record.callbackDelivered = true; + } + function deliverTerminalFailureCallback(record, event) { + if (record.callbackDelivered || event.eventType !== "failed") return; + if (config.onFailure) config.onFailure(event.error || "Solver job failed", event.meta, null, null); + record.callbackDelivered = true; + } + function terminalEventError(event) { + if (event.eventType !== "failed") return null; + return new Error(event.error || "Solver job failed"); + } + function isCancelablePhase() { + return phase === "solving" || phase === "pause-requested" || phase === "paused" || phase === "resuming"; + } + function phaseForLifecycleState(state) { + if (state === "STARTING") return "starting"; + if (state === "SOLVING") return "solving"; + if (state === "PAUSE_REQUESTED") return "pause-requested"; + if (state === "PAUSED") return "paused"; + if (state === "RESUMING") return "resuming"; + if (state === "CANCELLING") return "cancelling"; + return "idle"; + } + function isTerminalLifecycle(state) { + return state === "COMPLETED" || state === "CANCELLED" || state === "FAILED" || state === "TERMINATED_BY_CONFIG"; + } + function settlePendingFromTerminal(eventType, bundle, err) { + if (eventType === "cancelled") { + if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + } else if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error("Cancel did not settle before the job terminated")); + pendingCancel = null; + } + if (pendingPause) { + pendingPause.reject(err || new Error("Job terminated before pause settled")); + pendingPause = null; + } + if (pendingResume) { + pendingResume.reject(err || new Error("Job terminated before resume settled")); + pendingResume = null; + } + } + function resolveDeferred(name, value) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.resolve(value); + setDeferred(name, null); + } + function rejectDeferred(name, err) { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.reject(err); + setDeferred(name, null); + } + function getDeferred(name) { + if (name === "pause") return pendingPause; + if (name === "resume") return pendingResume; + if (name === "cancel") return pendingCancel; + return null; + } + function setDeferred(name, value) { + if (name === "pause") pendingPause = value; + if (name === "resume") pendingResume = value; + if (name === "cancel") pendingCancel = value; + } + function notifyError(err) { + if (err && lastNotifiedError === err) return; + lastNotifiedError = err || null; + if (config.onError) config.onError(err && err.message ? err.message : String(err)); + } + function ensureJobId(id) { + var jobId = normalizeCreateJobId(id); + if (jobId) return jobId; + throw new Error("Invalid solver backend createJob response"); + } +}; +function hasFunction(object, key) { + return !!(object && typeof object[key] === "function"); +} +function createDeferred() { + var resolve; + var reject; + var promise = new Promise(function(res, rej) { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +function normalizeJobEvent(payload, expectedId) { + if (!payload || typeof payload !== "object") return null; + var eventType = normalizeEventType(readField(payload, ["eventType", "event_type", "type"])); + if (!eventType) return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]); + if (jobId == null || jobId === "") jobId = expectedId; + if (jobId == null || jobId === "") return null; + if (String(jobId) !== String(expectedId)) return null; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + var meta = { + id: String(jobId), + jobId: String(jobId), + eventType, + eventSequence: readField(payload, ["eventSequence", "event_sequence"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state", "solverStatus", "solver_status"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || (solutionScore != null ? String(solutionScore) : null) || null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) + }; + return { + eventType, + meta, + solution, + error: readField(payload, ["error"], [payload, payload.data]) || null + }; +} +function normalizeSnapshot(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var solution = payload.solution || payload.data && payload.data.solution || null; + var solutionScore = readField(solution, ["score"], [solution]); + return { + id: jobId != null ? String(jobId) : null, + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]), + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || null, + currentScore: readField(payload, ["currentScore", "current_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: readField(payload, ["bestScore", "best_score"], [payload, payload.data]) || (solutionScore != null ? String(solutionScore) : null) || null, + telemetry: normalizeTelemetry(readField(payload, ["telemetry"], [payload, payload.data]), payload), + solution + }; +} +function normalizeAnalysis(payload, fallbackMeta) { + if (!payload || typeof payload !== "object") return null; + var analysisBody = payload.analysis || payload.data && payload.data.analysis || payload; + var constraints = readAnalysisConstraints(analysisBody); + var jobId = readField(payload, ["jobId", "job_id", "id"], [payload, payload.data]); + if (jobId == null || jobId === "") jobId = fallbackMeta && fallbackMeta.jobId; + var snapshotRevision = readField(payload, ["snapshotRevision", "snapshot_revision"], [payload, payload.data]); + if (snapshotRevision == null || snapshotRevision === "") { + snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; + } + return { + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: snapshotRevision != null ? snapshotRevision : null, + lifecycleState: normalizeLifecycleState2(readField(payload, ["lifecycleState", "lifecycle_state"], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType), + terminalReason: readField(payload, ["terminalReason", "terminal_reason"], [payload, payload.data]) || fallbackMeta && fallbackMeta.terminalReason || null, + analysis: analysisBody, + score: analysisBody.score != null ? analysisBody.score : null, + constraints + }; +} +function buildLiveSnapshot(event) { + return { + id: event.meta.jobId, + jobId: event.meta.jobId, + snapshotRevision: event.meta.snapshotRevision, + lifecycleState: event.meta.lifecycleState, + terminalReason: event.meta.terminalReason, + currentScore: event.meta.currentScore, + bestScore: event.meta.bestScore, + telemetry: event.meta.telemetry, + solution: event.solution + }; +} +function mergeMeta(meta, snapshot, eventType) { + if (!snapshot) return meta; + return { + id: meta && meta.id != null ? meta.id : snapshot.id, + jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, + eventType: meta && meta.eventType ? meta.eventType : eventType, + eventSequence: meta ? meta.eventSequence : null, + lifecycleState: meta && meta.lifecycleState || snapshot.lifecycleState || normalizeLifecycleState2(null, eventType), + terminalReason: meta && meta.terminalReason || snapshot.terminalReason || null, + telemetry: snapshot.telemetry || meta && meta.telemetry || null, + currentScore: snapshot.currentScore || meta && meta.currentScore || null, + bestScore: snapshot.bestScore || meta && meta.bestScore || null, + snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : meta && meta.snapshotRevision + }; +} +function readField(payload, names, sources) { + var fields = Array.isArray(names) ? names : [names]; + var roots = sources || [payload]; + for (var i = 0; i < roots.length; i++) { + var source = roots[i]; + if (!source || typeof source !== "object") continue; + for (var j = 0; j < fields.length; j++) { + if (source[fields[j]] != null) return source[fields[j]]; + } + } + return null; +} +function normalizeEventType(value) { + if (typeof value !== "string") return null; + var normalized = value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase(); + if (!normalized) return null; + if (normalized === "finished") return "completed"; + return normalized; +} +function normalizeLifecycleState2(value, eventType) { + if (typeof value === "string" && value.trim()) { + return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toUpperCase(); + } + if (eventType === "progress" || eventType === "best_solution" || eventType === "resumed") return "SOLVING"; + if (eventType === "pause_requested") return "PAUSE_REQUESTED"; + if (eventType === "paused") return "PAUSED"; + if (eventType === "completed") return "COMPLETED"; + if (eventType === "cancelled") return "CANCELLED"; + if (eventType === "failed") return "FAILED"; + return "IDLE"; +} +function normalizeTelemetry(rawTelemetry, payload) { + if (rawTelemetry && typeof rawTelemetry === "object") return rawTelemetry; + var telemetry = {}; + var movesPerSecond = readField(payload, ["movesPerSecond", "moves_per_second"]); + var stepCount = readField(payload, ["stepCount", "step_count"]); + if (movesPerSecond != null) telemetry.movesPerSecond = Number(movesPerSecond); + if (stepCount != null) telemetry.stepCount = Number(stepCount); + return Object.keys(telemetry).length ? telemetry : null; +} +function readMovesPerSecond(telemetry) { + if (!telemetry || typeof telemetry !== "object") return null; + const value = telemetry.movesPerSecond ?? telemetry.moves_per_second; + if (value == null) return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} +function readAnalysisConstraints(analysis) { + if (!analysis || typeof analysis !== "object") return null; + const a = analysis; + if (Array.isArray(a.constraints)) return a.constraints; + const nested = a.analysis; + if (nested && Array.isArray(nested.constraints)) return nested.constraints; + return null; +} +function isActiveLifecycle2(state) { + return state === "STARTING" || state === "SOLVING" || state === "PAUSE_REQUESTED" || state === "RESUMING" || state === "CANCELLING"; +} + +// ts-src/index.ts +var colors = { + pick, + project, + reset +}; +var score = { + parseHard, + parseSoft, + parseMedium, + getComponents, + colorClass +}; +export { + assert, + bindActivation, + colorClass, + colors, + createApiGuide, + createBackend, + createButton, + createFooter, + createHeader, + createModal, + createSolver, + createStatusBar, + createTable, + createTabs, + el, + escHtml, + gantt, + getComponents, + normalizeCreateJobId, + parseHard, + parseMedium, + parseSoft, + pick, + project, + rail, + reset, + score, + showError, + showTab, + showToast, + uid, + version +}; diff --git a/tests/accessibility-regressions.test.js b/tests/accessibility-regressions.test.js index 170d1b4..6b98496 100644 --- a/tests/accessibility-regressions.test.js +++ b/tests/accessibility-regressions.test.js @@ -1,44 +1,19 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const test = require('node:test'); -const vm = require('node:vm'); - -const { createDom } = require('./support/fake-dom'); - +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createStatusBar, createModal, showToast, createApiGuide } from '../static/sf/sf.mjs'; +import { createDom } from './support/fake-dom.js'; +import { mockGlobals } from './support/mock-globals.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); -function loadSf(files, overrides = {}) { +test('status bar constraint dots keep stable ids for solver analysis coloring', (t) => { const { document, window, Node } = createDom(); - const context = vm.createContext({ - console, - document, - window, - Node, - navigator: { - clipboard: { - writeText() { - return Promise.resolve(); - }, - }, - }, - setTimeout, - clearTimeout, - ...overrides, - }); - - files.forEach((file) => { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - vm.runInContext(source, context, { filename: file }); - }); - - return { SF: context.window.SF, document }; -} - -test('status bar constraint dots keep stable ids for solver analysis coloring', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/01-score.js', 'js-src/05-statusbar.js']); - - const statusBar = SF.createStatusBar({ + mockGlobals(t, { document, window, Node }); + const statusBar = createStatusBar({ constraints: [ { name: 'Hard A', type: 'hard' }, { name: 'Soft B', type: 'soft' }, @@ -50,25 +25,21 @@ test('status bar constraint dots keep stable ids for solver analysis coloring', assert.equal(dots[1].id, 'sf-cdot-1'); }); -test('modal, toast, and api guide copy controls expose aria-label attributes', () => { - const { SF } = loadSf([ - 'js-src/00-core.js', - 'js-src/06-modal.js', - 'js-src/09-toast.js', - 'js-src/12-api-guide.js', - ]); +test('modal, toast, and api guide copy controls expose aria-label attributes', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const modal = SF.createModal({ title: 'Example', body: 'Body' }); + const modal = createModal({ title: 'Example', body: 'Body' }); const modalClose = modal.el.querySelector('.sf-modal-close'); assert.equal(modalClose.attributes['aria-label'], 'Close modal'); assert.equal(modalClose.textContent, '×'); - SF.showToast({ message: 'Saved' }); + showToast({ message: 'Saved' }); const toastBtn = modal.el.ownerDocument.body.querySelector('.sf-toast-close'); assert.equal(toastBtn.attributes['aria-label'], 'Dismiss toast'); assert.equal(toastBtn.textContent, '×'); - const guide = SF.createApiGuide({ + const guide = createApiGuide({ endpoints: [{ path: '/x', curl: 'curl /x' }], }); const copyBtn = guide.querySelector('.sf-copy-btn'); @@ -78,6 +49,6 @@ test('modal, toast, and api guide copy controls expose aria-label attributes', ( test('reduced-motion CSS only targets solverforge scoped classes', () => { const css = fs.readFileSync(path.join(ROOT, 'css-src/14-animations.css'), 'utf8'); - assert.match(css, /\[class\^="sf-"\]/); + assert.ok(css.includes('[class^="sf-"]')); assert.doesNotMatch(css, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\*,/); }); diff --git a/tests/backend-contract.test.js b/tests/backend-contract.test.js index a104245..2d7347b 100644 --- a/tests/backend-contract.test.js +++ b/tests/backend-contract.test.js @@ -1,51 +1,37 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const test = require('node:test'); -const vm = require('node:vm'); - -const { createDom } = require('./support/fake-dom'); - -const ROOT = path.resolve(__dirname, '..'); - -function loadSf(files, overrides = {}) { - const { document, window, Node } = createDom(); - const context = vm.createContext({ - console, - document, - window, - Node, - Promise, - setTimeout, - clearTimeout, - ...overrides, - }); - - files.forEach((file) => { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - vm.runInContext(source, context, { filename: file }); - }); - - return { SF: context.window.SF, document }; -} +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mockGlobals } from './support/mock-globals.js'; +import { createBackend } from '../static/sf/sf.mjs'; + +test('createBackend defaults null and omitted config to HTTP backend', () => { + const omitted = createBackend(); + const explicitNull = createBackend(null); + const emptyConfig = createBackend({ baseUrl: '' }); + + for (const backend of [omitted, explicitNull, emptyConfig]) { + assert.equal(typeof backend.createJob, 'function'); + assert.equal(typeof backend.deleteJob, 'function'); + assert.equal(typeof backend.streamJobEvents, 'function'); + } +}); -test('tauri createJob normalizes documented object and numeric ids to strings', async () => { +test('tauri createJob normalizes documented object and numeric ids to strings', async (t) => { const calls = []; - const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], { + mockGlobals(t, { fetch() { throw new Error('unexpected fetch'); }, }); function createTauriBackend(result) { - return SF.createBackend({ + return createBackend({ type: 'tauri', invoke(command, payload) { calls.push({ command, payload }); return Promise.resolve(result); }, listen() { - return Promise.resolve(function () {}); + return Promise.resolve(function () { }); }, }); } @@ -63,21 +49,21 @@ test('tauri createJob normalizes documented object and numeric ids to strings', assert.equal(await backendWithNumber.createJob({}), '7'); }); -test('tauri createJob rejects non-scalar job id payloads', async () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], { +test('tauri createJob rejects non-scalar job id payloads', async (t) => { + mockGlobals(t, { fetch() { throw new Error('unexpected fetch'); }, }); function createTauriBackend(result) { - return SF.createBackend({ + return createBackend({ type: 'tauri', invoke() { return Promise.resolve(result); }, listen() { - return Promise.resolve(function () {}); + return Promise.resolve(function () { }); }, }); } @@ -90,16 +76,15 @@ test('tauri createJob rejects non-scalar job id payloads', async () => { test('tauri backend uses neutral job lifecycle command names', async () => { const calls = []; - const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js']); - const backend = SF.createBackend({ + const backend = createBackend({ type: 'tauri', invoke(command, payload) { calls.push({ command, payload }); return Promise.resolve(null); }, listen() { - return Promise.resolve(function () {}); + return Promise.resolve(function () { }); }, }); @@ -122,9 +107,9 @@ test('tauri backend uses neutral job lifecycle command names', async () => { assert.equal(calls[5].payload.snapshotRevision, 5); }); -test('HTTP backend uses configured job paths and snapshot revision query parameters', async () => { +test('HTTP backend uses configured job paths and snapshot revision query parameters', async (t) => { const requests = []; - const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], { + mockGlobals(t, { fetch(url, opts) { requests.push({ url, opts }); return Promise.resolve({ @@ -135,7 +120,7 @@ test('HTTP backend uses configured job paths and snapshot revision query paramet }, }); - const backend = SF.createBackend({ + const backend = createBackend({ type: 'rails', baseUrl: '/api', jobsPath: '/jobs', @@ -160,7 +145,7 @@ test('HTTP backend uses configured job paths and snapshot revision query paramet assert.equal(requests[6].opts.method, 'DELETE'); }); -test('HTTP backend lets EventSource reconnect without surfacing transient errors', async () => { +test('HTTP backend lets EventSource reconnect without surfacing transient errors', async (t) => { let instance; const errors = []; function FakeEventSource(url) { @@ -175,17 +160,17 @@ test('HTTP backend lets EventSource reconnect without surfacing transient errors this.readyState = FakeEventSource.CLOSED; }; - const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], { + mockGlobals(t, { EventSource: FakeEventSource, }); - const backend = SF.createBackend({ + const backend = createBackend({ type: 'rails', baseUrl: '/api', jobsPath: '/jobs', }); - const close = backend.streamJobEvents('job-9', function () {}, function (error) { + const close = backend.streamJobEvents('job-9', function () { }, function (error) { errors.push(error); }); @@ -207,16 +192,14 @@ test('HTTP backend lets EventSource reconnect without surfacing transient errors test('tauri streamJobEvents keeps id-less updates and filters mismatched job ids', async () => { let handler = null; const received = []; - const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js']); - - const backend = SF.createBackend({ + const backend = createBackend({ type: 'tauri', invoke() { return Promise.resolve('job-1'); }, listen(_eventName, onEvent) { handler = onEvent; - return Promise.resolve(function () {}); + return Promise.resolve(function () { }); }, }); diff --git a/tests/button-rail-knobs.test.js b/tests/button-rail-knobs.test.js index 4bdef34..8b4bc1f 100644 --- a/tests/button-rail-knobs.test.js +++ b/tests/button-rail-knobs.test.js @@ -1,37 +1,14 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const test = require('node:test'); -const vm = require('node:vm'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createButton, rail } from '../static/sf/sf.mjs'; +import { createDom } from './support/fake-dom.js'; +import { mockGlobals } from './support/mock-globals.js'; -const { createDom } = require('./support/fake-dom'); - -const ROOT = path.resolve(__dirname, '..'); - -function loadSf(files, overrides = {}) { +test('iconOnly buttons keep an accessible label without rendering text content', (t) => { const { document, window, Node } = createDom(); - const context = vm.createContext({ - console, - document, - window, - Node, - setTimeout, - clearTimeout, - ...overrides, - }); - - files.forEach((file) => { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - vm.runInContext(source, context, { filename: file }); - }); - - return { SF: context.window.SF, document }; -} + mockGlobals(t, { document, window, Node }); -test('iconOnly buttons keep an accessible label without rendering text content', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/03-buttons.js']); - - const button = SF.createButton({ + const button = createButton({ text: 'Settings', icon: 'fa-gear', iconOnly: true, @@ -41,10 +18,11 @@ test('iconOnly buttons keep an accessible label without rendering text content', assert.equal(button.attributes['aria-label'], 'Settings'); }); -test('rail card badges accept a single string badge and preserve heatmap alignment', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js']); +test('rail card badges accept a single string badge and preserve heatmap alignment', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const card = SF.rail.createCard({ + const card = rail.createCard({ name: 'Kiln 1', badges: 'TEMPRA', labelWidth: 220, diff --git a/tests/core-widgets.test.js b/tests/core-widgets.test.js index 7c5c14b..02af501 100644 --- a/tests/core-widgets.test.js +++ b/tests/core-widgets.test.js @@ -1,18 +1,15 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createButton, createTable, createTabs, showTab } from '../static/sf/sf.mjs'; +import { createDom } from './support/fake-dom.js'; +import { mockGlobals } from './support/mock-globals.js'; -const { loadSf } = require('./support/load-sf'); - -test('button, table, and tabs render and respond to basic interactions', () => { - const { SF, document } = loadSf([ - 'js-src/00-core.js', - 'js-src/03-buttons.js', - 'js-src/07-tabs.js', - 'js-src/08-table.js', - ]); +test('button, table, and tabs render and respond to basic interactions', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); let clicked = 0; - const button = SF.createButton({ + const button = createButton({ text: 'Solve', variant: 'success', size: 'small', @@ -31,7 +28,7 @@ test('button, table, and tabs render and respond to basic interactions', () => { assert.equal(clicked, 1); let selectedRow = null; - const table = SF.createTable({ + const table = createTable({ columns: [{ label: 'Job', className: 'job-col' }, { label: 'Status', align: 'right' }], rows: [['A-1', 'Ready']], onRowClick(index, row) { @@ -46,14 +43,14 @@ test('button, table, and tabs render and respond to basic interactions', () => { assert.equal(table.querySelectorAll('td')[0].textContent, 'A-1'); assert.equal(table.querySelectorAll('td')[1].style.textAlign, 'right'); - const tabs = SF.createTabs({ + const tabs = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan' }, { id: 'gantt', content: 'Gantt' }, ], }); document.body.appendChild(tabs.el); - SF.showTab('gantt'); + showTab('gantt'); assert.equal(tabs.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false); assert.equal(tabs.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true); }); diff --git a/tests/demo-browser-check.js b/tests/demo-browser-check.js index bffc95a..2fc33c6 100644 --- a/tests/demo-browser-check.js +++ b/tests/demo-browser-check.js @@ -1,10 +1,51 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const http = require('node:http'); -const path = require('node:path'); +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); -const SCREENSHOT_DIR = path.join(ROOT, 'screenshots'); +const SCREENSHOT_BASELINE_DIR = path.join(ROOT, 'screenshots'); +const SCREENSHOT_ARTIFACT_DIR = path.join(ROOT, 'target', 'browser-smoke', 'screenshots'); + +function usage() { + return 'Usage: node tests/demo-browser-check.js [--update-screenshots]'; +} + +function parseRunnerConfig(args) { + if (args.length === 0) { + return { + screenshotDir: SCREENSHOT_ARTIFACT_DIR, + updateScreenshots: false, + }; + } + + if (args.length === 1 && args[0] === '--update-screenshots') { + return { + screenshotDir: SCREENSHOT_BASELINE_DIR, + updateScreenshots: true, + }; + } + + throw new Error('Unknown browser check option: ' + args.join(' ') + '\n' + usage()); +} + +function prepareScreenshotDirectory(config) { + if (!config.updateScreenshots) { + fs.rmSync(config.screenshotDir, { recursive: true, force: true }); + } + fs.mkdirSync(config.screenshotDir, { recursive: true }); +} + +let runnerConfig; +try { + runnerConfig = parseRunnerConfig(process.argv.slice(2)); + prepareScreenshotDirectory(runnerConfig); +} catch (error) { + process.stderr.write((error && error.message ? error.message : String(error)) + '\n'); + process.exit(1); +} function contentTypeFor(filePath) { switch (path.extname(filePath).toLowerCase()) { @@ -14,6 +55,8 @@ function contentTypeFor(filePath) { return 'text/html; charset=utf-8'; case '.js': return 'application/javascript; charset=utf-8'; + case '.mjs': + return 'application/javascript; charset=utf-8'; case '.json': return 'application/json; charset=utf-8'; case '.svg': @@ -89,7 +132,7 @@ function createStaticServer(rootDir) { async function withPage(callback) { let playwright; try { - playwright = require('playwright'); + playwright = await import('playwright'); } catch (error) { throw new Error('Playwright is not installed. Run `make browser-setup` first.'); } @@ -147,8 +190,7 @@ async function runCheck(name, fn) { } async function captureScreenshot(target, filename) { - fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); - const screenshotPath = path.join(SCREENSHOT_DIR, filename); + const screenshotPath = path.join(runnerConfig.screenshotDir, filename); await target.screenshot({ path: screenshotPath }); assert.equal(fs.existsSync(screenshotPath), true); assert.equal(fs.statSync(screenshotPath).size > 0, true); @@ -187,6 +229,31 @@ async function checkFullSurface() { }); } +async function checkFullSurfaceEsm() { + await withPage(async ({ goto, page, assertNoBrowserErrors }) => { + const response = await goto('/demos/full-surface-esm.html'); + assert.equal(response.status(), 200); + + await page.waitForSelector('.sf-header', { timeout: 10000 }); + await page.waitForSelector('.sf-statusbar', { timeout: 10000 }); + await page.waitForSelector('.sf-tabs-container', { timeout: 10000 }); + await page.waitForSelector('.sf-table', { timeout: 10000 }); + await page.waitForSelector('.sf-rail-timeline', { timeout: 10000 }); + await page.waitForSelector('.sf-footer', { timeout: 10000 }); + + await page.getByRole('tab', { name: /gantt/i }).click({ timeout: 10000 }); + await page.waitForSelector('.sf-gantt-split', { timeout: 10000 }); + + await page.getByRole('tab', { name: /api/i }).click({ timeout: 10000 }); + await page.waitForSelector('.sf-api-guide', { timeout: 10000 }); + + const title = await page.locator('.sf-header-title').textContent(); + assert.equal(title, 'Planner123'); + + assertNoBrowserErrors(); + }); +} + async function checkRailDemo() { await withPage(async ({ goto, page, assertNoBrowserErrors }) => { const response = await goto('/demos/rail.html'); @@ -425,6 +492,7 @@ async function checkTimelineDemoWithoutResizeObserver() { (async function main() { try { await runCheck('full-surface demo', checkFullSurface); + await runCheck('full-surface ESM demo', checkFullSurfaceEsm); await runCheck('timeline demo', checkTimelineDemo); await runCheck('dense timeline demo', checkDenseTimelineDemo); await runCheck('timeline demo without ResizeObserver', checkTimelineDemoWithoutResizeObserver); diff --git a/tests/esm-surface.test.js b/tests/esm-surface.test.js new file mode 100644 index 0000000..ed2bfa4 --- /dev/null +++ b/tests/esm-surface.test.js @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { colors, pick, score, parseHard } from '../static/sf/sf.mjs'; + +test('ES module exposes flat utility exports and compatibility namespaces', () => { + assert.equal(parseHard('0hard/-42soft'), 0); + assert.equal(score.parseSoft('0hard/-42soft'), -42); + + const first = pick('line-a'); + assert.equal(colors.pick('line-a'), first); + assert.equal(typeof colors.project(0).dark, 'string'); +}); diff --git a/tests/global-bundle-contract.test.js b/tests/global-bundle-contract.test.js new file mode 100644 index 0000000..aa5ab42 --- /dev/null +++ b/tests/global-bundle-contract.test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { loadSfGlobal } from './support/load-sf-global.js'; + +test('classic bundle attaches window.SF and preserves public namespaces', (t) => { + const { SF, window } = loadSfGlobal(t); + + assert.ok(SF); + assert.equal(window.SF, SF); + assert.equal(typeof SF.createBackend, 'function'); + assert.equal(typeof SF.createSolver, 'function'); + assert.equal(typeof SF.rail.createTimeline, 'function'); + assert.equal(typeof SF.gantt.create, 'function'); + + assert.equal(SF.score.parseHard('0hard/-42soft'), 0); + assert.equal(SF.score.parseSoft('0hard/-42soft'), -42); + assert.equal(SF.score.parseMedium('0hard/7medium/-42soft'), 7); + assert.deepEqual(SF.score.getComponents('0hard/7medium/-42soft'), { + hard: 0, + medium: 7, + soft: -42, + }); + assert.equal(SF.score.colorClass('0hard/-42soft'), 'score-yellow'); + + const first = SF.colors.pick('machine-a'); + assert.equal(SF.colors.pick('machine-a'), first); + assert.equal(typeof SF.colors.project(0).main, 'string'); + SF.colors.reset(); + assert.equal(SF.colors.pick('machine-a'), first); +}); diff --git a/tests/instance-local-dom.test.js b/tests/instance-local-dom.test.js index 1ac1477..9da8c70 100644 --- a/tests/instance-local-dom.test.js +++ b/tests/instance-local-dom.test.js @@ -1,45 +1,16 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const test = require('node:test'); -const vm = require('node:vm'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHeader, createStatusBar, createTabs, showTab, gantt } from '../static/sf/sf.mjs'; +import { createDom } from './support/fake-dom.js'; +import { mockGlobals } from './support/mock-globals.js'; -const { createDom } = require('./support/fake-dom'); - -const ROOT = path.resolve(__dirname, '..'); - -function loadSf(files, overrides = {}) { +test('status bars only toggle the controls on their bound header', (t) => { const { document, window, Node } = createDom(); - const context = vm.createContext({ - console, - document, - window, - Node, - setTimeout, - clearTimeout, - ...overrides, - }); - - files.forEach((file) => { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - vm.runInContext(source, context, { filename: file }); - }); - - return { SF: context.window.SF, document }; -} - -test('status bars only toggle the controls on their bound header', () => { - const { SF } = loadSf([ - 'js-src/00-core.js', - 'js-src/03-buttons.js', - 'js-src/04-header.js', - 'js-src/05-statusbar.js', - ]); - - const headerOne = SF.createHeader({ actions: { onSolve() {}, onPause() {}, onResume() {}, onCancel() {} } }); - const headerTwo = SF.createHeader({ actions: { onSolve() {}, onPause() {}, onResume() {}, onCancel() {} } }); - const barOne = SF.createStatusBar({ header: headerOne }); - const barTwo = SF.createStatusBar({ header: headerTwo }); + mockGlobals(t, { document, window, Node }); + const headerOne = createHeader({ actions: { onSolve() { }, onPause() { }, onResume() { }, onCancel() { } } }); + const headerTwo = createHeader({ actions: { onSolve() { }, onPause() { }, onResume() { }, onCancel() { } } }); + const barOne = createStatusBar({ header: headerOne }); + const barTwo = createStatusBar({ header: headerTwo }); function isVisible(btn) { return btn.style.display !== 'none'; } @@ -84,14 +55,11 @@ test('status bars only toggle the controls on their bound header', () => { assertControls(headerTwo, { state: 'SOLVING', solve: false, pause: true, resume: false, cancel: true, spinner: true }); }); -test('status bar can show the same score again after reset', () => { - const { SF, document } = loadSf([ - 'js-src/00-core.js', - 'js-src/01-score.js', - 'js-src/05-statusbar.js', - ]); +test('status bar can show the same score again after reset', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const bar = SF.createStatusBar({}); + const bar = createStatusBar({}); document.body.appendChild(bar.el); const score = document.getElementById('sfScoreDisplay'); @@ -105,16 +73,17 @@ test('status bar can show the same score again after reset', () => { assert.equal(score.textContent, '0hard/0soft'); }); -test('tab switching stays scoped to the owning tab container', () => { - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']); +test('tab switching stays scoped to the owning tab container', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const tabsOne = SF.createTabs({ + const tabsOne = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan' }, { id: 'gantt', content: 'Gantt' }, ], }); - const tabsTwo = SF.createTabs({ + const tabsTwo = createTabs({ tabs: [ { id: 'alpha', active: true, content: 'Alpha' }, { id: 'beta', content: 'Beta' }, @@ -131,16 +100,17 @@ test('tab switching stays scoped to the owning tab container', () => { assert.equal(tabsTwo.el.querySelector('[data-tab-id="beta"]').classList.contains('active'), false); }); -test('global showTab updates every matching tab container independently', () => { - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']); +test('global showTab updates every matching tab container independently', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const tabsOne = SF.createTabs({ + const tabsOne = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan A' }, { id: 'gantt', content: 'Gantt A' }, ], }); - const tabsTwo = SF.createTabs({ + const tabsTwo = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan B' }, { id: 'gantt', content: 'Gantt B' }, @@ -150,23 +120,24 @@ test('global showTab updates every matching tab container independently', () => document.body.appendChild(tabsOne.el); document.body.appendChild(tabsTwo.el); - SF.showTab('gantt'); + showTab('gantt'); assert.equal(tabsOne.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false); assert.equal(tabsOne.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true); assert.equal(tabsTwo.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false); assert.equal(tabsTwo.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true); }); -test('root-scoped showTab only updates the targeted tab container', () => { - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']); +test('root-scoped showTab only updates the targeted tab container', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const tabsOne = SF.createTabs({ + const tabsOne = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan A' }, { id: 'gantt', content: 'Gantt A' }, ], }); - const tabsTwo = SF.createTabs({ + const tabsTwo = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan B' }, { id: 'gantt', content: 'Gantt B' }, @@ -176,23 +147,24 @@ test('root-scoped showTab only updates the targeted tab container', () => { document.body.appendChild(tabsOne.el); document.body.appendChild(tabsTwo.el); - SF.showTab('gantt', tabsOne.el); + showTab('gantt', tabsOne.el); assert.equal(tabsOne.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false); assert.equal(tabsOne.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true); assert.equal(tabsTwo.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), true); assert.equal(tabsTwo.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), false); }); -test('missing tab ids only clear active state inside the targeted tab container', () => { - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']); +test('missing tab ids only clear active state inside the targeted tab container', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const tabsOne = SF.createTabs({ + const tabsOne = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan A' }, { id: 'gantt', content: 'Gantt A' }, ], }); - const tabsTwo = SF.createTabs({ + const tabsTwo = createTabs({ tabs: [ { id: 'plan', active: true, content: 'Plan B' }, { id: 'gantt', content: 'Gantt B' }, @@ -209,11 +181,12 @@ test('missing tab ids only clear active state inside the targeted tab container' assert.equal(tabsTwo.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), false); }); -test('gantt instances get unique generated IDs by default', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js']); +test('gantt instances get unique generated IDs by default', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const ganttOne = SF.gantt.create({}); - const ganttTwo = SF.gantt.create({}); + const ganttOne = gantt.create({}); + const ganttTwo = gantt.create({}); const onePanes = ganttOne.el.querySelectorAll('.sf-gantt-pane'); const twoPanes = ganttTwo.el.querySelectorAll('.sf-gantt-pane'); const oneContainer = ganttOne.el.querySelector('.sf-gantt-container'); @@ -224,12 +197,13 @@ test('gantt instances get unique generated IDs by default', () => { assert.equal(oneContainer.id === twoContainer.id, false); }); -test('gantt.create falls back to built-in defaults when config is omitted', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js']); +test('gantt.create falls back to built-in defaults when config is omitted', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const gantt = SF.gantt.create(); - const panes = gantt.el.querySelectorAll('.sf-gantt-pane'); - const chartContainer = gantt.el.querySelector('.sf-gantt-container'); + const ganttChart = gantt.create(); + const panes = ganttChart.el.querySelectorAll('.sf-gantt-pane'); + const chartContainer = ganttChart.el.querySelector('.sf-gantt-container'); assert.equal(panes.length, 2); assert.equal(Boolean(panes[0].id), true); @@ -237,27 +211,31 @@ test('gantt.create falls back to built-in defaults when config is omitted', () = assert.equal(Boolean(chartContainer.id), true); }); -test('gantt remount recreates the chart and preserves refresh behavior', () => { +test('gantt remount recreates the chart and preserves refresh behavior', (t) => { const splitCalls = []; const refreshCalls = []; let ganttInstanceCount = 0; - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { - Split: function (targets, options) { - splitCalls.push({ targets, options }); - return { - destroy() {}, - }; - }, - Gantt: function () { - ganttInstanceCount++; - return { - change_view_mode() {}, - refresh(tasks) { - refreshCalls.push(tasks); - }, - }; - }, + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + globalThis.Split = function (targets, options) { + splitCalls.push({ targets, options }); + return { + destroy() { }, + }; + }; + globalThis.Gantt = function () { + ganttInstanceCount++; + return { + change_view_mode() { }, + refresh(tasks) { + refreshCalls.push(tasks); + }, + }; + }; + t.after(() => { + delete globalThis.Split; + delete globalThis.Gantt; }); const mountOne = document.createElement('div'); @@ -265,37 +243,41 @@ test('gantt remount recreates the chart and preserves refresh behavior', () => { document.body.appendChild(mountOne); document.body.appendChild(mountTwo); - const gantt = SF.gantt.create({}); - gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); - gantt.mount(mountOne); - gantt.mount(mountTwo); - gantt.refresh(); + const ganttChart = gantt.create({}); + ganttChart.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); + ganttChart.mount(mountOne); + ganttChart.mount(mountTwo); + ganttChart.refresh(); assert.equal(ganttInstanceCount >= 2, true); - assert.equal(mountOne.childNodes.includes(gantt.el), false); - assert.equal(mountTwo.childNodes.includes(gantt.el), true); - assert.notEqual(gantt.getChart(), null); + assert.equal(mountOne.childNodes.includes(ganttChart.el), false); + assert.equal(mountTwo.childNodes.includes(ganttChart.el), true); + assert.notEqual(ganttChart.getChart(), null); assert.equal(refreshCalls.length, 1); assert.equal(splitCalls.length, 2); }); -test('failed gantt remount keeps the existing mounted chart intact', () => { +test('failed gantt remount keeps the existing mounted chart intact', (t) => { let destroyCount = 0; - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { - Split: function () { - return { - destroy() { - destroyCount++; - }, - }; - }, - Gantt: function () { - return { - change_view_mode() {}, - refresh() {}, - }; - }, + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + globalThis.Split = function () { + return { + destroy() { + destroyCount++; + }, + }; + }; + globalThis.Gantt = function () { + return { + change_view_mode() { }, + refresh() { }, + }; + }; + t.after(() => { + delete globalThis.Split; + delete globalThis.Gantt; }); const validMount = document.createElement('div'); @@ -307,86 +289,95 @@ test('failed gantt remount keeps the existing mounted chart intact', () => { document.body.appendChild(validMount); document.body.appendChild(hiddenMount); - const gantt = SF.gantt.create({}); - gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); - gantt.mount(validMount); + const ganttChart = gantt.create({}); + ganttChart.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); + ganttChart.mount(validMount); assert.throws(function () { - gantt.mount(hiddenMount); + ganttChart.mount(hiddenMount); }, /target is not laid out yet/); - assert.equal(validMount.childNodes.includes(gantt.el), true); - assert.equal(hiddenMount.childNodes.includes(gantt.el), false); + assert.equal(validMount.childNodes.includes(ganttChart.el), true); + assert.equal(hiddenMount.childNodes.includes(ganttChart.el), false); assert.equal(destroyCount, 0); }); -test('gantt initSplit keeps accepting scalar splitMinSize values', () => { +test('gantt initSplit keeps accepting scalar splitMinSize values', (t) => { const splitCalls = []; - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { - Split: function (targets, options) { - splitCalls.push({ targets, options }); - return { - destroy() {}, - }; - }, + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + globalThis.Split = function (targets, options) { + splitCalls.push({ targets, options }); + return { + destroy() { }, + }; + }; + t.after(() => { + delete globalThis.Split; }); const mount = document.createElement('div'); document.body.appendChild(mount); - const gantt = SF.gantt.create({ splitMinSize: 160 }); - gantt.mount(mount); + const ganttChart = gantt.create({ splitMinSize: 160 }); + ganttChart.mount(mount); assert.equal(splitCalls.length, 1); assert.equal(splitCalls[0].options.minSize[0], 160); assert.equal(splitCalls[0].options.minSize[1], 160); }); -test('gantt sortable columns render and reorder grid rows without throwing', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { - Gantt: function () { - return { - change_view_mode() {}, - refresh() {}, - }; - }, +test('gantt sortable columns render and reorder grid rows without throwing', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + globalThis.Gantt = function () { + return { + change_view_mode() { }, + refresh() { }, + }; + }; + t.after(() => { + delete globalThis.Gantt; }); - const gantt = SF.gantt.create({ + const ganttChart = gantt.create({ columns: [ { key: 'name', label: 'Task', sortable: true }, { key: 'start', label: 'Start' }, ], }); - gantt.setTasks([ + ganttChart.setTasks([ { id: 'b', name: 'Beta', start: '2026-03-22', end: '2026-03-23' }, { id: 'a', name: 'Alpha', start: '2026-03-21', end: '2026-03-22' }, ]); - const header = gantt.el.querySelector('th'); + const header = ganttChart.el.querySelector('th'); header.click(); - const rows = gantt.el.querySelectorAll('.sf-gantt-row'); + const rows = ganttChart.el.querySelectorAll('.sf-gantt-row'); assert.equal(rows[0].dataset.taskId, 'a'); assert.equal(rows[1].dataset.taskId, 'b'); }); -test('gantt pinned tasks propagate pinned custom class to chart tasks', () => { +test('gantt pinned tasks propagate pinned custom class to chart tasks', (t) => { let seenTasks = null; - - const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { - Gantt: function (_selector, tasks) { - seenTasks = tasks; - return { - change_view_mode() {}, - refresh() {}, - }; - }, + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + + globalThis.Gantt = function (_selector, tasks) { + seenTasks = tasks; + return { + change_view_mode() { }, + refresh() { }, + }; + }; + t.after(() => { + delete globalThis.Gantt; }); - const gantt = SF.gantt.create({}); - gantt.setTasks([ + const ganttChart = gantt.create({}); + ganttChart.setTasks([ { id: 'task-1', start: '2026-03-21', end: '2026-03-22', pinned: true, custom_class: 'critical' }, ]); diff --git a/tests/map-module.test.js b/tests/map-module.test.js index eb22fad..2d13c64 100644 --- a/tests/map-module.test.js +++ b/tests/map-module.test.js @@ -1,7 +1,11 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const { loadSf } = require('./support/load-sf'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); function createLeafletStub() { const calls = { @@ -82,9 +86,14 @@ function createLeafletStub() { return { L, calls }; } -test('map fitBounds uses a Leaflet feature group with bounds support', () => { +test('map fitBounds uses a Leaflet feature group with bounds support', (t) => { const { L, calls } = createLeafletStub(); - const { SF } = loadSf(['js-src/00-core.js', 'static/sf/modules/sf-map.js'], { L }); + + // Load sf-map.js which attaches SF.map + const SF = {}; + globalThis.L = L; + const mapModuleSource = fs.readFileSync(path.join(ROOT, 'static', 'sf', 'modules', 'sf-map.js'), 'utf8'); + new Function('SF', mapModuleSource).call(null, SF); const map = SF.map.create({ container: 'map', @@ -97,4 +106,8 @@ test('map fitBounds uses a Leaflet feature group with bounds support', () => { assert.equal(calls.fitBounds.length, 1); assert.equal(calls.fitBounds[0].options.padding[0], 30); assert.equal(calls.fitBounds[0].options.padding[1], 30); + + t.after(() => { + delete globalThis.L; + }); }); diff --git a/tests/rail-timeline.test.js b/tests/rail-timeline.test.js index ce6845f..2189a22 100644 --- a/tests/rail-timeline.test.js +++ b/tests/rail-timeline.test.js @@ -1,7 +1,9 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); +import assert from 'node:assert/strict'; +import test from 'node:test'; -const { loadSf } = require('./support/load-sf'); +import { rail } from '../static/sf/sf.mjs'; +import { mockGlobals } from './support/mock-globals.js'; +import { createDom } from './support/fake-dom.js'; function buildAxis(dayCount, initialViewport) { return { @@ -95,7 +97,7 @@ function buildDenseHospitalLikeModel() { return { id: overview ? `location-${laneIndex}` : `employee-${laneIndex}`, - label: overview ? `By location · Unit ${laneIndex + 1}` : `By employee · Clinician ${laneIndex + 1}`, + label: overview ? `By location \u00b7 Unit ${laneIndex + 1}` : `By employee \u00b7 Clinician ${laneIndex + 1}`, mode: overview ? 'overview' : 'detailed', items: laneItems, }; @@ -103,8 +105,9 @@ function buildDenseHospitalLikeModel() { }; } -test('timeline detailed lanes pack overlapping items into stable track indices', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline detailed lanes pack overlapping items into stable track indices', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); const model = { axis: buildAxis(7), @@ -123,7 +126,7 @@ test('timeline detailed lanes pack overlapping items into stable track indices', ], }; - const timeline = SF.rail.createTimeline({ model }); + const timeline = rail.createTimeline({ model }); const before = blockTrackMap(timeline.el, '.sf-rail-timeline-item--detail'); timeline.setModel(model); @@ -137,13 +140,14 @@ test('timeline detailed lanes pack overlapping items into stable track indices', gamma: 0, }); assert.deepEqual(after, before); - assert.equal(SF.schedule, undefined); + // assert.equal(schedule, undefined); }); -test('timeline detailed geometry keeps adjacent non-overlapping tasks disjoint', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline detailed geometry keeps adjacent non-overlapping tasks disjoint', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(1), lanes: [ @@ -168,10 +172,11 @@ test('timeline detailed geometry keeps adjacent non-overlapping tasks disjoint', assert.ok(left.right <= right.left); }); -test('timeline detailed geometry renders true overlaps on different tracks', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline detailed geometry renders true overlaps on different tracks', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(1), lanes: [ @@ -198,8 +203,9 @@ test('timeline detailed geometry renders true overlaps on different tracks', () ); }); -test('timeline body keeps many solved lanes in one scrollable body viewport', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline body keeps many solved lanes in one scrollable body viewport', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); const model = { axis: buildAxis(7), @@ -218,17 +224,18 @@ test('timeline body keeps many solved lanes in one scrollable body viewport', () ], })), }; - const timeline = SF.rail.createTimeline({ model }); + const timeline = rail.createTimeline({ model }); const bodyViewport = timeline.el.querySelector('.sf-rail-timeline-body-viewport'); assert.ok(bodyViewport); assert.equal(timeline.el.querySelectorAll('.sf-rail-timeline-row').length, 60); }); -test('timeline overview lanes cluster overlaps and expand only the targeted region', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline overview lanes cluster overlaps and expand only the targeted region', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -266,11 +273,12 @@ test('timeline overview lanes cluster overlaps and expand only the targeted regi assert.equal(timeline.el.querySelectorAll('.sf-rail-timeline-item--cluster').length, 1); }); -test('timeline overview lanes reject duplicate cluster ids across disjoint groups in one lane', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline overview lanes reject duplicate cluster ids across disjoint groups in one lane', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -291,10 +299,11 @@ test('timeline overview lanes reject duplicate cluster ids across disjoint group }, /must identify at most one overview group per lane/); }); -test('timeline overview summaries accept additive summary metadata and render count/open/tone composition', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline overview summaries accept additive summary metadata and render count/open/tone composition', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -339,10 +348,11 @@ test('timeline overview summaries accept additive summary metadata and render co assert.equal(block.attributes['aria-label'].includes('3 open'), true); }); -test('timeline overview summaries aggregate raw and summarized items in the same group', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline overview summaries aggregate raw and summarized items in the same group', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -394,10 +404,11 @@ test('timeline overview summaries aggregate raw and summarized items in the same assert.equal(block.attributes['aria-label'].includes('1 emerald'), true); }); -test('timeline overview summaries fall back per field when summary metadata is partial', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline overview summaries fall back per field when summary metadata is partial', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -448,10 +459,11 @@ test('timeline overview summaries fall back per field when summary metadata is p assert.equal(block.attributes['aria-label'].includes('1 open'), true); }); -test('timeline does not invent open or tone aggregates when explicit summary count outruns inspectable items', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline does not invent open or tone aggregates when explicit summary count outruns inspectable items', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -496,10 +508,11 @@ test('timeline does not invent open or tone aggregates when explicit summary cou assert.equal(block.attributes['aria-label'].includes('emerald'), false); }); -test('timeline overview lanes cluster tightly adjacent items into one aggregate block', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline overview lanes cluster tightly adjacent items into one aggregate block', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -519,10 +532,11 @@ test('timeline overview lanes cluster tightly adjacent items into one aggregate assert.equal(timeline.el.querySelectorAll('.sf-rail-timeline-item--cluster').length, 1); }); -test('timeline syncs header/body scroll, updates zoom presets, and drag-pans from the header', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline syncs header/body scroll, updates zoom presets, and drag-pans from the header', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(28, { startMinute: 0, endMinute: 14 * 1440 }), lanes: [ @@ -560,12 +574,12 @@ test('timeline syncs header/body scroll, updates zoom presets, and drag-pans fro type: 'mousedown', button: 0, clientX: 360, - preventDefault() {}, + preventDefault() { }, }); headerViewport.dispatchEvent({ type: 'mousemove', clientX: 240, - preventDefault() {}, + preventDefault() { }, }); headerViewport.dispatchEvent({ type: 'mouseup' }); @@ -573,10 +587,11 @@ test('timeline syncs header/body scroll, updates zoom presets, and drag-pans fro assert.equal(bodyViewport.scrollLeft, headerViewport.scrollLeft); }); -test('timeline can omit zoom controls for fixed-horizon app surfaces', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline can omit zoom controls for fixed-horizon app surfaces', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ zoomPresets: [], model: { axis: buildAxis(7), @@ -596,10 +611,11 @@ test('timeline can omit zoom controls for fixed-horizon app surfaces', () => { assert.equal(timeline.el.querySelector('.sf-rail-timeline-zoom-controls'), null); }); -test('timeline updates viewport without rebuilding rows for simple pan changes', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline updates viewport without rebuilding rows for simple pan changes', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(28, { startMinute: 0, endMinute: 14 * 1440 }), lanes: [ @@ -626,27 +642,25 @@ test('timeline updates viewport without rebuilding rows for simple pan changes', assert.equal(Number(timeline.el.dataset.viewportStartMinute), 7 * 1440); }); -test('timeline derives content width from the measured body viewport instead of the padded host', () => { +test('timeline derives content width from the measured body viewport instead of the padded host', (t) => { const observers = []; - const { SF, document } = loadSf( - ['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js'], - { - ResizeObserver: class ResizeObserver { - constructor(callback) { - this.callback = callback; - observers.push(this); - } + const { document, window, Node } = createDom(); + mockGlobals(t, { + document, window, Node, ResizeObserver: class ResizeObserver { + constructor(callback) { + this.callback = callback; + observers.push(this); + } - observe(target) { - this.target = target; - } + observe(target) { + this.target = target; + } - disconnect() {} - }, - } - ); + disconnect() { } + }, + }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ labelWidth: 280, model: { axis: buildAxis(28, { startMinute: 0, endMinute: 14 * 1440 }), @@ -693,27 +707,25 @@ test('timeline derives content width from the measured body viewport instead of assert.equal(root.dataset.supportedViewportWidth, 'true'); }); -test('timeline compacts the label column before collapsing the visible track', () => { +test('timeline compacts the label column before collapsing the visible track', (t) => { const observers = []; - const { SF, document } = loadSf( - ['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js'], - { - ResizeObserver: class ResizeObserver { - constructor(callback) { - this.callback = callback; - observers.push(this); - } + const { document, window, Node } = createDom(); + mockGlobals(t, { + document, window, Node, ResizeObserver: class ResizeObserver { + constructor(callback) { + this.callback = callback; + observers.push(this); + } - observe(target) { - this.target = target; - } + observe(target) { + this.target = target; + } - disconnect() {} - }, - } - ); + disconnect() { } + }, + }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ labelWidth: 280, model: { axis: buildAxis(28, { startMinute: 0, endMinute: 14 * 1440 }), @@ -755,13 +767,11 @@ test('timeline compacts the label column before collapsing the visible track', ( assert.equal(root.dataset.supportedViewportWidth, 'true'); }); -test('timeline renders after append when ResizeObserver is unavailable', async () => { - const { SF, document } = loadSf( - ['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js'], - { ResizeObserver: undefined } - ); +test('timeline renders after append when ResizeObserver is unavailable', async (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node, ResizeObserver: undefined }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(28, { startMinute: 0, endMinute: 14 * 1440 }), lanes: [ @@ -794,11 +804,10 @@ test('timeline renders after append when ResizeObserver is unavailable', async ( assert.equal(bodyViewport.scrollWidth > bodyViewport.clientWidth, true); }); -test('timeline resynchronizes layout after detached model updates', async () => { - const { SF, document } = loadSf( - ['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js'], - { ResizeObserver: undefined } - ); +test('timeline resynchronizes layout after detached model updates', async (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node, ResizeObserver: undefined }); + const initialModel = { axis: buildAxis(28, { startMinute: 0, endMinute: 14 * 1440 }), @@ -827,7 +836,7 @@ test('timeline resynchronizes layout after detached model updates', async () => ], }; - const timeline = SF.rail.createTimeline({ labelWidth: 280, model: initialModel }); + const timeline = rail.createTimeline({ labelWidth: 280, model: initialModel }); timeline.setModel(updatedModel); const host = document.createElement('div'); @@ -849,10 +858,11 @@ test('timeline resynchronizes layout after detached model updates', async () => assert.equal(bodyViewport.scrollWidth > bodyViewport.clientWidth, true); }); -test('timeline exposes keyboard-focus tooltip parity and keyboard expansion for overview blocks', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline exposes keyboard-focus tooltip parity and keyboard expansion for overview blocks', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(14, { startMinute: 0, endMinute: 7 * 1440 }), lanes: [ @@ -883,7 +893,7 @@ test('timeline exposes keyboard-focus tooltip parity and keyboard expansion for clusterBlock.dispatchEvent({ type: 'keydown', key: 'Enter', - preventDefault() {}, + preventDefault() { }, }); assert.equal(timeline.el.querySelectorAll('.sf-rail-timeline-item--detail').length, 2); @@ -894,10 +904,11 @@ test('timeline exposes keyboard-focus tooltip parity and keyboard expansion for assert.equal(tooltip.attributes['aria-hidden'], 'true'); }); -test('timeline assigns stable fallback labels and ordering for unlabeled items and detail items', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline assigns stable fallback labels and ordering for unlabeled items and detail items', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(7, { startMinute: 0, endMinute: 3 * 1440 }), lanes: [ @@ -949,10 +960,11 @@ test('timeline assigns stable fallback labels and ordering for unlabeled items a assert.equal(expandedLabels[1].startsWith('Item 2'), true); }); -test('timeline cluster blocks stay clickable so users can collapse expanded groups from the UI', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline cluster blocks stay clickable so users can collapse expanded groups from the UI', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(7, { startMinute: 0, endMinute: 3 * 1440 }), lanes: [ @@ -985,10 +997,11 @@ test('timeline cluster blocks stay clickable so users can collapse expanded grou assert.equal(timeline.el.querySelectorAll('.sf-rail-timeline-item--detail').length, 0); }); -test('timeline scopes lane heading ids so aria-labelledby stays valid across instances', () => { - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline scopes lane heading ids so aria-labelledby stays valid across instances', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const first = SF.rail.createTimeline({ + const first = rail.createTimeline({ model: { axis: buildAxis(7, { startMinute: 0, endMinute: 3 * 1440 }), lanes: [ @@ -1003,7 +1016,7 @@ test('timeline scopes lane heading ids so aria-labelledby stays valid across ins ], }, }); - const second = SF.rail.createTimeline({ + const second = rail.createTimeline({ model: { axis: buildAxis(7, { startMinute: 0, endMinute: 3 * 1440 }), lanes: [ @@ -1034,11 +1047,12 @@ test('timeline scopes lane heading ids so aria-labelledby stays valid across ins assert.equal(document.getElementById(rows[1].attributes['aria-labelledby']).textContent.trim(), 'Ward East'); }); -test('timeline rejects non-numeric minute inputs instead of coercing them', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline rejects non-numeric minute inputs instead of coercing them', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: { startMinute: '2026-04-20T00:00:00Z', @@ -1050,7 +1064,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /createTimeline\(model\.axis\.startMinute\) must be a finite number/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: { ...buildAxis(7), @@ -1065,7 +1079,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /createTimeline\(model\.axis\.initialViewport\)\.startMinute must be a finite number/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1089,7 +1103,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /createTimeline\(model\.lanes\[\]\.items\[\]\.startMinute\) must be a finite number/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: { ...buildAxis(7), @@ -1101,7 +1115,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /createTimeline\(model\.axis\.ticks\[0\]\.minute\) is required/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: { ...buildAxis(7), @@ -1113,7 +1127,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /createTimeline\(model\.axis\.ticks\[0\]\.minute\) must be a finite number/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1134,7 +1148,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /createTimeline\(model\.lanes\[\]\.overlays\[0\]\) requires startMinute\/endMinute or dayIndex\/dayCount/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1154,7 +1168,7 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }); }, /createTimeline\(model\.lanes\[\]\.overlays\[0\]\)\.dayIndex must be a finite number/); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1178,11 +1192,12 @@ test('timeline rejects non-numeric minute inputs instead of coercing them', () = }, /rail\.createTimeline\(\)\.setViewport\(viewport\)\.startMinute must be a finite number/); }); -test('timeline rejects fractional minute inputs instead of rendering malformed clock labels', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline rejects fractional minute inputs instead of rendering malformed clock labels', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: { ...buildAxis(7), @@ -1194,7 +1209,7 @@ test('timeline rejects fractional minute inputs instead of rendering malformed c }, /createTimeline\(model\.axis\.ticks\[0\]\.minute\) must be an integer/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1212,7 +1227,7 @@ test('timeline rejects fractional minute inputs instead of rendering malformed c }, /createTimeline\(model\.lanes\[\]\.items\[\]\.startMinute\) must be an integer/); assert.throws(() => { - SF.rail.createTimeline({ + rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1232,7 +1247,7 @@ test('timeline rejects fractional minute inputs instead of rendering malformed c }); }, /createTimeline\(model\.lanes\[\]\.overlays\[0\]\)\.startMinute must be an integer/); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: buildAxis(7), lanes: [ @@ -1256,10 +1271,11 @@ test('timeline rejects fractional minute inputs instead of rendering malformed c }, /rail\.createTimeline\(\)\.setViewport\(viewport\)\.startMinute must be an integer/); }); -test('timeline renders weekend shading and default 6-hour ticks without explicit tick input', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline renders weekend shading and default 6-hour ticks without explicit tick input', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); - const timeline = SF.rail.createTimeline({ + const timeline = rail.createTimeline({ model: { axis: { startMinute: 0, @@ -1286,11 +1302,12 @@ test('timeline renders weekend shading and default 6-hour ticks without explicit assert.equal(timeline.el.querySelectorAll('.sf-rail-timeline-tick-label').length, 8); }); -test('timeline renders the repeatable dense hospital-like validation scenario', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/13-rail.js', 'js-src/13a-rail-timeline.js']); +test('timeline renders the repeatable dense hospital-like validation scenario', (t) => { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); const denseModel = buildDenseHospitalLikeModel(); - const timeline = SF.rail.createTimeline({ model: denseModel }); + const timeline = rail.createTimeline({ model: denseModel }); assert.equal(denseModel.lanes.length, 100); assert.equal(denseModel.lanes.reduce((sum, lane) => sum + lane.items.length, 0), 1500); diff --git a/tests/safe-html-rendering.test.js b/tests/safe-html-rendering.test.js index 0425ec6..8046485 100644 --- a/tests/safe-html-rendering.test.js +++ b/tests/safe-html-rendering.test.js @@ -1,58 +1,37 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const test = require('node:test'); -const vm = require('node:vm'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createModal, gantt } from '../static/sf/sf.mjs'; +import { createDom } from './support/fake-dom.js'; +import { mockGlobals } from './support/mock-globals.js'; -const { createDom } = require('./support/fake-dom'); - -const ROOT = path.resolve(__dirname, '..'); - -function loadSf(files, overrides = {}) { +test('createModal renders unsafeBody as raw HTML and preserves text mode by default', (t) => { const { document, window, Node } = createDom(); - const context = vm.createContext({ - console, - document, - window, - Node, - setTimeout, - clearTimeout, - ...overrides, - }); - - files.forEach((file) => { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - vm.runInContext(source, context, { filename: file }); - }); - - return { SF: context.window.SF, document }; -} - -test('createModal renders unsafeBody as raw HTML and preserves text mode by default', () => { - const { SF } = loadSf(['js-src/00-core.js', 'js-src/06-modal.js']); - - const safeModal = SF.createModal({ title: 'Safe', body: 'safe' }); + mockGlobals(t, { document, window, Node }); + const safeModal = createModal({ title: 'Safe', body: 'safe' }); assert.equal(safeModal.body.textContent, 'safe'); assert.equal(safeModal.body.innerHTML, ''); - const unsafeModal = SF.createModal({ title: 'Unsafe', unsafeBody: 'unsafe' }); + const unsafeModal = createModal({ title: 'Unsafe', unsafeBody: 'unsafe' }); assert.equal(unsafeModal.body.innerHTML, 'unsafe'); unsafeModal.setBody({ unsafeBody: 'updated' }); assert.equal(unsafeModal.body.innerHTML, 'updated'); }); -test('gantt creates the chart root as a namespaced SVG element', () => { +test('gantt creates the chart root as a namespaced SVG element', (t) => { let seenNamespace = null; let seenTag = null; - const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { - Gantt: function () { - return { - change_view_mode() {}, - refresh() {}, - }; - }, + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + globalThis.Gantt = function () { + return { + change_view_mode() { }, + refresh() { }, + }; + }; + t.after(() => { + delete globalThis.Gantt; }); const originalCreateElementNS = document.createElementNS.bind(document); @@ -62,9 +41,9 @@ test('gantt creates the chart root as a namespaced SVG element', () => { return originalCreateElementNS(namespaceURI, tagName); }; - const gantt = SF.gantt.create({}); - gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); - const chartRoot = gantt.el.querySelector('svg'); + const ganttChart = gantt.create({}); + ganttChart.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); + const chartRoot = ganttChart.el.querySelector('svg'); assert.equal(seenNamespace, 'http://www.w3.org/2000/svg'); assert.equal(seenTag, 'svg'); diff --git a/tests/solver-lifecycle.test.js b/tests/solver-lifecycle.test.js index 355a96d..0b9a3f8 100644 --- a/tests/solver-lifecycle.test.js +++ b/tests/solver-lifecycle.test.js @@ -1,35 +1,32 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSolver } from '../static/sf/sf.mjs'; +import { flush } from './support/utils.js'; -const { loadSf, flush } = require('./support/load-sf'); - -const SOLVER_FILES = ['js-src/00-core.js', 'js-src/10-backend.js', 'js-src/11-solver.js']; test('createSolver requires deleteJob for retained job cleanup', () => { - const { SF } = loadSf(SOLVER_FILES); - assert.throws(() => SF.createSolver({ + assert.throws(() => createSolver({ backend: { createJob: async () => 'job-missing-delete', streamJobEvents() { - return function () {}; + return function () { }; }, getSnapshot: async () => null, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, }, }), /createSolver\(config\.backend\.deleteJob\) must be a function/); }); test('solver normalizes numeric retained job id zero before lifecycle guards', async () => { - const { SF } = loadSf(SOLVER_FILES); const calls = []; const backend = { createJob: async () => 0, streamJobEvents(id) { calls.push(['streamJobEvents', id]); - return () => {}; + return () => { }; }, getSnapshot: async (id) => { calls.push(['getSnapshot', id]); @@ -39,13 +36,13 @@ test('solver normalizes numeric retained job id zero before lifecycle guards', a calls.push(['analyzeSnapshot', id]); return { jobId: id, snapshotRevision: 1, analysis: { constraints: [] } }; }, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); assert.equal(solver.getJobId(), '0'); @@ -66,13 +63,12 @@ test('solver normalizes documented object createJob ids before stream attachment ]; for (const [createJobResult, expectedId] of cases) { - const { SF } = loadSf(SOLVER_FILES); const calls = []; const backend = { createJob: async () => createJobResult, streamJobEvents(id) { calls.push(['streamJobEvents', id]); - return () => {}; + return () => { }; }, getSnapshot: async (id) => { calls.push(['getSnapshot', id]); @@ -82,13 +78,13 @@ test('solver normalizes documented object createJob ids before stream attachment calls.push(['analyzeSnapshot', id]); return { jobId: id, snapshotRevision: 1, analysis: { constraints: [] } }; }, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); assert.equal(solver.getJobId(), expectedId); @@ -112,23 +108,22 @@ test('solver rejects non-scalar createJob ids before stream attachment', async ( ]; for (const createJobResult of invalidResponses) { - const { SF } = loadSf(SOLVER_FILES); const calls = []; const backend = { createJob: async () => createJobResult, streamJobEvents(id) { calls.push(['streamJobEvents', id]); - return () => {}; + return () => { }; }, getSnapshot: async () => null, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await assert.rejects( () => solver.start({}), /Invalid solver backend createJob response/ @@ -141,7 +136,6 @@ test('solver rejects non-scalar createJob ids before stream attachment', async ( }); test('solver lifecycle handles progress, pause, resume, completion, and snapshot-bound analysis', async () => { - const { SF } = loadSf(SOLVER_FILES); const calls = []; const statusBar = { setLifecycleState(value) { @@ -217,7 +211,7 @@ test('solver lifecycle handles progress, pause, resume, completion, and snapshot const completedReady = new Promise((resolve) => { resolveCompleted = resolve; }); - const solver = SF.createSolver({ + const solver = createSolver({ backend, statusBar, onProgress(meta) { @@ -369,7 +363,6 @@ test('solver lifecycle handles progress, pause, resume, completion, and snapshot }); test('solver preserves retained runtime lifecycle after stream transport interruption', async () => { - const { SF } = loadSf(SOLVER_FILES); let onStreamError; let onMessage; const calls = []; @@ -413,7 +406,7 @@ test('solver preserves retained runtime lifecycle after stream transport interru }; const errors = []; - const solver = SF.createSolver({ + const solver = createSolver({ backend, onError(message) { errors.push(message); @@ -466,23 +459,22 @@ test('solver preserves retained runtime lifecycle after stream transport interru }); test('solver rejects pending lifecycle operations when the stream dies before authoritative state arrives', async () => { - const { SF } = loadSf(SOLVER_FILES); let onStreamError; const backend = { createJob: async () => 'job-pending', streamJobEvents(_id, _callback, errorCallback) { onStreamError = errorCallback; - return function () {}; + return function () { }; }, getSnapshot: async () => null, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); const pausePromise = solver.pause(); @@ -503,7 +495,6 @@ test('solver rejects pending lifecycle operations when the stream dies before au }); test('solver does not duplicate pause after authoritative pause-requested state loses transport', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; let onStreamError; const calls = []; @@ -528,9 +519,9 @@ test('solver does not duplicate pause after authoritative pause-requested state cancelJob: async (id) => { calls.push(['cancelJob', id]); }, - deleteJob: async () => {}, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); const pausePromise = solver.pause(); @@ -556,7 +547,6 @@ test('solver does not duplicate pause after authoritative pause-requested state }); test('solver does not duplicate resume or send pause after authoritative resuming state loses transport', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; let onStreamError; const calls = []; @@ -586,9 +576,9 @@ test('solver does not duplicate resume or send pause after authoritative resumin cancelJob: async (id) => { calls.push(['cancelJob', id]); }, - deleteJob: async () => {}, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); onMessage({ @@ -623,7 +613,6 @@ test('solver does not duplicate resume or send pause after authoritative resumin }); test('solver does not duplicate cancel after authoritative cancelling state loses transport', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; let onStreamError; const calls = []; @@ -648,9 +637,9 @@ test('solver does not duplicate cancel after authoritative cancelling state lose cancelJob: async (id) => { calls.push(['cancelJob', id]); }, - deleteJob: async () => {}, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); const cancelPromise = solver.cancel(); @@ -689,7 +678,6 @@ test('solver does not duplicate cancel after authoritative cancelling state lose }); test('solver resets lifecycle state when job creation fails', async () => { - const { SF } = loadSf(SOLVER_FILES); const states = []; const errors = []; const backend = { @@ -701,19 +689,19 @@ test('solver resets lifecycle state when job creation fails', async () => { }, getSnapshot: async () => null, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ + const solver = createSolver({ backend, statusBar: { setLifecycleState(value) { states.push(value); }, - updateMoves() {}, + updateMoves() { }, }, onError(message) { errors.push(message); @@ -730,7 +718,6 @@ test('solver resets lifecycle state when job creation fails', async () => { }); test('solver accepts an initial best_solution event before any progress event', async () => { - const { SF } = loadSf(SOLVER_FILES); const calls = []; const statusBar = { setLifecycleState(value) { @@ -750,17 +737,17 @@ test('solver accepts an initial best_solution event before any progress event', createJob: async () => 'job-bootstrap', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async () => null, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ + const solver = createSolver({ backend, statusBar, onSolution(snapshot, meta) { @@ -799,7 +786,6 @@ test('solver accepts an initial best_solution event before any progress event', }); test('solver ignores malformed and mismatched lifecycle events without corrupting state', async () => { - const { SF } = loadSf(SOLVER_FILES); const calls = []; const statusBar = { setLifecycleState(value) { @@ -821,17 +807,17 @@ test('solver ignores malformed and mismatched lifecycle events without corruptin createJob: async () => 'job-77', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async () => null, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; - const solver = SF.createSolver({ + const solver = createSolver({ backend, statusBar, onProgress(meta) { diff --git a/tests/solver-startup-queue.test.js b/tests/solver-startup-queue.test.js index f5bce37..a38819b 100644 --- a/tests/solver-startup-queue.test.js +++ b/tests/solver-startup-queue.test.js @@ -1,12 +1,10 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSolver } from '../static/sf/sf.mjs'; +import { flush } from './support/utils.js'; -const { loadSf, flush } = require('./support/load-sf'); - -const SOLVER_FILES = ['js-src/00-core.js', 'js-src/10-backend.js', 'js-src/11-solver.js']; test('solver queues pause during startup until the job exists', async () => { - const { SF } = loadSf(SOLVER_FILES); const calls = []; let resolveCreate; let onMessage; @@ -20,7 +18,7 @@ test('solver queues pause during startup until the job exists', async () => { streamJobEvents(id, callback) { calls.push(['streamJobEvents', id]); onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => { calls.push(['getSnapshot', id, revision]); @@ -42,18 +40,18 @@ test('solver queues pause during startup until the job exists', async () => { pauseJob: async (id) => { calls.push(['pauseJob', id]); }, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; const paused = []; - const solver = SF.createSolver({ + const solver = createSolver({ backend, onPaused(snapshot) { paused.push(snapshot); }, - onAnalysis() {}, + onAnalysis() { }, }); solver.start({}); @@ -86,7 +84,6 @@ test('solver queues pause during startup until the job exists', async () => { }); test('solver queues cancel during startup and settles on the terminal cancelled event', async () => { - const { SF } = loadSf(SOLVER_FILES); const calls = []; let resolveCreate; let onMessage; @@ -100,7 +97,7 @@ test('solver queues cancel during startup and settles on the terminal cancelled streamJobEvents(id, callback) { calls.push(['streamJobEvents', id]); onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => { calls.push(['getSnapshot', id, revision]); @@ -119,21 +116,21 @@ test('solver queues cancel during startup and settles on the terminal cancelled analysis: { score: '0hard/-4soft', constraints: [] }, }; }, - pauseJob: async () => {}, - resumeJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, cancelJob: async (id) => { calls.push(['cancelJob', id]); }, - deleteJob: async () => {}, + deleteJob: async () => { }, }; const cancellations = []; - const solver = SF.createSolver({ + const solver = createSolver({ backend, onCancelled(snapshot) { cancellations.push(snapshot); }, - onAnalysis() {}, + onAnalysis() { }, }); solver.start({}); diff --git a/tests/solver-terminal-state.test.js b/tests/solver-terminal-state.test.js index 8a3f402..d69f688 100644 --- a/tests/solver-terminal-state.test.js +++ b/tests/solver-terminal-state.test.js @@ -1,12 +1,10 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSolver } from '../static/sf/sf.mjs'; +import { flush } from './support/utils.js'; -const { loadSf, flush } = require('./support/load-sf'); - -const SOLVER_FILES = ['js-src/00-core.js', 'js-src/10-backend.js', 'js-src/11-solver.js']; test('solver ignores stale paused snapshot work after a newer cancelled event', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; let resolvePausedSnapshot; let snapshotCallCount = 0; @@ -14,7 +12,7 @@ test('solver ignores stale paused snapshot work after a newer cancelled event', createJob: async () => 'job-race', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => { snapshotCallCount += 1; @@ -33,15 +31,15 @@ test('solver ignores stale paused snapshot work after a newer cancelled event', analyzeSnapshot: async () => { throw new Error('analysis should not run in this test'); }, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; const paused = []; const cancelled = []; - const solver = SF.createSolver({ + const solver = createSolver({ backend, onPaused(snapshot) { paused.push(snapshot); @@ -86,13 +84,12 @@ test('solver ignores stale paused snapshot work after a newer cancelled event', }); test('solver keeps terminal lifecycle metadata when retained snapshots are pause-bound', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const backend = { createJob: async () => 'job-terminal-meta', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => ({ id: id, @@ -108,10 +105,10 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause terminalReason: null, analysis: { score: '0hard/-1soft', constraints: [] }, }), - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; const cancelled = []; @@ -120,7 +117,7 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause const cancelledReady = new Promise((resolve) => { resolveCancelled = resolve; }); - const solver = SF.createSolver({ + const solver = createSolver({ backend, onCancelled(snapshot, meta) { cancelled.push([snapshot, meta]); @@ -158,7 +155,6 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause { eventType: 'completed', lifecycleState: 'TERMINATED_BY_CONFIG' }, ].forEach((scenario) => { test(`solver delete clears the retained job after terminal ${scenario.lifecycleState}`, async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const calls = []; let createCount = 0; @@ -169,7 +165,7 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause }, streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => ({ id: id, @@ -183,9 +179,9 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause lifecycleState: scenario.lifecycleState, analysis: { score: '0hard/0soft', constraints: [] }, }), - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, deleteJob: async (id) => { calls.push(['deleteJob', id]); }, @@ -195,7 +191,7 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause const terminalReady = new Promise((resolve) => { resolveTerminal = resolve; }); - const solver = SF.createSolver({ + const solver = createSolver({ backend, onComplete() { resolveTerminal(); @@ -206,7 +202,7 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause onFailure() { resolveTerminal(); }, - onAnalysis() {}, + onAnalysis() { }, }); await solver.start({}); @@ -232,14 +228,13 @@ test('solver keeps terminal lifecycle metadata when retained snapshots are pause }); test('solver preserves terminal retained state when backend deletion fails', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const errors = []; const backend = { createJob: async () => 'job-delete-fails', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => ({ id: id, @@ -248,9 +243,9 @@ test('solver preserves terminal retained state when backend deletion fails', asy solution: { id: id, revision: revision }, }), analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, deleteJob: async () => { throw new Error('delete failed'); }, @@ -259,7 +254,7 @@ test('solver preserves terminal retained state when backend deletion fails', asy const completedReady = new Promise((resolve) => { resolveCompleted = resolve; }); - const solver = SF.createSolver({ + const solver = createSolver({ backend, onComplete() { resolveCompleted(); @@ -286,7 +281,6 @@ test('solver preserves terminal retained state when backend deletion fails', asy }); test('solver blocks completed retained cleanup when terminal sync fails', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const calls = []; const errors = []; @@ -299,7 +293,7 @@ test('solver blocks completed retained cleanup when terminal sync fails', async createJob: async () => 'job-sync-fails', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => { calls.push(['getSnapshot', id, revision]); @@ -309,14 +303,14 @@ test('solver blocks completed retained cleanup when terminal sync fails', async calls.push(['analyzeSnapshot', id, revision]); return { jobId: id, snapshotRevision: revision, analysis: { constraints: [] } }; }, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, deleteJob: async (id) => { calls.push(['deleteJob', id]); }, }; - const solver = SF.createSolver({ + const solver = createSolver({ backend, onComplete(snapshot, meta) { completed.push([snapshot, meta]); @@ -354,7 +348,6 @@ test('solver blocks completed retained cleanup when terminal sync fails', async }); test('solver retries completed terminal sync during delete and delivers completion once', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const calls = []; const errors = []; @@ -368,7 +361,7 @@ test('solver retries completed terminal sync during delete and delivers completi createJob: async () => 'job-sync-retry', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => { snapshotCalls += 1; @@ -382,14 +375,14 @@ test('solver retries completed terminal sync during delete and delivers completi }; }, analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, deleteJob: async (id) => { calls.push(['deleteJob', id]); }, }; - const solver = SF.createSolver({ + const solver = createSolver({ backend, onComplete(snapshot, meta) { completed.push([snapshot, meta]); @@ -428,7 +421,6 @@ test('solver retries completed terminal sync during delete and delivers completi }); test('solver delete waits for terminal snapshot settlement before clearing pending commands', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; let resolveSnapshot; let cancelSettled = false; @@ -452,8 +444,8 @@ test('solver delete waits for terminal snapshot settlement before clearing pendi }); }), analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, cancelJob: async (id) => { calls.push(['cancelJob', id]); }, @@ -461,7 +453,7 @@ test('solver delete waits for terminal snapshot settlement before clearing pendi calls.push(['deleteJob', id]); }, }; - const solver = SF.createSolver({ backend }); + const solver = createSolver({ backend }); await solver.start({}); const cancelPromise = solver.cancel(); @@ -508,14 +500,13 @@ test('solver delete waits for terminal snapshot settlement before clearing pendi }); test('solver derives terminal status score from retained snapshot solution score', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const scoreUpdates = []; const backend = { createJob: async () => 'job-snapshot-score', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => ({ id: id, @@ -524,23 +515,23 @@ test('solver derives terminal status score from retained snapshot solution score solution: { id: id, score: '0hard/0soft' }, }), analyzeSnapshot: async () => null, - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; let resolveCompleted; const completedReady = new Promise((resolve) => { resolveCompleted = resolve; }); - const solver = SF.createSolver({ + const solver = createSolver({ backend, statusBar: { - setLifecycleState() {}, + setLifecycleState() { }, updateScore(score) { scoreUpdates.push(score); }, - updateMoves() {}, + updateMoves() { }, }, onComplete() { resolveCompleted(); @@ -560,14 +551,13 @@ test('solver derives terminal status score from retained snapshot solution score }); test('solver derives terminal status score from snapshot-bound analysis score', async () => { - const { SF } = loadSf(SOLVER_FILES); let onMessage; const scoreUpdates = []; const backend = { createJob: async () => 'job-analysis-score', streamJobEvents(_id, callback) { onMessage = callback; - return function () {}; + return function () { }; }, getSnapshot: async (id, revision) => ({ id: id, @@ -581,28 +571,28 @@ test('solver derives terminal status score from snapshot-bound analysis score', lifecycleState: 'COMPLETED', analysis: { score: '0hard/0soft', constraints: [] }, }), - pauseJob: async () => {}, - resumeJob: async () => {}, - cancelJob: async () => {}, - deleteJob: async () => {}, + pauseJob: async () => { }, + resumeJob: async () => { }, + cancelJob: async () => { }, + deleteJob: async () => { }, }; let resolveCompleted; const completedReady = new Promise((resolve) => { resolveCompleted = resolve; }); - const solver = SF.createSolver({ + const solver = createSolver({ backend, statusBar: { - setLifecycleState() {}, + setLifecycleState() { }, updateScore(score) { scoreUpdates.push(score); }, - updateMoves() {}, + updateMoves() { }, }, onComplete() { resolveCompleted(); }, - onAnalysis() {}, + onAnalysis() { }, }); await solver.start({}); diff --git a/tests/support-mock-globals.test.js b/tests/support-mock-globals.test.js new file mode 100644 index 0000000..337c30d --- /dev/null +++ b/tests/support-mock-globals.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mockGlobals } from './support/mock-globals.js'; + +test('mockGlobals patches and restores globals', async (t) => { + globalThis.__testValue = 'original'; + + await t.test('patches the global during the test', async (t) => { + mockGlobals(t, { __testValue: 'patched' }); + assert.equal(globalThis.__testValue, 'patched'); + }); + + assert.equal(globalThis.__testValue, 'original'); + delete globalThis.__testValue; +}); + +test('mockGlobals restores a previously absent global', async (t) => { + assert.equal(globalThis.__testValue, undefined); + + await t.test('sets a previously absent global', async (t) => { + mockGlobals(t, { __testValue: 'new' }); + assert.equal(globalThis.__testValue, 'new'); + }); + + assert.equal(globalThis.__testValue, undefined); +}); diff --git a/tests/support/fake-dom.js b/tests/support/fake-dom.js index 0b0dac9..1a4267e 100644 --- a/tests/support/fake-dom.js +++ b/tests/support/fake-dom.js @@ -274,7 +274,7 @@ function walk(node, visit) { } function matchesSelector(node, selector) { - var dataMatch = selector.match(/^\[data-([a-z0-9-]+)=\"([^\"]+)\"\]$/i); + var dataMatch = selector.match(/^\[data-([a-z0-9-]+)="([^"]+)"\]$/i); if (dataMatch) { return node.dataset[toCamel(dataMatch[1])] === dataMatch[2]; } @@ -320,4 +320,4 @@ function createDom() { return { document, window: { document }, Node: FakeNode }; } -module.exports = { createDom, FakeElement, FakeNode, FakeTextNode }; +export { createDom, FakeElement, FakeNode, FakeTextNode }; diff --git a/tests/support/load-sf-global.js b/tests/support/load-sf-global.js new file mode 100644 index 0000000..612db8d --- /dev/null +++ b/tests/support/load-sf-global.js @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +import { createDom } from './fake-dom.js'; +import { mockGlobals } from './mock-globals.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const SF_RUNTIME_PATH = path.join(ROOT, 'static/sf/sf.js'); + +export function loadSfGlobal(t) { + const { document, window, Node } = createDom(); + mockGlobals(t, { document, window, Node }); + + const source = fs.readFileSync(SF_RUNTIME_PATH, 'utf8'); + const wrapped = `(function(window, document, Node) { ${source}\n return window.SF; })`; + const factory = vm.runInThisContext(wrapped, { filename: SF_RUNTIME_PATH }); + const SF = factory(window, document, Node); + + return { SF, document, window, Node }; +} diff --git a/tests/support/load-sf.js b/tests/support/load-sf.js deleted file mode 100644 index 5d65031..0000000 --- a/tests/support/load-sf.js +++ /dev/null @@ -1,38 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); - -const { createDom } = require('./fake-dom'); - -const ROOT = path.resolve(__dirname, '..', '..'); - -function loadSf(files, overrides = {}) { - const { document, window, Node } = createDom(); - const context = vm.createContext({ - console, - document, - window, - Node, - setTimeout, - clearTimeout, - Promise, - ...overrides, - }); - - files.forEach((file) => { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - vm.runInContext(source, context, { filename: file }); - }); - - return { SF: context.window.SF, context, document }; -} - -async function flush() { - await Promise.resolve(); - await Promise.resolve(); -} - -module.exports = { - loadSf, - flush, -}; diff --git a/tests/support/mock-globals.js b/tests/support/mock-globals.js new file mode 100644 index 0000000..c9588e3 --- /dev/null +++ b/tests/support/mock-globals.js @@ -0,0 +1,19 @@ +/** + * Patches globalThis with the given overrides for the duration of a test, + * then restores the original values automatically via `t.after`. + * + * @param {import('node:test').TestContext} t - The test context from node:test. + * @param {Record} overrides - Globals to patch. + */ +export function mockGlobals(t, overrides) { + const saved = {}; + for (const [k, v] of Object.entries(overrides)) { + saved[k] = globalThis[k]; + globalThis[k] = v; + } + t.after(() => { + for (const [k, v] of Object.entries(saved)) { + globalThis[k] = v; + } + }); +} diff --git a/tests/support/utils.js b/tests/support/utils.js new file mode 100644 index 0000000..a7f59bb --- /dev/null +++ b/tests/support/utils.js @@ -0,0 +1,4 @@ +export async function flush() { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/tests/types/solver-contracts.ts b/tests/types/solver-contracts.ts new file mode 100644 index 0000000..2db925e --- /dev/null +++ b/tests/types/solver-contracts.ts @@ -0,0 +1,62 @@ +import type { SolverEvent, SolverBackend } from "../../ts-src/solver/api.types"; + +const axumBackend = window.SF.createBackend({ + type: 'axum', + baseUrl: '', +}); + +const fetchBackend = window.SF.createBackend({ + type: 'fetch', + baseUrl: '/api/v1', + headers: { 'X-CSRF-Token': 'token' }, +}); + +const railsBackend = window.SF.createBackend({ + type: 'rails', + baseUrl: '/api', + jobsPath: '/jobs', +}); + +const aliasBackend = window.SF.createBackend({ + type: 'custom-http', + baseUrl: '/solver', +}); + +const tauriBackend = window.SF.createBackend({ + type: 'tauri', + invoke: async (_command: string, _payload?: Record) => ({ id: 'job-1' }), + listen: async (_event: string, _handler: (event: { payload: SolverEvent }) => void) => () => {}, + eventName: 'solver-update', + commands: { + createJob: 'create_job', + getSnapshot: 'get_snapshot', + analyzeSnapshot: 'analyze_snapshot', + pauseJob: 'pause_job', + resumeJob: 'resume_job', + cancelJob: 'cancel_job', + deleteJob: 'delete_job', + }, +}); + +const customSolverBackend: SolverBackend = { + createJob: async () => ({ jobId: 0 }), + getSnapshot: async () => ({ jobId: '0', snapshotRevision: 1, solution: {} }), + analyzeSnapshot: async () => ({ jobId: '0', snapshotRevision: 1, analysis: { constraints: [] } }), + pauseJob: async () => undefined, + resumeJob: async () => undefined, + cancelJob: async () => undefined, + deleteJob: async () => undefined, + streamJobEvents(_id, _onMessage, onError) { + if (onError) onError(new Error('transport failed')); + return () => {}; + }, +}; + +const solver = window.SF.createSolver({ backend: customSolverBackend }); + +void axumBackend; +void fetchBackend; +void railsBackend; +void aliasBackend; +void tauriBackend; +void solver; diff --git a/js-src/12-api-guide.js b/ts-src/components/api-guide.ts similarity index 53% rename from js-src/12-api-guide.js rename to ts-src/components/api-guide.ts index fe61746..ec55c21 100644 --- a/js-src/12-api-guide.js +++ b/ts-src/components/api-guide.ts @@ -2,28 +2,26 @@ SolverForge UI — API Guide Panel Generates REST API documentation from endpoint definitions. ============================================================================ */ +import {assert, el} from "../core"; -(function (sf) { - 'use strict'; +export const createApiGuide = function (config) { + assert(config, 'createApiGuide(config) requires a configuration object'); + assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); - sf.createApiGuide = function (config) { - sf.assert(config, 'createApiGuide(config) requires a configuration object'); - sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); - - var guide = sf.el('div', { className: 'sf-api-guide' }); + var guide = el('div', { className: 'sf-api-guide' }); var endpoints = config.endpoints; endpoints.forEach(function (ep) { - var section = sf.el('div', { className: 'sf-api-section' }); - section.appendChild(sf.el('h3', null, (ep.method || 'GET') + ' ' + ep.path)); + var section = el('div', { className: 'sf-api-section' }); + section.appendChild(el('h3', null, (ep.method || 'GET') + ' ' + ep.path)); if (ep.description) { - section.appendChild(sf.el('p', { style: { fontSize: '13px', color: 'var(--sf-gray-600)', marginBottom: '8px' } }, ep.description)); + section.appendChild(el('p', { style: { fontSize: '13px', color: 'var(--sf-gray-600)', marginBottom: '8px' } }, ep.description)); } if (ep.curl) { - var block = sf.el('div', { className: 'sf-api-code-block' }); - block.appendChild(sf.el('code', null, ep.curl)); - var copyBtn = sf.el('button', { + var block = el('div', { className: 'sf-api-code-block' }); + block.appendChild(el('code', null, ep.curl)); + var copyBtn = el('button', { className: 'sf-copy-btn', 'aria-label': 'Copy command', onClick: function () { @@ -42,4 +40,3 @@ return guide; }; -})(SF); diff --git a/js-src/03-buttons.js b/ts-src/components/buttons.ts similarity index 80% rename from js-src/03-buttons.js rename to ts-src/components/buttons.ts index 5deaafd..eb0d948 100644 --- a/js-src/03-buttons.js +++ b/ts-src/components/buttons.ts @@ -2,11 +2,10 @@ SolverForge UI — Button Factory ============================================================================ */ -(function (sf) { - 'use strict'; +import {assert, el} from "../core"; - sf.createButton = function (config) { - sf.assert(config, 'createButton(config) requires a configuration object'); +export const createButton = function (config) { + assert(config, 'createButton(config) requires a configuration object'); var classes = ['sf-btn']; @@ -18,17 +17,17 @@ if (config.outline) classes.push('sf-btn--outline'); if (config.iconOnly) classes.push('sf-btn--icon'); - var btn = sf.el('button', { + var btn = el('button', { className: classes.join(' '), type: 'button', }); if (config.disabled) btn.disabled = true; - sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); + assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); if (config.icon) { - var icon = sf.el('i', { className: 'fa-solid ' + config.icon }); + var icon = el('i', { className: 'fa-solid ' + config.icon }); btn.appendChild(icon); } @@ -62,5 +61,3 @@ return btn; }; - -})(SF); diff --git a/ts-src/components/footer.ts b/ts-src/components/footer.ts new file mode 100644 index 0000000..42452ae --- /dev/null +++ b/ts-src/components/footer.ts @@ -0,0 +1,21 @@ +/* ============================================================================ + SolverForge UI — Footer Factory + ============================================================================ */ + +import {assert, el} from "../core"; + +export const createFooter = function (config) { + assert(config, 'createFooter(config) requires a configuration object'); + + var footer = el('footer', { className: 'sf-footer' }); + if (config.links) { + config.links.forEach(function (link, i) { + if (i > 0) footer.appendChild(el('span', { className: 'sf-vr' })); + footer.appendChild(el('a', { href: link.url, target: '_blank' }, link.label)); + }); + } + if (config.version) { + footer.appendChild(el('span', { style: { marginLeft: 'auto' } }, config.version)); + } + return footer; + }; diff --git a/ts-src/components/header.ts b/ts-src/components/header.ts new file mode 100644 index 0000000..1c38477 --- /dev/null +++ b/ts-src/components/header.ts @@ -0,0 +1,211 @@ +/* ============================================================================ + SolverForge UI — Header Factory + ============================================================================ */ + +import { assert, el } from "../core"; +import { createButton } from "./buttons"; + + +export interface HeaderControls { + actions: HTMLDivElement | null; + spinner: HTMLDivElement | null; + solveBtn: HTMLButtonElement | null; + pauseBtn: HTMLButtonElement | null; + resumeBtn: HTMLButtonElement | null; + cancelBtn: HTMLButtonElement | null; + analyzeBtn: HTMLButtonElement | null; + nav: HTMLElement | null; +} + +export interface HeaderElement extends HTMLElement { + sfControls: HeaderControls; +} + +export interface HeaderTab { + id: string; + label: string; + icon?: string; + active?: boolean; +} + +export interface HeaderActions { + onSolve?: () => void; + onPause?: () => void; + onResume?: () => void; + onCancel?: () => void; + onAnalyze?: () => void; +} + +export interface HeaderConfig { + logo?: string; + title?: string; + subtitle?: string; + tabs?: HeaderTab[]; + actions?: HeaderActions; + onTabChange?: (tabId: string) => void; +} + + + +export const createHeader = function ( + config: HeaderConfig +): HeaderElement { + assert(config, 'createHeader(config) requires a configuration object'); + + var header = el('header', { className: 'sf-header' })as HeaderElement; + var controls = { + actions: null, + spinner: null, + solveBtn: null, + pauseBtn: null, + resumeBtn: null, + cancelBtn: null, + analyzeBtn: null, + nav: null, + }; + + // Logo + if (config.logo) { + var logo = el('img', { + className: 'sf-header-logo', + src: config.logo, + alt: 'Logo', + }); + header.appendChild(logo); + } + + // Brand text + var brand = el('div', { className: 'sf-header-brand' }); + if (config.title) { + brand.appendChild(el('div', { className: 'sf-header-title' }, config.title)); + } + if (config.subtitle) { + brand.appendChild(el('div', { className: 'sf-header-subtitle' }, config.subtitle)); + } + header.appendChild(brand); + + // Nav tabs + if (config.tabs && config.tabs.length > 0) { + assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); + var nav = el('nav', { className: 'sf-header-nav' }); + controls.nav = nav; + config.tabs.forEach(function (tab) { + assert(tab && tab.id, 'createHeader tab entries require an id'); + assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); + var btn = el('button', { + className: 'sf-nav-btn' + (tab.active ? ' active' : ''), + role: 'tab', + 'aria-selected': !!tab.active, + tabIndex: 0, + dataset: { tab: tab.id }, + onKeyDown: function (e) { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + var buttons = nav.querySelectorAll('.sf-nav-btn'); + var list = Array.prototype.slice.call(buttons); + var nextIndex = e.key === 'ArrowRight' + ? (list.indexOf(btn) + 1) % list.length + : (list.length + list.indexOf(btn) - 1) % list.length; + var next = list[nextIndex]; + if (next && next.focus) next.focus(); + }, + onClick: function () { + nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); + if (config.onTabChange) config.onTabChange(tab.id); + }, + }); + if (tab.icon) { + btn.appendChild(el('i', { className: 'fa-solid ' + tab.icon })); + } + btn.appendChild(document.createTextNode(tab.label)); + nav.appendChild(btn); + }); + header.appendChild(nav); + } + + // Action buttons + if (config.actions) { + assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); + assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); + assert(!config.actions.onPause || typeof config.actions.onPause === 'function', 'createHeader(config.actions.onPause) must be a function'); + assert(!config.actions.onResume || typeof config.actions.onResume === 'function', 'createHeader(config.actions.onResume) must be a function'); + assert(!config.actions.onCancel || typeof config.actions.onCancel === 'function', 'createHeader(config.actions.onCancel) must be a function'); + assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); + assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); + + var actions = el('div', { className: 'sf-header-actions' }); + controls.actions = actions; + + // Spinner + var spinner = el('div', { className: 'sf-solving-spinner' }); + controls.spinner = spinner; + actions.appendChild(spinner); + + if (config.actions.onSolve) { + var solveBtn = createButton({ + text: 'Solve', + variant: 'success', + icon: 'fa-play', + onClick: config.actions.onSolve, + }); + controls.solveBtn = solveBtn; + actions.appendChild(solveBtn); + } + + if (config.actions.onPause) { + var pauseBtn = createButton({ + text: 'Pause', + variant: 'default', + icon: 'fa-pause', + onClick: config.actions.onPause, + }); + pauseBtn.style.display = 'none'; + controls.pauseBtn = pauseBtn; + actions.appendChild(pauseBtn); + } + + if (config.actions.onResume) { + var resumeBtn = createButton({ + text: 'Resume', + variant: 'primary', + icon: 'fa-play', + onClick: config.actions.onResume, + }); + resumeBtn.style.display = 'none'; + controls.resumeBtn = resumeBtn; + actions.appendChild(resumeBtn); + } + + if (config.actions.onCancel) { + var cancelBtn = createButton({ + text: 'Stop', + variant: 'danger', + icon: 'fa-stop', + onClick: config.actions.onCancel, + }); + cancelBtn.style.display = 'none'; + controls.cancelBtn = cancelBtn; + actions.appendChild(cancelBtn); + } + + if (config.actions.onAnalyze) { + var analyzeBtn = createButton({ + variant: 'ghost', + icon: 'fa-chart-bar', + circle: true, + tooltip: 'Score Analysis', + onClick: config.actions.onAnalyze, + }); + controls.analyzeBtn = analyzeBtn; + actions.appendChild(analyzeBtn); + } + + header.appendChild(actions); + } + + header.sfControls = controls; + return header; +}; diff --git a/ts-src/components/modal.ts b/ts-src/components/modal.ts new file mode 100644 index 0000000..e63a3d7 --- /dev/null +++ b/ts-src/components/modal.ts @@ -0,0 +1,136 @@ +/* ============================================================================ + SolverForge UI — Modal Factory + ============================================================================ */ + +import { assert, el, uid } from "../core"; + +export interface ModalBodyObject { + unsafeBody?: string; + unsafeHtml?: string; +} + +export type ModalContent = + | string + | Node + | ModalBodyObject + | null + | undefined; + +export interface ModalConfig { + title?: string; + body?: ModalContent; + unsafeBody?: string; + footer?: Node[]; + width?: string; + onClose?: () => void; +} + +export interface ModalApi { + el: HTMLDivElement; + body: HTMLDivElement; + open(): void; + close(): void; + setBody(content: ModalContent): void; +} + +export const createModal = function ( + config: ModalConfig +): ModalApi { + assert(config, 'createModal(config) requires a configuration object'); + assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); + + var overlay = el('div', { className: 'sf-modal-overlay' }) as HTMLDivElement; + var dialogId = uid('sf-modal'); + var dialog = el('div', { + className: 'sf-modal', + id: dialogId, + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': dialogId + '-title', + }) as HTMLDivElement; + var body = el('div', { className: 'sf-modal-body' }) as HTMLDivElement; + + // Header + var header = el('div', { className: 'sf-modal-header' }); + var titleEl = el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); + header.appendChild(titleEl); + + var closeBtn = el('button', { + className: 'sf-modal-close', + 'aria-label': 'Close modal', + onClick: function () { api.close(); }, + }, '×'); + header.appendChild(closeBtn); + + dialog.appendChild(header); + + // Body + setBodyContent(body, config.body, config.unsafeBody); + dialog.appendChild(body); + + // Footer + if (config.footer) { + var footer = el('div', { className: 'sf-modal-footer' }); + config.footer.forEach(function (child) { + footer.appendChild(child); + }); + dialog.appendChild(footer); + } + + overlay.appendChild(dialog); + + var previousFocus = null; + + // Close on backdrop click + overlay.addEventListener('click', function (e) { + if (e.target === overlay) api.close(); + }); + + // Close on Escape + function onKeyDown(e) { + if (e.key === 'Escape') api.close(); + } + + var api = { el: overlay, body: body } as ModalApi; + + api.open = function () { + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add('open'); + document.addEventListener('keydown', onKeyDown); + }; + + api.close = function () { + overlay.classList.remove('open'); + document.removeEventListener('keydown', onKeyDown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); + if (config.onClose) config.onClose(); + }; + + api.setBody = function (content) { + setBodyContent(body, content); + }; + + if (config.width) { + dialog.style.maxWidth = config.width; + } + + return api; +}; + +function setBodyContent(target: HTMLElement, content: unknown, explicitUnsafeHtml?: string) { + target.textContent = ''; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === 'string') { + target.textContent = content; + } else if (content && typeof content === 'object' && 'unsafeBody' in content) { + target.innerHTML = (content as { unsafeBody: string }).unsafeBody; + } else if (content && typeof content === 'object' && 'unsafeHtml' in content) { + target.innerHTML = (content as { unsafeHtml: string }).unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } +} diff --git a/ts-src/components/statusbar.ts b/ts-src/components/statusbar.ts new file mode 100644 index 0000000..062560a --- /dev/null +++ b/ts-src/components/statusbar.ts @@ -0,0 +1,274 @@ +/* ============================================================================ + SolverForge UI — Status Bar Factory + ============================================================================ */ + +import { bindActivation, el } from "../core"; +import { colorClass, parseHard, parseSoft } from "../utils/score"; +import { HeaderControls } from "./header"; + +export interface StatusBarConstraint { + name?: string; + type?: 'hard' | 'soft'; + score?: string; +} + +export interface StatusBarConfig { + constraints?: StatusBarConstraint[]; + + onConstraintClick?: (index: number) => void; + + header?: { + sfControls?: HeaderControls; + }; +} + +export interface StatusBarApi { + el: HTMLDivElement; + + bindHeader(header: { + sfControls?: HeaderControls; + } | null): StatusBarApi; + + updateScore(scoreStr: string | null): void; + + setLifecycleState(state: string): void; + + setSolving(solving: boolean): void; + + updateMoves(mps: number | null): void; + + updateConstraintDots( + constraints: StatusBarConstraint[] + ): void; + + colorDotsByScore(scoreStr: string): void; + + colorDotsFromAnalysis( + constraints: StatusBarConstraint[] + ): void; +} + +export const createStatusBar = function ( + config: StatusBarConfig = {} +): StatusBarApi { + var bar = el('div', { className: 'sf-statusbar' }) as HTMLDivElement; + var lastScore: string | null = null; + var controls: HeaderControls | null = null; + + // Score display + var scoreEl = el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); + bar.appendChild(scoreEl); + + // Separator + bar.appendChild(el('span', { className: 'sf-statusbar-sep' }, '|')); + + // Constraint dots container + var dotsContainer = el('div', { className: 'sf-statusbar-constraints' }) as HTMLDivElement; + bar.appendChild(dotsContainer); + + // Separator + moves display + var movesSep = el('span', { className: 'sf-statusbar-sep' }, '|'); + movesSep.style.display = 'none'; + bar.appendChild(movesSep); + + var movesEl = el('span'); + movesEl.style.display = 'none'; + bar.appendChild(movesEl); + + // Separator + status text + bar.appendChild(el('span', { className: 'sf-statusbar-sep' }, '|')); + var statusEl = el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); + bar.appendChild(statusEl); + + // Build initial constraint dots + if (config && config.constraints) { + buildDots(dotsContainer, config.constraints, config.onConstraintClick); + } + + var api: StatusBarApi = { + el: bar, + bindHeader: function (header) { + controls = header && header.sfControls ? header.sfControls : null; + return api; + }, + + updateScore: function (scoreStr) { + if (scoreStr && scoreStr !== lastScore) { + scoreEl.textContent = scoreStr; + var colorClassName = colorClass(scoreStr); + scoreEl.classList.remove('improved', 'score-green', 'score-red', 'score-yellow'); + scoreEl.classList.add(colorClassName); + void scoreEl.offsetWidth; + scoreEl.classList.add('improved'); + lastScore = scoreStr; + } else if (!scoreStr) { + scoreEl.textContent = '\u2014'; + scoreEl.classList.remove('score-green', 'score-red', 'score-yellow', 'improved'); + lastScore = null; + } + }, + + setLifecycleState: function (state) { + var normalized = normalizeLifecycleState(state); + var solveBtn = controls && controls.solveBtn; + var pauseBtn = controls && controls.pauseBtn; + var resumeBtn = controls && controls.resumeBtn; + var cancelBtn = controls && controls.cancelBtn; + var spinner = controls && controls.spinner; + + if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? '' : 'none'; + if (pauseBtn) { + pauseBtn.style.display = shouldShowPause(normalized) ? '' : 'none'; + pauseBtn.disabled = normalized === 'PAUSE_REQUESTED'; + } + if (resumeBtn) { + resumeBtn.style.display = normalized === 'PAUSED' ? '' : 'none'; + resumeBtn.disabled = false; + } + if (cancelBtn) { + cancelBtn.style.display = shouldShowCancel(normalized) ? '' : 'none'; + cancelBtn.disabled = false; + } + if (spinner) spinner.classList.toggle('active', shouldSpin(normalized)); + + statusEl.textContent = lifecycleLabel(normalized); + statusEl.style.color = isActiveLifecycle(normalized) + ? 'var(--sf-emerald-600)' + : normalized === 'FAILED' + ? 'var(--sf-red-600)' + : normalized === 'CANCELLED' + ? 'var(--sf-amber-700)' + : 'var(--sf-gray-500)'; + }, + + setSolving: function (solving) { + api.setLifecycleState(solving ? 'SOLVING' : 'IDLE'); + }, + + updateMoves: function (mps) { + if (mps != null && mps > 0) { + movesEl.textContent = mps.toLocaleString() + ' moves/s'; + movesEl.style.display = ''; + movesSep.style.display = ''; + } else { + movesEl.style.display = 'none'; + movesSep.style.display = 'none'; + } + }, + + updateConstraintDots: function (constraints) { + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + }, + + colorDotsByScore: function (scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot: HTMLDivElement) { + var isHard = dot.dataset.type === 'hard'; + dot.classList.toggle('violated', isHard && hard < 0); + dot.classList.toggle('violated-soft', !isHard && soft < 0); + }); + }, + + colorDotsFromAnalysis: function (constraints) { + if (!constraints || constraints.length === 0) return; + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot, i) { + var c = constraints[i]; + if (!dot) return; + var isHardConstraint = c.type === 'hard'; + var scoreVal = isHardConstraint ? parseHard(c.score) : parseSoft(c.score); + var violated = scoreVal < 0; + dot.classList.toggle('violated', isHardConstraint && violated); + dot.classList.toggle('violated-soft', !isHardConstraint && violated); + }); + }, + } + + if (config && config.header) { + api.bindHeader(config.header); + } + + api.setLifecycleState('IDLE'); + + return api; + }; + + function buildDots(container, constraints, onClick) { + container.innerHTML = ''; + if (!constraints) return; + constraints.forEach(function (c, i) { + var dot = el('div', { + className: 'sf-constraint-dot', + id: 'sf-cdot-' + i, + title: c.name || ('Constraint ' + i), + role: onClick ? 'button' : null, + tabIndex: onClick ? '0' : null, + 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, + dataset: { type: c.type || 'hard', index: String(i) }, + }); + if (onClick) { + dot.style.cursor = 'pointer'; + bindActivation(dot, function () { onClick(i); }); + } + container.appendChild(dot); + }); + } + + function normalizeLifecycleState(value) { + if (typeof value !== 'string' || !value.trim()) return 'IDLE'; + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[\s-]+/g, '_') + .toUpperCase(); + } + + function shouldShowSolve(state) { + return state === 'IDLE' + || state === 'COMPLETED' + || state === 'CANCELLED' + || state === 'FAILED' + || state === 'TERMINATED_BY_CONFIG'; + } + + function shouldShowPause(state) { + return state === 'STARTING' + || state === 'SOLVING' + || state === 'PAUSE_REQUESTED'; + } + + function shouldShowCancel(state) { + return state === 'STARTING' + || state === 'SOLVING' + || state === 'PAUSE_REQUESTED' + || state === 'PAUSED' + || state === 'RESUMING' + || state === 'CANCELLING'; + } + + function shouldSpin(state) { + return state === 'STARTING' + || state === 'SOLVING' + || state === 'PAUSE_REQUESTED' + || state === 'RESUMING' + || state === 'CANCELLING'; + } + + function isActiveLifecycle(state) { + return shouldSpin(state); + } + + function lifecycleLabel(state) { + if (state === 'STARTING') return 'Starting...'; + if (state === 'SOLVING') return 'Solving...'; + if (state === 'PAUSE_REQUESTED') return 'Pause requested...'; + if (state === 'PAUSED') return 'Paused'; + if (state === 'RESUMING') return 'Resuming...'; + if (state === 'CANCELLING') return 'Cancelling...'; + if (state === 'COMPLETED') return 'Completed'; + if (state === 'CANCELLED') return 'Cancelled'; + if (state === 'FAILED') return 'Failed'; + if (state === 'TERMINATED_BY_CONFIG') return 'Completed'; + return 'Ready'; + } diff --git a/js-src/08-table.js b/ts-src/components/table.ts similarity index 62% rename from js-src/08-table.js rename to ts-src/components/table.ts index e64d90e..f7b9695 100644 --- a/js-src/08-table.js +++ b/ts-src/components/table.ts @@ -2,23 +2,22 @@ SolverForge UI — Table Factory ============================================================================ */ -(function (sf) { - 'use strict'; +import {assert, bindActivation, el} from "../core"; - sf.createTable = function (config) { - sf.assert(config, 'createTable(config) requires a configuration object'); - sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); - sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); +export const createTable = function (config) { + assert(config, 'createTable(config) requires a configuration object'); + assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); + assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); - var wrapper = sf.el('div', { className: 'sf-table-container' }); - var table = sf.el('table', { className: 'sf-table' }); + var wrapper = el('div', { className: 'sf-table-container' }); + var table = el('table', { className: 'sf-table' }); // Header if (config.columns) { - var thead = sf.el('thead'); - var tr = sf.el('tr'); + var thead = el('thead'); + var tr = el('tr'); config.columns.forEach(function (col) { - var th = sf.el('th', null, typeof col === 'string' ? col : col.label); + var th = el('th', null, typeof col === 'string' ? col : col.label); if (col.align) th.style.textAlign = col.align; if (col.width) th.style.width = col.width; tr.appendChild(th); @@ -28,14 +27,14 @@ } // Body - var tbody = sf.el('tbody'); + var tbody = el('tbody'); if (config.rows) { config.rows.forEach(function (row, rowIdx) { - var tr = sf.el('tr'); + var tr = el('tr'); row.forEach(function (cell, colIdx) { - var td = sf.el('td'); + var td = el('td'); if (typeof cell === 'string' || typeof cell === 'number') { - td.textContent = cell; + td.textContent = String(cell); } else if (cell instanceof Node) { td.appendChild(cell); } else if (cell && cell.unsafeHtml) { @@ -50,7 +49,7 @@ tr.style.cursor = 'pointer'; tr.setAttribute('role', 'button'); tr.tabIndex = 0; - sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); + bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); } tbody.appendChild(tr); }); @@ -60,5 +59,3 @@ return wrapper; }; - -})(SF); diff --git a/js-src/07-tabs.js b/ts-src/components/tabs.ts similarity index 73% rename from js-src/07-tabs.js rename to ts-src/components/tabs.ts index c1dc694..07644f3 100644 --- a/js-src/07-tabs.js +++ b/ts-src/components/tabs.ts @@ -2,10 +2,9 @@ SolverForge UI — Tab Switching ============================================================================ */ -(function (sf) { - 'use strict'; +import {assert, el, uid} from "../core"; - sf.showTab = function (tabId, root) { +export const showTab = function (tabId, root) { if (root) { activateTabInScope(root, tabId); return; @@ -16,15 +15,15 @@ }); }; - sf.createTabs = function (config) { - sf.assert(config, 'createTabs(config) requires a configuration object'); - sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); +export const createTabs = function (config) { + assert(config, 'createTabs(config) requires a configuration object'); + assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); - var container = sf.el('div', { className: 'sf-tabs-container' }); - var tabsId = sf.uid('sf-tabs'); + var container = el('div', { className: 'sf-tabs-container' }); + var tabsId = uid('sf-tabs'); config.tabs.forEach(function (tab) { - var panel = sf.el('div', { + var panel = el('div', { className: 'sf-tab-panel' + (tab.active ? ' active' : ''), id: tabsId + '-' + tab.id, dataset: { tabId: tab.id }, @@ -40,7 +39,7 @@ return { el: container, show: function (tabId) { - sf.showTab(tabId, container); + showTab(tabId, container); }, }; }; @@ -53,5 +52,3 @@ var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); if (panel) panel.classList.add('active'); } - -})(SF); diff --git a/ts-src/components/toast.ts b/ts-src/components/toast.ts new file mode 100644 index 0000000..22e882f --- /dev/null +++ b/ts-src/components/toast.ts @@ -0,0 +1,66 @@ +/* ============================================================================ + SolverForge UI — Toast Notifications + jQuery-free replacement for showError/showSimpleError. + ============================================================================ */ + +import {assert, el} from "../core"; + +var container = null; + +function ensureContainer() { + if (container && document.body.contains(container)) return; + container = el('div', { className: 'sf-toast-container' }); + document.body.appendChild(container); +} + +export const showToast = function (config) { + assert(config, 'showToast(config) requires a configuration object'); + + ensureContainer(); + + var variant = config.variant || 'danger'; + var toast = el('div', { + className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', + role: 'status', + 'aria-live': 'polite', + }); + + var msg = el('div', { className: 'sf-toast-message' }); + if (config.title) { + msg.appendChild(el('div', { className: 'sf-toast-title' }, config.title)); + } + if (config.message) { + msg.appendChild(el('div', null, config.message)); + } + if (config.detail) { + var pre = el('pre', { style: { margin: '4px 0 0', fontSize: '11px', whiteSpace: 'pre-wrap' } }); + pre.appendChild(el('code', null, config.detail)); + msg.appendChild(pre); + } + toast.appendChild(msg); + + var closeBtn = el('button', { + className: 'sf-toast-close', + 'aria-label': 'Dismiss toast', + onClick: function () { dismiss(); }, + }, '\u00d7'); + toast.appendChild(closeBtn); + + container.appendChild(toast); + + var delay = config.delay || 10000; + var timer = setTimeout(dismiss, delay); + + function dismiss() { + clearTimeout(timer); + toast.classList.remove('sf-toast-enter'); + toast.classList.add('sf-toast-exit'); + setTimeout(function () { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 200); + } +}; + +export const showError = function (title, detail) { + showToast({ title: 'Error', message: title, detail: detail, variant: 'danger', delay: 30000 }); +}; diff --git a/ts-src/core/index.ts b/ts-src/core/index.ts new file mode 100644 index 0000000..5cd3c43 --- /dev/null +++ b/ts-src/core/index.ts @@ -0,0 +1,85 @@ +/* ============================================================================ + SolverForge UI — Core + ============================================================================ */ + +export const version = '0.6.5'; + +let uidCounter = 0; + +/* ── Utilities ── */ + +export const escHtml = function (str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}; + +export const assert = function (cond, message) { + if (!cond) throw new Error('[SolverForge] ' + message); +}; + +export const normalizeCreateJobId = function (raw) { + var value = raw; + if (value && typeof value === 'object') { + if (value.id != null) value = value.id; + else if (value.jobId != null) value = value.jobId; + else if (value.job_id != null) value = value.job_id; + else if (value.data && typeof value.data === 'object' && value.data.id != null) value = value.data.id; + else return ''; + } + + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim(); + return ''; +}; + +export const el = function ( + tag: string, + attrs: Record | null = {}, + ...children: (string | Node | null | undefined)[] +): HTMLElement { + var el: HTMLElement = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function (key) { + var value = attrs[key]; + if (key === 'className') el.className = value as string; + else if (key === 'style' && typeof value === 'object') { + Object.assign(el.style, value as Partial); + } + else if (key.indexOf('on') === 0) { + el.addEventListener(key.slice(2).toLowerCase(), value as EventListener); + } + else if (key === 'dataset') Object.assign(el.dataset, value as Record); + else if (key === 'html') el.textContent = value as string; + else if (key === 'unsafeHtml') el.innerHTML = value as string; + else el.setAttribute(key, value as string); + }); + } + children.forEach(function (child) { + if (child == null) return; + if (typeof child === 'string') el.appendChild(document.createTextNode(child)); + else if (child instanceof Node) el.appendChild(child); + }); + return el; +}; + +export const uid = function (prefix) { + uidCounter += 1; + return (prefix || 'sf') + '-' + uidCounter; +}; + +export const bindActivation = function (el, onActivate) { + if (!el || typeof onActivate !== 'function') return; + + function handleActivate(e) { + if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; + if (e.type === 'keydown') e.preventDefault(); + onActivate(e); + } + + el.addEventListener('click', handleActivate); + el.addEventListener('keydown', handleActivate); +}; diff --git a/ts-src/gantt/gantt.ts b/ts-src/gantt/gantt.ts new file mode 100644 index 0000000..841676a --- /dev/null +++ b/ts-src/gantt/gantt.ts @@ -0,0 +1,404 @@ +/* ============================================================================ + SolverForge UI — Gantt (Frappe Gantt + Split.js wrapper) + Requires: Frappe Gantt (Gantt) and Split (Split) loaded globally. + ============================================================================ */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck frappe gantt does not provide types +// https://github.com/frappe/gantt/issues/341 + +import { assert, bindActivation, el, escHtml, uid } from "../core"; + +export const create = function (config) { + config = config || {}; + var instanceId = uid('sf-gantt'); + var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); + var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); + var chartContainerId = config.chartContainer || (instanceId + '-container'); + var svgId = config.svgId || (instanceId + '-svg'); + var ganttChart = null; + var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; + var tasks = []; + var sortState = { key: null, direction: 'asc' }; + + // ── Build DOM ── + var wrapper = el('div', { className: 'sf-gantt-split' }); + + // Grid pane + var gridPane = el('div', { className: 'sf-gantt-pane', id: gridPaneId }); + var gridHeader = el('div', { className: 'sf-gantt-pane-header' }); + gridHeader.appendChild(el('h3', null, config.gridTitle || 'Tasks')); + var gridControls = el('div', { className: 'sf-gantt-pane-controls' }); + gridHeader.appendChild(gridControls); + gridPane.appendChild(gridHeader); + + var gridContent = el('div', { className: 'sf-gantt-pane-content' }); + var grid = el('div', { className: 'sf-gantt-grid' }); + gridContent.appendChild(grid); + gridPane.appendChild(gridContent); + + // Chart pane + var chartPane = el('div', { className: 'sf-gantt-pane', id: chartPaneId }); + var chartHeader = el('div', { className: 'sf-gantt-pane-header' }); + chartHeader.appendChild(el('h3', null, config.chartTitle || 'Timeline')); + + var viewControls = el('div', { className: 'sf-gantt-view-controls' }); + var viewSelect = el('select', { className: 'sf-gantt-view-select' }); + var modes = [ + { value: 'Quarter Day', label: 'Quarter Day' }, + { value: 'Half Day', label: 'Half Day' }, + { value: 'Day', label: 'Day' }, + { value: 'Week', label: 'Week' }, + { value: 'Month', label: 'Month' }, + ]; + modes.forEach(function (m) { + var opt = el('option', { value: m.value }, m.label); + if (m.value === (config.viewMode || 'Quarter Day')) opt.selected = true; + viewSelect.appendChild(opt); + }); + viewSelect.addEventListener('change', function () { + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); + }); + viewControls.appendChild(viewSelect); + + var chartControls = el('div', { className: 'sf-gantt-pane-controls' }); + chartHeader.appendChild(viewControls); + chartHeader.appendChild(chartControls); + chartPane.appendChild(chartHeader); + + var chartContent = el('div', { className: 'sf-gantt-pane-content' }); + var chartContainer = el('div', { className: 'sf-gantt-container', id: chartContainerId }); + chartContent.appendChild(chartContainer); + chartPane.appendChild(chartContent); + + wrapper.appendChild(gridPane); + wrapper.appendChild(chartPane); + + // ── API ── + var ctrl = { el: wrapper }; + + ctrl.mount = function (parent) { + assert(parent, 'gantt.mount(parent) requires a mount target'); + var target = typeof parent === 'string' ? document.getElementById(parent) : parent; + assert(target, 'gantt.mount(parent) target not found: ' + parent); + validateMountTarget(target); + + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); + target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } + initSplit(); + bindResizeObserver(); + }; + + ctrl.setTasks = function (newTasks) { + assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); + tasks = newTasks; + renderGrid(newTasks); + renderChart(newTasks); + }; + + ctrl.refresh = function () { + if (ganttChart && tasks.length > 0) { + ganttChart.refresh(tasksToFrappe(tasks)); + } + }; + + ctrl.getChart = function () { return ganttChart; }; + + ctrl.changeViewMode = function (mode) { + viewSelect.value = mode; + if (ganttChart) ganttChart.change_view_mode(mode); + }; + + ctrl.highlightTask = function (taskId) { + grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { + row.classList.toggle('selected', row.dataset.taskId === taskId); + }); + var svg = chartContainer.querySelector('svg'); + if (svg) { + svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { + bw.classList.remove('highlighted'); + }); + var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); + if (bar) bar.classList.add('highlighted'); + } + }; + + ctrl.destroy = function () { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (splitInstance) { splitInstance.destroy(); splitInstance = null; } + ganttChart = null; + mounted = false; + mountTarget = null; + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); + }; + + return ctrl; + + function initSplit() { + if (typeof Split !== 'function') return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + + splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { + direction: 'vertical', + sizes: splitSizes, + minSize: splitMinSize, + snapOffset: 30, + gutterSize: 4, + cursor: 'col-resize', + onDragEnd: function () { + if (ganttChart) { + setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 100); + } + }, + }); + } + + function bindResizeObserver() { + if (typeof ResizeObserver !== 'function') return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function () { + if (!ganttChart) return; + setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); + }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + } + + function normalizePair(value, fallback) { + if (typeof value === 'number' && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + + function validateMountTarget(target) { + assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); + assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); + } + + function getElementSize(target, axis) { + var clientKey = 'client' + axis; + var offsetKey = 'offset' + axis; + var rectKey = axis === 'Width' ? 'width' : 'height'; + + if (typeof target[clientKey] === 'number') return target[clientKey]; + if (typeof target[offsetKey] === 'number') return target[offsetKey]; + if (typeof target.getBoundingClientRect === 'function') { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; + } + return 0; + } + + function tasksToFrappe(taskList) { + return taskList + .filter(function (t) { return t.start && t.end; }) + .map(function (t) { + var customClass = t.custom_class || ''; + if (t.pinned) { + customClass = customClass ? customClass + ' pinned' : 'pinned'; + } + return { + id: t.id, + name: t.name || t.label || t.id, + start: t.start, + end: t.end, + custom_class: customClass, + dependencies: t.dependencies || '', + }; + }); + } + + function renderChart(taskList) { + var frappeTasks = tasksToFrappe(taskList); + + if (frappeTasks.length === 0) { + chartContainer.textContent = ''; + chartContainer.appendChild(el('div', { + className: 'sf-gantt-empty-state', + style: { + padding: '24px', + color: 'var(--sf-gray-400)', + fontFamily: 'var(--sf-font-mono)', + fontSize: '13px', + }, + }, 'No scheduled tasks to display.')); + ganttChart = null; + return; + } + + chartContainer.textContent = ''; + chartContainer.appendChild(createSvgRoot(svgId)); + + ganttChart = new Gantt('#' + svgId, frappeTasks, { + view_mode: viewSelect.value || 'Quarter Day', + date_format: 'YYYY-MM-DD HH:mm', + custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, + on_click: function (task) { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + on_date_change: function (task, start, end) { + if (config.onDateChange) config.onDateChange(task, start, end); + }, + }); + } + + function renderGrid(taskList) { + while (grid.firstChild) grid.removeChild(grid.firstChild); + var table = el('table', { className: 'sf-gantt-table' }); + var columns = config.columns || [ + { key: 'name', label: 'Task' }, + { key: 'start', label: 'Start' }, + { key: 'end', label: 'End' }, + ]; + var sortedTasks = sortTasks(taskList); + + var thead = el('thead'); + var headerRow = el('tr'); + columns.forEach(function (col) { + headerRow.appendChild(buildHeaderCell(col)); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + var tbody = el('tbody'); + sortedTasks.forEach(function (task) { + var rowClasses = ['sf-gantt-row']; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); + + var tr = el('tr', { + className: rowClasses.join(' '), + dataset: { taskId: task.id }, + onClick: function () { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + }); + + columns.forEach(function (col) { + var td = el('td'); + if (col.key === 'name') { + td.className = 'sf-task-name'; + td.textContent = task.name || task.label || task.id; + } else if (col.render) { + var content = col.render(task); + if (typeof content === 'string') td.textContent = content; + else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; + else if (content instanceof Node) td.appendChild(content); + } else { + td.textContent = task[col.key] || ''; + td.style.fontFamily = 'var(--sf-font-mono)'; + td.style.fontSize = '12px'; + } + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + grid.appendChild(table); + } + + function buildHeaderCell(col) { + if (!col.sortable) { + return el('th', null, col.label); + } + + var isCurrent = sortState.key === col.key; + var th = el('th', { + className: 'sortable' + (isCurrent ? ' active' : ''), + role: 'button', + tabIndex: 0, + 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', + }); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '\u25B2' : '\u25BC') : '')); + + bindActivation(th, function () { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + sortState.key = col.key; + sortState.direction = 'asc'; + } + renderGrid(tasks); + }); + + return th; + } + + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function (a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; + }); + return sorted; + } + + function sortValue(value, key) { + if (value == null) return ''; + if (key === 'start' || key === 'end') { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; + } + if (typeof value === 'number') return value; + return String(value).toLowerCase(); + } + + function defaultPopup(task) { + var t = tasks.find(function (x) { return x.id === task.id; }); + if (!t) return ''; + return '
' + + '

' + escHtml(t.name || t.id) + '

' + + '

Start: ' + escHtml(t.start) + '

' + + '

End: ' + escHtml(t.end) + '

' + + (t.duration_minutes ? '

Duration: ' + t.duration_minutes + ' min

' : '') + + (t.pinned ? '

Pinned

' : '') + + '
'; + } + + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.id = id; + return svg; + } + return el('svg', { id: id }); + } +}; + +// Export a gantt namespace object for backwards compatibility +const gantt = { create }; +export { gantt }; +export default gantt; diff --git a/ts-src/gantt/index.ts b/ts-src/gantt/index.ts new file mode 100644 index 0000000..101bc29 --- /dev/null +++ b/ts-src/gantt/index.ts @@ -0,0 +1,5 @@ +/* ============================================================================ + SolverForge UI — Gantt Module Index + ============================================================================ */ + +export { gantt } from "./gantt"; diff --git a/ts-src/global.d.ts b/ts-src/global.d.ts new file mode 100644 index 0000000..77ebbec --- /dev/null +++ b/ts-src/global.d.ts @@ -0,0 +1,24 @@ +// -------------------------------------------------------------------------- +// Global Window Extensions +// SolverForge UI - TypeScript Type Definitions +// -------------------------------------------------------------------------- + +import type { GlobalAPI } from "./sf.types"; + +declare global { + interface Window { + SF: GlobalAPI; + } + // External library types loaded globally + const Split: ( + elements: string[], + options?: Record, + ) => unknown; + const Gantt: new ( + selector: string, + tasks: unknown[], + options?: Record, + ) => unknown; +} + +export {}; diff --git a/ts-src/index.ts b/ts-src/index.ts new file mode 100644 index 0000000..9bbb02e --- /dev/null +++ b/ts-src/index.ts @@ -0,0 +1,37 @@ +// IIFE — window.SF unchanged for existing integrators + +import * as colorsApi from "./utils/colors"; +import * as scoreApi from "./utils/score"; + +export * from "./core/index"; +export * from "./components/api-guide"; +export * from "./components/buttons"; +export * from "./components/footer"; +export * from "./components/header"; +export * from "./components/modal"; +export * from "./components/statusbar"; +export * from "./components/table"; +export * from "./components/tabs"; +export * from "./components/toast"; +export * from "./gantt/index"; +export * from "./rail/index"; +export * from "./solver/backend"; +export * from "./solver/solver"; +export * from "./utils/colors"; +export * from "./utils/score"; + +// Retain the shipped classic API namespaces while also exposing flat ESM +// named exports from the utility modules above. +export const colors = { + pick: colorsApi.pick, + project: colorsApi.project, + reset: colorsApi.reset, +}; + +export const score = { + parseHard: scoreApi.parseHard, + parseSoft: scoreApi.parseSoft, + parseMedium: scoreApi.parseMedium, + getComponents: scoreApi.getComponents, + colorClass: scoreApi.colorClass, +}; diff --git a/ts-src/rail/card.ts b/ts-src/rail/card.ts new file mode 100644 index 0000000..a352393 --- /dev/null +++ b/ts-src/rail/card.ts @@ -0,0 +1,441 @@ +/* ============================================================================ + SolverForge UI — Timeline Rail + Resource-lane timeline: header + cards with positioned blocks. + ============================================================================ */ + +import { assert, bindActivation, el } from "../core"; + +export interface TimelineCardApi { + el: HTMLElement; + rail: HTMLElement; + + addBlock(blockConfig: TimelineBlockConfig): HTMLElement; + + setUnassigned(items: UnassignedItem[]): void; + + clearBlocks(): void; + + setSolving(solving: boolean): void; +} + +export interface HeatmapConfig { + horizon?: number; + label?: string; + segments: HeatmapSegment[]; + labelWidth?: number; + railConfig?: TimelineCardConfig; +} + +export interface TimelineBlockConfig { + horizon: number; + start: number; + end: number; + + minWidthPct?: number; + + color?: string; + borderColor?: string; + className?: string; + + late?: boolean; + + id?: string; + delay?: string; + + label?: string; + meta?: string; + + onHover?: (e: MouseEvent, config: TimelineBlockConfig) => void; + onLeave?: () => void; + onClick?: (e: Event, config: TimelineBlockConfig) => void; +} + +export type UnassignedItem = + | string + | { + id?: string; + label?: string; + }; + +export interface HeatmapSegment { + start: number; + end: number; + + color?: string; + opacity?: number; + tooltip?: string; +} + +export interface TimelineCardConfig { + id?: string; + + labelWidth?: number; + + columns?: number; + + name?: string; + + type?: string; + + typeStyle?: { + bg?: string; + color?: string; + border?: string; + }; + + badges?: Array< + | string + | { + label?: string; + style?: { + bg?: string; + color?: string; + border?: string; + }; + } + >; + + gauges?: Array<{ + label: string; + pct?: number; + text?: string; + style?: string; + }>; + + stats?: Array<{ + label: string; + value: unknown; + }>; + + heatmap?: { + horizon?: number; + label?: string; + segments: HeatmapSegment[]; + }; + + unassigned?: UnassignedItem[]; + + onUnassignedClick?: (item: UnassignedItem) => void; +} + + +export const createHeader = function (config) { + assert(config, 'createHeader(config) requires a configuration object'); + assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); + + var labelWidth = config.labelWidth || 200; + var columns = config.columns || []; + + var header = el('div', { className: 'sf-timeline-header' }); + header.style.gridTemplateColumns = labelWidth + 'px 1fr'; + + var spacer = el('div', { className: 'sf-timeline-label-spacer' }, config.label || ''); + header.appendChild(spacer); + + var days = el('div', { className: 'sf-timeline-days' }); + days.style.gridTemplateColumns = 'repeat(' + columns.length + ', 1fr)'; + + columns.forEach(function (col) { + var colEl = el('div', { className: 'sf-timeline-day-col' }); + colEl.appendChild(el('span', null, typeof col === 'string' ? col : col.label)); + days.appendChild(colEl); + }); + + header.appendChild(days); + return header; +}; + +export const createCard = function (config) { + assert(config, 'createCard(config) requires a configuration object'); + + var labelWidth = config.labelWidth || 200; + var card = el('div', { className: 'sf-resource-card' }); + var state = { + unassigned: [], + railConfig: config, + }; + + if (config.id) card.dataset.resourceId = config.id; + + // Header row (identity + gauges) + var resHeader = el('div', { className: 'sf-resource-header' }); + resHeader.style.gridTemplateColumns = labelWidth + 'px 1fr'; + + var identity = el('div', { className: 'sf-resource-identity' }); + if (config.name) { + identity.appendChild(el('div', { className: 'sf-resource-name' }, config.name)); + } + if (config.badges || config.type) { + var meta = el('div', { className: 'sf-resource-meta' }); + if (config.type) { + var badge = el('span', { className: 'sf-resource-type-badge' }, config.type); + if (config.typeStyle) { + badge.style.background = config.typeStyle.bg || ''; + badge.style.color = config.typeStyle.color || ''; + badge.style.border = config.typeStyle.border || ''; + } + meta.appendChild(badge); + } + var badges = Array.isArray(config.badges) + ? config.badges + : config.badges + ? [config.badges] + : []; + if (badges.length) { + badges.forEach(function (entry) { + if (!entry) return; + if (typeof entry === 'string') { + meta.appendChild(el('span', { className: 'sf-resource-type-badge' }, entry)); + return; + } + var extraBadge = el('span', { className: 'sf-resource-type-badge' }, entry.label || ''); + if (entry.style) { + extraBadge.style.background = entry.style.bg || ''; + extraBadge.style.color = entry.style.color || ''; + extraBadge.style.border = entry.style.border || ''; + } + meta.appendChild(extraBadge); + }); + } + identity.appendChild(meta); + } + resHeader.appendChild(identity); + + // Gauges + if (config.gauges && config.gauges.length > 0) { + var gauges = el('div', { className: 'sf-gauges' }); + config.gauges.forEach(function (g) { + var row = el('div', { className: 'sf-gauge-row' }); + row.appendChild(el('span', { className: 'sf-gauge-label' }, g.label)); + var track = el('div', { className: 'sf-gauge-track' }); + var fill = el('div', { + className: 'sf-gauge-fill' + (g.style ? ' sf-gauge-fill--' + g.style : ''), + }); + fill.style.width = Math.min(g.pct || 0, 100) + '%'; + track.appendChild(fill); + row.appendChild(track); + if (g.text) row.appendChild(el('span', { className: 'sf-gauge-value' }, g.text)); + gauges.appendChild(row); + }); + resHeader.appendChild(gauges); + } + + card.appendChild(resHeader); + + // Body (stats + rail) + var body = el('div', { className: 'sf-resource-body' }); + body.style.gridTemplateColumns = labelWidth + 'px 1fr'; + + // Stats panel + var stats = el('div', { className: 'sf-resource-stats' }); + if (config.stats) { + config.stats.forEach(function (s) { + var row = el('div', { className: 'sf-stat-row' }); + row.appendChild(el('span', { className: 'sf-stat-label' }, s.label)); + row.appendChild(el('span', { className: 'sf-stat-value' }, String(s.value))); + stats.appendChild(row); + }); + } + body.appendChild(stats); + + // Rail + var railContainer = el('div', { className: 'sf-rail-container' }); + var rail = el('div', { className: 'sf-rail' }); + if (config.id) rail.id = 'sf-rail-' + config.id; + + // Day grid + var numCols = config.columns || 5; + var dayGrid = el('div', { className: 'sf-day-grid' }); + dayGrid.style.gridTemplateColumns = 'repeat(' + numCols + ', 1fr)'; + for (var i = 0; i < numCols; i++) { + dayGrid.appendChild(el('div', { className: 'sf-day-col' })); + } + rail.appendChild(dayGrid); + + railContainer.appendChild(rail); + body.appendChild(railContainer); + card.appendChild(body); + + // Optional heatmap strip + if (config.heatmap) { + var heatmapCfg: HeatmapConfig = { + horizon: config.heatmap.horizon || 1, + label: config.heatmap.label, + segments: config.heatmap.segments, + labelWidth: labelWidth, + }; + heatmapCfg.railConfig = config; + var heatmap = createHeatmap(heatmapCfg); + if (heatmap) card.appendChild(heatmap); + } + + // Optional unassigned list + var unassignedRail = el('div', { className: 'sf-unassigned-rail' }); + if (config.unassigned) { + state.unassigned = config.unassigned; + renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); + } + if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); + + // API + var cardApi: TimelineCardApi = { + el: card, + rail: rail, + + addBlock: function (blockConfig) { + return addBlock(rail, blockConfig); + }, + + setUnassigned: function (items) { + state.unassigned = Array.isArray(items) ? items : []; + if (state.unassigned.length === 0 && unassignedRail.parentNode) { + unassignedRail.innerHTML = ''; + unassignedRail.parentNode?.removeChild(unassignedRail); + return; + } + if (state.unassigned.length > 0) { + renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); + } else { + unassignedRail.innerHTML = ''; + } + if (state.unassigned.length > 0 && !unassignedRail.parentNode) { + card.appendChild(unassignedRail); + } + }, + + clearBlocks: function () { + rail.querySelectorAll('.sf-block, .sf-changeover').forEach(function (el) { + el.remove(); + }); + }, + + setSolving: function (solving) { + card.classList.toggle('solving', solving); + } + }; + + return cardApi; +}; + +export const createHeatmap = function (config) { + if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; + + var heatmap = el('div', { className: 'sf-heatmap' }); + heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + 'px 1fr'; + var label = el('div', { className: 'sf-heatmap-label' }, config.label || ''); + heatmap.appendChild(label); + + var track = el('div', { className: 'sf-heatmap-track' }); + var columns = config.railConfig && config.railConfig.columns || 1; + track.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)'; + heatmap.appendChild(track); + + var horizon = config.horizon || 1; + config.segments.forEach(function (segment) { + if (!segment || segment.end <= segment.start) return; + var band = el('div', { className: 'sf-heatmap-segment' }); + var start = Math.max(0, segment.start); + var width = Math.max(0, segment.end - start); + band.style.left = (start / horizon * 100) + '%'; + band.style.width = Math.max(width / horizon * 100, 0.25) + '%'; + if (segment.color) band.style.background = segment.color; + if (segment.opacity != null) band.style.opacity = segment.opacity; + if (segment.tooltip) band.title = segment.tooltip; + track.appendChild(band); + }); + + return heatmap; +}; + +export const createUnassignedRail = function (tasks, onTaskClick) { + var rail = el('div', { className: 'sf-unassigned-rail' }); + renderUnassigned(rail, tasks, onTaskClick); + return rail; +}; + +export const addBlock = function (rail, config) { + assert(rail, 'addBlock(rail) requires a rail element'); + assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); + assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); + + var horizon = config.horizon || 1; + var startPct = (config.start / horizon) * 100; + var widthPct = ((config.end - config.start) / horizon) * 100; + var minWidthPct = config.minWidthPct == null ? 0.5 : config.minWidthPct; + + var block = el('div', { className: 'sf-block' }); + block.style.left = startPct + '%'; + block.style.width = Math.max(widthPct, minWidthPct) + '%'; + + if (config.color) { + block.style.background = config.color; + block.style.borderLeftColor = config.borderColor || config.color; + } + if (config.className) block.classList.add(config.className); + if (config.late) block.classList.add('late'); + if (config.id) block.dataset.blockId = config.id; + if (config.delay) block.style.animationDelay = config.delay; + + if (config.label) { + block.appendChild(el('div', { className: 'sf-block-label' }, config.label)); + } + if (config.meta) { + block.appendChild(el('div', { className: 'sf-block-meta' }, config.meta)); + } + + if (config.onHover) { + block.addEventListener('mouseenter', function (e) { config.onHover(e, config); }); + } + if (config.onLeave) { + block.addEventListener('mouseleave', function () { config.onLeave(); }); + } + if (config.onClick) { + block.setAttribute('role', 'button'); + block.tabIndex = 0; + bindActivation(block, function (e) { config.onClick(e, config); }); + } + + rail.appendChild(block); + return block; +}; + +export const addChangeover = function (rail, config) { + assert(rail, 'addChangeover(rail) requires a rail element'); + assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); + assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); + + var horizon = config.horizon || 1; + var startPct = (config.start / horizon) * 100; + var widthPct = ((config.end - config.start) / horizon) * 100; + + var co = el('div', { className: 'sf-changeover' }); + co.style.left = startPct + '%'; + co.style.width = widthPct + '%'; + rail.appendChild(co); + return co; +}; + +function renderUnassigned(unassignedRail, items, onTaskClick) { + unassignedRail.innerHTML = ''; + (items || []).forEach(function (item) { + var label = typeof item === 'string' ? item : item.label || item.id || ''; + if (!label) return; + var pill = el('button', { + className: 'sf-unassigned-pill', + onClick: function () { + if (onTaskClick) onTaskClick(item); + }, + }, label); + unassignedRail.appendChild(pill); + }); +} + +// Export rail namespace for backwards compatibility with sf.rail.* +export const rail = { + createHeader, + createCard, + createHeatmap, + createUnassignedRail, + addBlock, + addChangeover, +}; diff --git a/ts-src/rail/index.ts b/ts-src/rail/index.ts new file mode 100644 index 0000000..590acdc --- /dev/null +++ b/ts-src/rail/index.ts @@ -0,0 +1,19 @@ +/** ============================================================================ + SolverForge UI — Rail Module Index + Central export point for all rail-related functionality. + ============================================================================ */ + +import { createHeader, createCard, createHeatmap, createUnassignedRail, addBlock, addChangeover } from "./card"; +import { createTimeline } from "./timeline"; + +// Rail namespace object for backwards compatibility with SF.rail.* +// This provides SF.rail.createHeader, SF.rail.createTimeline, etc. +export const rail = { + createHeader, + createCard, + createHeatmap, + createUnassignedRail, + addBlock, + addChangeover, + createTimeline, +}; diff --git a/ts-src/rail/timeline.ts b/ts-src/rail/timeline.ts new file mode 100644 index 0000000..74810cd --- /dev/null +++ b/ts-src/rail/timeline.ts @@ -0,0 +1,1748 @@ +/* ============================================================================ + SolverForge UI — Rail Timeline + Canonical dense scheduling surface for resource-lane timelines. + ============================================================================ */ + +import {assert, el, uid} from "../core"; +import {addBlock} from "./card"; +import type { + TimelineConfig, + TimelineLane, + TimelineApi, + RailOverviewGroup, +} from './timeline.types'; + +var DAY_MINUTES = 24 * 60; +var SIX_HOUR_MINUTES = 6 * 60; +var WEEK_MINUTES = 7 * DAY_MINUTES; +var TRACK_HEIGHT = 34; +var TRACK_GAP = 8; +var TRACK_PADDING = 12; +var OVERVIEW_HEIGHT = 68; +var OVERVIEW_BLOCK_HEIGHT = 34; +var OVERVIEW_GROUP_GAP_MINUTES = 30; +var MIN_LABEL_WIDTH = 180; +var MIN_VISIBLE_TRACK_WIDTH = 320; +var MIN_CONTENT_TRACK_WIDTH = 480; +var MIN_SUPPORTED_VIEWPORT_WIDTH = 500; + +var TONE_MAP = { + emerald: { + id: 'emerald', + background: 'rgba(16, 185, 129, 0.22)', + border: '#059669', + text: '#064e3b', + overlay: 'rgba(16, 185, 129, 0.10)', + }, + blue: { + id: 'blue', + background: 'rgba(59, 130, 246, 0.22)', + border: '#2563eb', + text: '#1e40af', + overlay: 'rgba(59, 130, 246, 0.10)', + }, + amber: { + id: 'amber', + background: 'rgba(245, 158, 11, 0.24)', + border: '#d97706', + text: '#92400e', + overlay: 'rgba(245, 158, 11, 0.10)', + }, + rose: { + id: 'rose', + background: 'rgba(244, 63, 94, 0.22)', + border: '#e11d48', + text: '#9f1239', + overlay: 'rgba(244, 63, 94, 0.10)', + }, + violet: { + id: 'violet', + background: 'rgba(139, 92, 246, 0.22)', + border: '#7c3aed', + text: '#5b21b6', + overlay: 'rgba(139, 92, 246, 0.10)', + }, + cyan: { + id: 'cyan', + background: 'rgba(6, 182, 212, 0.22)', + border: '#0891b2', + text: '#155e75', + overlay: 'rgba(6, 182, 212, 0.10)', + }, + red: { + id: 'red', + background: 'rgba(239, 68, 68, 0.22)', + border: '#dc2626', + text: '#991b1b', + overlay: 'rgba(239, 68, 68, 0.10)', + }, + slate: { + id: 'slate', + background: 'rgba(100, 116, 139, 0.20)', + border: '#475569', + text: '#1e293b', + overlay: 'rgba(100, 116, 139, 0.08)', + }, +}; + +export const createTimeline = function (config: TimelineConfig): TimelineApi { + assert(config && config.model, 'rail.createTimeline(config.model) requires a normalized model'); + + var labelWidth = config.labelWidth == null + ? 280 + : assertFiniteNumber(config.labelWidth, 'rail.createTimeline(labelWidth)'); + assert(labelWidth > 0, 'rail.createTimeline(labelWidth) must be greater than zero'); + var state = { + cleanup: [], + config: config, + destroyed: false, + expandedClusters: {}, + hasQueuedPostMountSync: false, + instanceId: uid('sf-rail-timeline'), + labelWidth: labelWidth, + model: normalizeModel(config.model), + scrollSync: null, + viewport: null, + layout: null, + }; + + state.viewport = clampViewport(state.model.axis, state.model.axis.initialViewport); + + var root = el('section', { + className: 'sf-rail-timeline', + dataset: { + labelWidth: String(labelWidth), + }, + }); + root.setAttribute('role', 'region'); + root.setAttribute('aria-label', config.title || 'Scheduling timeline'); + + var toolbar = el('div', { className: 'sf-rail-timeline-toolbar' }); + var toolbarCopy = el('div', { className: 'sf-rail-timeline-toolbar-copy' }); + toolbarCopy.appendChild(el('div', { className: 'sf-rail-timeline-toolbar-title' }, config.title || 'Scheduling timeline')); + toolbarCopy.appendChild(el('div', { className: 'sf-rail-timeline-toolbar-subtitle' }, config.subtitle || 'Sticky header, sticky lane labels, hidden scrollbar, drag-to-pan.')); + toolbar.appendChild(toolbarCopy); + + var zoomControls = el('div', { className: 'sf-rail-timeline-zoom-controls' }); + var zoomButtons = []; + normalizeZoomPresets(config.zoomPresets).forEach(function (preset) { + var button = el('button', { + className: 'sf-rail-timeline-zoom-button', + type: 'button', + dataset: { zoom: preset }, + }, preset === 'reset' ? 'Reset' : preset.toUpperCase()); + button.addEventListener('click', function () { + if (preset === 'reset') { + api.setViewport(state.model.axis.initialViewport); + return; + } + api.setViewport(buildPresetViewport(state.model.axis, state.viewport, preset)); + }); + zoomButtons.push(button); + zoomControls.appendChild(button); + }); + if (zoomButtons.length) { + toolbar.appendChild(zoomControls); + } + root.appendChild(toolbar); + + var shell = el('div', { className: 'sf-rail-timeline-shell' }); + var headerViewport = el('div', { className: 'sf-rail-timeline-header-viewport' }); + var bodyViewport = el('div', { className: 'sf-rail-timeline-body-viewport' }); + var headerRow = el('div', { className: 'sf-rail-timeline-header-row' }); + var lanes = el('div', { className: 'sf-rail-timeline-lanes' }); + headerViewport.appendChild(headerRow); + bodyViewport.appendChild(lanes); + shell.appendChild(headerViewport); + shell.appendChild(bodyViewport); + root.appendChild(shell); + + var tooltip = el('div', { className: 'sf-tooltip sf-rail-timeline-tooltip' }); + tooltip.id = uid('sf-rail-timeline-tooltip'); + tooltip.setAttribute('role', 'tooltip'); + tooltip.setAttribute('aria-hidden', 'true'); + root.appendChild(tooltip); + + bindScrollSync(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(headerViewport, bodyViewport, state, root, zoomButtons); + bindDragPan(bodyViewport, headerViewport, state, root, zoomButtons); + bindResizeObserver(bodyViewport, state, syncLayoutFromViewport); + bindWindowResize(state, syncLayoutFromViewport); + + function renderStructure() { + renderHeader(); + renderLanes(); + } + + function applyMeasuredLayout() { + state.layout = measureLayout(bodyViewport, state); + applyLayout(root, headerRow, lanes, state.layout); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + } + + function renderHeader() { + headerRow.innerHTML = ''; + + var corner = el('div', { className: 'sf-rail-timeline-label-corner' }, config.label || 'Lane'); + headerRow.appendChild(corner); + + var axis = el('div', { className: 'sf-rail-timeline-axis sf-rail-timeline-axis--header' }); + axis.style.height = '82px'; + renderAxisDecor(axis, state.model.axis, true); + headerRow.appendChild(axis); + } + + function renderLanes() { + lanes.innerHTML = ''; + + state.model.lanes.forEach(function (lane: TimelineLane, laneIndex) { + var laneRender: { blocks: unknown[]; height: number; trackCount: number; expandedClusterId?: string | null } = lane.mode === 'overview' + ? buildOverviewRender(lane, state, function () { + rerenderTimeline(); + }) + : buildDetailedRender(lane, lane.items); + + var row = el('div', { + className: 'sf-rail-timeline-row sf-rail-timeline-row--' + lane.mode + (laneRender.expandedClusterId ? ' sf-rail-timeline-row--expanded' : ''), + dataset: { + laneId: lane.id, + mode: lane.mode, + trackCount: String(laneRender.trackCount), + }, + }); + if (laneRender.expandedClusterId) { + row.dataset.expandedClusterId = laneRender.expandedClusterId; + } + row.setAttribute('role', 'group'); + + var label = buildLaneLabel( + lane, + laneRender, + row, + buildScopedId(state.instanceId, 'lane-title-' + laneIndex) + ); + row.appendChild(label); + + var track = el('div', { className: 'sf-rail-timeline-track' }); + track.style.height = laneRender.height + 'px'; + renderAxisDecor(track, state.model.axis, false); + renderOverlays(track, lane.overlays, state.model.axis); + laneRender.blocks.forEach(function (blockConfig) { + appendLaneBlock(track, lane, blockConfig, state.model.axis, tooltip, root); + }); + row.appendChild(track); + lanes.appendChild(row); + }); + } + + function rerenderTimeline() { + renderStructure(); + syncLayoutFromViewport(); + } + + function syncLayoutFromViewport() { + applyMeasuredLayout(); + syncScrollToViewport(); + } + + function syncScrollToViewport() { + if (!state.layout) return; + var scrollLeft = viewportToScrollLeft(state, bodyViewport); + state.scrollSync = bodyViewport; + bodyViewport.scrollLeft = scrollLeft; + headerViewport.scrollLeft = scrollLeft; + state.scrollSync = null; + } + + var api = { + destroy: function () { + if (state.destroyed) return; + state.destroyed = true; + state.cleanup.forEach(function (cleanup) { + if (typeof cleanup === 'function') cleanup(); + }); + root.innerHTML = ''; + }, + el: root, + expandCluster: function (laneId, clusterId) { + setExpandedCluster(state, laneId, clusterId); + rerenderTimeline(); + }, + setModel: function (nextModel) { + state.model = normalizeModel(nextModel); + state.viewport = clampViewport(state.model.axis, state.viewport); + pruneExpandedClusters(state); + rerenderTimeline(); + queuePostMountSync(state, syncLayoutFromViewport); + }, + setViewport: function (nextViewport) { + state.viewport = clampViewport( + state.model.axis, + normalizeViewportInput(nextViewport, 'rail.createTimeline().setViewport(viewport)') + ); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + }, + }; + + renderStructure(); + syncLayoutFromViewport(); + queuePostMountSync(state, syncLayoutFromViewport); + + return api; +}; + +function appendLaneBlock(track, lane, blockConfig, axis, tooltip, root) { + var tone = blockConfig.tone; + var relativeStart = blockConfig.startMinute - axis.startMinute; + var relativeEnd = blockConfig.endMinute - axis.startMinute; + var horizon = axis.endMinute - axis.startMinute; + var block = addBlock(track, { + start: relativeStart, + end: relativeEnd, + horizon: horizon, + label: blockConfig.label, + meta: blockConfig.metaLabel, + color: tone.background, + borderColor: tone.border, + minWidthPct: 0, + onClick: blockConfig.onClick, + onHover: function (event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }, + onLeave: function () { + hideTooltip(tooltip); + }, + }); + + block.classList.add('sf-rail-timeline-item'); + block.classList.add(blockConfig.kindClass); + block.style.left = positionPct(blockConfig.startMinute, axis) + '%'; + block.style.width = spanPctExact(blockConfig.startMinute, blockConfig.endMinute, axis) + '%'; + block.style.top = blockConfig.top + 'px'; + block.style.height = blockConfig.height + 'px'; + block.style.bottom = 'auto'; + block.style.color = tone.text; + block.tabIndex = 0; + block.dataset.itemId = blockConfig.itemId; + block.dataset.laneId = lane.id; + block.dataset.startMinute = String(blockConfig.startMinute); + block.dataset.endMinute = String(blockConfig.endMinute); + if (blockConfig.trackIndex != null) block.dataset.trackIndex = String(blockConfig.trackIndex); + if (blockConfig.clusterId) block.dataset.clusterId = blockConfig.clusterId; + if (blockConfig.onClick) { + block.setAttribute('role', 'button'); + block.setAttribute('aria-expanded', blockConfig.expanded ? 'true' : 'false'); + } else { + block.setAttribute('role', 'group'); + } + if (blockConfig.ariaLabel) block.setAttribute('aria-label', blockConfig.ariaLabel); + block.setAttribute('aria-describedby', tooltip.id); + if (blockConfig.summary) appendOverviewSummary(block, blockConfig.summary); + if (blockConfig.detailHint) { + block.appendChild(el('span', { className: 'sf-rail-timeline-detail-hint' }, blockConfig.detailHint)); + } + block.title = blockConfig.tooltip.title; + block.addEventListener('mousemove', function (event) { + showTooltip(tooltip, root, blockConfig.tooltip, event); + }); + block.addEventListener('focus', function () { + showTooltipForElement(tooltip, root, blockConfig.tooltip, block); + }); + block.addEventListener('blur', function () { + hideTooltip(tooltip); + }); + block.addEventListener('keydown', function (event) { + if (event && event.key === 'Escape') hideTooltip(tooltip); + }); +} + +function appendOverviewSummary(block, summary) { + var footer = el('div', { className: 'sf-rail-timeline-summary-footer' }); + if (summary.badges.length > 0) { + var badgeRail = el('div', { className: 'sf-rail-timeline-summary-badges' }); + summary.badges.forEach(function (badge) { + badgeRail.appendChild(el('span', { + className: 'sf-rail-timeline-summary-pill sf-rail-timeline-summary-pill--' + badge.kind, + }, badge.text)); + }); + footer.appendChild(badgeRail); + } + if (summary.toneSegments.length > 0) { + var toneBar = el('div', { + className: 'sf-rail-timeline-summary-tonebar', + 'aria-hidden': 'true', + }); + var total = summary.toneSegments.reduce(function (sum, segment) { + return sum + segment.count; + }, 0) || 1; + summary.toneSegments.forEach(function (segment) { + var toneSegment = el('span', { className: 'sf-rail-timeline-summary-tone-segment' }); + toneSegment.style.background = segment.tone.border; + toneSegment.style.width = ((segment.count / total) * 100) + '%'; + toneBar.appendChild(toneSegment); + }); + footer.appendChild(toneBar); + } + if (footer.children.length > 0) block.appendChild(footer); +} + +function bindScrollSync(source, target, state, root, zoomButtons) { + source.addEventListener('scroll', function () { + handleScroll(source, target, state, root, zoomButtons); + }); + target.addEventListener('scroll', function () { + handleScroll(target, source, state, root, zoomButtons); + }); +} + +function bindDragPan(source, target, state, root, zoomButtons) { + var drag = { + active: false, + startClientX: 0, + startScrollLeft: 0, + }; + + source.addEventListener('mousedown', function (event) { + if (event.button != null && event.button !== 0) return; + drag.active = true; + drag.startClientX = event.clientX != null ? event.clientX : 0; + drag.startScrollLeft = source.scrollLeft || 0; + source.classList.add('is-dragging'); + if (event.preventDefault) event.preventDefault(); + }); + + source.addEventListener('mousemove', function (event) { + if (!drag.active) return; + var clientX = event.clientX != null ? event.clientX : drag.startClientX; + var delta = clientX - drag.startClientX; + source.scrollLeft = clampNumber(drag.startScrollLeft - delta, 0, getMaxScrollLeft(source)); + handleScroll(source, target, state, root, zoomButtons); + if (event.preventDefault) event.preventDefault(); + }); + + function finishDrag() { + if (!drag.active) return; + drag.active = false; + source.classList.remove('is-dragging'); + } + + source.addEventListener('mouseup', finishDrag); + source.addEventListener('mouseleave', finishDrag); +} + +function handleScroll(source, target, state, root, zoomButtons) { + if (state.destroyed) return; + if (!state.layout) return; + if (state.scrollSync === source) return; + + state.scrollSync = source; + target.scrollLeft = source.scrollLeft; + state.viewport = scrollLeftToViewport(state, source); + updateViewportMetadata(root, state); + updateZoomButtons(zoomButtons, state); + state.scrollSync = null; +} + +function measurePackedHeight(packed) { + return packed.trackCount > 0 + ? TRACK_PADDING * 2 + packed.trackCount * TRACK_HEIGHT + Math.max(0, packed.trackCount - 1) * TRACK_GAP + : OVERVIEW_HEIGHT; +} + +function buildDetailBlockConfig(item: unknown, lane: unknown, trackIndex: number, top: number, config: { clusterId?: string | null; detailHint?: string } = {}) { + const i = item as { endMinute: number; id: string; label: string; meta: unknown; startMinute: number; tone: string }; + const l = lane as { id: string }; + return { + clusterId: config.clusterId || null, + detailHint: config.detailHint || '', + endMinute: i.endMinute, + height: TRACK_HEIGHT, + itemId: i.id, + kindClass: 'sf-rail-timeline-item--detail', + label: i.label, + metaLabel: describeMeta(i.meta), + startMinute: i.startMinute, + top: top, + ariaLabel: buildItemAriaLabel(i, l), + tooltip: buildItemTooltip(i, l), + tone: i.tone, + trackIndex: trackIndex, + }; +} + +function buildOverviewBlockConfig(group, height, options) { + var config = options || {}; + return { + clusterId: config.clusterId || null, + endMinute: group.endMinute, + height: OVERVIEW_BLOCK_HEIGHT, + itemId: config.itemId, + kindClass: config.kindClass, + label: group.summary.primaryLabel, + metaLabel: group.summary.secondaryLabel, + onClick: config.onClick || null, + startMinute: group.startMinute, + summary: buildOverviewBlockSummary(group, !!config.expanded), + top: config.top != null ? config.top : Math.max(Math.round((height - OVERVIEW_BLOCK_HEIGHT) / 2), TRACK_PADDING), + ariaLabel: buildOverviewAriaLabel(group, group.lane, !!config.expanded), + expanded: !!config.expanded, + tooltip: config.tooltip, + tone: group.tone, + }; +} + +function buildDetailedRender(lane, items) { + var packed = packItems(items); + var height = measurePackedHeight(packed); + + var blocks = packed.items.map(function (entry) { + return buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP) + ); + }); + + return { + blocks: blocks, + height: height, + trackCount: packed.trackCount || 1, + }; +} + +function buildOverviewRender(lane, state, rerender) { + var groups = groupOverviewItems(lane); + var expandedClusterId = state.expandedClusters[lane.id] || null; + var expandedGroup: RailOverviewGroup|null = null; + var packedExpanded = null; + var expandedDetailsTop = 0; + + groups.forEach(function (group) { + if (!expandedGroup && expandedClusterId && group.clusterKey === expandedClusterId && group.isCluster) { + expandedGroup = group; + } + }); + + if (expandedGroup) { + packedExpanded = packItems(expandedGroup.detailItems); + expandedDetailsTop = TRACK_PADDING + OVERVIEW_BLOCK_HEIGHT + TRACK_GAP; + } + + var height = packedExpanded + ? Math.max(OVERVIEW_HEIGHT, expandedDetailsTop + measurePackedHeight(packedExpanded)) + : OVERVIEW_HEIGHT; + + var blocks = []; + groups.forEach(function (group) { + if (group.isCluster) { + var isExpanded = !!(expandedGroup && group.renderId === expandedGroup.renderId); + blocks.push(buildOverviewBlockConfig(group, height, { + clusterId: group.clusterKey, + itemId: group.renderId, + kindClass: 'sf-rail-timeline-item--cluster', + expanded: isExpanded, + onClick: function () { + setExpandedCluster( + state, + lane.id, + state.expandedClusters[lane.id] === group.clusterKey ? null : group.clusterKey + ); + if (state.config && state.config.onClusterToggle) { + state.config.onClusterToggle(lane.id, state.expandedClusters[lane.id] || null); + } + if (typeof rerender === 'function') rerender(); + }, + top: isExpanded ? TRACK_PADDING : null, + tooltip: buildClusterTooltip(group, lane), + })); + if (isExpanded) { + packedExpanded.items.forEach(function (entry) { + blocks.push(buildDetailBlockConfig( + entry.item, + lane, + entry.trackIndex, + expandedDetailsTop + TRACK_PADDING + entry.trackIndex * (TRACK_HEIGHT + TRACK_GAP), + { + clusterId: group.clusterKey, + detailHint: 'Expanded', + } + )); + }); + } + return; + } + + blocks.push(buildOverviewBlockConfig(group, height, { + itemId: group.items[0].id, + kindClass: 'sf-rail-timeline-item--overview', + tooltip: buildOverviewTooltip(group, lane), + })); + }); + + return { + blocks: blocks, + expandedClusterId: expandedGroup ? expandedGroup.clusterKey : null, + height: height, + trackCount: packedExpanded ? Math.max(packedExpanded.trackCount, 1) : 1, + }; +} + +function buildLaneLabel(lane, laneRender, row, headingId) { + var label = el('div', { + className: 'sf-rail-timeline-lane-label', + dataset: { laneId: lane.id }, + }); + label.style.minHeight = laneRender.height + 'px'; + + var heading = el('div', { className: 'sf-rail-timeline-lane-heading' }); + var title = el('div', { className: 'sf-rail-timeline-lane-title' }, lane.label); + title.id = headingId; + heading.appendChild(title); + if (lane.mode) { + heading.appendChild(el('div', { className: 'sf-rail-timeline-lane-mode' }, lane.mode)); + } + label.appendChild(heading); + if (row) row.setAttribute('aria-labelledby', title.id); + + if (lane.badges.length > 0) { + var badges = el('div', { className: 'sf-rail-timeline-lane-badges' }); + lane.badges.forEach(function (badge) { + var badgeEl = el('span', { className: 'sf-rail-timeline-lane-badge' }, badge.label); + if (badge.style) { + badgeEl.style.background = badge.style.bg || ''; + badgeEl.style.border = badge.style.border || ''; + badgeEl.style.color = badge.style.color || ''; + } + badges.appendChild(badgeEl); + }); + label.appendChild(badges); + } + + if (lane.stats.length > 0) { + var stats = el('div', { className: 'sf-rail-timeline-lane-stats' }); + lane.stats.forEach(function (stat) { + var statRow = el('div', { className: 'sf-rail-timeline-lane-stat' }); + statRow.appendChild(el('span', { className: 'sf-rail-timeline-lane-stat-label' }, stat.label)); + statRow.appendChild(el('span', { className: 'sf-rail-timeline-lane-stat-value' }, String(stat.value))); + stats.appendChild(statRow); + }); + label.appendChild(stats); + } + + return label; +} + +function buildClusterTooltip(group, lane) { + var first = group.detailItems[0] || group.items[0]; + var payload = { + rows: [ + { key: 'Lane', value: lane.label }, + { key: 'Window', value: formatMinuteRange(group.startMinute, group.endMinute, lane.axis) }, + { key: 'Items', value: String(group.summary.count) }, + ], + title: group.label, + }; + + if (group.summary.openCount > 0) { + payload.rows.push({ key: 'Open', value: String(group.summary.openCount) }); + } + if (group.summary.toneSegments.length > 0) { + payload.rows.push({ key: 'Mix', value: describeToneSegments(group.summary.toneSegments) }); + } + + if (first && first.meta) { + payload.rows.push({ key: 'Sample', value: describeMeta(first.meta) }); + } + + return payload; +} + +function buildItemTooltip(item, lane) { + var rows = [ + { key: 'Lane', value: lane.label }, + { key: 'Time', value: formatMinuteRange(item.startMinute, item.endMinute, lane.axis) }, + ]; + + appendMetaRows(rows, item.meta); + + return { + rows: rows, + title: item.label, + }; +} + +function buildOverviewBlockMeta(group) { + if (group.summary && group.summary.secondaryLabel) return group.summary.secondaryLabel; + var labels = []; + group.items.slice(0, 2).forEach(function (item) { + labels.push(item.label); + }); + if (group.count > 2) labels.push('+' + (group.count - 2) + ' more'); + return labels.join(' \u2022 '); +} + +function buildPresetViewport(axis, currentViewport, preset) { + var duration = preset === '1w' ? WEEK_MINUTES : preset === '2w' ? WEEK_MINUTES * 2 : WEEK_MINUTES * 4; + var visibleDuration = clampNumber(duration, DAY_MINUTES, axis.endMinute - axis.startMinute); + var center = currentViewport.startMinute + (currentViewport.endMinute - currentViewport.startMinute) / 2; + var start = Math.round(center - visibleDuration / 2); + return clampViewport(axis, { + startMinute: start, + endMinute: start + visibleDuration, + }); +} + +function clampNumber(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function clampViewport(axis, viewport) { + var totalDuration = axis.endMinute - axis.startMinute; + var next = viewport || axis.initialViewport || { + startMinute: axis.startMinute, + endMinute: axis.endMinute, + }; + var duration = next.endMinute - next.startMinute; + duration = Math.min(duration, totalDuration); + + var start = clampNumber(next.startMinute, axis.startMinute, axis.endMinute - duration); + + return { + endMinute: start + duration, + startMinute: start, + }; +} + +function assertFiniteNumber(value, label) { + assert(typeof value === 'number' && isFinite(value), label + ' must be a finite number'); + return value; +} + +function assertMinuteValue(value, label) { + return assertInteger(value, label); +} + +function assertInteger(value, label) { + var number = assertFiniteNumber(value, label); + assert(Math.floor(number) === number, label + ' must be an integer'); + return number; +} + +function assertNonNegativeInteger(value, label) { + var number = assertInteger(value, label); + assert(number >= 0, label + ' must be greater than or equal to zero'); + return number; +} + +function describeMeta(meta) { + if (meta == null) return ''; + if (typeof meta === 'string') return meta; + if (typeof meta === 'number') return String(meta); + if (Array.isArray(meta)) { + return meta.map(function (entry) { + if (entry && entry.label && entry.value != null) return entry.label + ': ' + entry.value; + return String(entry || ''); + }).filter(Boolean).join(' \u2022 '); + } + if (typeof meta === 'object') { + return Object.keys(meta).map(function (key) { + return key + ': ' + meta[key]; + }).join(' \u2022 '); + } + return String(meta); +} + +function appendMetaRows(rows, meta) { + if (meta == null) return; + if (typeof meta === 'string' || typeof meta === 'number') { + rows.push({ key: 'Meta', value: String(meta) }); + return; + } + if (Array.isArray(meta)) { + meta.forEach(function (entry, index) { + if (!entry) return; + if (entry.label && entry.value != null) { + rows.push({ key: entry.label, value: String(entry.value) }); + return; + } + rows.push({ key: 'Meta ' + (index + 1), value: String(entry) }); + }); + return; + } + if (typeof meta === 'object') { + Object.keys(meta).forEach(function (key) { + rows.push({ key: key, value: String(meta[key]) }); + }); + } +} + +function normalizeMinuteRange(startValue, endValue, startLabel, endLabel) { + var startMinute = assertMinuteValue(startValue, startLabel); + var endMinute = assertMinuteValue(endValue, endLabel); + assert(endMinute > startMinute, endLabel + ' must be greater than startMinute'); + return { + endMinute: endMinute, + startMinute: startMinute, + }; +} + +function normalizeId(value, prefix, suffix) { + return value != null ? String(value) : prefix + suffix; +} + +function buildScopedId(scope, suffix) { + return scope + '-' + suffix; +} + +function setExpandedCluster(state, laneId, clusterId) { + if (clusterId == null) delete state.expandedClusters[laneId]; + else state.expandedClusters[laneId] = String(clusterId); +} + +function normalizeAxis(axis) { + assert(axis && axis.startMinute != null && axis.endMinute != null, 'createTimeline(model.axis.startMinute/endMinute) are required'); + var axisRange = normalizeMinuteRange( + axis.startMinute, + axis.endMinute, + 'createTimeline(model.axis.startMinute)', + 'createTimeline(model.axis.endMinute)' + ); + + var normalized: Record = { + endMinute: axisRange.endMinute, + startMinute: axisRange.startMinute, + }; + + normalized.days = normalizeDays(axis.days, normalized.startMinute, normalized.endMinute); + normalized.ticks = normalizeTicks(axis.ticks, normalized.startMinute, normalized.endMinute); + normalized.initialViewport = clampViewport( + normalized, + normalizeViewportInput(axis.initialViewport, 'createTimeline(model.axis.initialViewport)') || { + startMinute: normalized.startMinute, + endMinute: normalized.endMinute, + } + ); + + return normalized; +} + +function normalizeBadge(badge) { + if (!badge) return null; + if (typeof badge === 'string') return { label: badge }; + return { + label: badge.label || '', + style: badge.style || null, + }; +} + +function normalizeDays(days, startMinute, endMinute) { + var list = []; + var source = Array.isArray(days) && days.length > 0 ? days : null; + var cursor = startMinute; + var index = 0; + + if (!source) { + while (cursor < endMinute) { + list.push(makeDay({ + endMinute: Math.min(cursor + DAY_MINUTES, endMinute), + isWeekend: false, + label: 'Day ' + (index + 1), + startMinute: cursor, + }, index)); + cursor += DAY_MINUTES; + index += 1; + } + return list; + } + + source.forEach(function (day, dayIndex) { + if (cursor >= endMinute) return; + if (typeof day === 'string') { + var generatedEnd = Math.min(cursor + DAY_MINUTES, endMinute); + list.push(makeDay({ + endMinute: generatedEnd, + isWeekend: inferWeekend(day), + label: day, + startMinute: cursor, + }, dayIndex)); + cursor = generatedEnd; + return; + } + + var nextStart = day.startMinute != null + ? day.startMinute + : cursor; + var nextEnd = day.endMinute != null + ? day.endMinute + : Math.min(nextStart + DAY_MINUTES, endMinute); + var dayRange = normalizeMinuteRange( + nextStart, + nextEnd, + 'createTimeline(model.axis.days[' + dayIndex + '].startMinute)', + 'createTimeline(model.axis.days[' + dayIndex + '].endMinute)' + ); + list.push(makeDay({ + endMinute: dayRange.endMinute, + isWeekend: day.isWeekend != null ? !!day.isWeekend : inferWeekend(day.label), + label: day.label || 'Day ' + (dayIndex + 1), + startMinute: dayRange.startMinute, + subLabel: day.subLabel || day.meta || '', + }, dayIndex)); + cursor = dayRange.endMinute; + }); + + return list; +} + +function normalizeItem(item, pathKey, ordinal) { + assert(item && item.startMinute != null && item.endMinute != null, 'timeline items require startMinute/endMinute'); + var itemRange = normalizeMinuteRange( + item.startMinute, + item.endMinute, + 'createTimeline(model.lanes[].items[].startMinute)', + 'createTimeline(model.lanes[].items[].endMinute)' + ); + + return { + clusterId: item.clusterId != null ? String(item.clusterId) : null, + detailItems: Array.isArray(item.detailItems) + ? item.detailItems.map(function (detailItem, detailIndex) { + return normalizeItem(detailItem, pathKey + '-' + detailIndex, detailIndex); + }) + : [], + endMinute: itemRange.endMinute, + id: normalizeId(item.id, 'item-', pathKey), + label: item.label || 'Item ' + (ordinal + 1), + meta: item.meta != null ? item.meta : '', + originalIndex: ordinal, + summary: normalizeOverviewSummary(item.summary, 'createTimeline(model.lanes[].items[].summary)'), + startMinute: itemRange.startMinute, + tone: resolveTone(item.tone || item.color || 'slate'), + }; +} + +function normalizeLane(lane, index, axis) { + assert(lane && Array.isArray(lane.items), 'timeline lanes require an items array'); + + var normalizedLane = { + axis: axis, + badges: [], + id: normalizeId(lane.id, 'lane-', index), + items: lane.items.map(function (item, itemIndex) { + return normalizeItem(item, index + '-' + itemIndex, itemIndex); + }), + label: lane.label || 'Lane ' + (index + 1), + mode: lane.mode === 'overview' ? 'overview' : 'detailed', + overlays: Array.isArray(lane.overlays) + ? lane.overlays.map(function (overlay, overlayIndex) { + return normalizeOverlay(overlay, overlayIndex, axis); + }).filter(Boolean) + : [], + stats: Array.isArray(lane.stats) ? lane.stats : [], + }; + + normalizedLane.items.sort(compareItems); + + if (Array.isArray(lane.badges)) { + lane.badges.forEach(function (badge) { + var normalizedBadge = normalizeBadge(badge); + if (normalizedBadge) normalizedLane.badges.push(normalizedBadge); + }); + } else { + var singleBadge = normalizeBadge(lane.badges); + if (singleBadge) normalizedLane.badges.push(singleBadge); + } + + return normalizedLane; +} + +function normalizeModel(model) { + assert(model && model.axis && Array.isArray(model.lanes), 'createTimeline(model.axis/model.lanes) are required'); + var axis = normalizeAxis(model.axis); + + return { + axis: axis, + lanes: model.lanes.map(function (lane, index) { + return normalizeLane(lane, index, axis); + }), + }; +} + +function normalizeOverlay(overlay, index, axis) { + var label = 'createTimeline(model.lanes[].overlays[' + index + '])'; + assert(overlay && typeof overlay === 'object', label + ' must be an object'); + + var startMinute = overlay.startMinute; + var endMinute = overlay.endMinute; + + if ((startMinute == null || endMinute == null) && overlay.dayIndex != null) { + var dayIndex = assertInteger(overlay.dayIndex, label + '.dayIndex'); + var day = axis.days[dayIndex]; + assert(day, label + '.dayIndex must reference an existing day'); + var dayCount = overlay.dayCount == null ? 1 : assertInteger(overlay.dayCount, label + '.dayCount'); + assert(dayCount > 0, label + '.dayCount must be greater than zero'); + var lastDay = axis.days[Math.min(axis.days.length - 1, dayIndex + dayCount - 1)] || day; + startMinute = day.startMinute; + endMinute = lastDay.endMinute; + } + + assert( + startMinute != null && endMinute != null, + label + ' requires startMinute/endMinute or dayIndex/dayCount' + ); + var overlayRange = normalizeMinuteRange( + startMinute, + endMinute, + label + '.startMinute', + label + '.endMinute' + ); + + return { + endMinute: overlayRange.endMinute, + id: normalizeId(overlay.id, 'overlay-', index), + label: overlay.label || '', + meta: overlay.meta || '', + startMinute: overlayRange.startMinute, + tone: resolveTone(overlay.tone || overlay.color || 'slate'), + }; +} + +function normalizeTicks(ticks, startMinute, endMinute) { + var list = []; + + if (Array.isArray(ticks) && ticks.length > 0) { + ticks.forEach(function (tick, index) { + if (typeof tick === 'number') { + var numericTick = assertMinuteValue(tick, 'createTimeline(model.axis.ticks[' + index + '])'); + list.push({ id: 'tick-' + index, label: formatClock(numericTick), minute: numericTick }); + return; + } + assert(tick && typeof tick === 'object', 'createTimeline(model.axis.ticks[' + index + ']) must be a number or object'); + assert(tick.minute != null, 'createTimeline(model.axis.ticks[' + index + '].minute) is required'); + var minute = assertMinuteValue(tick.minute, 'createTimeline(model.axis.ticks[' + index + '].minute)'); + list.push({ + id: normalizeId(tick.id, 'tick-', index), + label: tick.label || formatClock(minute), + minute: minute, + }); + }); + return list; + } + + for (var minute = startMinute; minute < endMinute; minute += SIX_HOUR_MINUTES) { + list.push({ + id: 'tick-' + minute, + label: formatClock(minute), + minute: minute, + }); + } + + return list; +} + +function makeDay(day, index) { + return { + endMinute: day.endMinute, + id: normalizeId(day.id, 'day-', index), + isWeekend: !!day.isWeekend, + label: day.label || 'Day ' + (index + 1), + startMinute: day.startMinute, + subLabel: day.subLabel || '', + }; +} + +function compareItems(left, right) { + if (left.startMinute !== right.startMinute) return left.startMinute - right.startMinute; + if (left.endMinute !== right.endMinute) return left.endMinute - right.endMinute; + if (left.label !== right.label) return left.label < right.label ? -1 : 1; + return left.originalIndex - right.originalIndex; +} + +function normalizeOverviewSummary(summary, label) { + if (summary == null) return null; + assert(summary && typeof summary === 'object', label + ' must be an object'); + + var normalized = { + count: summary.count == null ? null : assertNonNegativeInteger(summary.count, label + '.count'), + openCount: summary.openCount == null ? null : assertNonNegativeInteger(summary.openCount, label + '.openCount'), + primaryLabel: summary.primaryLabel == null ? '' : String(summary.primaryLabel), + secondaryLabel: summary.secondaryLabel == null ? '' : String(summary.secondaryLabel), + toneSegments: Array.isArray(summary.toneSegments) + ? summary.toneSegments.map(function (segment, index) { + assert(segment && typeof segment === 'object', label + '.toneSegments[' + index + '] must be an object'); + return { + count: assertNonNegativeInteger(segment.count, label + '.toneSegments[' + index + '].count'), + tone: resolveTone(segment.tone || segment.color || 'slate'), + }; + }).filter(function (segment) { + return segment.count > 0; + }) + : [], + }; + + if (normalized.count != null && normalized.openCount != null) { + assert(normalized.openCount <= normalized.count, label + '.openCount must not exceed count'); + } + + return normalized; +} + +function renderAxisDecor(track, axis, includeLabels) { + appendWeekendBands(track, axis); + appendDayDividers(track, axis); + appendTicks(track, axis, includeLabels); + if (includeLabels) appendDayBands(track, axis); +} + +function appendDayBands(track, axis) { + axis.days.forEach(function (day) { + var band = el('div', { className: 'sf-rail-timeline-day-band' }); + band.style.left = positionPct(day.startMinute, axis) + '%'; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; + band.appendChild(el('div', { className: 'sf-rail-timeline-day-label' }, day.label)); + if (day.subLabel) { + band.appendChild(el('div', { className: 'sf-rail-timeline-day-sub' }, day.subLabel)); + } + track.appendChild(band); + }); +} + +function appendDayDividers(track, axis) { + axis.days.forEach(function (day, index) { + if (index === 0) return; + var divider = el('div', { className: 'sf-rail-timeline-day-divider' }); + divider.style.left = positionPct(day.startMinute, axis) + '%'; + track.appendChild(divider); + }); +} + +function appendTicks(track, axis, includeLabels) { + axis.ticks.forEach(function (tick) { + if (tick.minute < axis.startMinute || tick.minute >= axis.endMinute) return; + var tickEl = el('div', { className: 'sf-rail-timeline-tick' }); + tickEl.style.left = positionPct(tick.minute, axis) + '%'; + track.appendChild(tickEl); + + if (!includeLabels) return; + var label = el('div', { className: 'sf-rail-timeline-tick-label' }, tick.label); + label.style.left = positionPct(tick.minute, axis) + '%'; + track.appendChild(label); + }); +} + +function appendWeekendBands(track, axis) { + axis.days.forEach(function (day) { + if (!day.isWeekend) return; + var band = el('div', { className: 'sf-rail-timeline-weekend-band' }); + band.style.left = positionPct(day.startMinute, axis) + '%'; + band.style.width = spanPct(day.startMinute, day.endMinute, axis) + '%'; + track.appendChild(band); + }); +} + +function renderOverlays(track, overlays, axis) { + overlays.forEach(function (overlay) { + var band = el('div', { className: 'sf-rail-timeline-overlay' }); + band.style.left = positionPct(overlay.startMinute, axis) + '%'; + band.style.width = spanPct(overlay.startMinute, overlay.endMinute, axis) + '%'; + band.style.background = overlay.tone.overlay; + band.style.borderColor = overlay.tone.border; + if (overlay.label) band.title = overlay.label; + track.appendChild(band); + }); +} + +function groupOverviewItems(lane) { + var groups = []; + var current = null; + + lane.items.forEach(function (item) { + if (!current || item.startMinute > current.endMinute + OVERVIEW_GROUP_GAP_MINUTES) { + if (current) groups.push(current); + current = { + clusterId: item.clusterId, + endMinute: item.endMinute, + items: [item], + lane: lane, + startMinute: item.startMinute, + }; + return; + } + current.items.push(item); + current.endMinute = Math.max(current.endMinute, item.endMinute); + if (!current.clusterId && item.clusterId) current.clusterId = item.clusterId; + }); + if (current) groups.push(current); + + groups.forEach(function (group, groupIndex) { + finalizeGroup(group, lane, groupIndex); + }); + assertUniqueClusterKeys(lane, groups); + + return groups; +} + +function finalizeGroup(group, lane, index) { + var detailItems = []; + + group.items.forEach(function (item) { + if (item.detailItems.length > 0) { + item.detailItems.forEach(function (detailItem) { + detailItems.push(detailItem); + }); + return; + } + detailItems.push(item); + }); + + detailItems.sort(compareItems); + group.detailItems = detailItems; + group.isCluster = detailItems.length > 1 || group.items.some(function (item) { + return item.detailItems.length > 0; + }); + group.renderId = group.isCluster + ? buildScopedId('cluster', lane.id + '-' + index + '-' + (group.items[0] ? group.items[0].id : 'group')) + : normalizeId(group.items[0] ? group.items[0].id : null, 'group-', lane.id + '-' + index); + group.clusterKey = group.isCluster ? String(group.clusterId || group.renderId) : null; + group.summary = deriveOverviewSummary(group); + group.count = group.summary.count; + group.label = group.summary.primaryLabel; + group.metaLabel = group.summary.secondaryLabel; + group.tone = group.summary.primaryTone || dominantTone(group.detailItems); +} + +function assertUniqueClusterKeys(lane, groups) { + var seen = {}; + + groups.forEach(function (group) { + if (!group.clusterKey) return; + assert( + !seen[group.clusterKey], + 'createTimeline(model.lanes[].items[].clusterId) must identify at most one overview group per lane; lane "' + lane.id + '" reuses "' + group.clusterKey + '"' + ); + seen[group.clusterKey] = true; + }); +} + +function dominantTone(items) { + var toneSegments = buildToneSegmentsFromItems(items); + if (!toneSegments.length) return resolveTone('slate'); + return toneSegments[0].tone; +} + +function effectiveOverviewItems(item) { + return item.detailItems.length > 0 ? item.detailItems : [item]; +} + +function deriveOverviewContribution(item) { + var items = effectiveOverviewItems(item); + var summary = item.summary; + var derivedCount = items.length; + var count = summary && summary.count != null ? summary.count : derivedCount; + var canDeriveAggregateMetrics = !summary || summary.count == null || summary.count === derivedCount; + var openCount = null; + var toneSegments = []; + + if (summary && summary.openCount != null) openCount = summary.openCount; + else if (canDeriveAggregateMetrics) openCount = inferOpenCount(items); + + if (summary && summary.toneSegments.length > 0) toneSegments = summary.toneSegments; + else if (canDeriveAggregateMetrics) toneSegments = buildToneSegmentsFromItems(items); + + return { + count: count, + openCount: openCount, + openCountKnown: openCount != null, + toneSegments: toneSegments, + toneSegmentsKnown: summary && summary.toneSegments.length > 0 + ? true + : canDeriveAggregateMetrics, + }; +} + +function deriveOverviewSummary(group) { + var contributions = group.items.map(deriveOverviewContribution); + var summaries = group.items.map(function (item) { + return item.summary; + }).filter(Boolean); + var count = contributions.reduce(function (sum, contribution) { + return sum + contribution.count; + }, 0); + var openCount = contributions.every(function (contribution) { + return contribution.openCountKnown; + }) + ? contributions.reduce(function (sum, contribution) { + return sum + contribution.openCount; + }, 0) + : null; + var toneSegments = contributions.every(function (contribution) { + return contribution.toneSegmentsKnown; + }) + ? mergeToneSegments(contributions.reduce(function (segments, contribution) { + return segments.concat(contribution.toneSegments); + }, [])) + : []; + var primarySummary = summaries.length === 1 ? summaries[0] : null; + + return { + count: count, + openCount: openCount, + primaryLabel: primarySummary && primarySummary.primaryLabel + ? primarySummary.primaryLabel + : count > 1 + ? count + ' assignments' + : group.items[0].label, + primaryTone: toneSegments[0] ? toneSegments[0].tone : dominantTone(group.detailItems), + secondaryLabel: primarySummary && primarySummary.secondaryLabel + ? primarySummary.secondaryLabel + : count > 1 + ? buildOverviewBlockMeta({ + count: count, + items: group.detailItems, + }) + : describeMeta(group.items[0].meta), + toneSegments: toneSegments, + }; +} + +function inferOpenCount(items) { + return items.reduce(function (count, item) { + if (!item) return count; + if (item.summary && item.summary.openCount != null) return count + item.summary.openCount; + if (!item.meta || typeof item.meta !== 'object' || Array.isArray(item.meta)) return count; + if (typeof item.meta.openCount === 'number' && isFinite(item.meta.openCount)) return count + item.meta.openCount; + if (typeof item.meta.unassignedCount === 'number' && isFinite(item.meta.unassignedCount)) return count + item.meta.unassignedCount; + if (item.meta.open === true || item.meta.unassigned === true) return count + 1; + if (typeof item.meta.status === 'string' && /open|unassigned/i.test(item.meta.status)) return count + 1; + return count; + }, 0); +} + +function mergeToneSegments(segments) { + var byTone = {}; + segments.forEach(function (segment) { + if (!segment || !(segment.count > 0)) return; + var toneId = segment.tone.id || segment.tone.border || 'slate'; + if (!byTone[toneId]) { + byTone[toneId] = { + count: 0, + tone: segment.tone, + }; + } + byTone[toneId].count += segment.count; + }); + return Object.keys(byTone).map(function (toneId) { + return byTone[toneId]; + }).sort(compareToneSegments); +} + +function buildToneSegmentsFromItems(items) { + return mergeToneSegments(items.map(function (item) { + return { + count: 1, + tone: item.tone, + }; + })); +} + +function compareToneSegments(left, right) { + if (left.count !== right.count) return right.count - left.count; + if (left.tone.id === right.tone.id) return 0; + return left.tone.id < right.tone.id ? -1 : 1; +} + +function buildOverviewBlockSummary(group, expanded) { + var badges = []; + if (group.summary.count > 1) { + badges.push({ kind: 'count', text: group.summary.count + ' total' }); + } + if (group.summary.openCount > 0) { + badges.push({ kind: 'open', text: group.summary.openCount + ' open' }); + } + if (group.isCluster) { + badges.push({ kind: 'action', text: expanded ? 'Enter to collapse' : 'Enter to inspect' }); + } + return { + badges: badges, + toneSegments: group.summary.toneSegments, + }; +} + +function buildItemAriaLabel(item, lane) { + var parts = [ + lane.label, + item.label, + formatMinuteRange(item.startMinute, item.endMinute, lane.axis), + ]; + var meta = describeMeta(item.meta); + if (meta) parts.push(meta); + return parts.join(' \u00b7 '); +} + +function buildOverviewAriaLabel(group, lane, expanded) { + var parts = [ + lane.label, + group.summary.primaryLabel, + formatMinuteRange(group.startMinute, group.endMinute, lane.axis), + ]; + if (group.summary.secondaryLabel) parts.push(group.summary.secondaryLabel); + if (group.summary.count > 1) parts.push(group.summary.count + ' assignments'); + if (group.summary.openCount > 0) parts.push(group.summary.openCount + ' open'); + if (group.summary.toneSegments.length > 0) parts.push(describeToneSegments(group.summary.toneSegments)); + if (group.isCluster) parts.push(expanded ? 'Expanded. Press Enter to collapse' : 'Press Enter to expand'); + return parts.join(' \u00b7 '); +} + +function describeToneSegments(segments) { + return segments.map(function (segment) { + return segment.count + ' ' + segment.tone.id; + }).join(', '); +} + +function buildOverviewTooltip(group, lane) { + if (group.summary.count > 1 || group.summary.openCount > 0 || group.summary.toneSegments.length > 1) { + return buildClusterTooltip(group, lane); + } + return buildItemTooltip(group.items[0], lane); +} + +function packItems(items) { + var trackEnds = []; + var packed = []; + + items.slice().sort(compareItems).forEach(function (item) { + var trackIndex = 0; + while (trackIndex < trackEnds.length && item.startMinute < trackEnds[trackIndex]) { + trackIndex += 1; + } + if (trackIndex === trackEnds.length) trackEnds.push(item.endMinute); + else trackEnds[trackIndex] = item.endMinute; + packed.push({ + item: item, + trackIndex: trackIndex, + }); + }); + + return { + items: packed, + trackCount: trackEnds.length, + }; +} + +function positionPct(minute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return ((minute - axis.startMinute) / total) * 100; +} + +function spanPct(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max(((endMinute - startMinute) / total) * 100, 0.25); +} + +function spanPctExact(startMinute, endMinute, axis) { + var total = axis.endMinute - axis.startMinute; + if (total <= 0) return 0; + return Math.max(((endMinute - startMinute) / total) * 100, 0); +} + +function formatClock(minute) { + var normalized = minute % DAY_MINUTES; + if (normalized < 0) normalized += DAY_MINUTES; + var hours = Math.floor(normalized / 60); + var minutes = normalized % 60; + return pad(hours) + ':' + pad(minutes); +} + +function formatMinuteRange(startMinute, endMinute, axis) { + return formatMinute(startMinute, axis) + ' \u2192 ' + formatMinute(endMinute, axis); +} + +function formatMinute(minute, axis) { + var dayLabel = ''; + axis.days.forEach(function (day) { + if (minute >= day.startMinute && minute < day.endMinute && !dayLabel) { + dayLabel = day.label; + } + }); + return (dayLabel ? dayLabel + ' ' : '') + formatClock(minute); +} + +function pad(value) { + return value < 10 ? '0' + value : String(value); +} + +function inferWeekend(label) { + return /sat|sun|weekend/i.test(String(label || '')); +} + +function isColorString(value) { + return /^#|^rgb|^hsl/i.test(String(value || '')); +} + +function resolveTone(tone) { + if (tone && typeof tone === 'object') { + return { + id: tone.id || tone.name || tone.borderColor || tone.color || 'custom', + background: tone.background || tone.bg || tone.color || TONE_MAP.slate.background, + border: tone.border || tone.borderColor || tone.color || TONE_MAP.slate.border, + overlay: tone.overlay || tone.band || tone.background || tone.bg || TONE_MAP.slate.overlay, + text: tone.text || tone.textColor || tone.foreground || TONE_MAP.slate.text, + }; + } + if (TONE_MAP[tone]) return TONE_MAP[tone]; + if (isColorString(tone)) { + return { + id: String(tone), + background: tone, + border: tone, + overlay: tone, + text: '#111827', + }; + } + return TONE_MAP.slate; +} + +function measureLayout(bodyViewport, state) { + var viewportWidth = getMeasuredViewportWidth(bodyViewport); + if (!(viewportWidth > 0)) return null; + + var preferredLabelWidth = state.labelWidth; + var maxLabelWidth = viewportWidth - MIN_VISIBLE_TRACK_WIDTH; + var effectiveLabelWidth = preferredLabelWidth; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var totalDuration = state.model.axis.endMinute - state.model.axis.startMinute; + var scale = totalDuration > 0 && visibleDuration > 0 + ? totalDuration / visibleDuration + : 1; + if (effectiveLabelWidth < MIN_LABEL_WIDTH) effectiveLabelWidth = MIN_LABEL_WIDTH; + if (maxLabelWidth >= MIN_LABEL_WIDTH) effectiveLabelWidth = Math.min(effectiveLabelWidth, maxLabelWidth); + else effectiveLabelWidth = MIN_LABEL_WIDTH; + + var visibleTrackWidth = Math.max(viewportWidth - effectiveLabelWidth, 0); + var contentTrackWidth = Math.max( + Math.round(visibleTrackWidth * scale), + visibleTrackWidth, + MIN_CONTENT_TRACK_WIDTH + ); + var contentWidth = effectiveLabelWidth + contentTrackWidth; + + return { + contentWidth: contentWidth, + contentTrackWidth: contentTrackWidth, + effectiveLabelWidth: effectiveLabelWidth, + visibleTrackWidth: visibleTrackWidth, + viewportWidth: viewportWidth, + }; +} + +function viewportToScrollLeft(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) return 0; + return Math.round(((state.viewport.startMinute - axis.startMinute) / remainingDuration) * maxScrollLeft); +} + +function scrollLeftToViewport(state, viewportEl) { + var axis = state.model.axis; + var totalDuration = axis.endMinute - axis.startMinute; + var visibleDuration = state.viewport.endMinute - state.viewport.startMinute; + var remainingDuration = Math.max(totalDuration - visibleDuration, 0); + var maxScrollLeft = getMaxScrollLeft(viewportEl); + if (remainingDuration <= 0 || maxScrollLeft <= 0) { + return clampViewport(axis, { + startMinute: axis.startMinute, + endMinute: axis.startMinute + visibleDuration, + }); + } + var ratio = clampNumber((viewportEl.scrollLeft || 0) / maxScrollLeft, 0, 1); + var startMinute = axis.startMinute + remainingDuration * ratio; + return clampViewport(axis, { + startMinute: startMinute, + endMinute: startMinute + visibleDuration, + }); +} + +function getMaxScrollLeft(viewportEl) { + var scrollWidth = viewportEl.scrollWidth || 0; + var clientWidth = viewportEl.clientWidth || viewportEl.offsetWidth || 0; + return Math.max(scrollWidth - clientWidth, 0); +} + +function bindResizeObserver(bodyViewport, state, syncLayoutFromViewport) { + if (typeof ResizeObserver !== 'function') return; + + var resizeObserver = new ResizeObserver(function () { + if (state.destroyed) return; + syncLayoutFromViewport(); + }); + resizeObserver.observe(bodyViewport); + state.cleanup.push(function () { + resizeObserver.disconnect(); + }); +} + +function bindWindowResize(state, syncLayoutFromViewport) { + if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return; + + function handleResize() { + if (state.destroyed) return; + syncLayoutFromViewport(); + } + + window.addEventListener('resize', handleResize); + state.cleanup.push(function () { + if (typeof window.removeEventListener === 'function') window.removeEventListener('resize', handleResize); + }); +} + +function getMeasuredViewportWidth(bodyViewport) { + if (!bodyViewport) return 0; + if (typeof bodyViewport.clientWidth === 'number' && bodyViewport.clientWidth > 0) { + return Math.round(bodyViewport.clientWidth); + } + if (typeof bodyViewport.offsetWidth === 'number' && bodyViewport.offsetWidth > 0) { + return Math.round(bodyViewport.offsetWidth); + } + if (typeof bodyViewport.getBoundingClientRect === 'function') { + var rect = bodyViewport.getBoundingClientRect(); + if (rect && typeof rect.width === 'number' && rect.width > 0) { + return Math.round(rect.width); + } + } + return 0; +} + +function applyLayout(root, headerRow, lanes, layout) { + setCustomProperty(root.style, '--sf-rail-label-width', layout ? layout.effectiveLabelWidth + 'px' : ''); + setCustomProperty(root.style, '--sf-rail-content-width', layout ? layout.contentWidth + 'px' : ''); + headerRow.style.width = layout ? layout.contentWidth + 'px' : ''; + lanes.style.width = layout ? layout.contentWidth + 'px' : ''; + root.dataset.supportedViewportWidth = layout + ? String(layout.viewportWidth >= MIN_SUPPORTED_VIEWPORT_WIDTH) + : ''; +} + +function setCustomProperty(style, name, value) { + if (!style) return; + if (typeof style.setProperty === 'function') { + style.setProperty(name, value); + return; + } + style[name] = value; +} + +function queuePostMountSync(state, syncLayoutFromViewport) { + if (state.hasQueuedPostMountSync || typeof setTimeout !== 'function') return; + state.hasQueuedPostMountSync = true; + + var timerId = setTimeout(function () { + state.hasQueuedPostMountSync = false; + if (state.destroyed) return; + syncLayoutFromViewport(); + }, 0); + + state.cleanup.push(function () { + if (typeof clearTimeout === 'function') clearTimeout(timerId); + }); +} + +function normalizeViewportInput(viewport, label) { + if (viewport == null) return null; + assert(typeof viewport === 'object', label + ' must be an object'); + + return normalizeMinuteRange( + viewport.startMinute, + viewport.endMinute, + label + '.startMinute', + label + '.endMinute' + ); +} + +function showTooltip(tooltip, root, payload, event) { + if (!payload) return; + tooltip.setAttribute('aria-hidden', 'false'); + tooltip.innerHTML = ''; + tooltip.appendChild(el('div', { className: 'sf-tooltip-title' }, payload.title)); + (payload.rows || []).forEach(function (row) { + var rowEl = el('div', { className: 'sf-tooltip-row' }); + rowEl.appendChild(el('span', { className: 'sf-tooltip-key' }, row.key)); + rowEl.appendChild(el('span', { className: 'sf-tooltip-val' }, row.value)); + tooltip.appendChild(rowEl); + }); + + var hostRect = root.getBoundingClientRect ? root.getBoundingClientRect() : { left: 0, top: 0 }; + var left = event && event.clientX != null ? event.clientX + 16 : hostRect.left + 16; + var top = event && event.clientY != null ? event.clientY + 16 : hostRect.top + 16; + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + tooltip.classList.add('visible'); +} + +function showTooltipForElement(tooltip, root, payload, element) { + var rect = element && typeof element.getBoundingClientRect === 'function' + ? element.getBoundingClientRect() + : null; + showTooltip(tooltip, root, payload, rect ? { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + } : null); +} + +function hideTooltip(tooltip) { + tooltip.setAttribute('aria-hidden', 'true'); + tooltip.classList.remove('visible'); +} + +function updateViewportMetadata(root, state) { + var axis = state.model.axis; + var duration = state.viewport.endMinute - state.viewport.startMinute; + root.dataset.timelineSpanMinutes = String(axis.endMinute - axis.startMinute); + root.dataset.viewportDurationMinutes = String(Math.round(duration)); + root.dataset.viewportStartMinute = String(Math.round(state.viewport.startMinute)); + root.dataset.viewportEndMinute = String(Math.round(state.viewport.endMinute)); +} + +function updateZoomButtons(buttons, state) { + var duration = Math.round(state.viewport.endMinute - state.viewport.startMinute); + var initial = state.model.axis.initialViewport; + buttons.forEach(function (button) { + var preset = button.dataset.zoom; + var active = false; + if (preset === 'reset') { + active = Math.round(initial.startMinute) === Math.round(state.viewport.startMinute) + && Math.round(initial.endMinute) === Math.round(state.viewport.endMinute); + } else if (preset === '1w') active = duration === WEEK_MINUTES; + else if (preset === '2w') active = duration === WEEK_MINUTES * 2; + else if (preset === '4w') active = duration === WEEK_MINUTES * 4; + button.classList.toggle('active', active); + }); +} + +function normalizeZoomPresets(presets) { + if (presets == null) return ['1w', '2w', '4w', 'reset']; + assert(Array.isArray(presets), 'rail.createTimeline(zoomPresets) must be an array'); + presets.forEach(function (preset, index) { + assert( + ['1w', '2w', '4w', 'reset'].indexOf(preset) >= 0, + 'rail.createTimeline(zoomPresets[' + index + ']) must be one of 1w, 2w, 4w, reset' + ); + }); + return presets.slice(); +} + +function pruneExpandedClusters(state) { + Object.keys(state.expandedClusters).forEach(function (laneId) { + var exists = state.model.lanes.some(function (lane) { + return lane.id === laneId; + }); + if (!exists) delete state.expandedClusters[laneId]; + }); +} diff --git a/ts-src/rail/timeline.types.ts b/ts-src/rail/timeline.types.ts new file mode 100644 index 0000000..9f77091 --- /dev/null +++ b/ts-src/rail/timeline.types.ts @@ -0,0 +1,284 @@ +/* ============================================================================ + * PUBLIC API TYPES + * ========================================================================== */ + +export interface TimelineConfig { + model: TimelineModel; + + title?: string; + subtitle?: string; + label?: string; + + labelWidth?: number; + + zoomPresets?: ZoomPreset[]; + + onClusterToggle?: ( + laneId: string, + clusterId: string | null + ) => void; +} + +export type ZoomPreset = + | '1w' + | '2w' + | '4w' + | 'reset'; + +export interface TimelineApi { + el: HTMLElement; + + destroy(): void; + + expandCluster( + laneId: string, + clusterId: string | null + ): void; + + setModel(nextModel: TimelineModel): void; + + setViewport(nextViewport: TimelineViewport): void; +} + +/* ============================================================================ + * MODEL + * ========================================================================== */ + +export interface TimelineModel { + axis: TimelineAxis; + lanes: TimelineLane[]; +} + +export interface TimelineAxis { + startMinute: number; + endMinute: number; + + days?: TimelineDay[]; + ticks?: TimelineTick[]; + + initialViewport?: TimelineViewport; +} + +export interface TimelineViewport { + startMinute: number; + endMinute: number; +} + +/* ============================================================================ + * AXIS + * ========================================================================== */ + +export type TimelineDay = + | string + | TimelineDayObject; + +export interface TimelineDayObject { + id?: string; + + startMinute?: number; + endMinute?: number; + + label?: string; + subLabel?: string; + meta?: string; + + isWeekend?: boolean; +} + +export type TimelineTick = + | number + | TimelineTickObject; + +export interface TimelineTickObject { + id?: string; + + minute: number; + + label?: string; +} + +/* ============================================================================ + * LANES + * ========================================================================== */ + +export interface TimelineLane { + id?: string; + + label?: string; + + mode?: 'overview' | 'detailed'; + + items: TimelineItem[]; + + overlays?: TimelineOverlay[]; + + badges?: TimelineBadge | TimelineBadge[]; + + stats?: TimelineStat[]; +} + +export interface TimelineStat { + label: string; + value: string | number; +} + +export type TimelineBadge = + | string + | TimelineBadgeObject; + +export interface TimelineBadgeObject { + label: string; + + style?: { + bg?: string; + border?: string; + color?: string; + }; +} + +/* ============================================================================ + * ITEMS + * ========================================================================== */ + +export interface TimelineItem { + id?: string; + + startMinute: number; + endMinute: number; + + label?: string; + + meta?: TimelineMeta; + + tone?: TimelineToneInput; + color?: TimelineToneInput; + + clusterId?: string; + + detailItems?: TimelineItem[]; + + summary?: TimelineOverviewSummary; +} + +export type TimelineMeta = + | string + | number + | Record + | TimelineMetaEntry[]; + +export interface TimelineMetaEntry { + label?: string; + value?: unknown; +} + +/* ============================================================================ + * OVERVIEW SUMMARY + * ========================================================================== */ + +export interface TimelineOverviewSummary { + count?: number; + + openCount?: number; + + primaryLabel?: string; + secondaryLabel?: string; + + toneSegments?: TimelineToneSegment[]; +} + +export interface TimelineToneSegment { + count: number; + + tone?: TimelineToneInput; + color?: TimelineToneInput; +} + +/* ============================================================================ + * OVERLAYS + * ========================================================================== */ + +export interface TimelineOverlay { + id?: string; + + label?: string; + meta?: string; + + startMinute?: number; + endMinute?: number; + + dayIndex?: number; + dayCount?: number; + + tone?: TimelineToneInput; + color?: TimelineToneInput; +} + +/* ============================================================================ + * TONES + * ========================================================================== */ + +export type TimelineToneInput = + | TimelineToneName + | string + | TimelineToneObject; + +export type TimelineToneName = + | 'emerald' + | 'blue' + | 'amber' + | 'rose' + | 'violet' + | 'cyan' + | 'red' + | 'slate'; + +export interface TimelineToneObject { + id?: string; + name?: string; + + background?: string; + bg?: string; + + border?: string; + borderColor?: string; + + overlay?: string; + band?: string; + + text?: string; + textColor?: string; + foreground?: string; + + color?: string; +} + +/* ============================================================================ + * OVERVIEW GROUP + * ========================================================================== */ + +/** + * Overview group for clustered timeline items. + */ +export interface RailOverviewGroup { + clusterId: string | null; + clusterKey: string | null; + count: number; + detailItems: unknown[]; + endMinute: number; + isCluster: boolean; + items: unknown[]; + label: string; + lane: unknown; + metaLabel: string; + renderId: string; + startMinute: number; + summary: { + count: number; + openCount: number | null; + primaryLabel: string; + primaryTone: unknown; + secondaryLabel: string; + toneSegments: unknown[]; + }; + tone: unknown; +} diff --git a/ts-src/sf.types.ts b/ts-src/sf.types.ts new file mode 100644 index 0000000..edbcbb2 --- /dev/null +++ b/ts-src/sf.types.ts @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// Global API +// ------------------------------------------------------------------------ +import type { BackendConfig, BackendAdapter, SolverConfig, SolverApi } from "./solver/api.types"; +import type { createApiGuide } from "./components/api-guide"; +import type { createButton } from "./components/buttons"; +import type { createFooter } from "./components/footer"; +import type { createHeader } from "./components/header"; +import type { createModal } from "./components/modal"; +import type { createStatusBar } from "./components/statusbar"; +import type { createTable } from "./components/table"; +import type { createTabs, showTab } from "./components/tabs"; +import type { showError, showToast } from "./components/toast"; +import type { assert, bindActivation, el, escHtml, normalizeCreateJobId, uid } from "./core"; +import type * as ColorsApi from "./utils/colors"; +import type * as GanttApi from "./gantt"; +import type * as RailApi from "./rail"; +import type * as ScoreApi from "./utils/score"; + +export interface GlobalAPI { + version: string; + assert: typeof assert; + bindActivation: typeof bindActivation; + colors: typeof ColorsApi; + createApiGuide: typeof createApiGuide; + createBackend: (config?: BackendConfig | null) => BackendAdapter; + createButton: typeof createButton; + createFooter: typeof createFooter; + createHeader: typeof createHeader; + createModal: typeof createModal; + createSolver: (config: SolverConfig) => SolverApi; + createStatusBar: typeof createStatusBar; + createTable: typeof createTable; + createTabs: typeof createTabs; + el: typeof el; + escHtml: typeof escHtml; + gantt: typeof GanttApi.gantt; + normalizeCreateJobId: typeof normalizeCreateJobId; + rail: typeof RailApi.rail; + score: typeof ScoreApi; + showError: typeof showError; + showTab: typeof showTab; + showToast: typeof showToast; + uid: typeof uid; +} diff --git a/ts-src/solver/api.types.ts b/ts-src/solver/api.types.ts new file mode 100644 index 0000000..5b3126c --- /dev/null +++ b/ts-src/solver/api.types.ts @@ -0,0 +1,311 @@ +/* ============================================================================ + SolverForge UI — API Types + Types shared between solver backend and lifecycle management. + ============================================================================ */ + +// ------------------------------------------------------------------------ +// Backend +// ------------------------------------------------------------------------ + +/** + * Minimum contract required by SF.createSolver(). + * Custom backends only need to implement these methods. + */ +export interface SolverBackend { + createJob(data: unknown): Promise; + getSnapshot(id: string, snapshotRevision?: string | number): Promise; + analyzeSnapshot(id: string, snapshotRevision?: string | number): Promise; + pauseJob(id: string): Promise; + resumeJob(id: string): Promise; + cancelJob(id: string): Promise; + deleteJob(id: string): Promise; + streamJobEvents( + id: string, + onMessage: (payload: SolverEvent) => void, + onError?: (err: Error) => void, + ): () => void; +} + +/** + * Full built-in adapter shape returned by SF.createBackend(). + * Adds convenience methods not required by createSolver(). + */ +export interface BackendAdapter extends SolverBackend { + getJob(id: string): Promise; + getJobStatus(id: string): Promise; + getDemoData(name: string): Promise; + listDemoData(): Promise; +} + +export type BackendConfig = HttpBackendConfig | TauriBackendConfig; + +export interface HttpBackendConfig { + type?: string; // 'axum' | 'fetch' | 'rails' | custom plugins + baseUrl?: string; + jobsPath?: string; + demoDataPath?: string; + headers?: Record; +} + +export interface TauriBackendConfig { + type: "tauri"; + invoke: ( + command: string, + payload?: Record, + ) => Promise; + listen: ( + event: string, + handler: (event: { payload: SolverEvent }) => void, + ) => Promise<() => void>; + commands?: Partial>; + eventName?: string; +} + +// ------------------------------------------------------------------------ +// Backend Payload Types +// ------------------------------------------------------------------------ + +/** + * Recursive type for backend payloads that can have nested data/metadata. + * Used for normalizeJobEvent, normalizeSnapshot, normalizeAnalysis. + */ +export interface BackendPayload extends Record { + [key: string]: unknown | BackendPayload; + data?: BackendPayload; + metadata?: BackendPayload; + solution?: unknown; + error?: string; + eventType?: string; + jobId?: string; + id?: string; + eventSequence?: number; + lifecycleState?: string; + currentScore?: string | number; + bestScore?: string | number; + snapshotRevision?: number | string; + terminalReason?: string; + telemetry?: SolverTelemetry | BackendPayload; +} + +// ------------------------------------------------------------------------ +// Internal Solver Types +// ------------------------------------------------------------------------ + +/** + * Internal solver phase - mirrors Rust lifecycle with client-side transitions. + * Rust: SolverLifecycleState (Solving, PauseRequested, Paused, Completed, Cancelled, Failed) + * JS adds: idle, starting, resuming, cancelling + */ +export type SolverPhase = + | 'idle' + | 'starting' + | 'solving' + | 'pause-requested' + | 'paused' + | 'resuming' + | 'cancelling'; + +/** + * Deferred promise for async operations (pause, resume, cancel). + */ +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +/** + * Terminal sync status for completed/cancelled/failed events. + */ +export type TerminalSyncStatus = 'pending' | 'synced' | 'failed'; + +/** + * Record for tracking terminal synchronization state. + * Used to ensure snapshot/analysis sync completes before delete is allowed. + */ +export interface TerminalSyncRecord { + jobId: string; + eventType: string; + meta: EventMeta; + status: TerminalSyncStatus; + promise: Promise | null; + error: Error | null; + callbackDelivered: boolean; +} + +/** + * Normalized job event from backend payload. + */ +export interface NormalizedJobEvent { + eventType: string; + meta: EventMeta; + solution: unknown | null; + error: string | null; +} + +// ------------------------------------------------------------------------ +// Solver Event Types +// ------------------------------------------------------------------------ + +/** + * Telemetry data from the solver runtime. + */ +export interface SolverTelemetry extends Record { + [key: string]: unknown; +} + +/** + * Canonical SSE/IPC event payload from the solver runtime. + */ +export interface SolverEvent { + eventType: string; + jobId?: string; + job_id?: string; + id?: string; + eventSequence?: number; + lifecycleState?: string; + currentScore?: string; + bestScore?: string; + snapshotRevision?: number | string; + telemetry?: SolverTelemetry; + solution?: unknown; + data?: { id?: string; jobId?: string }; +} + +// ------------------------------------------------------------------------ +// Solver Core Types +// ------------------------------------------------------------------------ + +/** + * Union type for all valid solver lifecycle states. + */ +export type LifecycleState = + | 'IDLE' + | 'STARTING' + | 'SOLVING' + | 'PAUSE_REQUESTED' + | 'PAUSED' + | 'RESUMING' + | 'CANCELLING' + | 'COMPLETED' + | 'CANCELLED' + | 'FAILED' + | 'TERMINATED_BY_CONFIG'; + +/** + * Metadata attached to solver events. + */ +export interface EventMeta { + id: string; + jobId: string; + eventType: string; + eventSequence: number | null; + lifecycleState: LifecycleState; + terminalReason: string | null; + telemetry: SolverTelemetry | null; + currentScore: string | null; + bestScore: string | null; + snapshotRevision: number | string | null; +} + +/** + * A point-in-time snapshot of solver state. + */ +export interface SolverSnapshot { + id: string | null; + jobId: string | null; + snapshotRevision: number | string | null; + lifecycleState: LifecycleState | null; + terminalReason: string | null; + currentScore: string | null; + bestScore: string | null; + telemetry: SolverTelemetry | null; + solution: unknown | null; +} + +/** + * Analysis results for a solver snapshot. + */ +export interface SolverAnalysis { + jobId: string | null; + snapshotRevision: number | string | null; + lifecycleState: LifecycleState | null; + terminalReason: string | null; + analysis: unknown | null; + score: string | number | null; + constraints: unknown[] | null; +} + +// ------------------------------------------------------------------------ +// Solver Configuration & API +// ------------------------------------------------------------------------ + +export interface SolverConfig { + backend: SolverBackend; + statusBar?: { + setLifecycleState?: (state: LifecycleState) => void; + setSolving?: (solving: boolean) => void; + updateScore?: (score: string | number | null) => void; + updateMoves?: (value: number | null) => void; + colorDotsFromAnalysis?: (constraints: unknown[]) => void; + }; + onProgress?: (meta?: EventMeta) => void; + onSolution?: (snapshot?: SolverSnapshot, meta?: EventMeta) => void; + onPauseRequested?: (meta?: EventMeta) => void; + onPaused?: (snapshot?: SolverSnapshot, meta?: EventMeta) => void; + onResumed?: (meta?: EventMeta) => void; + onComplete?: (snapshot?: SolverSnapshot, meta?: EventMeta) => void; + onCancelled?: (snapshot?: SolverSnapshot | null, meta?: EventMeta) => void; + onFailure?: ( + error?: string, + meta?: EventMeta, + snapshot?: SolverSnapshot | null, + analysis?: SolverAnalysis | null, + ) => void; + onAnalysis?: (analysis?: SolverAnalysis, meta?: EventMeta) => void; + onError?: (message?: string) => void; +} + +export interface SolverApi { + start(data?: unknown): Promise; + pause(): Promise<{ snapshot: SolverSnapshot | null; meta: EventMeta; analysis: SolverAnalysis | null } | void>; + resume(): Promise; + cancel(): Promise<{ snapshot: SolverSnapshot | null; meta: EventMeta; analysis: SolverAnalysis | null } | void>; + delete(): Promise; + getSnapshot(snapshotRevision?: number | string): Promise; + analyzeSnapshot(snapshotRevision?: number | string): Promise; + isRunning(): boolean; + getJobId(): string | null; + getLifecycleState(): LifecycleState; + getSnapshotRevision(): number | string | null; +} + +/* ============================================================================ + SolverForge UI — Backend Types + Type definitions used only in backend.ts + ============================================================================ */ + +/** + * HTTP error with status information. + */ +export interface HTTPError extends Error { + status: number; + statusText: string; + method: string; + path: string; + url: string; +} + +/** + * Server-Sent Events error with transport information. + */ +export interface SSEError extends Error { + code: string; + transport: string; + url: string; +} diff --git a/ts-src/solver/backend.ts b/ts-src/solver/backend.ts new file mode 100644 index 0000000..c7fbf8c --- /dev/null +++ b/ts-src/solver/backend.ts @@ -0,0 +1,299 @@ +/* ============================================================================ + SolverForge UI — Backend Adapters + Pluggable transport: Axum, Tauri IPC, generic fetch. + ============================================================================ */ + +import { assert, normalizeCreateJobId } from "../core"; +import type { + BackendConfig, + BackendAdapter, + SolverEvent, + HttpBackendConfig, + TauriBackendConfig, + HTTPError, SSEError +} from "./api.types"; + +/** + * Creates a backend adapter for the given transport type. + */ +export function createBackend( + config: BackendConfig | null = {} +): BackendAdapter { + const resolvedConfig = config || {}; + const type = resolvedConfig.type ?? 'axum'; + + if (type === 'tauri') { + return createTauriBackend(resolvedConfig as TauriBackendConfig); + } + + return createHttpBackend(resolvedConfig); +} + +/** + * @param raw + * @returns + */ +function resolveJobId(raw: unknown): string { + return normalizeCreateJobId(raw); +} + +/** + * Extracts a job id string from a solver event payload. + * @param payload + * @returns + */ +function resolveEventJobId(payload: SolverEvent): string { + if (!payload || typeof payload !== 'object') return ''; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ''; +} + +/** + * @param path + * @param snapshotRevision + * @returns + */ +function withSnapshotRevision(path: string, snapshotRevision?: string | number): string { + if (snapshotRevision == null || snapshotRevision === '') return path; + return path + '?snapshot_revision=' + encodeURIComponent(String(snapshotRevision)); +} + +/* ── HTTP backend (Axum, Rails, anything) ── */ + +/** + * Create a new HTTP backend instance. + * @param config + * @returns + */ +function createHttpBackend(config: HttpBackendConfig): BackendAdapter { + var baseUrl = config.baseUrl || ''; + var jobsPath = config.jobsPath || '/jobs'; + var demoDataPath = config.demoDataPath || '/demo-data'; + var extraHeaders = config.headers || {}; + + /** + * Builds the HTTP headers object used for API requests. + * + * Default JSON headers are merged with configured global headers + * and optional request-specific headers. + * + * @param extra + * Additional headers to merge into the request. + * + * @returns + * The final merged headers object. + */ + function headers(extra: Record = {}): Record { + return { + 'Content-Type': 'application/json', + ...extraHeaders, + ...extra, + }; + } + + /** + * Creates an enriched Error with HTTP request context. + * @param method + * @param path + * @param res + * @returns + */ + function createRequestError(method: string, path: string, res: { status: number; statusText: string }): HTTPError { + var err = new Error(res.status + ' ' + res.statusText) as HTTPError; + err.status = res.status; + err.statusText = res.statusText; + err.method = method; + err.path = path; + err.url = baseUrl + path; + return err; + } + + /** + * Performs an HTTP request to the API. + * + * @template TResponse Expected response type. + * @template TBody Request body type. + * + * @param method HTTP method (`GET`, `POST`, `PUT`, etc.). + * @param path Relative endpoint path. + * @param body Data sent in the request body. + * + * @returns + * + * @throws {Error} Throws an error when the HTTP response is not successful. + */ + function request( + method: string, + path: string, + body?: TBody + ): Promise { + const opts: RequestInit = { + method, + headers: headers(), + }; + + if (body !== undefined) opts.body = JSON.stringify(body); + + + return fetch(baseUrl + path, opts).then(function (res) { + if (!res.ok) throw createRequestError(method, path, res); + const contentType = res.headers.get('content-type') || ''; + + if (contentType.includes('json')) { + return res.json() as Promise; + } + + return res.text() as unknown as TResponse; + }); + } + + return { + createJob: function (data) { + return request('POST', jobsPath, data).then(resolveJobId); + }, + getJob: function (id) { + return request('GET', jobsPath + '/' + id); + }, + getJobStatus: function (id) { + return request('GET', jobsPath + '/' + id + '/status'); + }, + getSnapshot: function (id, snapshotRevision) { + return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/snapshot', snapshotRevision)); + }, + analyzeSnapshot: function (id, snapshotRevision) { + return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/analysis', snapshotRevision)); + }, + pauseJob: function (id) { + return request('POST', jobsPath + '/' + id + '/pause'); + }, + resumeJob: function (id) { + return request('POST', jobsPath + '/' + id + '/resume'); + }, + cancelJob: function (id) { + return request('POST', jobsPath + '/' + id + '/cancel'); + }, + deleteJob: function (id) { + return request('DELETE', jobsPath + '/' + id); + }, + getDemoData: function (name) { + return request('GET', demoDataPath + '/' + (name || 'STANDARD')); + }, + listDemoData: function () { + return request('GET', demoDataPath); + }, + streamJobEvents: function (id, onMessage, onError) { + var url = baseUrl + jobsPath + '/' + id + '/events'; + var es = new EventSource(url); + var closed = false; + es.onmessage = function (e) { + try { onMessage(JSON.parse(e.data)); } catch { } + }; + es.onerror = function () { + if (closed || !onError) return; + if (typeof EventSource !== 'undefined' && es.readyState === EventSource.CLOSED) { + onError(createSseClosedError(url)); + } + }; + return function close() { + closed = true; + es.onmessage = null; + es.onerror = null; + es.close(); + }; + }, + }; +} + +/* ── Tauri IPC backend ── */ + +/** + * Create a new IPC backend for Tauri + * @param config + * @returns + */ +function createTauriBackend(config: TauriBackendConfig): BackendAdapter { + assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); + assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); + assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); + + var invoke = config.invoke; + var listen = config.listen; + var commands = config.commands || {}; + var eventName = config.eventName || 'solver-update'; + + return { + createJob: function (data) { + return invoke(commands.createJob || 'create_job', { request: data }).then(resolveJobId); + }, + getJob: function (id) { + return invoke(commands.getJob || 'get_job', { id: id }); + }, + getJobStatus: function (id) { + return invoke(commands.getJobStatus || 'get_job_status', { id: id }); + }, + getSnapshot: function (id, snapshotRevision) { + var payload = { + id: id, + ...(snapshotRevision != null && snapshotRevision !== '' + ? { snapshotRevision } + : {}), + }; + return invoke(commands.getSnapshot || 'get_snapshot', payload); + }, + analyzeSnapshot: function (id, snapshotRevision) { + var payload = { + id: id, + ...(snapshotRevision != null && snapshotRevision !== '' + ? { snapshotRevision } + : {}), + }; + return invoke(commands.analyzeSnapshot || 'analyze_snapshot', payload); + }, + pauseJob: function (id) { + return invoke(commands.pauseJob || 'pause_job', { id: id }); + }, + resumeJob: function (id) { + return invoke(commands.resumeJob || 'resume_job', { id: id }); + }, + cancelJob: function (id) { + return invoke(commands.cancelJob || 'cancel_job', { id: id }); + }, + deleteJob: function (id) { + return invoke(commands.deleteJob || 'delete_job', { id: id }); + }, + getDemoData: function (name) { + return invoke(commands.demoData || 'demo_seed', { name: name }); + }, + listDemoData: function () { + return Promise.resolve([]); + }, + streamJobEvents: function (id, onMessage, _onError) { + var targetId = String(id); + var unlisten = null; + listen(eventName, function (event) { + var payload= (event && event.payload) || {} as SolverEvent ; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; + onMessage(payload as SolverEvent); + }).then(function (fn) { unlisten = fn; }); + return function close() { if (unlisten) unlisten(); }; + }, + }; +} + +/** + * Creates an enriched Error for SSE stream closure events. + * @param url + * @returns + */ +function createSseClosedError(url: string): SSEError { + var err = (new Error('Event stream closed for ' + url)) as SSEError; + err.code = 'SSE_CLOSED'; + err.transport = 'sse'; + err.url = url; + return err; +} diff --git a/ts-src/solver/solver.ts b/ts-src/solver/solver.ts new file mode 100644 index 0000000..e45eda5 --- /dev/null +++ b/ts-src/solver/solver.ts @@ -0,0 +1,1237 @@ +/* ============================================================================ + SolverForge UI — Solver Lifecycle + Shared job orchestration for start, pause, resume, cancel, and snapshots. + ============================================================================ */ + +import { assert, normalizeCreateJobId } from "../core"; + +import type { + SolverBackend, + SolverConfig, + SolverApi, + EventMeta, + SolverSnapshot, + SolverAnalysis, + LifecycleState, + BackendPayload, + SolverPhase, + Deferred, + TerminalSyncRecord, + NormalizedJobEvent, +} from "./api.types"; + +/** + * Creates a shared solver lifecycle orchestrator. + */ +export const createSolver = function ( + config: SolverConfig +): SolverApi { + assert(config, 'createSolver(config) requires a configuration object'); + assert(config.backend, 'createSolver(config.backend) is required'); + assert(hasFunction(config.backend, 'createJob'), 'createSolver(config.backend.createJob) must be a function'); + assert(hasFunction(config.backend, 'getSnapshot'), 'createSolver(config.backend.getSnapshot) must be a function'); + assert(hasFunction(config.backend, 'analyzeSnapshot'), 'createSolver(config.backend.analyzeSnapshot) must be a function'); + assert(hasFunction(config.backend, 'pauseJob'), 'createSolver(config.backend.pauseJob) must be a function'); + assert(hasFunction(config.backend, 'resumeJob'), 'createSolver(config.backend.resumeJob) must be a function'); + assert(hasFunction(config.backend, 'cancelJob'), 'createSolver(config.backend.cancelJob) must be a function'); + assert(hasFunction(config.backend, 'deleteJob'), 'createSolver(config.backend.deleteJob) must be a function'); + assert(hasFunction(config.backend, 'streamJobEvents'), 'createSolver(config.backend.streamJobEvents) must be a function'); + assert(!config.onProgress || typeof config.onProgress === 'function', 'createSolver(config.onProgress) must be a function'); + assert(!config.onSolution || typeof config.onSolution === 'function', 'createSolver(config.onSolution) must be a function'); + assert(!config.onPauseRequested || typeof config.onPauseRequested === 'function', 'createSolver(config.onPauseRequested) must be a function'); + assert(!config.onPaused || typeof config.onPaused === 'function', 'createSolver(config.onPaused) must be a function'); + assert(!config.onResumed || typeof config.onResumed === 'function', 'createSolver(config.onResumed) must be a function'); + assert(!config.onCancelled || typeof config.onCancelled === 'function', 'createSolver(config.onCancelled) must be a function'); + assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); + assert(!config.onFailure || typeof config.onFailure === 'function', 'createSolver(config.onFailure) must be a function'); + assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); + assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); + + var backend: SolverBackend = config.backend; + + var statusBar: SolverConfig['statusBar'] = + config.statusBar; + + var closeStream: (() => void) | null = null; + + var activeJobId: string | null = null; + + var retainedJobId: string | null = null; + + var lifecycleState: LifecycleState = 'IDLE'; + + var phase: SolverPhase = 'idle'; + + var runToken: number = 0; + + var lastSnapshotRevision: number | string | null = + null; + + var lastMeta: EventMeta | null = null; + + var lastNotifiedError: Error | null = null; + + var queuedAction: string | null = null; + + var pendingPause: + | Deferred<{ + snapshot: SolverSnapshot | null; + meta: EventMeta; + analysis: SolverAnalysis | null; + } | null> + | null = null; + + var pendingResume: + | Deferred + | null = null; + + var pendingCancel: + | Deferred<{ + snapshot: SolverSnapshot | null; + meta: EventMeta; + analysis: SolverAnalysis | null; + } | null> + | null = null; + + var terminalSync: TerminalSyncRecord | null = + null; + + var api: SolverApi = { + /** + * Start a new solver job. + */ + start: function (data?: unknown): Promise { + if (retainedJobId) { + return Promise.reject( + new Error( + 'Cannot start a new solve while a retained job exists; wait for a terminal lifecycle state and call delete() first' + ) + ); + } + + if (phase !== 'idle') { + return Promise.resolve(); + } + + resetForStart(); + phase = 'starting'; + runToken += 1; + + applyLifecycleState('STARTING'); + updateMoves(null); + + var token = runToken; + + return backend + .createJob(data) + .then(function (id) { + if (token !== runToken) return; + + var jobId = ensureJobId(id); + + activeJobId = jobId; + retainedJobId = jobId; + + phase = 'solving'; + + applyLifecycleState('SOLVING'); + + attachStream(token, jobId); + + if (queuedAction === 'pause') { + queuedAction = null; + requestPause(token, jobId); + } else if (queuedAction === 'cancel') { + queuedAction = null; + requestCancel(token, jobId); + } + }) + .catch(function (err) { + if (token !== runToken) return; + + if (retainedJobId) { + failTransport(err); + } else { + failStartup(err); + } + + throw err; + }); + }, + + /** + * Request to pause the current solver job. + */ + pause: function (): Promise<{ snapshot: SolverSnapshot | null, meta: EventMeta, analysis: SolverAnalysis | null } | void> { + if (pendingPause) { + return pendingPause.promise; + } + + if (phase === 'starting' && !activeJobId) { + queuedAction = 'pause'; + pendingPause = createDeferred(); + + return pendingPause.promise; + } + + var jobId = currentJobId(); + + if (phase !== 'solving' || !jobId) { + return Promise.resolve(); + } + + pendingPause = createDeferred(); + + if (!ensureStreamAttached(runToken, jobId, 'pause')) { + return pendingPause.promise; + } + + requestPause(runToken, jobId); + + return pendingPause.promise; + }, + + /** + * Resume a paused solver job. + */ + resume: function (): Promise { + if (pendingResume) { + return pendingResume.promise; + } + + var jobId = currentJobId(); + + if (phase !== 'paused' || !jobId) { + return Promise.resolve(); + } + + pendingResume = createDeferred(); + + if (!ensureStreamAttached(runToken, jobId, 'resume')) { + return pendingResume.promise; + } + + requestResume(runToken, jobId); + + return pendingResume.promise; + }, + + /** + * Request to cancel the current solver job. + */ + cancel: function (): Promise<{ snapshot: SolverSnapshot | null, meta: EventMeta, analysis: SolverAnalysis | null } | void> { + if (pendingCancel) { + return pendingCancel.promise; + } + + if (phase === 'starting' && !activeJobId) { + queuedAction = 'cancel'; + pendingCancel = createDeferred(); + + return pendingCancel.promise; + } + + var jobId = currentJobId(); + + if (phase === 'cancelling' && jobId) { + pendingCancel = createDeferred(); + + if (!ensureStreamAttached(runToken, jobId, 'cancel')) { + return pendingCancel.promise; + } + + return pendingCancel.promise; + } + + if (!jobId || !isCancelablePhase()) { + return Promise.resolve(); + } + + pendingCancel = createDeferred(); + + if (!ensureStreamAttached(runToken, jobId, 'cancel')) { + return pendingCancel.promise; + } + + requestCancel(runToken, jobId); + + return pendingCancel.promise; + }, + + /** + * Delete the retained job and its backend state. + */ + delete: function (): Promise { + if (!retainedJobId) { + return Promise.resolve(); + } + + if (!isTerminalLifecycle(lifecycleState)) { + return Promise.reject( + new Error( + 'Cannot delete a retained job before it reaches a terminal lifecycle state' + ) + ); + } + + var jobId = retainedJobId; + + return ensureTerminalSyncBeforeDelete(jobId) + .then(function () { + if (retainedJobId !== jobId) return; + + return backend.deleteJob(jobId); + }) + .then(function () { + if (retainedJobId !== jobId) return; + + resetAfterDelete(); + }) + .catch(function (err) { + notifyError(err); + throw err; + }); + }, + + /** + * Get a snapshot for the current job. + */ + getSnapshot: function (snapshotRevision?: number | string): Promise { + var jobId = currentJobId(); + + if (!jobId) { + return Promise.reject( + new Error('No retained job is available') + ); + } + + var revision = + resolveRequestedSnapshotRevision(snapshotRevision); + + return backend + .getSnapshot(jobId, revision) + .then(function (payload) { + return normalizeSnapshot(payload as BackendPayload, lastMeta); + }); + }, + + /** + * Get analysis for a snapshot of the current job. + */ + analyzeSnapshot: function (snapshotRevision?: number | string): Promise { + var jobId = currentJobId(); + + if (!jobId) { + return Promise.reject( + new Error('No retained job is available') + ); + } + + var revision = + resolveRequestedSnapshotRevision(snapshotRevision); + + return backend + .analyzeSnapshot(jobId, revision) + .then(function (payload) { + return normalizeAnalysis(payload as BackendPayload, lastMeta); + }); + }, + + /** + * Check if the solver is currently running. + */ + isRunning: function (): boolean { + return phase !== 'idle' && phase !== 'paused'; + }, + + /** + * Get the current job ID. + */ + getJobId: function (): (string | null) { + return activeJobId != null + ? activeJobId + : retainedJobId; + }, + + /** + * Get the current lifecycle state. + */ + getLifecycleState: function (): LifecycleState { + return lifecycleState; + }, + + /** + * Get the current snapshot revision. + */ + getSnapshotRevision: function (): number | string | null { + return lastSnapshotRevision; + } + }; + + return api; + + /** + * Send a pause request to the backend. + */ + function requestPause(token: number, id: string): void { + phase = 'pause-requested'; + backend.pauseJob(id).catch(function (err) { + if (token !== runToken) return; + phase = 'solving'; + rejectDeferred('pause', err); + notifyError(err); + }); + } + + /** + * Attach an event stream for the given job. + */ + function attachStream(token: number, id: string): void { + closeStream = backend.streamJobEvents(id, function (payload) { + if (token !== runToken) return; + handleEvent(token, id, payload); + }, function (err) { + if (token !== runToken) return; + failTransport(err); + }); + } + + /** + * Ensure the stream is attached, creating it if necessary. + */ + function ensureStreamAttached(token: number, id: string, pendingName: string): boolean { + if (closeStream) return true; + try { + attachStream(token, id); + return true; + } catch (err) { + failTransport(err); + rejectDeferred(pendingName, err); + return false; + } + } + + /** + * Send a resume request to the backend. + */ + function requestResume(token: number, id: string): void { + phase = 'resuming'; + backend.resumeJob(id).catch(function (err) { + if (token !== runToken) return; + phase = 'paused'; + rejectDeferred('resume', err); + notifyError(err); + }); + } + + /** + * Send a cancel request to the backend. + */ + function requestCancel(token: number, id: string): void { + phase = 'cancelling'; + backend.cancelJob(id).catch(function (err) { + if (token !== runToken) return; + phase = lifecycleState === 'PAUSED' ? 'paused' : 'solving'; + rejectDeferred('cancel', err); + notifyError(err); + }); + } + + /** + * Handle an incoming event from the solver backend. + */ + function handleEvent(token: number, expectedId: string, payload: unknown): void { + var event = normalizeJobEvent(payload as BackendPayload, expectedId); + if (!event) return; + + lastMeta = event.meta; + if (event.meta.snapshotRevision != null) { + lastSnapshotRevision = event.meta.snapshotRevision; + } + retainedJobId = event.meta.jobId; + activeJobId = event.meta.jobId; + + if (event.eventType === 'progress') { + if (!event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onProgress) config.onProgress(event.meta); + return; + } + + if (event.eventType === 'best_solution') { + if (!event.solution || !event.meta.currentScore) return; + phase = phaseForLifecycleState(event.meta.lifecycleState); + applyEventMeta(event.meta); + if (config.onSolution) { + config.onSolution(buildLiveSnapshot(event), event.meta); + } + return; + } + + if (event.eventType === 'pause_requested') { + phase = 'pause-requested'; + applyEventMeta(event.meta); + if (config.onPauseRequested) config.onPauseRequested(event.meta); + return; + } + + if (event.eventType === 'paused') { + phase = 'paused'; + applyEventMeta(event.meta); + syncSnapshotBundle(event.meta, true).then(function (bundle) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + applyBundle(bundle); + if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta); + resolveDeferred('pause', bundle); + }).catch(function (err) { + if (token !== runToken || hasNewerEvent(event.meta)) return; + rejectDeferred('pause', err); + notifyError(err); + }); + return; + } + + if (event.eventType === 'resumed') { + phase = 'solving'; + applyEventMeta(event.meta); + if (config.onResumed) config.onResumed(event.meta); + resolveDeferred('resume', event.meta); + return; + } + + if (event.eventType === 'completed') { + phase = 'idle'; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, true); + return; + } + + if (event.eventType === 'cancelled') { + phase = 'idle'; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + return; + } + + if (event.eventType === 'failed') { + phase = 'idle'; + applyEventMeta(event.meta); + runTerminalSync(createTerminalSync(event), token, event, false); + } + } + + /** + * Fetch and sync snapshot and optional analysis for terminal events. + */ + function syncSnapshotBundle(meta: EventMeta, requireSnapshot: boolean): Promise<{ meta: EventMeta, snapshot: SolverSnapshot | null, analysis: SolverAnalysis | null }> { + var analysisRequired = !!config.onAnalysis; + var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null; + + return backend.getSnapshot(meta.jobId, snapshotRevision).then(function (snapshotPayload) { + var snapshot = normalizeSnapshot(snapshotPayload as BackendPayload, meta); + if (!snapshot) throw new Error('Solver backend returned an invalid snapshot payload'); + + var mergedMeta = mergeMeta(meta, snapshot, meta.eventType); + var result = { + meta: mergedMeta, + snapshot: snapshot, + analysis: null, + }; + + if (!analysisRequired) return result; + + return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function (analysisPayload) { + result.analysis = normalizeAnalysis(analysisPayload as BackendPayload, mergedMeta); + return result; + }); + }).catch(function (err) { + if (requireSnapshot) throw err; + + var fallback = { meta: meta, snapshot: null, analysis: null }; + if (!analysisRequired || snapshotRevision == null) return fallback; + + return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function (analysisPayload) { + fallback.analysis = normalizeAnalysis(analysisPayload as BackendPayload, meta); + return fallback; + }).catch(function () { + return fallback; + }); + }); + } + + /** + * Apply a snapshot bundle to the current state. + */ + function applyBundle(bundle: { meta: EventMeta, snapshot: SolverSnapshot | null, analysis: SolverAnalysis | null }): void { + if (!bundle) return; + lastMeta = bundle.meta; + if (bundle.meta && bundle.meta.snapshotRevision != null) { + lastSnapshotRevision = bundle.meta.snapshotRevision; + } + applyEventMeta(bundle.meta, bundle.analysis); + if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta); + } + + /** + * Finalize state after a terminal event. + */ + function finalizeTerminal(meta: EventMeta): void { + closeCurrentStream(); + activeJobId = null; + queuedAction = null; + phase = 'idle'; + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : 'IDLE'); + updateMoves(null); + } + + /** + * Handle transport-level failure (stream closed). + */ + function failTransport(err: Error): void { + var jobId = activeJobId || retainedJobId; + retainedJobId = jobId; + closeCurrentStream(); + activeJobId = null; + phase = phaseForLifecycleState(lifecycleState); + queuedAction = null; + rejectDeferred('pause', err); + rejectDeferred('resume', err); + rejectDeferred('cancel', err); + notifyError(err); + } + + /** + * Handle startup failure (job creation failed). + */ + function failStartup(err: Error): void { + closeCurrentStream(); + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + phase = 'idle'; + queuedAction = null; + rejectDeferred('pause', err); + rejectDeferred('resume', err); + rejectDeferred('cancel', err); + applyLifecycleState('IDLE'); + updateMoves(null); + notifyError(err); + } + + /** + * Apply event metadata to the UI status bar. + */ + function applyEventMeta( + meta: EventMeta | null, + analysis?: SolverAnalysis | null + ) { + applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState); + updateScore(readDisplayScore(meta, analysis)); + updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null); + if (analysis) { + var constraints = readAnalysisConstraints(analysis); + if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) { + statusBar.colorDotsFromAnalysis(constraints); + } + } + } + + /** + * Extract display score from meta or analysis. + */ + function readDisplayScore(meta: EventMeta | null, analysis: SolverAnalysis | null): string | number | null { + if (meta && (meta.currentScore || meta.bestScore)) return meta.currentScore || meta.bestScore; + if (analysis && analysis.score != null) return analysis.score; + return null; + } + + /** + * Apply lifecycle state to the UI status bar. + */ + function applyLifecycleState(state: LifecycleState): void { + lifecycleState = state || 'IDLE'; + if (!statusBar) return; + if (typeof statusBar.setLifecycleState === 'function') { + statusBar.setLifecycleState(lifecycleState); + return; + } + if (typeof statusBar.setSolving === 'function') { + statusBar.setSolving(isActiveLifecycle(lifecycleState)); + } + } + + /** + * Update the score display on the status bar. + */ + function updateScore(score: string | number | null): void { + if (statusBar && typeof statusBar.updateScore === 'function') { + statusBar.updateScore(score); + } + } + + /** + * Update the moves per second display on the status bar. + */ + function updateMoves(value: number | null): void { + if (statusBar && typeof statusBar.updateMoves === 'function') { + statusBar.updateMoves(value); + } + } + + /** + * Reset internal state before starting a new job. + */ + function resetForStart(): void { + closeCurrentStream(); + activeJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + lastNotifiedError = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + } + + /** + * Reset internal state after a job is deleted. + */ + function resetAfterDelete(): void { + closeCurrentStream(); + rejectDeferred('pause', new Error('Solver job was deleted before pause settled')); + rejectDeferred('resume', new Error('Solver job was deleted before resume settled')); + rejectDeferred('cancel', new Error('Solver job was deleted before cancel settled')); + runToken += 1; + activeJobId = null; + retainedJobId = null; + lastSnapshotRevision = null; + lastMeta = null; + queuedAction = null; + pendingPause = null; + pendingResume = null; + pendingCancel = null; + terminalSync = null; + phase = 'idle'; + applyLifecycleState('IDLE'); + updateScore(null); + updateMoves(null); + } + + /** + * Close the current event stream. + */ + function closeCurrentStream(): void { + if (!closeStream) return; + closeStream(); + closeStream = null; + } + + /** + * Get the current job ID (active or retained). + */ + function currentJobId(): string | null { + return activeJobId != null ? activeJobId : retainedJobId; + } + + /** + * Check if there's a newer event than the given meta. + */ + function hasNewerEvent(meta: EventMeta): boolean { + var currentSequence = lastMeta && typeof lastMeta.eventSequence === 'number' ? lastMeta.eventSequence : null; + var candidateSequence = meta && typeof meta.eventSequence === 'number' ? meta.eventSequence : null; + if (currentSequence == null || candidateSequence == null) return false; + return currentSequence > candidateSequence; + } + + /** + * Resolve the snapshot revision to request. + */ + function resolveRequestedSnapshotRevision(snapshotRevision: number | string | null): number | string | null { + if (snapshotRevision != null && snapshotRevision !== '') return snapshotRevision; + return lastSnapshotRevision; + } + + /** + * Create a terminal sync record for an event. + */ + function createTerminalSync(event: { eventType: string, meta: EventMeta }): TerminalSyncRecord { + var existing = terminalSync && terminalSync.jobId === event.meta.jobId ? terminalSync : null; + terminalSync = { + jobId: event.meta.jobId, + eventType: event.eventType, + meta: event.meta, + status: 'pending', + promise: null, + error: null, + callbackDelivered: existing ? existing.callbackDelivered : false, + }; + return terminalSync; + } + + /** + * Run terminal sync for a completed/cancelled/failed event. + */ + function runTerminalSync(record: TerminalSyncRecord, token: number, event: { eventType: string, meta: EventMeta, error?: string }, requireSnapshot: boolean): Promise { + record.status = 'pending'; + record.error = null; + record.meta = event.meta; + record.promise = syncSnapshotBundle(event.meta, requireSnapshot).then(function (bundle) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = 'synced'; + record.error = null; + record.meta = bundle.meta; + finalizeTerminal(bundle.meta); + applyBundle(bundle); + deliverTerminalCallback(record, event, bundle); + settlePendingFromTerminal(event.eventType, bundle, terminalEventError(event)); + return record; + }).catch(function (err) { + if (terminalSync !== record || token !== runToken || hasNewerEvent(event.meta)) return record; + record.status = 'failed'; + record.error = err; + finalizeTerminal(event.meta); + deliverTerminalFailureCallback(record, event); + settlePendingFromTerminal(event.eventType, null, err); + notifyError(err); + return record; + }); + return record.promise; + } + + /** + * Ensure terminal sync is complete before allowing delete. + */ + function ensureTerminalSyncBeforeDelete(jobId: string): Promise { + var record = terminalSync && terminalSync.jobId === jobId ? terminalSync : null; + if (!record) return Promise.resolve(); + + return Promise.resolve(record.promise).then(function () { + if (!requiresSuccessfulTerminalSync(record)) return; + if (record.status === 'synced') return; + return retryTerminalSync(record); + }); + } + + /** + * Retry terminal sync if it failed. + */ + function retryTerminalSync(record: TerminalSyncRecord): Promise { + var retryEvent = { + eventType: record.eventType, + meta: record.meta, + error: null, + }; + return runTerminalSync(record, runToken, retryEvent, true).then(function () { + if (record.status !== 'synced') { + throw record.error || new Error('Terminal snapshot synchronization failed'); + } + }); + } + + /** + * Check if a record requires successful terminal sync. + */ + function requiresSuccessfulTerminalSync(record: { eventType: string, meta: EventMeta }): boolean { + return record.eventType === 'completed'; + } + + /** + * Deliver terminal callback for completed/cancelled/failed events. + */ + function deliverTerminalCallback(record: TerminalSyncRecord, event: { eventType: string, meta: EventMeta, error?: string }, bundle: { meta: EventMeta, snapshot: SolverSnapshot | null, analysis: SolverAnalysis | null }): void { + if (record.callbackDelivered) return; + if (event.eventType === 'completed') { + if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta); + } else if (event.eventType === 'cancelled') { + if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta); + } else if (event.eventType === 'failed') { + if (config.onFailure) config.onFailure(event.error || 'Solver job failed', bundle.meta, bundle.snapshot, bundle.analysis); + } + record.callbackDelivered = true; + } + + /** + * Deliver failure callback when terminal sync fails. + */ + function deliverTerminalFailureCallback(record: TerminalSyncRecord, event: { eventType: string, meta: EventMeta, error?: string }): void { + if (record.callbackDelivered || event.eventType !== 'failed') return; + if (config.onFailure) config.onFailure(event.error || 'Solver job failed', event.meta, null, null); + record.callbackDelivered = true; + } + + /** + * Create an error for a terminal event. + */ + function terminalEventError(event: { eventType: string, error?: string }): Error | null { + if (event.eventType !== 'failed') return null; + return new Error(event.error || 'Solver job failed'); + } + + /** + * Check if the current phase is cancelable. + */ + function isCancelablePhase(): boolean { + return phase === 'solving' || phase === 'pause-requested' || phase === 'paused' || phase === 'resuming'; + } + + /** + * Map lifecycle state to internal phase. + */ + function phaseForLifecycleState(state: LifecycleState): SolverPhase { + if (state === 'STARTING') return 'starting'; + if (state === 'SOLVING') return 'solving'; + if (state === 'PAUSE_REQUESTED') return 'pause-requested'; + if (state === 'PAUSED') return 'paused'; + if (state === 'RESUMING') return 'resuming'; + if (state === 'CANCELLING') return 'cancelling'; + return 'idle'; + } + + /** + * Check if a lifecycle state is terminal. + */ + function isTerminalLifecycle(state: LifecycleState): boolean { + return state === 'COMPLETED' + || state === 'CANCELLED' + || state === 'FAILED' + || state === 'TERMINATED_BY_CONFIG'; + } + + /** + * Settle pending deferreds when a terminal event occurs. + */ + function settlePendingFromTerminal(eventType: string, bundle: { meta: EventMeta, snapshot: SolverSnapshot | null, analysis: SolverAnalysis | null } | null, err: Error | null): void { + if (eventType === 'cancelled') { + if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error('Cancel did not settle before the job terminated')); + pendingCancel = null; + } + } else if (pendingCancel) { + if (bundle) pendingCancel.resolve(bundle); + else pendingCancel.reject(err || new Error('Cancel did not settle before the job terminated')); + pendingCancel = null; + } + + if (pendingPause) { + pendingPause.reject(err || new Error('Job terminated before pause settled')); + pendingPause = null; + } + if (pendingResume) { + pendingResume.reject(err || new Error('Job terminated before resume settled')); + pendingResume = null; + } + } + + /** + * Resolve a deferred promise. + */ + function resolveDeferred(name: string, value: unknown): void { + var deferred = getDeferred(name); + if (!deferred) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (deferred as any).resolve(value); + setDeferred(name, null); + } + + /** + * Reject a deferred promise. + */ + function rejectDeferred(name: string, err: Error): void { + var deferred = getDeferred(name); + if (!deferred) return; + deferred.reject(err); + setDeferred(name, null); + } + + /** + * Get a deferred by name. + */ + function getDeferred(name: string) { + if (name === 'pause') return pendingPause; + if (name === 'resume') return pendingResume; + if (name === 'cancel') return pendingCancel; + return null; + } + + /** + * Set a deferred by name. + */ + function setDeferred(name: string, value: unknown): void { + if (name === 'pause') pendingPause = value as Deferred<{ snapshot: SolverSnapshot | null; meta: EventMeta; analysis: SolverAnalysis | null } | null>; + if (name === 'resume') pendingResume = value as Deferred; + if (name === 'cancel') pendingCancel = value as Deferred<{ snapshot: SolverSnapshot | null; meta: EventMeta; analysis: SolverAnalysis | null } | null>; + } + + /** + * Notify error through the config.onError callback. + */ + function notifyError(err: Error): void { + if (err && lastNotifiedError === err) return; + lastNotifiedError = err || null; + if (config.onError) config.onError(err && err.message ? err.message : String(err)); + } + + /** + * Ensure a valid job ID from the backend response. + */ + function ensureJobId(id: unknown): string { + var jobId = normalizeCreateJobId(id); + if (jobId) return jobId; + throw new Error('Invalid solver backend createJob response'); + } +}; + +/** + * Check if an object has a function property. + */ +function hasFunction(object: object, key: string): boolean { + return !!(object && typeof object[key] === 'function'); +} + +/** + * Create a deferred promise object. + */ +function createDeferred(): Deferred { + var resolve: (value: T) => void; + var reject: (error: Error) => void; + var promise = new Promise(function (res, rej) { + resolve = res; + reject = rej; + }); + return { promise: promise, resolve: resolve, reject: reject }; +} + +/** + * Normalize a job event payload into a standard event object. + */ +function normalizeJobEvent(payload: BackendPayload, expectedId: string): NormalizedJobEvent | null { + if (!payload || typeof payload !== 'object') return null; + + var eventType = normalizeEventType(readField(payload, ['eventType', 'event_type', 'type']) as string | null); + if (!eventType) return null; + + var jobId: string | null = (readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as string | null); + if (jobId == null || jobId === '') jobId = expectedId; + if (jobId == null || jobId === '') return null; + if (String(jobId) !== String(expectedId)) return null; + + var solution = payload.solution || (payload.data && payload.data.solution) || null; + var solutionScore = readField(solution as BackendPayload | null, ['score'], [solution as BackendPayload | null]); + var meta: EventMeta = { + id: String(jobId), + jobId: String(jobId), + eventType: eventType, + eventSequence: readField(payload, ['eventSequence', 'event_sequence'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as number | null, + lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state', 'solverStatus', 'solver_status'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as string | null, eventType), + terminalReason: (readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as string | null) || null, + telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload), + currentScore: (readField(payload, ['currentScore', 'current_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as string | null) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: (readField(payload, ['bestScore', 'best_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as string | null) || (solutionScore != null ? String(solutionScore) : null) || null, + snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) as number | string | null, + }; + + return { + eventType: eventType, + meta: meta, + solution: solution, + error: (readField(payload, ['error'], [payload, payload.data]) as string | null) || null, + }; +} + +/** + * Normalize a snapshot payload into a standard snapshot object. + */ +function normalizeSnapshot( + payload: BackendPayload, + fallbackMeta: EventMeta | null +): SolverSnapshot | null { + if (!payload || typeof payload !== 'object') return null; + + var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]) as string | null; + if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; + var solution = payload.solution || (payload.data && payload.data.solution) || null; + var solutionScore = readField(solution as BackendPayload | null, ['score'], [solution as BackendPayload | null]); + return { + id: jobId != null ? String(jobId) : null, + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]) as number | string | null, + lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]) as string | null, fallbackMeta && fallbackMeta.eventType), + terminalReason: (readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) as string | null) || null, + currentScore: (readField(payload, ['currentScore', 'current_score'], [payload, payload.data]) as string | null) || (solutionScore != null ? String(solutionScore) : null) || null, + bestScore: (readField(payload, ['bestScore', 'best_score'], [payload, payload.data]) as string | null) || (solutionScore != null ? String(solutionScore) : null) || null, + telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.data]), payload), + solution: solution, + }; +} + +/** + * Normalize an analysis payload into a standard analysis object. + */ +function normalizeAnalysis( + payload: BackendPayload, + fallbackMeta: EventMeta | null +): SolverAnalysis | null { + if (!payload || typeof payload !== 'object') return null; + + var analysisBody = payload.analysis || (payload.data && payload.data.analysis) || payload; + var constraints = readAnalysisConstraints(analysisBody as SolverAnalysis | null); + var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]) as string | null; + if (jobId == null || jobId === '') jobId = fallbackMeta && fallbackMeta.jobId; + var snapshotRevision: number | string | null = (readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]) as number | string | null); + if (snapshotRevision == null || snapshotRevision === '') { + snapshotRevision = fallbackMeta && fallbackMeta.snapshotRevision; + } + return { + jobId: jobId != null ? String(jobId) : null, + snapshotRevision: snapshotRevision != null ? snapshotRevision : null, + lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]) as string | null, fallbackMeta && fallbackMeta.eventType), + terminalReason: (readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) as string | null) || (fallbackMeta && fallbackMeta.terminalReason) || null, + analysis: analysisBody, + score: (analysisBody as Record).score != null ? ((analysisBody as Record).score as string | number) : null, + constraints: constraints, + }; +} + +/** + * Build a live snapshot from an event. + */ +function buildLiveSnapshot(event: { eventType: string, meta: EventMeta, solution: unknown }): SolverSnapshot { + return { + id: event.meta.jobId, + jobId: event.meta.jobId, + snapshotRevision: event.meta.snapshotRevision, + lifecycleState: event.meta.lifecycleState, + terminalReason: event.meta.terminalReason, + currentScore: event.meta.currentScore, + bestScore: event.meta.bestScore, + telemetry: event.meta.telemetry, + solution: event.solution, + }; +} + +/** + * Merge metadata from event and snapshot. + */ +function mergeMeta(meta: EventMeta | null, snapshot: SolverSnapshot | null, eventType: string): EventMeta { + if (!snapshot) return meta; + return { + id: meta && meta.id != null ? meta.id : snapshot.id, + jobId: meta && meta.jobId != null ? meta.jobId : snapshot.jobId, + eventType: meta && meta.eventType ? meta.eventType : eventType, + eventSequence: meta ? meta.eventSequence : null, + lifecycleState: (meta && meta.lifecycleState) || snapshot.lifecycleState || normalizeLifecycleState(null, eventType), + terminalReason: (meta && meta.terminalReason) || snapshot.terminalReason || null, + telemetry: snapshot.telemetry || (meta && meta.telemetry) || null, + currentScore: snapshot.currentScore || (meta && meta.currentScore) || null, + bestScore: snapshot.bestScore || (meta && meta.bestScore) || null, + snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : (meta && meta.snapshotRevision), + }; +} + +/** + * Read a field from a payload, trying multiple possible names and sources. + */ +function readField( + payload: BackendPayload, + names: string | string[], + sources?: BackendPayload[] +): unknown { + var fields = Array.isArray(names) ? names : [names]; + var roots = sources || [payload]; + for (var i = 0; i < roots.length; i++) { + var source = roots[i]; + if (!source || typeof source !== 'object') continue; + for (var j = 0; j < fields.length; j++) { + if (source[fields[j]] != null) return source[fields[j]]; + } + } + return null; +} + +/** + * Normalize an event type string. + */ +function normalizeEventType(value: string): string | null { + if (typeof value !== 'string') return null; + var normalized = value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[\s-]+/g, '_') + .toLowerCase(); + if (!normalized) return null; + if (normalized === 'finished') return 'completed'; + return normalized; +} + +/** + * Normalize a lifecycle state string. + */ +function normalizeLifecycleState(value: string | null, eventType: string | null): LifecycleState { + if (typeof value === 'string' && value.trim()) { + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[\s-]+/g, '_') + .toUpperCase() as LifecycleState; + } + + if (eventType === 'progress' || eventType === 'best_solution' || eventType === 'resumed') return 'SOLVING' as LifecycleState; + if (eventType === 'pause_requested') return 'PAUSE_REQUESTED' as LifecycleState; + if (eventType === 'paused') return 'PAUSED' as LifecycleState; + if (eventType === 'completed') return 'COMPLETED' as LifecycleState; + if (eventType === 'cancelled') return 'CANCELLED' as LifecycleState; + if (eventType === 'failed') return 'FAILED' as LifecycleState; + return 'IDLE' as LifecycleState; +} + +/** + * Normalize telemetry data. + */ +function normalizeTelemetry(rawTelemetry: unknown, payload: BackendPayload): Record | null { + if (rawTelemetry && typeof rawTelemetry === 'object') return rawTelemetry as Record; + + var telemetry: Record = ({}); + var movesPerSecond = readField(payload, ['movesPerSecond', 'moves_per_second']); + var stepCount = readField(payload, ['stepCount', 'step_count']); + if (movesPerSecond != null) telemetry.movesPerSecond = Number(movesPerSecond); + if (stepCount != null) telemetry.stepCount = Number(stepCount); + return Object.keys(telemetry).length ? telemetry : null; +} + +/** + * Extract movesPerSecond from telemetry. + */ +function readMovesPerSecond(telemetry: Record | null): number | null { + if (!telemetry || typeof telemetry !== 'object') return null; + + const value = telemetry.movesPerSecond ?? telemetry.moves_per_second; + if (value == null) return null; + + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function readAnalysisConstraints(analysis: SolverAnalysis | null): unknown[] | null { + if (!analysis || typeof analysis !== 'object') return null; + const a = analysis as unknown as Record; + if (Array.isArray(a.constraints)) return a.constraints as unknown[]; + const nested = a.analysis as Record | null; + if (nested && Array.isArray(nested.constraints)) return nested.constraints as unknown[]; + return null; +} + +/** + * Check if a lifecycle state is active (not idle or terminal). + */ +function isActiveLifecycle(state: LifecycleState): boolean { + return state === 'STARTING' + || state === 'SOLVING' + || state === 'PAUSE_REQUESTED' + || state === 'RESUMING' + || state === 'CANCELLING'; +} diff --git a/ts-src/utils/colors.ts b/ts-src/utils/colors.ts new file mode 100644 index 0000000..7a99e0b --- /dev/null +++ b/ts-src/utils/colors.ts @@ -0,0 +1,64 @@ +/* ============================================================================ + SolverForge UI — Color Factory + Tango palette + project color assignment. + ============================================================================ */ + +var SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8]; +var SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B]; + +var colorMap = {}; +var nextColorCount = 0; + +function buildPercentageColor(floor, ceil, pct) { + var red = (floor & 0xFF0000) + Math.floor(pct * ((ceil & 0xFF0000) - (floor & 0xFF0000))) & 0xFF0000; + var green = (floor & 0x00FF00) + Math.floor(pct * ((ceil & 0x00FF00) - (floor & 0x00FF00))) & 0x00FF00; + var blue = (floor & 0x0000FF) + Math.floor(pct * ((ceil & 0x0000FF) - (floor & 0x0000FF))) & 0x0000FF; + return red | green | blue; +} + +function nextColor() { + var colorIndex = nextColorCount % SEQUENCE_1.length; + var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); + var color; + if (shadeIndex === 0) { + color = SEQUENCE_1[colorIndex]; + } else if (shadeIndex === 1) { + color = SEQUENCE_2[colorIndex]; + } else { + shadeIndex -= 3; + var base = Math.floor((shadeIndex / 2) + 1); + var divisor = 2; + while (base >= divisor) divisor *= 2; + base = (base * 2) - divisor + 1; + color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); + } + nextColorCount++; + return '#' + color.toString(16).padStart(6, '0'); +} + +export const pick = function (key) { + if (colorMap[key] !== undefined) return colorMap[key]; + var c = nextColor(); + colorMap[key] = c; + return c; +}; + +export const reset = function () { + colorMap = {}; + nextColorCount = 0; +}; + +var PROJECT_COLORS = [ + { main: '#10b981', dark: '#047857', light: 'rgba(16,185,129,0.15)' }, + { main: '#3b82f6', dark: '#1d4ed8', light: 'rgba(59,130,246,0.15)' }, + { main: '#8b5cf6', dark: '#6d28d9', light: 'rgba(139,92,246,0.15)' }, + { main: '#f59e0b', dark: '#b45309', light: 'rgba(245,158,11,0.15)' }, + { main: '#ec4899', dark: '#be185d', light: 'rgba(236,72,153,0.15)' }, + { main: '#06b6d4', dark: '#0e7490', light: 'rgba(6,182,212,0.15)' }, + { main: '#f43f5e', dark: '#be123c', light: 'rgba(244,63,94,0.15)' }, + { main: '#84cc16', dark: '#4d7c0f', light: 'rgba(132,204,22,0.15)' }, +]; + +export const project = function (index) { + return PROJECT_COLORS[index % PROJECT_COLORS.length]; +}; diff --git a/ts-src/utils/score.ts b/ts-src/utils/score.ts new file mode 100644 index 0000000..1785d3e --- /dev/null +++ b/ts-src/utils/score.ts @@ -0,0 +1,35 @@ +/* ============================================================================ + SolverForge UI — Score Parsing + ============================================================================ */ + +export const parseHard = function (scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)hard/); + return m ? parseInt(m[1], 10) : 0; +}; + +export const parseSoft = function (scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)soft/); + return m ? parseInt(m[1], 10) : 0; +}; + +export const parseMedium = function (scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)medium/); + return m ? parseInt(m[1], 10) : 0; +}; + +export const getComponents = function (scoreStr) { + return { + hard: parseHard(scoreStr), + medium: parseMedium(scoreStr), + soft: parseSoft(scoreStr), + }; +}; + +export const colorClass = function (scoreStr) { + var hard = parseHard(scoreStr); + var soft = parseSoft(scoreStr); + return hard < 0 ? 'score-red' : soft < 0 ? 'score-yellow' : 'score-green'; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..14bc687 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021", "DOM"], + "noEmit": true, + "strict": false, + "noImplicitAny": false, + "skipLibCheck": true + }, + "include": ["ts-src/**/*.ts", "ts-src/**/*.d.ts", "tests/types/**/*.ts"], + "exclude": ["node_modules", "static", "target", "scripts"] +}