From b31bb07306165fd364aec7513ef57972c339942e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 14:42:02 +0000 Subject: [PATCH 1/8] Add Vitest test suite and modular src layout Extract pure logic into src/lib modules, move app script to src/app/main.js, add 23 unit tests, npm test scripts, and GitHub Actions CI. Co-authored-by: Sanket Sharma --- .github/workflows/ci.yml | 19 + .gitignore | 2 + CHANGELOG.md | 15 + README.md | 43 + index.html | 1731 +------------------------------------- package-lock.json | 1585 ++++++++++++++++++++++++++++++++++ package.json | 14 + scripts/wire-main.js | 148 ++++ src/app/main.js | 1600 +++++++++++++++++++++++++++++++++++ src/data/constants.js | 58 ++ src/lib/capture.js | 193 +++++ src/lib/date-core.js | 14 + src/lib/dates.js | 106 +++ src/lib/domain.js | 49 ++ src/lib/tree.js | 81 ++ tests/capture.test.js | 50 ++ tests/dates.test.js | 48 ++ tests/domain.test.js | 27 + tests/smoke.test.js | 21 + tests/tree.test.js | 52 ++ vitest.config.js | 8 + 21 files changed, 4134 insertions(+), 1730 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/wire-main.js create mode 100644 src/app/main.js create mode 100644 src/data/constants.js create mode 100644 src/lib/capture.js create mode 100644 src/lib/date-core.js create mode 100644 src/lib/dates.js create mode 100644 src/lib/domain.js create mode 100644 src/lib/tree.js create mode 100644 tests/capture.test.js create mode 100644 tests/dates.test.js create mode 100644 tests/domain.test.js create mode 100644 tests/smoke.test.js create mode 100644 tests/tree.test.js create mode 100644 vitest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7e530d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25fbf5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61f084e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## Testing infrastructure (2026-06-15) + +### Added +- Extracted pure logic into `src/data/constants.js` and `src/lib/*` modules (domain, tree, dates, capture). +- Moved the app script to `src/app/main.js` and wired `index.html` as an ES module entry point. +- Vitest unit tests under `tests/` for domain inference, task tree math, gantt dates, and capture parsing. +- `package.json` with `npm test` / `npm run test:watch`. +- GitHub Actions workflow (`.github/workflows/ci.yml`) to run tests on push and PR. + +### Reasoning +- The prototype was a single 1700+ line inline script with no automated checks. +- Pulling testable logic into modules gives a stable base for refactors and new features without changing UI behavior. +- ES modules keep the zero-build-step workflow (open `index.html` or serve statically) while enabling Node-based unit tests. diff --git a/README.md b/README.md index d0fba22..d151d50 100644 --- a/README.md +++ b/README.md @@ -1 +1,44 @@ # Task Management Tool + +TaskBoard prototype: a single-page task planner with gantt timeline, balance-scale dashboard, voice capture, and transcript extraction. + +AI-perc:47% + +## Run locally + +Open `index.html` in a browser, or serve the repo root: + +```bash +npx --yes serve . +``` + +## Development + +Install dependencies and run tests: + +```bash +npm install +npm test +``` + +Watch mode: + +```bash +npm run test:watch +``` + +## Project layout + +| Path | Purpose | +|------|---------| +| `index.html` | UI markup and styles | +| `src/app/main.js` | Application logic (DOM, rendering, interactions) | +| `src/data/constants.js` | Team roster, clients, sizes, colors | +| `src/lib/` | Testable pure functions (domain, tree, dates, capture) | +| `tests/` | Vitest unit tests | + +## Before opening a PR + +1. Run `npm test` and confirm all tests pass. +2. Smoke-check the UI in a browser (filter, gantt, task detail sheet). +3. Update `CHANGELOG.md` if you change behavior. diff --git a/index.html b/index.html index 63f226a..a881025 100644 --- a/index.html +++ b/index.html @@ -830,1735 +830,6 @@

Team photos

- + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3e94015 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1585 @@ +{ + "name": "task-management-tool", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "task-management-tool", + "version": "0.1.0", + "devDependencies": { + "vitest": "^3.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b15c4a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "task-management-tool", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/scripts/wire-main.js b/scripts/wire-main.js new file mode 100644 index 0000000..9daf578 --- /dev/null +++ b/scripts/wire-main.js @@ -0,0 +1,148 @@ +import { readFileSync, writeFileSync } from "fs"; + +const mainPath = "src/app/main.js"; +let src = readFileSync(mainPath, "utf8"); + +const preamble = `import { + PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, + SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, + R0G, R1G, SPAN_G, TODAY_PX, + C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, +} from "../data/constants.js"; +import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; +import { + createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, + taskDoneAt as taskDoneAtIn, contains, depthOf as depthOfIn, heightOf, fitsDepth as fitsDepthIn, +} from "../lib/tree.js"; +import { createDateHelpers } from "../lib/dates.js"; +import { + cap1, stripCaptions, findOwnerId, findDue, findSize, + normalizeProposal, mockTranscript, isoCap, +} from "../lib/capture.js"; + +`; + +const dataStart = src.indexOf("/* ================= sample data ================= */"); +src = preamble + src.slice(dataStart); + +// Remove constants and domain helpers now imported from modules (through RESP_MAP_TEXT block) +src = src.replace( + /const PEOPLE = \{[\s\S]*?const VOCAB_TEXT=[\s\S]*?;\n\n/, + `const RESP_MAP_TEXT = buildRespMapText();\nconst VOCAB_TEXT = buildVocabText();\n\n` +); + +src = src.replace( + /let UID = 0; const T=\(title,o,opts=\{\}\)=>[\s\S]*?;\nconst SIZE_PTS=\{s:1,m:2,l:4,xl:8\}, SIZE_NAMES=\{s:"S",m:"M",l:"L",xl:"XL"\};\nconst LEAD=\{s:1,m:3,l:7,xl:14\};[^\n]*\n\n/, + `const { T } = createTaskFactory();\n\n` +); + +// Tree helpers +src = src.replace( + /const flat=\(nodes,fn,depth=0,path=\[\]\)=>[\s\S]*?;\nconst findPath=\(id,nodes=DATA,path=\[\]\)=>[\s\S]*?;\nfunction counts\(n\)[\s\S]*?return \{done:d,total:t\}; \}\nconst pct=n=>[\s\S]*?;\n/, + "" +); + +src = src.replace( + /function progFrac\(n\)\{ let done=0,tot=0; flat\(\[n\],x=>[\s\S]*?return tot\?done\/tot:0; \}\n/, + "const progFrac = (n) => { let done = 0, tot = 0; flat([n], (x) => { if (x.children.length) return; const w = SIZE_PTS[x.size || \"m\"]; tot += w; if (x.done) done += w; }); return tot ? done / tot : 0; };\n" +); + +src = src.replace( + /const taskDone=n=>!n\.children\.length\?n\.done:pct\(n\)===100;\nconst taskDoneAt=n=>\{ let m=null;[\s\S]*?return m; \};\n/, + "const taskDoneAt = (n) => taskDoneAtIn(n, TODAY);\n" +); + +// Gantt date helpers +src = src.replace( + /const R0G=0, R1G=90, SPAN_G=R1G-R0G;[^\n]*\nconst ZOOMS=\[[\s\S]*?;\nlet ZOOM=2, showDone=false;\nconst GBAR_H=\{s:26,m:34,l:44,xl:56\};\nconst dayN=iso=>[\s\S]*?;\nconst dayIso=d=>[\s\S]*?;\nconst barSpan=n=>[\s\S]*?;\n/, + `let ZOOM = 2, showDone = false;\nconst {\n dayN, dayIso, barSpan, workDays, barColor, barGeom,\n rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD,\n} = createDateHelpers(TODAY);\n` +); + +src = src.replace( + /const C_LATE="[^"]*", C_TODAY="[^"]*", C_RADAR="[^"]*", C_LATER="[^"]*", C_DONE="[^"]*";\nfunction barColor\(e,s,done\)\{[\s\S]*?return C_LATER;[^\n]*\n\}\n/, + "" +); + +src = src.replace( + /function rollupSpan\(n\)\{[\s\S]*?return e===-Infinity\?barSpan\(n\):\{s,e\}; \}\nconst spanFor=n=>n\.children\.length\?rollupSpan\(n\):barSpan\(n\);\n/, + "" +); + +src = src.replace( + /function workDays\(s,e\)\{[\s\S]*?return c; \}\nfunction leafWeight\(n\)\{[\s\S]*?return w>0\?w:1; \}\n/, + "" +); + +src = src.replace( + /function progWD\(n\)\{[\s\S]*?return tot\?done\/tot:0; \}\n/, + "" +); + +src = src.replace( + /function isUrgent\(n\)\{[\s\S]*?return !isNaN\(s\)&&s<=0; \}\n/, + "" +); + +src = src.replace( + /const fmtD=iso=>new Date\(iso\)\.toLocaleDateString\("en-GB",\{day:"numeric",month:"short"\}\);\n/, + "" +); + +src = src.replace( + /function barGeom\(s,e,done\)\{[\s\S]*?return \[cs,Math\.min\(Math\.max\(re,cs\+0\.5\),R1G\)\];\n\}\n/, + "" +); + +// Tree depth helpers near detach +src = src.replace( + /const contains=\(n,id\)=>n\.id===id\|\|n\.children\.some\(c=>contains\(c,id\)\);\nconst depthOf=id=>findPath\(id\)\.length-1;\nconst heightOf=n=>n\.children\.length\?1\+Math\.max\(\.\.\.n\.children\.map\(heightOf\)\):0;\nconst fitsDepth=\(node,destId\)=>depthOf\(destId\)\+1\+heightOf\(node\)<=2;\n/, + `const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path);\nconst depthOf = (id) => depthOfIn(id, DATA);\nconst fitsDepth = (node, destId) => fitsDepthIn(node, destId, DATA);\n` +); + +// Capture helpers +src = src.replace( + /function findOwnerId\(t\)\{ let best=null,pos=-1;[\s\S]*?return best; \}\nfunction findDue\(t\)\{[\s\S]*?return null; \}\nfunction findSize\(t\)\{[\s\S]*?return \{small:"s"[\s\S]*?\}\[m\[1\]\]\|\|null; \}\nconst cap1=s=>s\? s\.replace\(\/\^\[a-z\]\/,c=>c\.toUpperCase\(\)\):s;\n/, + "" +); + +src = src.replace( + /function mockTranscript\(text\)\{[\s\S]*?return \{assistantSay:`I identified \$\{projects\.length\} project\$\{projects\.length!==1\?"s":""\} and \$\{nT\} task\$\{nT!==1\?"s":""\} from this conversation\.`,projects\};\n\}\n/, + "" +); + +src = src.replace( + /function normalizeProposal\(raw\)\{ RUID=0;[\s\S]*?return \{assistantSay:raw\.assistantSay\|\|"",projects\};\n\}\n/, + "let RUID = 0;\nfunction normalizeProposalWrapped(raw) { RUID = 0; return normalizeProposal(raw); }\n" +); + +src = src.replace(/normalizeProposal\(/g, "normalizeProposalWrapped("); + +src = src.replace( + /function stripCaptions\(t\)\{ if\(!t\) return t;[\s\S]*?\.trim\(\); \}\n/, + "" +); + +// isoCap used in captureContext - check if defined in main +if (!src.includes("function isoCap")) { + // isoCap imported from capture.js +} + +const globals = ` +const _globals = { + toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, + toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, + sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, + closeTeam, closeSheet, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, + toggleExp, updTask, refreshBarMenu, addChild, deleteTask, addCapTask, barDown, barContext, pickSearch, + uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, + doSearch, refreshCard, delCapTask, setTask, setTaskOwner, setTaskSize, setSub, setSubOwner, addSub, + delSub, commitCapture, openTranscript, openReview, openTeam, openCapture, openDetail, toggleListen, + stopListen, renderAll, setGView, toggleSubs, toggleFocus, toggleShowDone, setZoom, moveTask, +}; +Object.assign(window, _globals); +`; + +src = src.replace(/renderAll\(\);\s*$/, `renderAll();\n${globals}`); + +writeFileSync(mainPath, src); +console.log("Patched", mainPath); diff --git a/src/app/main.js b/src/app/main.js new file mode 100644 index 0000000..a08db94 --- /dev/null +++ b/src/app/main.js @@ -0,0 +1,1600 @@ +import { + PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, + SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, + R0G, R1G, SPAN_G, TODAY_PX, + C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, +} from "../data/constants.js"; +import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; +import { + createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, + taskDoneAt as taskDoneAtIn, contains, depthOf as depthOfIn, heightOf, fitsDepth as fitsDepthIn, +} from "../lib/tree.js"; +import { createDateHelpers } from "../lib/dates.js"; +import { + cap1, stripCaptions, findOwnerId, findDue, findSize, + normalizeProposal, mockTranscript, isoCap, +} from "../lib/capture.js"; + +/* ================= sample data ================= */ +/* al = ASR aliases: common Whisper mishearings of each name. + In production this mapping is done by the extraction LLM given the roster, + plus Whisper initial_prompt biasing ("Team: Jean, Florian, Iannis, …"). */ +const RESP_MAP_TEXT = buildRespMapText(); +const VOCAB_TEXT = buildVocabText(); + +const { T } = createTaskFactory(); + +const DATA = [ + /* ---- Client pilot: Derichebourg (waste-sorting robot) ---- */ + T("Derichebourg pilot — sorting robot","ia",{p:"high",d:"2026-07-10",open:true,c:[ + T("Integrate RS03 drive motors","sk",{p:"high",d:"2026-06-16",s:"l",open:true,c:[ + T("Mount RS03 motors and couplers","sk",{done:true,d:"2026-06-08",s:"m"}), + T("Wire motor CAN bus to controller","sk",{d:"2026-06-14",s:"m"}), + T("Calibrate RS03 torque limits","sk",{d:"2026-06-17",s:"s"}), + ]}), + T("Tune obstacle avoidance for the sorting line","ak",{p:"high",d:"2026-06-19",s:"l",open:true,c:[ + T("Collect depth data along the conveyor","ak",{done:true,d:"2026-06-10",s:"m"}), + T("Train avoidance model","ak",{d:"2026-06-18",s:"l"}), + T("Field test near the conveyor","ak",{d:"2026-06-22",s:"m"}), + ]}), + T("Fix D-Wave board brownout under load","ly",{p:"high",d:"2026-06-12",s:"m",open:true,c:[ + T("Diagnose the power regulator","ly",{done:true,d:"2026-06-11",s:"s"}), + T("Replace regulator and retest","ly",{d:"2026-06-13",s:"m"}), + ]}), + T("Approve motor procurement budget","jn",{d:"2026-06-15",s:"s"}), + T("Coordinate on-site pilot install","fd",{d:"2026-06-30",s:"l"}), + ]}), + + /* ---- Client pilot: JCDecaux (billboard-servicing robot) ---- */ + T("JCDecaux pilot — billboard servicing","ak",{p:"high",d:"2026-07-20",open:true,c:[ + T("Design board-mount manipulator arm","ia",{d:"2026-06-23",s:"l",open:true,c:[ + T("CAD the arm linkage","ia",{d:"2026-06-18",s:"m"}), + T("Source Feetech servos for the arm","lm",{d:"2026-06-20",s:"s"}), + ]}), + T("Autonomous navigation between billboards","ak",{p:"high",d:"2026-06-26",s:"xl",open:true,c:[ + T("Build city route planner","ak",{d:"2026-06-24",s:"l"}), + T("GPS waypoint following","ak",{d:"2026-06-25",s:"m"}), + ]}), + T("Hub-motor sizing for outdoor terrain","sk",{d:"2026-06-20",s:"m"}), + T("Demo prep for JCDecaux","fd",{d:"2026-06-27",s:"s"}), + ]}), + + /* ---- Client pilot: Onet (floor-cleaning autonomy) ---- */ + T("Onet pilot — floor-cleaning autonomy","ak",{d:"2026-08-01",open:true,c:[ + T("Map the Onet facility floorplan","ak",{d:"2026-06-21",s:"m"}), + T("Integrate Feetech servos for the brush arm","sk",{d:"2026-06-24",s:"m",open:true,c:[ + T("Mount the brush assembly","lm",{d:"2026-06-22",s:"s"}), + T("Tune servo sweep pattern","sk",{d:"2026-06-25",s:"s"}), + ]}), + T("Safety e-stop wiring","ly",{p:"high",d:"2026-06-16",s:"s"}), + ]}), + + /* ---- Internal R&D: core platform ---- */ + T("RoboOS v2 — core platform","ia",{p:"high",d:"2026-07-31",open:true,c:[ + T("Migrate OS to RS04 motor drivers","sk",{p:"high",d:"2026-06-24",s:"l",open:true,c:[ + T("Port CAN driver to RS04","sk",{d:"2026-06-22",s:"m"}), + T("Bench-test RS04 closed loop","sk",{d:"2026-06-23",s:"m"}), + ]}), + T("Real-time locomotion controller","ak",{d:"2026-06-29",s:"l"}), + T("Evaluate EL05 actuators","sk",{d:"2026-06-17",s:"m",open:true,c:[ + T("Run EL05 load tests","sk",{done:true,d:"2026-06-09",s:"s"}), + T("Compare EL05 vs RS02 efficiency","sk",{d:"2026-06-18",s:"s"}), + ]}), + T("Nightly build + hardware-in-the-loop rig","ia",{d:"2026-06-21",s:"m"}), + T("Assemble robot chassis v2","ia",{d:"2026-07-06",s:"l"}), + ]}), + + /* ---- Client pilot: NSI (inventory-scanning robot) ---- */ + T("NSI pilot — inventory scanning","ia",{d:"2026-07-15",open:true,c:[ + T("Scoping follow-up with NSI","fd",{d:"2026-06-19",s:"s"}), + T("Barcode scanner integration","sk",{d:"2026-06-28",s:"m"}), + T("Aisle navigation tuning","ak",{d:"2026-07-02",s:"m"}), + ]}), +]; + +/* subtasks inherit their parent task's owner by default — with an occasional + (seeded-random) different owner sprinkled in, like a real team would have */ +{ let seed=7; const rnd=()=>(seed=(seed*1103515245+12345)%2147483648)/2147483648; + const keys=Object.keys(PEOPLE); + const walk=(nodes,parent,depth)=>nodes.forEach(n=>{ + if(depth>=2&&parent) n.owner=rnd()<0.7?parent.owner:keys[Math.floor(rnd()*keys.length)]; + walk(n.children,n,depth+1); }); + walk(DATA,null,0); } + +const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path); +const depthOf = (id) => depthOfIn(id, DATA); +const fitsDepth = (node, destId) => fitsDepthIn(node, destId, DATA); + +/* ================= helpers ================= */ +/* size-weighted progress (0..1): done size-points / total size-points across the leaves */ +const progFrac = (n) => { let done = 0, tot = 0; flat([n], (x) => { if (x.children.length) return; const w = SIZE_PTS[x.size || "m"]; tot += w; if (x.done) done += w; }); return tot ? done / tot : 0; }; +function dueChip(due,done){ if(!due||done) return ""; + const dd=Math.round((new Date(due)-TODAY)/864e5); + const cls=dd<0?"overdue":dd<=3?"soon":""; + const lbl=dd<0?`${-dd}d overdue`:dd===0?"Due today":dd===1?"Due tomorrow":"Due "+new Date(due).toLocaleDateString("en-GB",{day:"numeric",month:"short"}); + return `${lbl}`; } +const prChip=p=>({high:'High',med:'Medium',low:'Low'})[p]; +const av=(pid,cls="sm")=>{const p=PEOPLE[pid];return p.photo + ? `` + : `${p.initials}`;}; +const GRIP_SVG=''; + +/* ================= undo (ctrl/cmd+Z) ================= */ +const UNDO=[]; +function snap(){ UNDO.push(JSON.stringify(DATA)); if(UNDO.length>60) UNDO.shift(); } +function undo(){ if(!UNDO.length) return; + DATA.splice(0,DATA.length,...JSON.parse(UNDO.pop())); + closeSheet(); ding(0); renderAll(); } +document.addEventListener("keydown",e=>{ + if(e.key==="Escape"){ closeSheet(); closeCapture(); closeTeam(); closeBarMenu(); closeTranscript(); closeReview(); hideTip(); return; } + if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key.toLowerCase()==="z"){ + if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; // let fields keep their own undo + e.preventDefault(); undo(); }}); + +/* ================= dashboard ================= */ +let lastTilt=0, ownerFilter="all"; +const want=o=>ownerFilter==="all"||o===ownerFilter; +function setFilter(k){ ownerFilter=k; closeFlyouts(); setTimeout(renderAll,0); } +/* one roll-out open at a time; expandable icons stay lit while their options are out */ +function closeFlyouts(exceptId){ + document.querySelectorAll(".gfctl.open").forEach(c=>{ if(c.id!==exceptId){ c.classList.remove("open"); + if(c.classList.contains("gfexp")){ const b=c.querySelector(".gficon"); if(b)b.classList.remove("on"); } } }); +} +function toggleFlyout(name){ + const c=document.getElementById("ctl-"+name); if(!c) return; + const willOpen=!c.classList.contains("open"); + closeFlyouts(willOpen?c.id:null); + c.classList.toggle("open",willOpen); + const b=c.querySelector(".gficon"); if(b) b.classList.toggle("on",willOpen); +} +let _descTimer=null; +/* toggles flash a one-line description, then it rolls back in */ +function flashDesc(name,text){ + const c=document.getElementById("ctl-"+name); if(!c) return; + closeFlyouts(c.id); + const dd=c.querySelector(".gfdesc"); if(dd&&text) dd.textContent=text; + c.classList.add("open"); clearTimeout(_descTimer); + _descTimer=setTimeout(()=>c.classList.remove("open"),1800); +} +/* click anywhere outside a control closes any open roll-out */ +document.addEventListener("pointerdown",e=>{ if(!e.target.closest(".gfctl")) closeFlyouts(); },true); +/* owners are an always-visible segmented row of avatars (built once, re-rendered on change) */ +function renderFilter(){ + const pop=document.getElementById("gfilterPop"); + if(pop) pop.innerHTML= + ``+ + Object.entries(PEOPLE).map(([k,p])=> + ``).join(""); + // light the collapsed owner icon when a specific person is filtered (so it's clear a filter is on) + const pb=document.getElementById("gpeoplebtn"); if(pb) pb.classList.toggle("gactive",ownerFilter!=="all"); +} +let SCALE_SUB=false; // false = task view (top-level tasks), true = subtask view (leaves) +function setScaleView(sub){ SCALE_SUB=sub; renderDash(); } +const taskDoneAt = (n) => taskDoneAtIn(n); +function renderDash(){ + renderGantt(); + if(!document.getElementById("myday")) return; // this version runs without the scale pane + const sv=document.getElementById("scview"); + if(sv) sv.innerHTML= + ``+ + ``; + const inView=(n,depth)=>SCALE_SUB?!n.children.length:depth===1; + const mine=[]; + flat(DATA,(n,depth,path)=>{ if(!inView(n,depth)||!want(n.owner)||taskDone(n)) return; + const dd=n.due?Math.round((new Date(n.due)-TODAY)/864e5):null; + mine.push({n,proj:(path[0]||n).title,dd,dn:false}); + }); + // balance scale over the selected time window: outstanding work weighs the left arm, + // work finished within the window counterweights on the right + const HZ=ZOOMS[ZOOM].h; + const myLate=mine.filter(x=>x.dd!==null&&x.dd<0).sort((a,b)=>a.dd-b.dd); + const myToday=mine.filter(x=>x.dd===0); + const upcoming=HZ>0?mine.filter(x=>x.dd!==null&&x.dd>0&&x.dd<=HZ).sort((a,b)=>a.dd-b.dd):[]; + const banked=[]; + flat(DATA,(n,depth,path)=>{ if(!inView(n,depth)||!want(n.owner)||!taskDone(n)) return; + const da=taskDoneAt(n); if(!da) return; + const ago=Math.round((TODAY-new Date(da))/864e5); + if(ago>=0&&ago<=Math.max(HZ,0)) banked.push({n,proj:(path[0]||n).title,dn:true}); }); + const PT=x=>SIZE_PTS[x.n.size||"m"]; + const pill=(x,cls,side)=>`
+ + + ${av(x.n.owner,"xs")} +
`; + let lt=0,rt=0; + // angry-birds physics: late = heaviest arm (3×), start-today middle (2×), due-today lightest (1×) + const leftHtml=[ + ...myLate.map(x=>{lt+=PT(x)*3;return pill(x,"late","l");}), + ...myToday.map(x=>{lt+=PT(x)*2;return pill(x,"today","l");}), + ...upcoming.map(x=>{lt+=PT(x)*1;return pill(x,"soon","l");})].join(""); + const rightHtml=banked.map(x=>{rt+=PT(x)*2;return pill(x,"done","r");}).join(""); + let tilt=Math.max(-9,Math.min(9,(lt-rt)*0.8)); + document.getElementById("myday").innerHTML=` +
+
+
${leftHtml}${rightHtml}
+
+
+
+
+
late · due today${HZ>0?` · ${({7:"due this week",21:"due in 3 weeks",42:"due in 6 weeks"})[HZ]}`:""} · ${HZ>0?"done this period":"done today"} ✓
`; + sizeScale(); + settleScale(lastTilt); // position pills synchronously, then animate to the new tilt + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(()=>requestAnimationFrame(()=>applyTilt(tilt))); + lastTilt=tilt; +} +const SPLIT_MQ="(min-width:1100px) and (orientation:landscape)"; +function applyTilt(tilt){ + const b=document.getElementById("beamEl"); if(!b) return; + b.style.transform=`translateX(-50%) rotate(${-tilt}deg)`; + settleScale(tilt); +} +/* physics layout, in plank coordinates: u = distance along the beam, v = height above it. + Pills lie parallel to the plank and slide along it; on the dipping side they jam + against the card wall (which, seen from the plank, is a slanted line — so the wall + column staircases: each box rests a little further up-plank than the one below it). + On the raised side they slide down against the fulcrum. */ +function layoutScale(tilt){ + const wrap=document.getElementById("scaleEl"), beam=document.getElementById("beamEl"), + pile=document.getElementById("pileEl"); + if(!wrap||!beam||!pile) return 999; + const W=wrap.clientWidth||600, H=wrap.clientHeight||235; + const B=beam.offsetWidth||W*0.96, C=B/2; + const tan=Math.tan(Math.abs(tilt)*Math.PI/180); + const els=s=>[...pile.querySelectorAll(".pill.side-"+s)]; + const boxes=[]; + const place=(list,wall,dir,limit,stairs)=>{ + const base=84+(Math.min(Math.abs(tilt),9)/9)*Math.max(H-230,0); // steeper tilt → taller jam + let i=0,col=0,u=wall; + while(i0?u>limit-170:u0&&v+h>cap) break; // column full → next column up-plank + let u0=dir>0?u:u-w; + if(stairs) u0=dir>0?Math.max(u0,wall+v*tan):Math.min(u0,wall-w-v*tan); // wall staircase + u0=dir>0?Math.min(Math.max(u0,0),limit-w):Math.max(Math.min(u0,B-w),limit); + el.style.left=u0+"px"; el.style.bottom=(7+v)+"px"; + boxes.push({u:u0,v,w,h}); + v+=h+5; colW=Math.max(colW,w); i++; + } + u+=dir*(colW+8); col++; + } + }; + const FW=36; // fulcrum keep-out + if(tilt>=-0.5) place(els("l"),0,1,C-FW,tilt>0.5); else place(els("l"),C-FW,-1,0,false); + if(tilt<= 0.5) place(els("r"),B,-1,C+FW,tilt<-0.5); else place(els("r"),C+FW,1,B,false); + // highest pile point in screen space (used to grow the stacked card) + const phi=-tilt*Math.PI/180, cy=H-70; + let minTop=cy; + boxes.forEach(b=>{const yl=-(b.v+b.h+7); + [b.u-C,b.u+b.w-C].forEach(xl=>{ + const y=cy+xl*Math.sin(phi)+yl*Math.cos(phi); + if(yt<0?t:(t<1?t*TW:TW+(t-1)); // day → stretched-day units +const gx=t=>(uDay(t)-R0G)/SPAN_EFFV*100; // day → % position on the track +/* a due date means END of that day, and open work never lives in the past: + — done tasks keep their historical span + — late and due-today tasks ALL span exactly the today box, ending ON the today line + — future tasks start today at the earliest and end at the end of their due day */ +/* re-renders triggered by clicks are deferred out of the input event: mutating the DOM + while Chrome is still dispatching the click can wedge its hover/input pipeline + (frozen cursor + dead hover until a tab switch) */ +const defer=fn=>setTimeout(fn,0); +function toggleShowDone(){ showDone=!showDone; const b=$id("gdonebtn"); if(b)b.classList.toggle("on",showDone); + defer(renderGantt); } +/* the control cluster is FIXED to the viewport so it stays put while the chart scrolls under it. + At the top it sits just under the date ribbon; as the ribbon scrolls away it rises to the top. */ +function placeFloat(){ const g=document.querySelector(".gantt"), fl=$id("gfloat"); + if(!g||!fl) return; const gr=g.getBoundingClientRect(), ax=document.querySelector(".gaxis"); + let top=gr.top+10; + if(ax){ const ar=ax.getBoundingClientRect(); top=Math.max(top,ar.bottom+8); } + fl.style.position="fixed"; + fl.style.right=Math.max(14,(window.innerWidth-gr.right)+14)+"px"; + fl.style.top=top+"px"; } +window.addEventListener("resize",()=>placeFloat()); +const EXP=new Set(); // individually EXPANDED tasks (when the global toggle is off) +const COL=new Set(); // individually COLLAPSED tasks (when the global toggle is on) +/* a task's subtasks are open if: global toggle on AND not individually collapsed, OR + global toggle off AND individually expanded — so the chevron always works either way */ +const subOpen=id=>subsAll?!COL.has(id):EXP.has(id); +function toggleExp(id){ if(subsAll){ COL.has(id)?COL.delete(id):COL.add(id); } + else { EXP.has(id)?EXP.delete(id):EXP.add(id); } defer(renderGantt); } +let subsAll=false; // global show/hide all subtasks +function toggleSubs(){ subsAll=!subsAll; COL.clear(); // start each "show all" fully expanded + const b=$id("gsubbtn"); if(b)b.classList.toggle("on",subsAll); + defer(renderGantt); } +let focusToday=false; // show only today's priorities (late + due today + started) +function toggleFocus(){ focusToday=!focusToday; const b=$id("gfocusbtn"); if(b)b.classList.toggle("on",focusToday); + defer(renderGantt); } +let GVIEW="proj"; // "proj" | "tasks" | "subs" +function setGView(v){ GVIEW=v; defer(renderGantt); } +function setZoom(i){ ZOOM=i; setTimeout(renderAll,0); } // drives the scale window and the gantt zoom +function renderGantt(){ + const VIS=ZOOMS[ZOOM].v; + // fixed-width today box: convert TODAY_PX into day units for the current zoom + panel width. + // On phones the box is narrower so the rest of the timeline isn't squeezed off-screen. + const TPX=(typeof window!=="undefined"&&window.innerWidth<=740)?150:TODAY_PX; + const host=document.querySelector(".gscroll")||document.getElementById("gantt"); + const PW=Math.max((host&&host.clientWidth)||0,360); + const dayPx=Math.max((PW-TPX)/Math.max(VIS-1,1),3); + TW=TPX/dayPx; SPAN_EFFV=SPAN_G+(TW-1); + // calendar axis: month headers + one weekday-letter + number per day (weekly when too tight) + const showDaily=dayPx>=20; + const months=[]; let lastM=-1; + for(let d=0;d<=R1G;d++){ const dt=new Date(dayIso(d)), m=dt.getFullYear()*12+dt.getMonth(); + if(m!==lastM){ lastM=m; months.push({d,label:dt.toLocaleDateString("en-GB",{month:"short",year:"numeric"})}); } } + const dticks=[]; + for(let d=0;d<=R1G;d++){ const dt=new Date(dayIso(d)), wknd=dt.getDay()%6===0; + if(showDaily||dt.getDay()===1||d===0) dticks.push({d,wd:"SMTWTFS"[dt.getDay()],num:dt.getDate(),wknd,today:d===0}); } + const zoomEl=document.getElementById("gzoom"); + if(zoomEl){ // build once; afterwards only toggle classes (keeps the clicked button alive) + if(!zoomEl.childElementCount) + zoomEl.innerHTML=ZOOMS.map((z,i)=>``).join(""); + [...zoomEl.children].forEach((b,i)=>b.classList.toggle("active",ZOOM===i)); + } + // keep the three toggle pictograms lit in line with their state + [["gdonebtn",showDone],["gsubbtn",subsAll],["gfocusbtn",focusToday]].forEach(([id,on])=>{ + const b=document.getElementById(id); if(b) b.classList.toggle("on",on); }); + const gv=document.getElementById("gview"), GVKEYS=["proj","tasks"]; + if(gv){ + if(!gv.childElementCount) + gv.innerHTML=[["proj","By project"],["tasks","Prioritized tasks"]] + .map(([k,l])=>``).join(""); + [...gv.children].forEach((b,i)=>b.classList.toggle("active",GVIEW===GVKEYS[i])); + } + // calendar grid: faint day lines, firmer Monday lines, alternate weeks washed + const wk=[0]; + for(let d=1;d<=R1G;d++) if(new Date(dayIso(d)).getDay()===1) wk.push(d); + wk.push(R1G); + const deco=[]; + for(let i=0;i`); + for(let d=1;d`); } + months.forEach(m=>{ if(m.d>0) deco.push(`
`); }); + const td=new Date(dayIso(0)); + const ord=n=>{const s=["th","st","nd","rd"],v=n%100;return n+(s[(v-20)%10]||s[v]||s[0]);}; + const todayStr="Today, "+td.toLocaleDateString("en-GB",{weekday:"long"})+" "+ord(td.getDate())+" "+td.toLocaleDateString("en-GB",{month:"long"}); + const todayMid=(gx(0)+gx(1))/2; + const rows=[deco.join("")+ + `
+
+
${months.map((m,i)=>{const end=i+1${m.label}`;}).join("")}
+
${dticks.map(t=> t.today + ? `${todayStr}` + : `${t.wd}${t.num}`).join("")}
`]; + let any=false; + /* ctx (priority views only): {proj, parent, col} — bar takes the project colour and the + tooltip carries the full story: where it lives, how big, how important */ + const barRow=(n,extra,isSub,ctx)=>{ // one chart row for a task or a subtask + if(!n.due) return ""; + const {s,e}=spanFor(n), done=isSub?n.done:taskDone(n); + if(eR1G) return ""; + const late=!done&&e<0, [tcs,tce]=barGeom(s,e,done), sz=n.size||"m"; + const h=isSub?Math.max(15,Math.round(GBAR_H[sz]*0.62)):GBAR_H[sz]; // subtasks shorter than tasks + // a task with subtasks shows its duration-weighted completion as a darker fill inside its + // own bar (same two-tone idea as the project summary bar, applied in place) + const hasKids=!isSub&&n.children.length>0, donePct=hasKids?Math.round(progWD(n)*100):0; + const col=barColor(e,s,done); + const fillBg=(hasKids&&!done&&donePct>0) + ? `background-color:${col};background-image:linear-gradient(90deg,rgba(0,0,0,.26) 0 ${donePct}%,rgba(0,0,0,0) ${donePct}% 100%)` + : `background:${col}`; + const tip=`${n.title} · ${SIZE_NAMES[sz]} · ${fmtD(dayIso(e))}${late?' (late)':''}${hasKids?` · ${donePct}% done`:""}`+(ctx + ?` — ${ctx.proj}${ctx.parent?" › "+ctx.parent:""} · ${({high:"high",med:"medium",low:"low"})[n.priority||"med"]} priority · ${PEOPLE[n.owner].name}` + :""); + return `
+
+ + ${av(n.owner,"xs")} + ${n.title} + + +
${extra?`
${extra}
`:""} +
`; + }; + const chevFor=t=>{ const exp=subOpen(t.id); + return t.children.length?``:""; }; + // subtask visibility is controlled only by Show subtasks / per-task expansion — NOT by focus. + // When focus is on AND subtasks are visible, only the urgent subtasks are included. + const subRows=(t,ctx)=>(!subOpen(t.id))?"":t.children + .filter(c=>want(c.owner)&&(showDone||!c.done)&&(!focusToday||isUrgent(c))) + .map(c=>barRow(c,"",true,ctx?{proj:ctx.proj,parent:t.title,col:ctx.col}:null)).join(""); + if(GVIEW==="proj") DATA.forEach(p=>{ + let open=0, maxE=1, anyLeaf=false; + flat([p],n=>{ if(n.children.length||!want(n.owner)) return; + anyLeaf=true; + if(!n.done) open++; + if(n.due) maxE=Math.max(maxE,dayN(n.due)); }); + if(!anyLeaf&&!want(p.owner)) return; + const col=PEOPLE[p.owner].color; + // thin summary bar spanning the project's whole task range (earliest start → latest due) + const sp=rollupSpan(p); + const scs=Math.max(sp.s,R0G), sce=Math.max(Math.min(sp.e+1,R1G),scs+0.5); + const taskRows=[...p.children] + .filter(t=>{ let rel=want(t.owner); + flat([t],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); return rel; }) + .filter(t=>showDone||!taskDone(t)) + .filter(t=>!focusToday||isUrgent(t)) + /* render in the project's own task order (no date sort) so manual reordering — + from the project window or by dragging a bar — is reflected directly */ + .map(t=>barRow(t,chevFor(t),false,null)+subRows(t,null)).join(""); + if(focusToday&&!taskRows) return; // nothing urgent in this project — hide it + any=true; + const prog=progWD(p), ppc=Math.round(prog*100), spanW=gx(sce)-gx(scs); + // project bar thickness scales with the project's total weight (sum of its leaf size points) + let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=SIZE_PTS[x.size||"m"]; }); + const ph=Math.max(7,Math.min(20,Math.round(5+Math.sqrt(pPts)*2.2))); + rows.push(`
+
+ +
+
+ ${ppc}% +
+ ${taskRows}
`); + }); + else{ + // priority views: project boxes drop away; the most urgent & biggest work floats to the top + const wantTask=GVIEW==="tasks", PRW={high:0,med:1,low:2}, cand=[]; + flat(DATA,(n,depth,path)=>{ + if(wantTask?depth!==1:n.children.length>0) return; + let rel=want(n.owner); + if(wantTask&&!rel) flat([n],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); + if(!rel) return; + if((wantTask?taskDone(n):n.done)&&!showDone) return; + if(!n.due) return; + const {s,e}=barSpan(n); + if(eR1G) return; + const root=path[0]||n, par=path.length>1?path[path.length-2]:null; + cand.push({n,e,root,par}); + }); + cand.sort((a,b)=>a.e-b.e + ||SIZE_PTS[b.n.size||"m"]-SIZE_PTS[a.n.size||"m"] + ||PRW[a.n.priority||"med"]-PRW[b.n.priority||"med"]); + any=cand.length>0; + rows.push('
'); + cand.forEach(({n,root,par})=>{ + const ctx={proj:root.title,col:PEOPLE[root.owner].color, + parent:(!wantTask&&par&&par!==root)?par.title:null}; + rows.push(barRow(n,wantTask?chevFor(n):"",!wantTask,ctx)); + if(wantTask) rows.push(subRows(n,ctx)); + }); + rows.push('
'); + } + document.getElementById("gantt").innerHTML= + `
`+ + rows.join("")+ + (any?"":'
No scheduled tasks for this filter.
')+ + `
`; + const sc=document.querySelector(".gscroll"); + sc.addEventListener("scroll",pinFlags,{passive:true}); + const gpane=document.querySelector(".gantt"); + if(gpane&&!gpane._floatBound){ gpane._floatBound=true; gpane.addEventListener("scroll",placeFloat,{passive:true}); } + pinFlags(); placeFloat(); placeOverflowTitles(); + // unstick Chromium's hover hit-testing after the DOM swap (otherwise tooltips/hover + // stay dead until you move the mouse or switch tabs) + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(kickHover); +} +/* After replacing #gantt's innerHTML, nudge native :hover (the done-dot and resize ears) back + to life with a synchronous reflow. The TOOLTIP is intentionally NOT synthesized here — it is + driven only by real hover events (see the hover module below). Synthesizing it from + elementFromPoint made the tip pop on clicks (e.g. when a filter flyout closed and the + hit-test fell through to a bar behind it). We only clear a now-stale tip. */ +function kickHover(){ + try{ + document.body.style.pointerEvents="none"; + void document.body.offsetHeight; // force synchronous reflow → refresh native :hover + document.body.style.pointerEvents=""; + }catch(e){} + if(typeof tipEl!=="undefined" && tipEl && !tipEl.isConnected) hideTip(); // hovered bar was replaced +} +/* keep project names visible: if a flag's pole is outside the scrolled viewport, + pin the flag to the nearest edge with an arrow; it snaps back when the pole returns */ +/* when a bar is too narrow to show even half its title, hide the inner label and print the + full title as plain text just to the right of the bar (no background) */ +function placeOverflowTitles(){ + document.querySelectorAll("#gantt .gttlout").forEach(x=>x.remove()); + document.querySelectorAll("#gantt .gtrack").forEach(track=>{ + track.querySelectorAll(":scope > .gbar").forEach(bar=>{ + const ttl=bar.querySelector(".ttl"); if(!ttl) return; + const full=ttl.scrollWidth, vis=ttl.clientWidth; + if(full>4 && vis{ + const lbl=row.querySelector(".gsumlbl"), line=row.querySelector(".gsumline"); + if(!lbl||!line) return; + // keep the project name visible: slide the label to the viewport's left edge as its + // range line scrolls past, but never beyond the line's right end + const ll=line.offsetLeft, lr=ll+line.offsetWidth; + let left=Math.max(ll,v0+4); + left=Math.min(left,Math.max(lr-46,ll)); + lbl.style.left=left+"px"; + }); +} +window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); }); + +/* --- floating "gripped pill" ghost, shared by bar drags and pop-up task drags --- */ +function makeGhost(text,color){ const g=document.createElement("div"); + g.className="dragghost"; g.textContent=text; + if(color) g.style.background=color; + document.body.appendChild(g); return g; } +function placeGhost(g,e){ g.style.left=(e.clientX+16)+"px"; g.style.top=(e.clientY-34)+"px"; } +const rootOf=id=>findPath(id)[0]; +function projUnder(e,dragId){ + const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".pgroup[data-pid]"); + return t&&+t.dataset.pid!==rootOf(dragId).id?t:null; } +function dropInto(groupEl,id){ const node=detach(id); + findPath(+groupEl.dataset.pid).pop().children.push(node); } + +/* --- bar dragging: move / resize ears / drop on a project pill --- */ +let G=null, suppressCtx=0; +const siblingsOf=id=>{ const p=findPath(id); p.pop(); const par=p.pop(); return par?par.children:DATA; }; +/* right-click (desktop) opens the quick menu — but a touch long-press also fires contextmenu, + and that case already opened the detail popup, so swallow it for ~0.8s after a long-press */ +function barContext(ev,id,rect){ ev.preventDefault(); + if(Date.now()-suppressCtx<800) return; openBarMenu(id,rect); } +function barDown(e,id,mode){ + if(e.button!==undefined && e.button>0) return; // right/middle mouse → let oncontextmenu open the quick menu + e.preventDefault(); e.stopPropagation(); hideTip(); + const el=e.target.closest(".gbar"), track=el.parentElement; + const n=findPath(id).pop(), {s,e:en}=barSpan(n); + const touch=e.pointerType==="touch"; + el.classList.add("dragging"); + G={id,mode,el,n,touch,longTimer:null,ppd:track.getBoundingClientRect().width/SPAN_EFFV, + x0:e.clientX,y0:e.clientY,s0:s,e0:en,s,e:en,moved:false,overProj:null, + axis:mode==="move"?null:"date",reTo:null,reRow:null, // move starts axis-undecided + ghost:mode==="move"?makeGhost(n.title,el.style.background):null}; + if(G.ghost) G.ghost.style.display="none"; // appears once the bar actually moves + // touch: a LONG-PRESS on a bar opens the full detail popup (a short tap opens the quick menu) + if(touch && mode==="move"){ + G.longTimer=setTimeout(()=>{ if(!G||G.moved) return; + document.removeEventListener("pointermove",barMove); + document.removeEventListener("pointerup",barUp); + document.removeEventListener("pointercancel",barUp); + G.el.classList.remove("dragging"); G.el.style.opacity=""; if(G.ghost) G.ghost.remove(); + const nid=G.id; G=null; suppressCtx=Date.now(); // swallow the contextmenu this long-press will also fire + if(navigator.vibrate){ try{navigator.vibrate(8);}catch(_){} } openDetail(nid); + },480); + } + document.addEventListener("pointermove",barMove,{passive:false}); + document.addEventListener("pointerup",barUp); + document.addEventListener("pointercancel",barUp); +} +function clearReMark(){ document.querySelectorAll(".grow.reinsb,.grow.reinsa") + .forEach(x=>x.classList.remove("reinsb","reinsa")); } +/* vertical drag of a bar = reorder among its siblings (or drop into another project) */ +function barReorderMove(ev){ + G.moved=true; G.el.style.opacity=".45"; + if(G.ghost){ G.ghost.style.display=""; G.ghost.textContent="⇅ "+G.n.title; placeGhost(G.ghost,ev); } + clearReMark(); G.reTo=null; G.reRow=null; + if(G.overProj){ G.overProj.classList.remove("gdropover"); G.overProj=null; } + const el=document.elementFromPoint(ev.clientX,ev.clientY); + const bar=el?.closest?.(".gbar[data-tid]"), sibs=siblingsOf(G.id); + if(bar&&+bar.dataset.tid!==G.id&&sibs.some(s=>s.id===+bar.dataset.tid)){ + const row=bar.closest(".grow"), r=bar.getBoundingClientRect(), after=ev.clientY>r.top+r.height/2; + row.classList.add(after?"reinsa":"reinsb"); G.reRow=row; + G.reTo=sibs.findIndex(s=>s.id===+bar.dataset.tid)+(after?1:0); + } else { // not over a sibling → offer to move into another project + const t=projUnder(ev,G.id); G.overProj=t; if(t) t.classList.add("gdropover"); + } +} +function barMove(ev){ + if(!G) return; + ev.preventDefault(); + // any real movement cancels a pending long-press (it's a drag, not a press) + if(G.longTimer&&(Math.abs(ev.clientX-G.x0)>5||Math.abs(ev.clientY-G.y0)>5)){ clearTimeout(G.longTimer); G.longTimer=null; } + if(G.axis===null){ // decide intent on first real movement + const dx=ev.clientX-G.x0, dy=ev.clientY-G.y0; + if(Math.max(Math.abs(dx),Math.abs(dy))<5) return; + G.axis=Math.abs(dy)>Math.abs(dx)*1.25?"reorder":"date"; + } + if(G.axis==="reorder") return barReorderMove(ev); + const dd=Math.round((ev.clientX-G.x0)/G.ppd); + if(dd!==0) G.moved=true; + if(G.mode==="move"){ G.s=G.s0+dd; G.e=G.e0+dd; } + else if(G.mode==="l"){ G.s=Math.min(G.s0+dd,G.e0); } + else { G.e=Math.max(G.e0+dd,G.s0); } + const [cs,ce]=barGeom(G.s,G.e,G.n.done); + G.el.style.left=gx(cs)+"%"; + G.el.style.width=(gx(ce)-gx(cs))+"%"; + if(G.ghost){ G.ghost.style.display=G.moved?"":"none"; + G.ghost.textContent=G.n.title+" · due "+fmtD(dayIso(G.e)); + placeGhost(G.ghost,ev); } + if(G.mode==="move"){ + const t=projUnder(ev,G.id); + if(G.overProj&&G.overProj!==t) G.overProj.classList.remove("gdropover"); + G.overProj=t; if(t) t.classList.add("gdropover"); + } +} +function barUp(e){ + document.removeEventListener("pointermove",barMove); + document.removeEventListener("pointerup",barUp); + document.removeEventListener("pointercancel",barUp); + if(!G) return; // long-press already handled it + if(G.longTimer){ clearTimeout(G.longTimer); G.longTimer=null; } + G.el.classList.remove("dragging"); G.el.style.opacity=""; + if(G.ghost) G.ghost.remove(); + clearReMark(); + if(e&&e.type==="pointercancel"){ if(G.overProj)G.overProj.classList.remove("gdropover"); G=null; return; } + // vertical reorder / cross-project move + if(G.axis==="reorder"){ + const dropped=G.overProj; if(dropped) dropped.classList.remove("gdropover"); + if(dropped&&fitsDepth(G.n,+dropped.dataset.pid)){ snap(); dropInto(dropped,G.id); G=null; ding(2); renderAll(); return; } + const arr=siblingsOf(G.id), from=arr.findIndex(s=>s.id===G.id); let to=G.reTo; + if(to!=null&&from>-1){ if(to>from) to--; if(to!==from){ snap(); const [nd]=arr.splice(from,1); arr.splice(to,0,nd); ding(2); } } + G=null; renderAll(); return; + } + const n=G.n, dropped=G.overProj; + if(dropped) dropped.classList.remove("gdropover"); + if(!G.moved&&!dropped){ // a tap/click that didn't drag + const r=G.el.getBoundingClientRect(), touch=G.touch, mode=G.mode, nid=n.id; G=null; + if(mode!=="move") return; // tap on a resize ear → do nothing + if(touch) openBarMenu(nid,r); // mobile: tap = quick menu (long-press already gives the popup) + else openDetail(nid); // desktop: left-click = full detail popup + return; } + snap(); + if(G.mode==="move"){ n.due=dayIso(G.e); if(n.start) n.start=dayIso(G.s); } + else if(G.mode==="l"){ n.start=dayIso(G.s); } + else { if(!n.start) n.start=dayIso(G.s0); n.due=dayIso(G.e); } + if(dropped) dropInto(dropped,n.id); + G=null; ding(2); renderAll(); +} + +/* --- grip a row in a pop-up. Stay inside the pop-up → reorder the list. + Drag outside it → the pop-up closes and you assign the item to another + project (drop on its box) or task (drop on its bar), depth rules permitting. --- */ +let RD=null; +function rowDown(e,id){ + e.preventDefault(); e.stopPropagation(); hideTip(); + const path=findPath(id), n=path[path.length-1], parent=path[path.length-2]; + RD={id,n,parentId:parent?parent.id:null,box:document.querySelector(".tbox"), + mode:"reorder",over:null,toIdx:null, + ghost:makeGhost(n.title,PEOPLE[n.owner].color)}; + placeGhost(RD.ghost,e); + document.addEventListener("pointermove",rowMove,{passive:false}); + document.addEventListener("pointerup",rowUp); + document.addEventListener("pointercancel",rowUp); +} +function clearRowMark(){ document.querySelectorAll(".ptask.insb,.ptask.insa") + .forEach(x=>x.classList.remove("insb","insa")); } +function rowMove(e){ + e.preventDefault(); placeGhost(RD.ghost,e); + if(RD.mode==="reorder"){ + const r=RD.box&&RD.box.getBoundingClientRect(); + if(!r||e.clientXr.right||e.clientYr.bottom){ + RD.mode="assign"; RD.over=null; RD.toIdx=null; + clearRowMark(); closeSheet(); // left the pop-up → reveal the timeline targets + }else{ + clearRowMark(); RD.over=null; RD.toIdx=null; + const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".ptask[data-cid]"); + if(t&&+t.dataset.cid!==RD.id){ + const tr=t.getBoundingClientRect(), after=e.clientY>tr.top+tr.height/2; + t.classList.add(after?"insa":"insb"); + RD.over=t; + RD.toIdx=[...RD.box.querySelectorAll(".ptask[data-cid]")].indexOf(t)+(after?1:0); + } + return; + } + } + // assign mode: task bars first (finer target), then project boxes + const el=document.elementFromPoint(e.clientX,e.clientY); + let t=el?.closest?.(".gbar[data-tid]")||null; + if(t){ const tid=+t.dataset.tid; + if(tid===RD.id||tid===RD.parentId||contains(RD.n,tid)||!fitsDepth(RD.n,tid)) t=null; } + if(!t){ const g=el?.closest?.(".pgroup[data-pid]"); + if(g){ const pid=+g.dataset.pid; + if(pid!==RD.parentId&&fitsDepth(RD.n,pid)) t=g; } } + if(RD.over&&RD.over!==t) RD.over.classList.remove("gdropover"); + RD.over=t; if(t) t.classList.add("gdropover"); +} +function rowUp(){ + document.removeEventListener("pointermove",rowMove); + document.removeEventListener("pointerup",rowUp); + document.removeEventListener("pointercancel",rowUp); + clearRowMark(); RD.ghost.remove(); + const S=RD; RD=null; + if(S.mode==="reorder"){ + if(S.toIdx!=null&&S.parentId!=null){ + const parent=findPath(S.parentId).pop(); + const from=parent.children.findIndex(c=>c.id===S.id); + let to=S.toIdx; if(to>from) to--; + if(from>-1&&to!==from){ snap(); + const [nd]=parent.children.splice(from,1); + parent.children.splice(to,0,nd); + ding(2); renderAll(); openDetail(S.parentId); } + } + return; // released inside the pop-up: nothing moved, pop-up stays open + } + if(S.over){ S.over.classList.remove("gdropover"); + snap(); const node=detach(S.id); + findPath(+(S.over.dataset.tid||S.over.dataset.pid)).pop().children.push(node); + ding(2); renderAll(); } +} + +/* --- drag a project flag vertically to reorder projects; a plain click opens the popup --- */ +let PD=null; +function projDown(e,pid){ + e.preventDefault(); e.stopPropagation(); hideTip(); + PD={pid,started:false,x0:e.clientX,y0:e.clientY,over:null,pos:null,ghost:null}; + document.addEventListener("pointermove",projMove,{passive:false}); + document.addEventListener("pointerup",projUp); + document.addEventListener("pointercancel",projUp); +} +function clearProjMark(){ document.querySelectorAll(".pgroup.insb,.pgroup.insa") + .forEach(x=>x.classList.remove("insb","insa")); } +function projMove(e){ + e.preventDefault(); + if(!PD.started){ + if(Math.hypot(e.clientX-PD.x0,e.clientY-PD.y0)<6) return; // click tolerance + PD.started=true; + const p=DATA.find(x=>x.id===PD.pid); + PD.ghost=makeGhost("⇅ "+p.title,PEOPLE[p.owner].color); + } + placeGhost(PD.ghost,e); + clearProjMark(); PD.over=null; + const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".pgroup[data-pid]"); + if(t&&+t.dataset.pid!==PD.pid){ + const r=t.getBoundingClientRect(); + PD.pos=e.clientYx.id===pid), [proj]=DATA.splice(from,1); + let to=DATA.findIndex(x=>x.id===+over.dataset.pid); + if(pos==="after") to++; + DATA.splice(to,0,proj); + ding(2); renderAll(); +} + +/* ---- moving tasks between projects/parents (via modal "Move to") ---- */ +/* strict 3-level hierarchy: project (0) -> task (1) -> subtask (2) */ +function detach(id){ const p=findPath(id), node=p.pop(), parent=p.pop(); + const arr=parent?parent.children:DATA; arr.splice(arr.indexOf(node),1); return node; } +function moveInto(id,dest){ if(!dest||id===dest||contains(findPath(id).pop(),dest))return false; + if(!fitsDepth(findPath(id).pop(),dest)) return false; // would exceed 3 levels + const target=findPath(dest).pop(); const node=detach(id); + target.children.push(node); target.open=true; return true; } +function moveTask(id,dest){ snap(); + if(moveInto(id,+dest)){ renderAll(); openDetail(id); } else UNDO.pop(); } + +function toggleDone(id){ snap(); const n=findPath(id).pop(); + const stamp=x=>x.doneAt=x.done?TODAY.toISOString().slice(0,10):null; + if(!n.children.length){ n.done=!n.done; stamp(n); } + else { const target=pct(n)!==100; flat([n],x=>{if(!x.children.length){x.done=target;stamp(x);}}); } + renderAll(); } + +/* ================= task modal — comprehensive & editable ================= */ +/* ===== on-the-go bar menu: quick edits without the full detail sheet ===== */ +let BARMENU=null; +const BM=document.createElement("div"); BM.id="barMenu"; BM.className="barmenu"; document.body.appendChild(BM); +function openBarMenu(id,anchor){ + const path=findPath(id); if(!path) return; const n=path.pop(); + if(anchor&&anchor.getBoundingClientRect) anchor=anchor.getBoundingClientRect(); + if(anchor) BM._anchor={left:anchor.left,right:anchor.right,top:anchor.top,bottom:anchor.bottom}; + const a=BM._anchor||{left:100,right:160,top:100,bottom:130}; + BM.innerHTML=` +
Owner
+
${Object.entries(PEOPLE).map(([k,p])=>``).join("")}
+
Size${["s","m","l","xl"].map(z=>``).join("")}
+
Due
`; + BM.classList.add("show"); BARMENU=id; + const mw=BM.offsetWidth||236, mh=BM.offsetHeight||170, gap=8, vw=window.innerWidth, vh=window.innerHeight; + // sit to the RIGHT of the pill (flip left only if there's no room) + let left=a.right+gap; if(left+mw>vw-8) left=Math.max(8,a.left-mw-gap); + // align with the pill's top and roll down; if that would overflow, align to its bottom and roll up + let top=(a.top+mh<=vh-8)?a.top:Math.max(8,a.bottom-mh); + top=Math.max(8,Math.min(top,vh-mh-8)); + BM.style.left=left+"px"; BM.style.top=top+"px"; +} +function refreshBarMenu(id){ if(BARMENU===id) openBarMenu(id); } +function closeBarMenu(){ BM.classList.remove("show"); BARMENU=null; } +document.addEventListener("pointerdown",e=>{ if(BARMENU&&!e.target.closest("#barMenu")) closeBarMenu(); },true); + +function updTask(id,f,v,quiet){ snap(); const n=findPath(id).pop(); + if(f==="title") n.title=v.trim()||n.title; + else if(f==="owner") n.owner=v; + else if(f==="priority") n.priority=v; + else if(f==="due") n.due=v||null; + else if(f==="start") n.start=v||null; + else if(f==="size") n.size=v||null; + renderAll(); if(!quiet&&f!=="title") openDetail(id); } +function deleteTask(id){ const n=findPath(id).pop(); + if(typeof confirm!=="undefined"&&!confirm('Delete "'+n.title+'"'+(n.children.length?" and its subtasks":"")+"?")) return; + snap(); detach(id); closeSheet(); renderAll(); } +function addChild(id){ const el=document.getElementById("dSubNew"), v=el.value.trim(); if(!v) return; + if(findPath(id).length>=3) return; // subtasks can't have children + snap(); const n=findPath(id).pop(); n.children.push(T(cap1(v),n.owner,{d:n.due||null})); + renderAll(); openDetail(id); } +function openDetail(id){ + const path=findPath(id); if(!path) return; + const n=path[path.length-1], leaf=!n.children.length; + document.getElementById("dCrumb").innerHTML=path.length>1 + ?path.slice(0,-1).map(x=>``).join(" › ")+" ›" + :"Project"; + const ti=document.getElementById("dTitle"); + ti.value=n.title; ti.onchange=e=>updTask(id,"title",e.target.value); + const par=path.length>1?path[path.length-2].id:null; + const mopts=['']; + flat(DATA,(x,depth)=>{ if(contains(n,x.id))return; + if(depth+1+heightOf(n)>2) return; // keep the 3-level hierarchy + mopts.push(``); }); + // Size: leaves carry an editable t-shirt size; projects/parent tasks SHOW the rolled-up + // point total (sum of their leaves' size points) — not a field the user fills in. + let _szPts=0; flat([n],x=>{ if(x.children.length) return; _szPts+=SIZE_PTS[x.size||"m"]; }); + const sizeFld=leaf + ? `
Size
` + : `
Size${_szPts} pts
`; + document.getElementById("dBody").innerHTML=` +
Owner${av(n.owner)}
+
Due${dueChip(n.due,leaf&&n.done)}
+ ${sizeFld} + ${leaf?`
Status +
`:""} +
Move to
+ ${path.length>=3?"":`
${path.length>1?"Subtasks":"Tasks — grip ⠿ to drag onto another project"}
`} + ${n.children.map(ch=>{ const lp=pct(ch), lleaf=!ch.children.length; + let _cp=0; flat([ch],x=>{ if(x.children.length) return; _cp+=SIZE_PTS[x.size||"m"]; }); + const szCtl=lleaf + ? `` + : `${_cp} pts`; + return `
+ + + + ${ownerPill(ch.owner,`updTask(${ch.id},'owner',this.value,true);openDetail(${id})`)} + ${szCtl} + ${dueChip(ch.due,lleaf&&ch.done)}
`;}).join("")} + ${path.length>=3?"":`
`} + `; + document.getElementById("tmodal").classList.add("show"); + document.getElementById("scrim").classList.add("show"); +} +function closeSheet(){ document.getElementById("tmodal").classList.remove("show"); + document.getElementById("scrim").classList.remove("show"); } + +/* ================= conversational capture ================= */ +/* Real product: each turn's transcript + context (roster, project list, today) is POSTed + to /extract, which calls the LLM (ChatGPT) with a JSON schema and returns structured + fields + the next question. Here callExtract() falls back to a local mock so the whole + UX is demoable; swap EXTRACT_URL for the live endpoint and nothing else changes. */ +const $id=x=>document.getElementById(x); +const EXTRACT_URL=null; // e.g. "/extract" once the serverless function is live + +let actx; +function ding(step=0){ try{ + actx=actx||new (window.AudioContext||window.webkitAudioContext)(); + const o=actx.createOscillator(), g=actx.createGain(), t0=actx.currentTime; + o.type="sine"; o.frequency.setValueAtTime(740+step*80,t0); o.frequency.exponentialRampToValueAtTime(1180+step*80,t0+0.08); + g.gain.setValueAtTime(0.0001,t0); g.gain.exponentialRampToValueAtTime(0.09,t0+0.02); + g.gain.exponentialRampToValueAtTime(0.0001,t0+0.35); + o.connect(g); g.connect(actx.destination); o.start(t0); o.stop(t0+0.4); +}catch(e){} } + +let CAP=null, rec=null, listening=false, capTTS=false, capLang="en"; // input language; output is always English +function toggleCapLang(){ capLang=capLang==="en"?"fr":"en"; + $id("langBtn").textContent="🌐 "+capLang.toUpperCase(); + if(listening){ stopListen(); } } + +function openCapture(){ + CAP={turns:[], history:[], draft:{}, pending:null, ready:false, busy:false}; capQueue=[]; + $id("capChat").innerHTML=""; $id("capInput").value=""; + $id("capCard").className="capcard"; $id("capActions").innerHTML=""; + updateKeyBadge(); renderCapCard({pending:null}); // show the (empty) creation card + botSay("Hi! What would you like to do? For example: “new project Roman Pilot, first task get insurance, due Monday.”"); + $id("vmodal").classList.add("show"); $id("vmodal").classList.remove("min"); + if(!getKey()) askKey(); // offer to connect GPT on first use + else setTimeout(()=>$id("capInput").focus(),50); +} +function minimizeCapture(){ stopListen(); $id("vmodal").classList.add("min"); } +function restoreCapture(){ $id("vmodal").classList.remove("min"); setTimeout(()=>{const i=$id("capInput"); i&&i.focus();},50); } +function closeCapture(){ stopListen(); window.speechSynthesis&&speechSynthesis.cancel(); + $id("vmodal").classList.remove("show","min"); CAP=null; } + +function bubble(text,cls){ const b=document.createElement("div"); + b.className="bub "+cls; b.textContent=text; + const c=$id("capChat"); c.appendChild(b); c.scrollTop=c.scrollHeight; return b; } +let lastBotText=""; +const normCap=s=>(s||"").toLowerCase().replace(/[^a-z0-9 ]/g,"").replace(/\s+/g," ").trim(); +function botSay(text){ lastBotText=text; bubble(text,"bot"); if(capTTS) speak(text); } +function speak(text){ try{ if(!window.speechSynthesis) return; + speechSynthesis.cancel(); const u=new SpeechSynthesisUtterance(text); + u.rate=1.05; u.lang="en-US"; + if(listening){ micPaused=true; try{rec.stop();}catch(e){} } // hard-pause the mic so it can't hear itself + speaking=true; + u.onend=u.onerror=()=>{ speaking=false; + if(listening&&micPaused){ micPaused=false; try{rec.start();}catch(e){} } }; + speechSynthesis.speak(u); }catch(e){ speaking=false; micPaused=false; } } +function toggleTTS(){ capTTS=!capTTS; + $id("ttsBtn").textContent=capTTS?"🔊 Voice on":"🔇 Voice off"; + $id("ttsBtn").classList.toggle("on",capTTS); + if(!capTTS&&window.speechSynthesis) speechSynthesis.cancel(); } + +async function sendTurn(){ + if(!CAP||CAP.busy) return; + const el=$id("capInput"), text=el.value.trim(); if(!text) return; + el.value=""; bubble(text,"me"); CAP.turns.push(text); + CAP.busy=true; const think=bubble("…","bot think"); + const r=await callExtract(text,CAP.draft,CAP.pending); + think.remove(); CAP.busy=false; + CAP.draft=r.draft; CAP.pending=r.pending; CAP.ready=r.ready; + botSay(r.assistantSay); + CAP.history.push({role:"user",content:text},{role:"assistant",content:r.assistantSay}); + if(CAP.history.length>16) CAP.history=CAP.history.slice(-16); + renderCapCard(r); +} + +/* ---- sidebar popovers: search (top) and settings (bottom) ---- */ +function closeSidePops(except){ ["sbsearchpop","sbsettingspop"].forEach(id=>{ if(id!==except){ const p=document.getElementById(id); if(p)p.classList.remove("show"); } }); } +function toggleSearch(){ const p=document.getElementById("sbsearchpop"); if(!p)return; closeSidePops("sbsearchpop"); + const open=p.classList.toggle("show"); if(open){ const i=document.getElementById("searchbox"); if(i){i.focus();i.select&&i.select();} } } +function toggleSettings(){ const p=document.getElementById("sbsettingspop"); if(!p)return; closeSidePops("sbsettingspop"); + const open=p.classList.toggle("show"); if(open){ const i=document.getElementById("setKeyInput"); if(i)i.value=getKey(); } } +function closeSettings(){ const p=document.getElementById("sbsettingspop"); if(p)p.classList.remove("show"); } +/* hide / show the whole left rail; the chart reclaims the space and re-lays out once it settles */ +function toggleSidebar(){ const hidden=document.body.classList.toggle("sbhide"); closeSidePops(); + const b=document.querySelector(".sbtoggle"); if(b) b.title=hidden?"Show sidebar":"Hide sidebar"; + placeFloat(); setTimeout(()=>{ if(typeof renderGantt==="function") renderGantt(); placeFloat(); },240); } + +/* ---- OpenAI key, kept in this browser session only (never written to the file) ---- */ +let OAI_KEY=""; +function getKey(){ try{ return sessionStorage.getItem("oai_key")||OAI_KEY; }catch(e){ return OAI_KEY; } } +function setKeyVal(v){ OAI_KEY=v; try{ v?sessionStorage.setItem("oai_key",v):sessionStorage.removeItem("oai_key"); }catch(e){} } +function askKey(){ const has=!!getKey(); + $id("keyInput").value=""; $id("keyInput").placeholder=has?"Key saved — paste a new one to replace":"sk-…"; + $id("clearKey").style.display=has?"inline-flex":"none"; + $id("keyModal").classList.add("show"); setTimeout(()=>$id("keyInput").focus(),50); } +function saveKey(){ const v=$id("keyInput").value.trim(); if(v) setKeyVal(v); + $id("keyInput").value=""; $id("keyModal").classList.remove("show"); updateKeyBadge(); } +function skipKey(){ $id("keyModal").classList.remove("show"); } +function clearKey(){ setKeyVal(""); $id("keyModal").classList.remove("show"); updateKeyBadge(); } +function updateKeyBadge(){ const b=$id("keyBtn"); if(b) b.textContent=getKey()?"🔑 GPT on":"🔑 Key"; } + +/* swappable extraction: a live endpoint, then OpenAI direct (browser key), then the mock */ +const OWNER_IDS=[...Object.keys(PEOPLE),null]; // owner must be a real teammate id, never free text +const OAI_SCHEMA={type:"object",additionalProperties:false, + required:["intent","project","task","tasks","remove","owner","parentId","due","size","pending","ready","assistantSay"], + properties:{ + remove:{type:"array",items:{type:"string"}}, + intent:{type:["string","null"],enum:["create_project","create_task","create_subtask",null]}, + project:{type:["string","null"]}, task:{type:["string","null"]}, + tasks:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size","subs"], + properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}, + subs:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size"], + properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}}}}}, + owner:{type:["string","null"],enum:OWNER_IDS}, parentId:{type:["integer","null"]}, + due:{type:["string","null"]}, size:{type:["string","null"],enum:["s","m","l","xl",null]}, + pending:{type:["string","null"]}, ready:{type:"boolean"}, assistantSay:{type:"string"}}}; +function captureContext(){ return {today:isoCap(TODAY), + people:Object.entries(PEOPLE).map(([id,p])=>({id,name:p.name,responsibility:p.role,aka:p.al})), + hardware:HARDWARE_VOCAB, clients:CLIENTS.map(c=>c.name), + projects:DATA.map(p=>({id:p.id,name:p.title,tasks:p.children.map(t=>({id:t.id,name:t.title}))}))}; } +async function callExtract(text,draft,pending){ + if(EXTRACT_URL){ try{ + const res=await fetch(EXTRACT_URL,{method:"POST",headers:{"Content-Type":"application/json"}, + body:JSON.stringify({text,draft,pending,context:captureContext()})}); + return await res.json(); + }catch(e){ /* fall through */ } } + const key=getKey(); + if(key){ try{ return await openaiExtract(text,draft,pending,key); } + catch(e){ bubble("⚠︎ GPT call failed ("+e.message+") — using offline parser.","bot think"); } } + await new Promise(r=>setTimeout(r,300)); // simulate latency + return mockExtract(text,draft,pending); +} +async function openaiExtract(text,draft,pending,key){ + const sys=`You convert a teammate's spoken/typed request into a structured task-capture object for a 3-level planner (project > task > subtask). +LANGUAGE: the user may speak or type in English OR French — understand both perfectly. ALL OUTPUT MUST BE IN ENGLISH: translate every project/task/subtask name to English, and write assistantSay in English, regardless of the input language. +Rules: +- Merge the NEW utterance into the CURRENT draft; KEEP earlier fields unless the user changes them. Never discard the project the user is building. The user may correct any field at any time ("actually call it X", "change owner to Y"). +- intent is one of create_project / create_task / create_subtask, and stays create_project while the user is still building a new project. +- When intent is create_project, collect the project's tasks in the "tasks" array. ALWAYS return the COMPLETE cumulative list — every task added so far in currentDraft.tasks PLUS any new one this turn. If the user says "add a task / first task / another task ...", append a new item; never drop previously added tasks and never switch intent or use parentId. +- Task titles are concise imperative phrases with NO leading article — "Clean the bathroom", not "a clean the bathroom". Each item has title, plus owner/due/size if stated (else null), plus a "subs" array (empty if none). +- SUBTASKS: the word "subtask" ALWAYS means an item inside some existing task's "subs" array — NEVER a new top-level task, no matter how many are added. When the user says "add subtask(s)" / "add N subtasks": if they name or imply a parent ("for the first task", "under clean the bathroom"), use it; if they DON'T name one, attach the subtasks to the LAST task currently in the tasks array. Return that task's complete subs list. Subtasks are leaves (no further subs). Never increase the number of top-level tasks when the user said "subtask". +- ASSIGNEE INFERENCE: when no owner is explicitly named for a task/subtask, look at the task's CONTENT and assign the teammate whose responsibility best matches it, using this RESPONSIBILITY MAP: +${RESP_MAP_TEXT} + Examples: "Install RS03 motor on prototype" → Iannis or Sanket; "Implement obstacle avoidance for the Derichebourg pilot" → Akshat; "Fix D-Wave board power issue" → Leynaïck. Only when nothing in the content maps to a responsibility, fall back to the PROJECT's owner. If the user says "owners same as the project", set every task's and subtask's owner to the project owner (this overrides inference). +- DOMAIN VOCABULARY — use these EXACT spellings; never invent or mis-spell hardware or client names: +${VOCAB_TEXT} + Map mis-heard variants to the canonical form (e.g. "RS zero three"/"RS-3" → "RS03", "dwave" → "D-Wave", "jaycee decaux" → "JCDecaux"). +- When the user gives an ordered list of due dates/owners "in that order" for the tasks, apply them positionally to the tasks in their current order. +- DELETING: you CAN delete. When the user asks to remove/delete a task or subtask (e.g. "delete the two tests you just added", "remove clean the toilet"), put each item's exact current title into the "remove" array. Otherwise "remove" is []. Never say you can't delete. +- Use intent create_task / create_subtask ONLY when adding to a project/task that ALREADY EXISTS in context.projects. Then set parentId to that existing id. +- "task" (singular) is only for create_task/create_subtask; for create_project leave "task" null and use "tasks". +- owner MUST be one of the provided people ids, or null. Names are frequently MIS-HEARD by voice transcription — map any spelling variant or mishearing listed in the responsibility map to the correct id (e.g. "Janice"/"Yannis"/"Ioannis" → Iannis "ia"; "Flo"/"Florine" → Florian "fd"; "Sankeet" → Sanket "sk"). Do NOT assign the work to a different real teammate just because the heard name is fuzzy; if you genuinely cannot resolve it, use null rather than guessing the wrong person. +- due: resolve relative dates ("Monday","tomorrow","in 3 days") to absolute YYYY-MM-DD using context.today; else null. size: s/m/l/xl if stated else null. +- pending = the single most useful field still needed ("projectName","taskTitle","parent","owner"), or null if nothing required is missing. Required: create_project needs project(name); create_task/subtask need task(title) and parentId. +- ready = true when required fields are present (a project is ready once it has a name, even with zero tasks). +- assistantSay = one short, natural sentence confirming what you understood and asking the next thing (or noting it's ready). Talk like a helpful colleague, not a form. If you just appended a task, acknowledge it and invite another or Create. +- Use the prior conversation messages to resolve references: "them/those/all of them", "the first one", "same", "same for the subtasks". "Same for X" / "same for the subtasks" means apply the value MOST RECENTLY set or discussed (e.g. the due date you just applied to the tasks) to X — do NOT guess a different attribute. If the last thing set was a due date, "same for the subtasks" sets that same due date on every subtask. +Return ONLY the JSON object.`; + const history=(typeof CAP!=="undefined"&&CAP&&CAP.history)?CAP.history.slice(-12):[]; + const body={model:"gpt-4o-mini",temperature:0, + messages:[{role:"system",content:sys},...history, + {role:"user",content:JSON.stringify({newUtterance:text,currentDraft:draft||{},pendingField:pending,context:captureContext()})}], + response_format:{type:"json_schema",json_schema:{name:"capture",strict:true,schema:OAI_SCHEMA}}}; + const res=await fetch("https://api.openai.com/v1/chat/completions",{method:"POST", + headers:{"Content-Type":"application/json","Authorization":"Bearer "+key}, + body:JSON.stringify(body)}); + if(!res.ok){ throw new Error(res.status+" "+(await res.text()).slice(0,140)); } + const o=JSON.parse((await res.json()).choices[0].message.content); + const clean=s=>s?canonHardware(cap1(s.replace(/^(?:a|an|the)\s+/i,"").trim())):s; + // merge by title: UPDATE fields on existing tasks (so "set size on each task" actually lands), + // APPEND genuinely new ones, and keep tasks GPT didn't mention so nothing is dropped. + // owner falls back to a domain guess from the title when the model left it null. + const cleanSubs=arr=>(arr||[]).map(s=>{const ti=clean(s.title); + return {title:ti,owner:s.owner||inferOwnerByDomain(ti),due:s.due||null,size:s.size||null};}).filter(s=>s.title); + const wantsSub=/\bsub ?-?tasks?\b/i.test(text||""); // user explicitly asked for subtasks + const out=((draft&&draft.tasks)||[]).map(t=>({...t,subs:(t.subs||[]).slice()})); + const idx=new Map(out.map((t,i)=>[(t.title||"").toLowerCase(),i])); + const fresh=[]; + (o.tasks||[]).forEach(t=>{ const ti=clean(t.title); if(!ti) return; const k=ti.toLowerCase(); + if(idx.has(k)){ const e=out[idx.get(k)]; + if(t.owner) e.owner=t.owner; if(t.due) e.due=t.due; if(t.size) e.size=t.size; + if(t.subs&&t.subs.length) e.subs=cleanSubs(t.subs); + } else fresh.push({title:ti,owner:t.owner||inferOwnerByDomain(ti),due:t.due||null,size:t.size||null,subs:cleanSubs(t.subs)}); }); + // safety net: if the user said "subtask", new items belong UNDER the last task, not as top-level tasks + if(wantsSub && out.length){ const last=out[out.length-1]; last.subs=last.subs||[]; + fresh.forEach(f=>last.subs.push({title:f.title,owner:f.owner,due:f.due,size:f.size})); } + else fresh.forEach(f=>{ out.push(f); idx.set(f.title.toLowerCase(),out.length-1); }); + // deletions: drop any task or subtask whose title GPT listed in "remove" + let finalTasks=out; + const rm=new Set((o.remove||[]).map(s=>(s||"").toLowerCase().trim()).filter(Boolean)); + if(rm.size){ finalTasks=out.filter(t=>!rm.has((t.title||"").toLowerCase())); + finalTasks.forEach(t=>{ t.subs=(t.subs||[]).filter(s=>!rm.has((s.title||"").toLowerCase())); }); } + return {draft:{intent:o.intent,project:o.project,task:clean(o.task),tasks:finalTasks,owner:o.owner,parentId:o.parentId,due:o.due,size:o.size}, + pending:o.pending,ready:o.ready,assistantSay:o.assistantSay}; +} + +/* ---- local stand-in for the LLM: understands intent + fills fields across turns ---- */ +function matchProject(t){ let best=null,score=0; + DATA.forEach(p=>{ const n=p.title.toLowerCase(); + if(t.includes(n)){ if(n.length>score){ score=n.length; best=p; } } }); + return best; } + +function mockExtract(text,draft,pending){ + draft=JSON.parse(JSON.stringify(draft||{})); + const raw=text.trim(), t=" "+raw.toLowerCase()+" "; + const newlyAsked=pending; + + // a turn that simply answers the bot's pending question routes wholesale into that field + if(pending==="projectName"){ draft.project=cap1(raw.replace(/[.?!]+$/,"")); } + else if(pending==="taskTitle"){ draft.task=cap1(raw.replace(/^(it'?s|its|to|the task is)\s+/i,"").replace(/[.?!]+$/,"")); } + else if(pending==="owner"){ const o=findOwnerId(t); if(o) draft.owner=o; } + else if(pending==="parent"){ const p=matchProject(t); if(p) draft.parentId=p.id; } + else if(pending==="due"){ const due=findDue(t); if(due) draft.due=due; } + + // mixed-initiative: always scan for explicit signals too (user may over-specify) + if(!draft.intent){ + if(/\bproject\b/.test(t)) draft.intent="create_project"; + else if(/\bsub ?task\b/.test(t)) draft.intent="create_subtask"; + else if(/\btask\b/.test(t)) draft.intent="create_task"; + } + if(!draft.owner){ const o=findOwnerId(t); if(o&&/\b(owner|own|assign|for|by)\b/.test(t)) draft.owner=o; } + const due=findDue(t); if(due) draft.due=due; + const sz=findSize(t); if(sz) draft.size=sz; + // project name from "(project|product) ... (called|named) X" or "project for X" + if(draft.intent==="create_project"&&!draft.project){ + let m=raw.match(/(?:project|product)[^.]*?(?:called|named|name is|is called)\s+(.+?)(?=\s+(?:and|the task|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i) + ||raw.match(/(?:new project)\s+(?:called\s+|named\s+|for\s+)?(.+?)(?=\s+(?:and|the task|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i); + if(m) draft.project=cap1(m[1].trim().replace(/^(?:called|named|is\s+called)\s+/i,"")); } + // explicit (re)naming at any turn — lets the user correct a wrong name ("call it X", "should be called X") + if(draft.intent==="create_project"){ + const rn=raw.match(/(?:call it|name it|rename it to|should be called|it'?s called|the project is(?: called)?|name is(?: called)?)\s+(.+?)(?=\s+(?:and|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i); + if(rn) draft.project=cap1(rn[1].trim().replace(/^(?:called|named)\s+/i,"")); } + draft.tasks=draft.tasks||[]; + // a task mentioned in this turn + const tm=raw.match(/(?:add (?:a |another )?|first |second |third |next )?(?:new )?task\s+(?:called\s+|named\s+|is\s+|to\s+|:\s*)?(.+?)(?=\s+(?:and|due|owner|by|with|subtask|the project)\b|[.,!?]|$)/i); + if(draft.intent==="create_project"){ + // building a project → append the task to its list (don't switch intent) + if(tm){ const v=canonHardware(tm[1].trim().replace(/^(?:called|named)\s+/i,"")); + if(v.length>1) draft.tasks.push({title:cap1(v),owner:findOwnerId(t)||inferOwnerByDomain(v),due:findDue(t),size:findSize(t),subs:[]}); } + } else if(!draft.task){ + if(tm){ const v=tm[1].trim().replace(/^(?:called|named)\s+/i,""); if(v.length>1) draft.task=cap1(v); } + } + // existing-project reference for create_task + if(draft.intent==="create_task"&&!draft.parentId){ const p=matchProject(t); if(p) draft.parentId=p.id; } + // strip a trailing "… to/in/for " the task regex may have swallowed + if(draft.task&&draft.parentId){ const p=findPath(draft.parentId)?.pop(); + if(p){ const esc=p.title.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); + draft.task=cap1(draft.task.replace(new RegExp("\\s+(?:to|in|for|under)\\s+"+esc+"\\s*$","i"),"").trim()); } } + + return finalize(draft,newlyAsked); +} + +function finalize(draft,justAsked){ + if(!draft.intent) return {draft,pending:null,ready:false, + assistantSay:"Tell me what to create — a project, a task, or a subtask."}; + const have={ projectName:!!draft.project, taskTitle:!!draft.task, + owner:!!draft.owner, parent:!!draft.parentId }; + let order; + if(draft.intent==="create_project") order=[["projectName","What should the project be called?"],["owner","Who owns it?"]]; + else order=[["taskTitle","What’s the task?"],["parent","Which project does it go in?"],["owner","Who owns it?"]]; + const next=order.find(([k])=>!have[k]); + // confirmation fragment for what we understood so far + const bits=[]; + if(draft.project) bits.push("project “"+draft.project+"”"); + if(draft.tasks&&draft.tasks.length) bits.push(draft.tasks.length+(draft.tasks.length>1?" tasks":" task")); + if(draft.task) bits.push("task “"+draft.task+"”"); + if(draft.parentId){ const p=findPath(draft.parentId)?.pop(); if(p) bits.push("in "+p.title); } + if(draft.owner) bits.push("owner "+PEOPLE[draft.owner].name); + if(draft.due) bits.push("due "+new Date(draft.due).toLocaleDateString("en-GB",{day:"numeric",month:"short"})); + const got=bits.length?"Got it — "+bits.join(", ")+". ":""; + if(next) return {draft,pending:next[0],ready:false,assistantSay:got+next[1]}; + return {draft,pending:null,ready:true, + assistantSay:got+"Ready to create — review and hit Create, or keep talking to adjust."}; +} + +/* ---- editable creation card (middle panel) mirrors the draft; edits write to CAP.draft ---- */ +function renderCapCard(r){ + const d=CAP.draft; d.tasks=d.tasks||[]; const miss=r.pending; + const card=$id("capCard"), title=$id("buildTitle"); + // the creation form stays hidden until the assistant knows what you want to build + const build=document.querySelector(".cappanel.build"); + if(build) build.style.display=d.intent?"flex":"none"; + if(!d.intent) return; + const ownerChips=`
${Object.entries(PEOPLE).map(([k,p])=> + ``).join("")}
`; + const projSel=``; + const rows=[]; + const row=(key,lbl,html)=>rows.push(`
${lbl}${html}
`); + if(d.intent==="create_project"){ + title.textContent="New project"; + row("projectName","Project",``); + row("owner","Owner",ownerChips); + row("due","Due",``); + // tasks (and their subtasks) default to the project owner when none is set + if(d.owner) d.tasks.forEach(t=>{ if(!t.owner) t.owner=d.owner; + (t.subs||[]).forEach(s=>{ if(!s.owner) s.owner=t.owner||d.owner; }); }); + const tl=d.tasks.length?d.tasks.map((tk,i)=>taskCardHTML(tk,i)).join("") + :`
No tasks yet — say “add a task called …” or type one below.
`; + rows.push(`
Tasks
${tl} +
+
`); + } else { + title.textContent=d.intent==="create_subtask"?"New subtask":"New task"; + row("taskTitle","Task",``); + row("parent","Project",projSel); + row("owner","Owner",ownerChips); + row("due","Due",``); + } + card.innerHTML=rows.join(""); card.className="capcard show"; + const ready=d.intent==="create_project"?(!!d.project):(!!d.task&&!!d.parentId); + $id("capActions").innerHTML=``; +} +function refreshCard(){ renderCapCard({pending:CAP.pending}); } +function addCapTask(){ const el=$id("capTaskNew"); if(!el) return; const v=el.value.trim(); if(!v) return; + CAP.draft.tasks=CAP.draft.tasks||[]; CAP.draft.tasks.push({title:v[0].toUpperCase()+v.slice(1),owner:null,due:null,size:null,subs:[]}); + refreshCard(); setTimeout(()=>$id("capTaskNew")&&$id("capTaskNew").focus(),0); } +function delCapTask(i){ CAP.draft.tasks.splice(i,1); refreshCard(); } + +/* a task = title + owner + due + size + its own subtasks, editably & tidily */ +const escq=s=>(s||"").replace(/"/g,"""); +const ownerOpts=v=>``+Object.entries(PEOPLE).map(([k,p])=> + ``).join(""); +function ownerPill(v,onch){ const col=v?PEOPLE[v].color:"#c2c8d2"; + return ` + `; } +function duePill(v,onch,sm){ return ``; } +function szSeg(v,onch){ return `${["s","m","l","xl"].map(z=> + ``).join("")}`; } +function taskCardHTML(tk,i){ tk.subs=tk.subs||[]; + return `
+
+ +
+
+ ${ownerPill(tk.owner,`setTaskOwner(${i},this.value)`)} + ${duePill(tk.due,`setTask(${i},'due',this.value)`)} + ${szSeg(tk.size,`setTaskSize(${i},Z)`)}
+ ${tk.subs.map((s,j)=>`
+ + ${ownerPill(s.owner,`setSubOwner(${i},${j},this.value)`)} + ${duePill(s.due,`setSub(${i},${j},'due',this.value)`,true)} +
`).join("")} +
+
+
`; +} +function setTask(i,f,v){ CAP.draft.tasks[i][f]=(f==="due"||f==="size"||f==="owner")?(v||null):v; } +function setTaskOwner(i,v){ CAP.draft.tasks[i].owner=v||null; refreshCard(); } +function setTaskSize(i,z){ const t=CAP.draft.tasks[i]; t.size=(t.size===z?null:z); refreshCard(); } +function setSub(i,j,f,v){ CAP.draft.tasks[i].subs[j][f]=(f==="due"||f==="size"||f==="owner")?(v||null):v; } +function setSubOwner(i,j,v){ CAP.draft.tasks[i].subs[j].owner=v||null; refreshCard(); } +function addSub(i){ const el=$id("subNew"+i); if(!el) return; const v=el.value.trim(); if(!v) return; + CAP.draft.tasks[i].subs=CAP.draft.tasks[i].subs||[]; + CAP.draft.tasks[i].subs.push({title:v[0].toUpperCase()+v.slice(1),owner:null,due:null,size:null}); + refreshCard(); setTimeout(()=>$id("subNew"+i)&&$id("subNew"+i).focus(),0); } +function delSub(i,j){ CAP.draft.tasks[i].subs.splice(j,1); refreshCard(); } + +function commitCapture(){ + const d=CAP.draft, owner=d.owner||"fd"; + snap(); + let focusId; + if(d.intent==="create_project"){ + /* by default a project, its tasks and their subtasks share the same due date — + any level left blank inherits its parent's date */ + const projDue=d.due||null; + const proj=T(cap1(d.project||"New project"),owner,{d:projDue,open:true}); + (d.tasks||[]).forEach(tk=>{ + const tDue=tk.due||projDue||null; + const task=T(cap1(tk.title),tk.owner||owner,{d:tDue,s:tk.size||null,open:(tk.subs&&tk.subs.length>0)}); + (tk.subs||[]).forEach(s=>task.children.push(T(cap1(s.title),s.owner||tk.owner||owner,{d:s.due||tDue||null,s:s.size||null}))); + proj.children.push(task); }); + DATA.push(proj); focusId=proj.id; + } else { + const parent=d.parentId?findPath(d.parentId).pop():null; + if(!parent){ UNDO.pop(); botSay("Which project should it go in?"); CAP.pending="parent"; renderCapCard({pending:"parent"}); return; } + const node=T(cap1(d.task||"New task"),owner,{d:d.due||parent.due||null,s:d.size||null}); + if(!fitsDepth(node,parent.id)){ UNDO.pop(); + botSay("That would nest too deep — the hierarchy stops at project › task › subtask."); return; } + parent.children.push(node); parent.open=true; focusId=node.id; + } + ding(3); closeCapture(); renderAll(); // submitted — no extra screen +} + +/* ================= full-transcript processing → Review & Approve ================= */ +/* Paste a whole conversation; the LLM (or the offline mock) proposes MULTIPLE projects and + tasks at once. Nothing is created until the user accepts items and hits "Push approved". */ +const CLIENT_NAMES=[...CLIENTS.map(c=>c.name),null]; +const OAI_PROPOSAL_SCHEMA={type:"object",additionalProperties:false, + required:["assistantSay","projects"], + properties:{ + assistantSay:{type:"string"}, + projects:{type:"array",items:{type:"object",additionalProperties:false, + required:["name","client","owner","due","tasks"], + properties:{ + name:{type:"string"}, + client:{type:["string","null"],enum:CLIENT_NAMES}, + owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]}, + tasks:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size","client","subs"], + properties:{ + title:{type:"string"}, + owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]}, + size:{type:["string","null"],enum:["s","m","l","xl",null]}, + client:{type:["string","null"],enum:CLIENT_NAMES}, + subs:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size"], + properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}} + }}} + }}}}}; +async function openaiTranscript(text,key){ + const sys=`You read a raw client/team conversation transcript and extract the NEW engineering projects and tasks it implies, for a 3-level planner (project > task > subtask). +LANGUAGE: the transcript may be English or French — ALL OUTPUT MUST BE IN ENGLISH. +- Group work into projects. A customer pilot becomes a project; set its "client" to the matching known client. Pure internal work has client=null. +- Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl or null), client (if the task is for a known client else null), and a subs array (usually empty). +- ASSIGNEE: infer each owner from this RESPONSIBILITY MAP using the task's content; only null if genuinely unclear: +${RESP_MAP_TEXT} +${VOCAB_TEXT} + Use the exact hardware/client spellings above; map mis-hearings to the canonical form. +- Do NOT re-create work that already exists in context.projects — only return genuinely new items. +- assistantSay: one short sentence, e.g. "I identified 2 projects and 7 tasks from this conversation." +Return ONLY the JSON object.`; + const body={model:"gpt-4o-mini",temperature:0, + messages:[{role:"system",content:sys}, + {role:"user",content:JSON.stringify({transcript:text,context:captureContext()})}], + response_format:{type:"json_schema",json_schema:{name:"proposal",strict:true,schema:OAI_PROPOSAL_SCHEMA}}}; + const res=await fetch("https://api.openai.com/v1/chat/completions",{method:"POST", + headers:{"Content-Type":"application/json","Authorization":"Bearer "+key},body:JSON.stringify(body)}); + if(!res.ok) throw new Error(res.status+" "+(await res.text()).slice(0,140)); + return JSON.parse((await res.json()).choices[0].message.content); +} +/* offline stand-in: split the transcript into action clauses, route them to per-client projects */ +let PROP = null; +function normalizeProposalWrapped(raw) { + return normalizeProposal(raw); +} +async function extractTranscript(text){ + const key=getKey(); + if(key){ try{ return normalizeProposalWrapped(await openaiTranscript(text,key)); }catch(e){ /* fall back to mock */ } } + await new Promise(r=>setTimeout(r,400)); + return normalizeProposalWrapped(mockTranscript(text)); +} +function openTranscript(){ $id("trInput").value=""; $id("transcriptModal").classList.add("show"); setTimeout(()=>$id("trInput").focus(),50); } +function closeTranscript(){ $id("transcriptModal").classList.remove("show"); } +async function runTranscript(){ const text=$id("trInput").value.trim(); if(text.length<8) return; + const btn=$id("trGo"); btn.disabled=true; btn.textContent="Processing…"; + try{ PROP=await extractTranscript(text); } finally{ btn.disabled=false; btn.textContent="Process"; } + closeTranscript(); openReview(); } +/* paperclip in the chat: attach a transcript file and run it straight into Review & Approve */ +async function attachTranscript(input){ + const f=input.files&&input.files[0]; input.value=""; if(!f) return; + let text=""; try{ text=await f.text(); }catch(e){} + text=stripCaptions(text); + if(!text||text.trim().length<8){ bubble("That file looks empty — try a transcript with some text in it.","bot think"); return; } + bubble("📎 "+f.name,"me"); + const think=bubble("Reading the transcript…","bot think"); + try{ PROP=await extractTranscript(text); } catch(e){ PROP=null; } + think.remove(); + if(!PROP){ bubble("I couldn't read that file.","bot think"); return; } + openReview(); +} +/* drop WebVTT/SRT timestamp + index lines so only the spoken text reaches the model */ +function openReview(){ renderReview(); $id("reviewModal").classList.add("show"); } +function closeReview(){ $id("reviewModal").classList.remove("show"); PROP=null; } +function rvObj(uid){ if(!PROP) return null; + for(const p of PROP.projects){ if(p.uid===uid) return p; + for(const t of p.tasks){ if(t.uid===uid) return t; for(const s of t.subs){ if(s.uid===uid) return s; } } } return null; } +function rvToggle(uid){ const o=rvObj(uid); if(o){ o.accepted=!o.accepted; renderReview(); } } +function rvText(uid,f,v){ const o=rvObj(uid); if(o) o[f]=v; } // no re-render: keep input focus +function rvOwner(uid,v){ const o=rvObj(uid); if(o){ o.owner=v||null; renderReview(); } } +function rvDue(uid,v){ const o=rvObj(uid); if(o) o.due=v||null; } +function rvSize(uid,z){ const o=rvObj(uid); if(o){ o.size=(o.size===z?null:z); renderReview(); } } +function rvTaskHTML(p,t){ + return `
+ + + + + + ${szSeg(t.size,`rvSize(${t.uid},Z)`)} + ${t.client?`${t.client}`:''} + + ${(t.subs||[]).map(s=>`
+ + + +
`).join("")} +
`; +} +function renderReview(){ if(!PROP) return; + let na=0,nt=0; + PROP.projects.forEach(p=>{ if(p.accepted){ na++; p.tasks.forEach(t=>{ if(t.accepted) nt++; }); } }); + $id("rvBanner").textContent=PROP.assistantSay||`I identified ${PROP.projects.length} project(s).`; + $id("rvBody").innerHTML=PROP.projects.map(p=>`
+
+ + + ${p.client?'Pilot · '+p.client:'Engineering'} +
+
+ + +
+
${p.tasks.map(t=>rvTaskHTML(p,t)).join("")||'
No tasks proposed for this project.
'}
+
`).join("")||'
Nothing detected. Try a more detailed transcript.
'; + $id("rvActions").innerHTML=` + `; +} +function pushApproved(){ if(!PROP) return; snap(); let np=0; + PROP.projects.forEach(p=>{ if(!p.accepted) return; + const proj=T(cap1(p.name||"New project"),p.owner||"fd",{d:p.due||null,open:true}); + if(p.client) proj.client=p.client; // light CRM link tag + (p.tasks||[]).forEach(t=>{ if(!t.accepted) return; + const subsOK=(t.subs||[]).filter(s=>s.accepted); + const task=T(cap1(t.title||"New task"),t.owner||p.owner||"fd",{d:t.due||p.due||null,s:t.size||null,open:subsOK.length>0}); + if(t.client) task.client=t.client; + subsOK.forEach(s=>task.children.push(T(cap1(s.title||"Subtask"),s.owner||t.owner||p.owner||"fd",{d:s.due||t.due||p.due||null,s:s.size||null}))); + proj.children.push(task); }); + DATA.push(proj); np++; }); + closeReview(); ding(3); renderAll(); +} + +/* ---- continuous mic: stays live, auto-sends each finished sentence as a turn until you + stop it. Finished sentences are queued so a slow extract call never drops one. ---- */ +let capQueue=[], speaking=false, micPaused=false; +function setMic(on){ listening=on; const f=$id("micFab"); if(f) f.classList.toggle("live",on); } +/* the bottom-right mic is the only mic: tap to open the chat (if needed) and talk; tap again to stop */ +function micFabTap(){ const m=$id("vmodal"); + if(CAP&&m&&m.classList.contains("min")){ restoreCapture(); return; } // expand instead of toggling mic + if(!CAP){ openCapture(); setTimeout(toggleListen,150); } else toggleListen(); } +function pushTurn(text){ capQueue.push(text); drainQueue(); } +async function drainQueue(){ if(!CAP||CAP.busy) return; + while(capQueue.length){ $id("capInput").value=capQueue.shift(); await sendTurn(); } } +function isBotEcho(text){ const n=normCap(text), b=normCap(lastBotText); + return n.length>5 && b && (b.includes(n)||n.includes(b)); } +function toggleListen(){ if(listening) return stopListen(); + const SR=window.SpeechRecognition||window.webkitSpeechRecognition; + if(!SR){ botSay("Speech recognition isn’t available in this browser — type your answer instead. (In production this streams to Whisper.)"); return; } + rec=new SR(); rec.lang=capLang==="fr"?"fr-FR":"en-US"; rec.interimResults=true; rec.continuous=true; + rec.onresult=e=>{ if(speaking||micPaused) return; // never while the bot is talking + let fin="",intr=""; + for(let i=e.resultIndex;i{ if(listening&&!micPaused){ try{rec.start();}catch(e){} } else if(!listening) setMic(false); }; + rec.onerror=()=>{}; + try{ rec.start(); setMic(true); }catch(e){ setMic(false); } +} +function stopListen(){ listening=false; micPaused=false; if(rec){ try{rec.stop();}catch(e){} } setMic(false); } + +/* ================= team photos ================= */ +function openTeam(){ renderTeam(); $id("teamModal").classList.add("show"); } +function closeTeam(){ $id("teamModal").classList.remove("show"); } +function renderTeam(){ + $id("teamList").innerHTML=Object.entries(PEOPLE).map(([k,p])=>`
+ ${av(k,"lg")}${p.name} + + ${p.photo?``:""}
`).join(""); +} +function uploadPhoto(k,input){ const f=input.files&&input.files[0]; if(!f) return; + const r=new FileReader(); r.onload=e=>{ PEOPLE[k].photo=e.target.result; renderTeam(); renderAll(); }; + r.readAsDataURL(f); } +function removePhoto(k){ delete PEOPLE[k].photo; renderTeam(); renderAll(); } + +/* ================= search ================= */ +function doSearch(){ + const q=document.getElementById("searchbox").value.trim().toLowerCase(); + const box=document.getElementById("searchres"); + if(q.length<2){ box.innerHTML=""; box.style.display="none"; return; } + const hits=[]; + flat(DATA,(n,d,path)=>{ if(hits.length>=8) return; + if(n.title.toLowerCase().includes(q)||PEOPLE[n.owner].name.toLowerCase().includes(q)) + hits.push({n,proj:path[0]?path[0].title:n.title}); }); + box.innerHTML=hits.map(h=>``).join("") + ||'
No matches
'; + box.style.display="block"; +} +function pickSearch(id){ const sb=document.getElementById("searchbox"); + sb.value=""; document.getElementById("searchres").style.display="none"; openDetail(id); } + +/* ================= hover tooltip — full task names ================= */ +/* IMPORTANT: which bar the cursor is over is read from each event's REAL target + (e.target.closest("[data-full]")), never from document.elementFromPoint. That was the bug: + after the chart's innerHTML is swapped under a stationary cursor, Chromium's elementFromPoint + hit-test cache goes stale and keeps returning the old/wrong node until the tab is re-focused — + so every filter change killed the tooltip and even moving the mouse didn't help, because every + detection path read from that one poisoned source. Event targets are always live, so this + survives re-renders with no tab switch and no synthetic-event trickery. */ +const TIP=document.createElement("div"); TIP.id="gtip"; document.body.appendChild(TIP); +let MX=-1,MY=-1,tipKey=null,tipTimer=null,tipEl=null; +let PTRDOWN=false; // physical button held (i.e. a drag in progress) — suppress the tip +function hideTip(){ clearTimeout(tipTimer); tipTimer=null; tipKey=null; tipEl=null; TIP.style.display="none"; } +function placeTip(){ + TIP.style.left=Math.max(8,Math.min(MX+14,window.innerWidth-TIP.offsetWidth-10))+"px"; + TIP.style.top =Math.max(6,MY-TIP.offsetHeight-16)+"px"; +} +function revealTip(){ tipTimer=null; + if(!tipEl||!tipEl.isConnected){ hideTip(); return; } // node vanished during the delay + TIP.textContent=tipEl.dataset.full; TIP.style.display="block"; placeTip(); +} +/* feed me the element under the pointer, taken from a live event target (or a fresh hit-test) */ +function hoverOn(target){ + if(PTRDOWN){ hideTip(); return; } + const host=(target&&target.closest)?target.closest("[data-full]"):null; + if(!host){ hideTip(); return; } + const key=host.dataset.tid||host.dataset.full; + if(key!==tipKey){ // moved onto a new bar → arm the reveal delay + clearTimeout(tipTimer); tipKey=key; tipEl=host; TIP.style.display="none"; + tipTimer=setTimeout(revealTip,600); return; + } + tipEl=host; // same bar (may be a fresh node after a render) + if(TIP.style.display==="block") placeTip(); // already shown → follow the cursor +} +document.addEventListener("pointerdown",()=>{PTRDOWN=true;},true); +document.addEventListener("pointerup",()=>{PTRDOWN=false;},true); +document.addEventListener("pointercancel",()=>{PTRDOWN=false;},true); +window.addEventListener("blur",()=>{PTRDOWN=false;}); +const onMove=e=>{ MX=e.clientX; MY=e.clientY; if(!e.buttons) PTRDOWN=false; hoverOn(e.target); }; +document.addEventListener("pointermove",onMove,{passive:true}); +document.addEventListener("pointerover",e=>{ MX=e.clientX; MY=e.clientY; hoverOn(e.target); },{passive:true}); +document.addEventListener("pointerout", e=>{ if(!e.relatedTarget) hideTip(); },{passive:true}); +document.addEventListener("mousemove",onMove,{passive:true}); // fallbacks if +document.addEventListener("mouseover",e=>{ MX=e.clientX; MY=e.clientY; hoverOn(e.target); },{passive:true}); // pointer events glitch +// scrolling hides the tip; the next real hover re-arms it +document.addEventListener("scroll",()=>hideTip(),true); +// (no idle watchdog: the tooltip appears ONLY on a real hover, never synthesized from a click/render) + +function renderAll(){ renderFilter(); renderDash(); + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(kickHover); // re-evaluate hover after every re-render +} +Object.defineProperty(window, "CAP", { get: () => CAP, configurable: true }); + +renderAll(); + +const _globals = { + toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, + toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, + sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, + closeTeam, closeSheet, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, + toggleExp, updTask, refreshBarMenu, addChild, deleteTask, addCapTask, barDown, barContext, pickSearch, + uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, + doSearch, refreshCard, delCapTask, setTask, setTaskOwner, setTaskSize, setSub, setSubOwner, addSub, + delSub, commitCapture, toggleListen, stopListen, renderAll, moveTask, setKeyVal, +}; +Object.assign(window, _globals); diff --git a/src/data/constants.js b/src/data/constants.js new file mode 100644 index 0000000..1e6cd9d --- /dev/null +++ b/src/data/constants.js @@ -0,0 +1,58 @@ +export const PEOPLE = { + jn: { name: "Jean", initials: "JN", color: "#27a468", role: "Finances", al: ["jean"] }, + fd: { name: "Florian", initials: "FD", color: "#3b6ef6", role: "Customer outreach, raising money, and recruitment", al: ["florian", "flo", "fluorine", "florine", "florent", "floriane"] }, + ia: { name: "Iannis", initials: "IA", color: "#e8930c", role: "Building the robot and operating system", al: ["iannis", "yannis", "yanis", "ianis", "ioannis", "janice", "janis", "yanni", "ennis"] }, + ak: { name: "Akshat", initials: "AK", color: "#9b59d0", role: "Obstacle avoidance and autonomous locomotion", al: ["akshat", "akshad", "akshot", "axat", "akshut"] }, + sk: { name: "Sanket", initials: "SK", color: "#d4488e", role: "Control and embedded systems", al: ["sanket", "sankeet", "sankit", "sunket", "sanke"] }, + lm: { name: "Liam", initials: "LM", color: "#0ea5b7", role: "General / operations", al: ["liam", "leam"] }, + ly: { name: "Leynaïck", initials: "LY", color: "#647acb", role: "Electronics (intern)", al: ["leynaïck", "leynaick", "lenaick", "laynaick", "leinaick", "lenix", "laynick"] }, +}; + +export const TODAY = new Date("2026-06-12"); + +export const HARDWARE_VOCAB = [ + "Robstride motors: RS00, RS02, RS03, RS04, EL05", + "Feetech motors (all models)", + "Hub motors (used for the wheels - primary locomotion)", + "D-Wave board (custom hardware board in the current robots)", +]; + +export const CLIENTS = [ + { name: "Onet", al: ["onet", "o net", "onnet", "aunet"] }, + { name: "Derichebourg", al: ["derichebourg", "de riche bourg", "derichbourg", "derich bourg", "deurichebourg"] }, + { name: "NSI", al: ["nsi", "n s i", "ensi", "n.s.i"] }, + { name: "Areas", al: ["areas", "aréas", "arias", "ariane", "arrears"] }, + { name: "JCDecaux", al: ["jcdecaux", "jc decaux", "jcd", "jic decaux", "jaycee decaux", "jay c decaux"] }, +]; + +export const DOMAIN_RULES = [ + { o: "ak", kw: ["obstacle", "avoidance", "autonom", "navigation", "locomot", "path planning", "slam", "perception", "mapping"] }, + { o: "sk", kw: ["control", "embedded", "firmware", "motor control", "pid", "actuator", "can bus", "servo", "rs0", "rs00", "rs02", "rs03", "rs04", "el05", "feetech", "hub motor", "motor"] }, + { o: "ia", kw: ["operating system", " os ", "assembly", "chassis", "mechanical", "integration", "build the robot", "robot build", "frame"] }, + { o: "ly", kw: ["electronic", "d-wave", "dwave", "board", "power", "wiring", "pcb", "circuit", "battery", "soldering", "harness"] }, + { o: "jn", kw: ["budget", "invoice", "finance", "cost", "payment", "accounting", "payroll"] }, + { o: "fd", kw: ["client", "outreach", "fundrais", "recruit", "hiring", "pilot", "sales", "demo", "investor", "contract"] }, +]; + +export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 8 }; +export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL" }; +export const LEAD = { s: 1, m: 3, l: 7, xl: 14 }; + +export const ZOOMS = [ + { l: "Day", h: 0, v: 3 }, + { l: "Week", h: 7, v: 7 }, + { l: "3 weeks", h: 21, v: 21 }, + { l: "6 weeks", h: 42, v: 42 }, +]; + +export const GBAR_H = { s: 26, m: 34, l: 44, xl: 56 }; +export const R0G = 0; +export const R1G = 90; +export const SPAN_G = R1G - R0G; +export const TODAY_PX = 240; + +export const C_LATE = "#ff5d5d"; +export const C_TODAY = "#2f80ff"; +export const C_RADAR = "#16c79a"; +export const C_LATER = "#9b8cff"; +export const C_DONE = "#c8cdd6"; diff --git a/src/lib/capture.js b/src/lib/capture.js new file mode 100644 index 0000000..f17f240 --- /dev/null +++ b/src/lib/capture.js @@ -0,0 +1,193 @@ +import { CLIENTS, PEOPLE, TODAY } from "../data/constants.js"; +import { canonHardware, findClient, inferOwnerByDomain, norm } from "./domain.js"; + +export const cap1 = (s) => (s ? s.replace(/^[a-z]/, (c) => c.toUpperCase()) : s); + +export function isoCap(d) { + return d.toISOString().slice(0, 10); +} + +export function stripCaptions(t) { + if (!t) return t; + return t + .replace(/^WEBVTT.*$/im, "") + .split(/\r?\n/) + .filter((l) => !/^\s*\d+\s*$/.test(l) && !/^\s*[\d:.,]+\s*-->\s*[\d:.,]+/.test(l)) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function findOwnerId(t) { + let best = null; + let pos = -1; + for (const [k, p] of Object.entries(PEOPLE)) { + for (const a of p.al) { + const i = t.indexOf(a); + if (i > pos) { + pos = i; + best = k; + } + } + } + return best; +} + +export function findDue(t, today = TODAY) { + const d = new Date(today); + const wd = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + const inDays = t.match(/in (\d+) days?/); + if (/\btoday\b/.test(t)) return isoCap(d); + if (/\btomorrow\b/.test(t)) { + d.setDate(d.getDate() + 1); + return isoCap(d); + } + if (/\bnext week\b/.test(t)) { + d.setDate(d.getDate() + 7); + return isoCap(d); + } + if (inDays) { + d.setDate(d.getDate() + +inDays[1]); + return isoCap(d); + } + const wi = wd.findIndex((w) => new RegExp("\\b" + w + "\\b").test(t)); + if (wi > -1) { + const delta = (wi - d.getDay() + 7) % 7 || 7; + d.setDate(d.getDate() + delta); + return isoCap(d); + } + return null; +} + +export function findSize(t) { + const m = t.match(/\b(extra large|x-?large|xl|small|medium|large)\b/); + if (!m) return null; + return ( + { + small: "s", + medium: "m", + large: "l", + xl: "xl", + "x-large": "xl", + xlarge: "xl", + "extra large": "xl", + }[m[1]] || null + ); +} + +export function normalizeProposal(raw) { + let ruid = 0; + const projects = (raw.projects || []) + .map((p) => ({ + uid: ++ruid, + accepted: true, + name: cap1(p.name || "New project"), + owner: p.owner || null, + due: p.due || null, + client: p.client || findClient(" " + (p.name || "") + " ") || null, + tasks: (p.tasks || []).map((t) => { + const ti = canonHardware(cap1(t.title || "")); + return { + uid: ++ruid, + accepted: true, + title: ti, + owner: t.owner || inferOwnerByDomain(ti) || null, + due: t.due || null, + size: t.size || null, + client: t.client || null, + subs: (t.subs || []).map((s) => { + const si = canonHardware(cap1(s.title || "")); + return { + uid: ++ruid, + accepted: true, + title: si, + owner: s.owner || inferOwnerByDomain(si) || null, + due: s.due || null, + size: s.size || null, + }; + }), + }; + }), + })) + .filter((p) => p.name); + return { assistantSay: raw.assistantSay || "", projects }; +} + +export function mockTranscript(text) { + const lc = norm(text); + const mentioned = CLIENTS.filter( + (c) => lc.includes(norm(c.name)) || c.al.some((a) => lc.includes(norm(a))) + ); + const VSRC = + "install|build|fix|implement|test|deploy|order|write|design|integrate|ship|prepare|configure|run|benchmark|schedule|review|deliver|set ?up|mount|wire|calibrate|debug|develop|create|add|update|replace|repair|assemble|program|tune|investigate|source|procure"; + const VERBS = new RegExp("\\b(" + VSRC + ")\\b", "i"); + const splitAnd = new RegExp( + "(?:,\\s*)?\\b(?:and|then|also)\\b\\s+(?=(?:we\\s+|i\\s+|they\\s+|the client\\s+|please\\s+|to\\s+)?(?:" + + VSRC + + ")\\b)", + "i" + ); + const clauses = text + .split(/[.;\n]+/) + .flatMap((c) => c.split(splitAnd)) + .map((s) => (s || "").trim()) + .filter(Boolean); + const tasks = []; + clauses.forEach((cl) => { + if (!VERBS.test(cl)) return; + const FILLER = + /^(?:the client wants us to|the client wants|they want us to|they want to|they want|we need to|we should|we'?ll|we|i need to|i'?ll|i|and|then|also|so|separately|additionally|internally|meanwhile|next|first|second|third|finally|please|can you|make sure to|let'?s|for)[,]?\s+/i; + let frag = cl.trim(); + let prev; + do { + prev = frag; + frag = frag.replace(FILLER, "").trim(); + } while (frag !== prev); + CLIENTS.forEach((c) => { + frag = frag.replace(new RegExp("^" + c.name + "\\s*,?\\s*", "i"), "").trim(); + }); + frag = frag.replace(/^to\s+/i, "").trim(); + if (frag.length < 4) return; + frag = canonHardware(frag); + const client = findClient(" " + cl.toLowerCase() + " "); + tasks.push({ + title: cap1(frag).slice(0, 90), + owner: inferOwnerByDomain(frag), + due: findDue(" " + cl.toLowerCase() + " "), + size: findSize(cl.toLowerCase()), + client, + subs: [], + }); + }); + const projects = []; + const getProj = (name, client) => { + let p = projects.find((x) => x.name === name); + if (!p) { + p = { name, client: client || null, owner: null, due: null, tasks: [] }; + projects.push(p); + } + return p; + }; + if (mentioned.length) { + tasks.forEach((tk) => { + const cl = tk.client || mentioned[0].name; + getProj("Pilot - " + cl, cl).tasks.push(tk); + }); + mentioned.forEach((c) => getProj("Pilot - " + c.name, c.name)); + } else { + const p = getProj("New engineering work", null); + tasks.forEach((tk) => p.tasks.push(tk)); + } + projects.forEach((p) => { + const cnt = {}; + p.tasks.forEach((t) => { + if (t.owner) cnt[t.owner] = (cnt[t.owner] || 0) + 1; + }); + p.owner = Object.keys(cnt).sort((a, b) => cnt[b] - cnt[a])[0] || null; + }); + const nT = projects.reduce((a, p) => a + p.tasks.length, 0); + return { + assistantSay: `I identified ${projects.length} project${projects.length !== 1 ? "s" : ""} and ${nT} task${nT !== 1 ? "s" : ""} from this conversation.`, + projects, + }; +} diff --git a/src/lib/date-core.js b/src/lib/date-core.js new file mode 100644 index 0000000..30bc5a0 --- /dev/null +++ b/src/lib/date-core.js @@ -0,0 +1,14 @@ +export function dayN(iso, today) { + return Math.round((new Date(iso) - today) / 864e5); +} + +export function dayIso(d, today) { + const x = new Date(today); + x.setDate(x.getDate() + d); + return x.toISOString().slice(0, 10); +} + +export function barSpan(n, today, lead) { + const e = dayN(n.due, today); + return { s: n.start ? dayN(n.start, today) : e - (lead[n.size || "m"] - 1), e }; +} diff --git a/src/lib/dates.js b/src/lib/dates.js new file mode 100644 index 0000000..44c801f --- /dev/null +++ b/src/lib/dates.js @@ -0,0 +1,106 @@ +import { LEAD } from "../data/constants.js"; +import { C_DONE, C_LATE, C_LATER, C_RADAR, C_TODAY } from "../data/constants.js"; +import { barSpan as _barSpan, dayIso, dayN } from "./date-core.js"; +import { flat } from "./tree.js"; +import { taskDone } from "./tree.js"; + +export { dayN, dayIso } from "./date-core.js"; + +export function createDateHelpers(today) { + const dayNLocal = (iso) => dayN(iso, today); + const dayIsoLocal = (d) => dayIso(d, today); + + const barSpan = (n) => _barSpan(n, today, LEAD); + + function workDays(s, e) { + if (isNaN(s) || isNaN(e) || e < s) return 0; + let c = 0; + for (let d = s; d <= e; d++) { + const wd = new Date(dayIsoLocal(d)).getDay(); + if (wd !== 0 && wd !== 6) c++; + } + return c; + } + + function barColor(e, s, done) { + if (done) return C_DONE; + if (e < 0) return C_LATE; + if (e === 0) return C_TODAY; + if (s <= 0) return C_RADAR; + return C_LATER; + } + + function barGeom(s, e, done, r0g = 0, r1g = 90) { + let rs; + let re; + if (done) { + rs = s; + re = e + 1; + } else if (e <= 0) { + rs = 0; + re = 1; + } else { + rs = Math.max(s, 0); + re = e + 1; + } + const cs = Math.max(rs, r0g); + return [cs, Math.min(Math.max(re, cs + 0.5), r1g)]; + } + + function rollupSpan(n) { + let s = Infinity; + let e = -Infinity; + flat([n], (x) => { + if (x.children.length || !x.due) return; + const sp = barSpan(x); + if (sp.s < s) s = sp.s; + if (sp.e > e) e = sp.e; + }); + return e === -Infinity ? barSpan(n) : { s, e }; + } + + const spanFor = (n) => (n.children.length ? rollupSpan(n) : barSpan(n)); + + function leafWeight(n) { + const { s, e } = barSpan(n); + const w = workDays(s, e); + return w > 0 ? w : 1; + } + + function progWD(n) { + let done = 0; + let tot = 0; + flat([n], (x) => { + if (x.children.length) return; + const w = leafWeight(x); + tot += w; + if (x.done) done += w; + }); + return tot ? done / tot : 0; + } + + function isUrgent(n) { + const done = n.children.length ? taskDone(n) : n.done; + if (done) return false; + const { s } = spanFor(n); + return !isNaN(s) && s <= 0; + } + + const fmtD = (iso) => + new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short" }); + + return { + dayN: dayNLocal, + dayIso: dayIsoLocal, + barSpan, + workDays, + barColor, + barGeom, + rollupSpan, + spanFor, + leafWeight, + progWD, + isUrgent, + fmtD, + }; +} diff --git a/src/lib/domain.js b/src/lib/domain.js new file mode 100644 index 0000000..1e42bd8 --- /dev/null +++ b/src/lib/domain.js @@ -0,0 +1,49 @@ +import { CLIENTS, DOMAIN_RULES, HARDWARE_VOCAB, PEOPLE } from "../data/constants.js"; + +export function norm(s) { + return " " + (s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() + " "; +} + +export function inferOwnerByDomain(t) { + const s = " " + (t || "").toLowerCase() + " "; + for (const r of DOMAIN_RULES) { + for (const k of r.kw) { + if (s.includes(k)) return r.o; + } + } + return null; +} + +export function canonHardware(s) { + if (!s) return s; + return s + .replace(/\bel[\s-]?0?5\b/ig, "EL05") + .replace(/\brs[\s-]?0*(\d{1,2})\b/ig, (m, n) => "RS" + String(n).padStart(2, "0")) + .replace(/\bd[\s-]?wave\b/ig, "D-Wave") + .replace(/\bfeetech\b/ig, "Feetech") + .replace(/\brobstride\b/ig, "Robstride"); +} + +export function findClient(t) { + const s = norm(t); + for (const c of CLIENTS) { + if (s.includes(norm(c.name))) return c.name; + for (const a of c.al) { + if (s.includes(norm(a))) return c.name; + } + } + return null; +} + +export function buildRespMapText() { + return Object.entries(PEOPLE) + .map(([id, p]) => `- ${p.name} (id "${id}"; voice transcription often mis-hears this name as: ${p.al.join(", ")}): ${p.role}`) + .join("\n"); +} + +export function buildVocabText() { + return ( + `HARDWARE (use these exact spellings):\n- ${HARDWARE_VOCAB.join("\n- ")}\n` + + `KNOWN CLIENTS (use these exact spellings):\n- ${CLIENTS.map((c) => c.name).join("\n- ")}` + ); +} diff --git a/src/lib/tree.js b/src/lib/tree.js new file mode 100644 index 0000000..41fa62c --- /dev/null +++ b/src/lib/tree.js @@ -0,0 +1,81 @@ +export function createTaskFactory() { + let uid = 0; + const T = (title, o, opts = {}) => ({ + id: ++uid, + title, + owner: o, + priority: opts.p || "med", + due: opts.d || null, + start: opts.st || null, + size: opts.s || null, + done: opts.done || false, + doneAt: opts.doneAt || null, + children: opts.c || [], + open: opts.open || false, + }); + return { T, resetUid: () => { uid = 0; }, getUid: () => uid }; +} + +export const flat = (nodes, fn, depth = 0, path = []) => + nodes.forEach((n) => { + fn(n, depth, path); + flat(n.children, fn, depth + 1, [...path, n]); + }); + +export function findPath(id, nodes, path = []) { + for (const n of nodes) { + if (n.id === id) return [...path, n]; + const r = findPath(id, n.children, [...path, n]); + if (r) return r; + } + return null; +} + +export function counts(n) { + if (!n.children.length) return { done: n.done ? 1 : 0, total: 1 }; + let d = 0; + let t = 0; + n.children.forEach((c) => { + const r = counts(c); + d += r.done; + t += r.total; + }); + return { done: d, total: t }; +} + +export const pct = (n) => { + const c = counts(n); + return c.total ? Math.round((100 * c.done) / c.total) : 0; +}; + +export function progFrac(n, sizePts) { + let done = 0; + let tot = 0; + flat([n], (x) => { + if (x.children.length) return; + const w = sizePts[x.size || "m"]; + tot += w; + if (x.done) done += w; + }); + return tot ? done / tot : 0; +} + +export const taskDone = (n) => (!n.children.length ? n.done : pct(n) === 100); + +export function taskDoneAt(n) { + let m = null; + flat([n], (x) => { + if (!x.children.length && x.doneAt && (!m || x.doneAt > m)) m = x.doneAt; + }); + return m || n.doneAt; +} + +export const contains = (n, id) => n.id === id || n.children.some((c) => contains(c, id)); + +export const depthOf = (id, nodes) => findPath(id, nodes).length - 1; + +export const heightOf = (n) => + n.children.length ? 1 + Math.max(...n.children.map(heightOf)) : 0; + +export const fitsDepth = (node, destId, nodes) => + depthOf(destId, nodes) + 1 + heightOf(node) <= 2; diff --git a/tests/capture.test.js b/tests/capture.test.js new file mode 100644 index 0000000..fd4d3f0 --- /dev/null +++ b/tests/capture.test.js @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + cap1, + findDue, + findOwnerId, + findSize, + mockTranscript, + normalizeProposal, + stripCaptions, +} from "../src/lib/capture.js"; + +describe("capture", () => { + it("capitalizes titles", () => { + expect(cap1("install rs03 motors")).toBe("Install rs03 motors"); + }); + + it("strips WebVTT caption metadata", () => { + const raw = `WEBVTT + +1 +00:00:01.000 --> 00:00:04.000 +Install RS03 motors for JCDecaux.`; + expect(stripCaptions(raw)).toContain("Install RS03 motors"); + expect(stripCaptions(raw)).not.toMatch(/-->/); + }); + + it("parses relative due dates", () => { + expect(findDue(" due tomorrow ")).toBe("2026-06-13"); + expect(findDue(" ship in 3 days ")).toBe("2026-06-15"); + }); + + it("resolves owner aliases from transcript text", () => { + expect(findOwnerId(" yannis will handle it ")).toBe("ia"); + }); + + it("parses t-shirt sizes", () => { + expect(findSize("this is a large task")).toBe("l"); + expect(findSize("extra large integration")).toBe("xl"); + }); + + it("extracts projects and tasks from a conversation offline", () => { + const raw = mockTranscript( + "For JCDecaux, install RS03 motors and calibrate obstacle avoidance by Monday." + ); + const proposal = normalizeProposal(raw); + expect(proposal.projects.length).toBeGreaterThan(0); + expect(proposal.projects[0].tasks.length).toBeGreaterThan(0); + expect(proposal.projects[0].tasks.some((t) => t.title.includes("RS03"))).toBe(true); + }); +}); diff --git a/tests/dates.test.js b/tests/dates.test.js new file mode 100644 index 0000000..6c6089b --- /dev/null +++ b/tests/dates.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { TODAY } from "../src/data/constants.js"; +import { createDateHelpers } from "../src/lib/dates.js"; + +describe("dates", () => { + const h = createDateHelpers(TODAY); + + it("maps iso dates to day offsets from today", () => { + expect(h.dayN("2026-06-12")).toBe(0); + expect(h.dayN("2026-06-15")).toBe(3); + }); + + it("derives bar span from due date and size lead time", () => { + const span = h.barSpan({ due: "2026-06-20", size: "m" }); + expect(span.e).toBe(8); + expect(span.s).toBe(6); + }); + + it("counts weekdays inside a span", () => { + expect(h.workDays(0, 4)).toBe(3); + }); + + it("assigns urgency colors", () => { + expect(h.barColor(-1, -2, false)).toBe("#ff5d5d"); + expect(h.barColor(0, 0, false)).toBe("#2f80ff"); + expect(h.barColor(5, 0, false)).toBe("#16c79a"); + expect(h.barColor(5, 3, false)).toBe("#9b8cff"); + expect(h.barColor(5, 3, true)).toBe("#c8cdd6"); + }); + + it("clamps late tasks to the today box", () => { + expect(h.barGeom(-3, -1, false)).toEqual([0, 1]); + }); + + it("rolls parent span across child leaves", () => { + const parent = { + due: "2026-06-30", + size: "m", + children: [ + { due: "2026-06-14", size: "s", children: [] }, + { due: "2026-06-22", size: "m", children: [] }, + ], + }; + const span = h.rollupSpan(parent); + expect(span.s).toBeLessThanOrEqual(span.e); + expect(span.e).toBe(10); + }); +}); diff --git a/tests/domain.test.js b/tests/domain.test.js new file mode 100644 index 0000000..ce2134b --- /dev/null +++ b/tests/domain.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { canonHardware, findClient, inferOwnerByDomain, norm } from "../src/lib/domain.js"; + +describe("domain", () => { + it("infers owner from task keywords", () => { + expect(inferOwnerByDomain("Train obstacle avoidance model")).toBe("ak"); + expect(inferOwnerByDomain("Wire motor CAN bus")).toBe("sk"); + expect(inferOwnerByDomain("Approve invoice payment")).toBe("jn"); + expect(inferOwnerByDomain("Random unrelated note")).toBeNull(); + }); + + it("canonicalizes hardware spellings", () => { + expect(canonHardware("install rs3 motors")).toBe("install RS03 motors"); + expect(canonHardware("fix d-wave board")).toBe("fix D-Wave board"); + expect(canonHardware("el 05 actuator")).toBe("EL05 actuator"); + }); + + it("finds clients with punctuation tolerance", () => { + expect(findClient("Demo for JCDecaux,")).toBe("JCDecaux"); + expect(findClient("meeting with jaycee decaux team")).toBe("JCDecaux"); + expect(findClient("internal standup")).toBeNull(); + }); + + it("normalizes text for fuzzy matching", () => { + expect(norm("JCDecaux,")).toBe(" jcdecaux "); + }); +}); diff --git a/tests/smoke.test.js b/tests/smoke.test.js new file mode 100644 index 0000000..29f0c06 --- /dev/null +++ b/tests/smoke.test.js @@ -0,0 +1,21 @@ +import { readFileSync } from "fs"; +import { describe, expect, it } from "vitest"; + +describe("project layout", () => { + it("loads the app as an ES module", () => { + const html = readFileSync("index.html", "utf8"); + expect(html).toContain('