From b0b3e97ec32c022d8e86003877af14c9b07c3f82 Mon Sep 17 00:00:00 2001 From: Jayanth Vasupilli <156448548+jayanthvee@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:52:20 -0700 Subject: [PATCH 01/12] docs(tickets): add PL-023 and PL-025 with the design rules and the approved Home mock PL-023 rebuilds apps/web as React + TypeScript in the approved look. PL-025 restyles the extension popup in the same language. D-023 records the owner's mandate that the admin panel lives in its own repository sharing this database through a least-privilege role. --- internal/agent/tickets/PL-023.md | 112 +++++++++++++ internal/agent/tickets/PL-025.md | 51 ++++++ internal/docs/DECISIONS.md | 1 + internal/docs/DESIGN.md | 157 +++++++++++++++++++ internal/docs/design/home-mock.html | 234 ++++++++++++++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 internal/agent/tickets/PL-023.md create mode 100644 internal/agent/tickets/PL-025.md create mode 100644 internal/docs/DESIGN.md create mode 100644 internal/docs/design/home-mock.html diff --git a/internal/agent/tickets/PL-023.md b/internal/agent/tickets/PL-023.md new file mode 100644 index 0000000..a3ac9aa --- /dev/null +++ b/internal/agent/tickets/PL-023.md @@ -0,0 +1,112 @@ +# PL-023: The web app — shell, Home, Tasks, Runs and Needs help + +Owner: T2 · Reviewer: T1 · Depends on: PL-022 (merged) · Phase: D + +## Description +The brief needs a real web app. `apps/web` is still the bare operator page from PL-015. This ticket rebuilds +`apps/web` in **React + TypeScript + Vite**, the stack the brief names, in the approved look, and delivers the +screens that use data we already have. + +**Read these first, and follow them exactly:** +- `internal/docs/DESIGN.md`: the words, type, color tokens, layout, sidebar and motion; +- `internal/docs/design/home-mock.html`: the approved Home screen. Open it in a browser. Port its CSS tokens and + structure; don't reinvent them. + +**The user is not technical.** The UI vocabulary table in DESIGN.md §2 is mandatory. No engine word (recipe, +record key, parked, ledger, version) may appear on any screen. A unit test enforces this (requirement 7). + +## Requirements +1. **The stack.** + - React 18, React Router and TypeScript in `apps/web`, built by Vite into `apps/web/dist`, which + `services/api/main.py` already serves. + - Plain CSS with the DESIGN.md tokens (one `tokens.css` plus CSS modules or co-located CSS). **No Tailwind, + no component library.** + - Public Sans through `@fontsource/public-sans`. + - Pin every new dependency to an exact version. + - Keep `npm --prefix apps/web run check` (typecheck, test, build) as the gate. +2. **The app shell.** + - The sidebar exactly as in DESIGN.md §6. Every item routes to a page. + - The header: page title, search (Ctrl K opens a simple search over task names; a stub list is fine), the + page's primary action, and the avatar. + - The "Chrome connected" status comes from the hub's session state (whether a session is attached); show + "Chrome not connected" otherwise. + - Pages not built in this ticket (Spreadsheets, Documents, Saved answers, Apps, Settings, Help) show a + designed empty state: one sentence plus "Coming soon". No blank pages. +3. **Home** matches the mock, backed by real data: + - the headline "N rows done this week. M need you." (with no rows: "Nothing has run yet." and the Teach + button); + - **time saved this week** (rows done × 3 minutes, minus run time), shown in the gold figure exactly as in the mock; + - Needs your help (top 3); + - Running now, with a live progress bar (poll every 2 s while a run is active); + - My tasks (latest 5). +4. **Tasks** (`/tasks`): + - A table of the saved recipes, shown as tasks: name, the app (from the recipe's first allowed origin), + last run, result, and one button ("Run again" or "Try one row"). Empty state: "Teach your first task" → + a link to `/teach`, a "Coming next" page. + - Clicking a task opens `/tasks/:id`, a read-only page for now: its steps in plain words (a navigate step + becomes "Open Odoo CRM", a fill becomes "Type the email", and so on) and its runs. The editor comes in + PL-024. +5. **Runs** (`/runs` and `/runs/:id`): + - **The list:** task, started, status, and "11 of 20 done". + - **A run page** with: + - live progress; + - pause, resume and cancel; + - a results table: each row's name or key, its outcome in plain words and the reason; + - **Export CSV**, using the existing `results.csv`. + - Time saved for a finished run: rows done × 3 minutes, minus the run's duration. Show it as "About 52 + minutes saved". +6. **Needs help** (`/needs-help`): + - Every row across runs that needs a person, with the plain reason and one action: + - **Try again**: re-queue. + - **Dismiss**. + - **Safety (unchanged from PL-015, mandatory):** a row that may already have been saved (any commit was + attempted) can only be re-queued after the person ticks "I checked Odoo and it isn't there". The API + enforces this already. The UI must show the checkbox and explain why. + - The Home and sidebar counts come from the same source. +7. **No engine words on screen.** + - Add a vitest that renders every page with sample data and fails if the visible text contains any of: + `recipe`, `ledger`, `parked`, `record key`, `verified`, `rung`, `idempotency`, `v1`, `v2`, `step_`, + `fill-`, `click-`. + - Each forbidden word maps to the plain wording in DESIGN.md §2. +8. **The API, only what the pages need.** + - Add `GET /api/v1/home`, which returns the week's counts (done, already there, needs help), the minutes + saved, the needs-help top 3, the running runs with their progress, and the latest tasks. Use one module in + `services/api`, following `internal/docs/sql-patterns.md`, with pytest tests. + - Reuse the existing `/runs`, `/recipes`, `/records/*` and `/approvals` routes for everything else. + - **No change** to the ledger, runner, hub protocol, or the re-queue safety check. +9. **Motion and accessibility.** + - The blur-in entrance runs on first render only. Live updates ease. Everything is off under reduced motion. + - All buttons are real ` +
  • Invoice INV-2041

    The total doesn't match its items.

  • +
  • SKU-88120

    The new price is negative.

  • + + +
    +

    Running now

    Live
    +
    + Add customer inquiries to Odoo CRM + +
    11 of 20 done2 already there
    +
    +
    + + +
    +

    My tasks

    See all
    + + + + + + + + +
    TaskLast runResult
    Add customer inquiriesOdoo CRMNowRunning
    Update product pricesOdoo Inventory2 hours ago1 needs help
    Enroll students in coursesOdoo ContactsYesterdayAll done
    Add leadsTwentyNeverReady
    +
    + + + + From 6d6148f16b94e301e2dd84511a75fe6ed97229f0 Mon Sep 17 00:00:00 2001 From: Jayanth Vasupilli <156448548+jayanthvee@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:03:43 -0700 Subject: [PATCH 02/12] feat(web): rebuild apps/web in React with the approved shell, tokens and router The PL-015 plain page becomes a React 18 + TypeScript app built by Vite, in the look of internal/docs/design/home-mock.html: its CSS tokens, the fog background, the frosted sidebar of four places plus Settings, Help and the Chrome status, the header with Ctrl K search and one primary action, and the blur-in motion that only runs on a page's first render. The shell serves the built page: a client-side route answers index.html so a refresh on /runs/abc does not 404, while a missing asset still does. GET /api/v1/executor/session reads the hub's own session registry for the sidebar's status line, changing no frame, socket or token check. --- apps/web/index.html | 11 +- apps/web/package-lock.json | 461 ++++++++++++++++++++++++---- apps/web/package.json | 23 +- apps/web/src/App.tsx | 40 +++ apps/web/src/api.ts | 105 ------- apps/web/src/app.ts | 394 ------------------------ apps/web/src/lib/api.ts | 87 ++++++ apps/web/src/lib/useJson.ts | 64 ++++ apps/web/src/lib/words.ts | 163 ++++++++++ apps/web/src/logic.ts | 359 ---------------------- apps/web/src/main.ts | 10 - apps/web/src/main.tsx | 31 ++ apps/web/src/pages/ComingSoon.tsx | 24 ++ apps/web/src/pages/Home.tsx | 6 + apps/web/src/pages/NeedsHelp.tsx | 6 + apps/web/src/pages/RunDetail.tsx | 6 + apps/web/src/pages/Runs.tsx | 6 + apps/web/src/pages/TaskDetail.tsx | 6 + apps/web/src/pages/Tasks.tsx | 6 + apps/web/src/shell/EmptyState.tsx | 18 ++ apps/web/src/shell/Header.tsx | 26 ++ apps/web/src/shell/Icons.tsx | 86 ++++++ apps/web/src/shell/Search.tsx | 77 +++++ apps/web/src/shell/Shell.tsx | 64 ++++ apps/web/src/shell/Sidebar.tsx | 110 +++++++ apps/web/src/shell/shell.css | 56 ++++ apps/web/src/styles.css | 126 -------- apps/web/src/styles/global.css | 87 ++++++ apps/web/src/styles/tokens.css | 12 + apps/web/src/types.ts | 121 ++++++-- apps/web/src/view.ts | 382 ----------------------- apps/web/tests/api.test.ts | 108 +++++++ apps/web/tests/commitGate.test.ts | 193 ------------ apps/web/tests/errorDetail.test.ts | 150 --------- apps/web/tests/helpers.ts | 138 --------- apps/web/tests/helpers.tsx | 114 +++++++ apps/web/tests/injection.test.ts | 101 ------ apps/web/tests/polling.test.ts | 116 ------- apps/web/tests/router.test.tsx | 84 +++++ apps/web/tests/runButtons.test.ts | 94 ------ apps/web/tests/shell.test.tsx | 122 ++++++++ apps/web/tests/words.test.ts | 184 +++++++++++ apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 11 +- services/api/hub.py | 17 + services/api/main.py | 29 +- tests/api/test_hub_session_state.py | 85 +++++ tests/api/test_web_serving.py | 39 +++ 48 files changed, 2276 insertions(+), 2283 deletions(-) create mode 100644 apps/web/src/App.tsx delete mode 100644 apps/web/src/api.ts delete mode 100644 apps/web/src/app.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/useJson.ts create mode 100644 apps/web/src/lib/words.ts delete mode 100644 apps/web/src/logic.ts delete mode 100644 apps/web/src/main.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/ComingSoon.tsx create mode 100644 apps/web/src/pages/Home.tsx create mode 100644 apps/web/src/pages/NeedsHelp.tsx create mode 100644 apps/web/src/pages/RunDetail.tsx create mode 100644 apps/web/src/pages/Runs.tsx create mode 100644 apps/web/src/pages/TaskDetail.tsx create mode 100644 apps/web/src/pages/Tasks.tsx create mode 100644 apps/web/src/shell/EmptyState.tsx create mode 100644 apps/web/src/shell/Header.tsx create mode 100644 apps/web/src/shell/Icons.tsx create mode 100644 apps/web/src/shell/Search.tsx create mode 100644 apps/web/src/shell/Shell.tsx create mode 100644 apps/web/src/shell/Sidebar.tsx create mode 100644 apps/web/src/shell/shell.css delete mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/styles/global.css create mode 100644 apps/web/src/styles/tokens.css delete mode 100644 apps/web/src/view.ts create mode 100644 apps/web/tests/api.test.ts delete mode 100644 apps/web/tests/commitGate.test.ts delete mode 100644 apps/web/tests/errorDetail.test.ts delete mode 100644 apps/web/tests/helpers.ts create mode 100644 apps/web/tests/helpers.tsx delete mode 100644 apps/web/tests/injection.test.ts delete mode 100644 apps/web/tests/polling.test.ts create mode 100644 apps/web/tests/router.test.tsx delete mode 100644 apps/web/tests/runButtons.test.ts create mode 100644 apps/web/tests/shell.test.tsx create mode 100644 apps/web/tests/words.test.ts create mode 100644 tests/api/test_hub_session_state.py create mode 100644 tests/api/test_web_serving.py diff --git a/apps/web/index.html b/apps/web/index.html index 3963c20..09ca2f8 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,14 +3,11 @@ - ProcessLens — runs + + ProcessLens -
    -

    ProcessLens

    - -
    -
    - +
    + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 9533983..e43b16d 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -7,12 +7,69 @@ "": { "name": "process-lens-web", "version": "0.1.0", + "dependencies": { + "@fontsource/public-sans": "5.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.30.6" + }, "devDependencies": { - "@types/node": "^22.10.0", - "happy-dom": "^20.0.0", - "typescript": "^5.6.3", - "vite": "^8.3.4", - "vitest": "^4.1.11" + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.8", + "@types/node": "22.20.5", + "@types/react": "18.3.31", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "6.0.0", + "happy-dom": "20.0.0", + "typescript": "5.6.3", + "vite": "8.3.4", + "vitest": "4.1.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@fontsource/public-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz", + "integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -32,6 +89,15 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", @@ -319,6 +385,77 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -354,6 +491,34 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -361,16 +526,39 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "node_modules/@vitejs/plugin-react": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.0.tgz", + "integrity": "sha512-Bu5/eP6td3WI654+tRq+ryW1PbgA90y5pqMKpb3U7UpNk6VjI53P/ncPUd192U9dSrepLy7DHnq1XEMDz5H++w==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -484,27 +672,50 @@ "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==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/buffer-image-size": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", - "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "peer": true, + "engines": { + "node": ">=10" }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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": ">=4.0" + "node": ">=12" } }, "node_modules/chai": { @@ -524,6 +735,24 @@ "dev": true, "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -534,18 +763,13 @@ "node": ">=8" } }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } + "license": "MIT", + "peer": true }, "node_modules/es-module-lexer": { "version": "2.3.2", @@ -608,24 +832,36 @@ } }, "node_modules/happy-dom": { - "version": "20.14.6", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.6.tgz", - "integrity": "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.0.tgz", + "integrity": "sha512-GkWnwIFxVGCf2raNrxImLo397RdGhLapj5cT3R2PT7FwL62Ze1DROhzmYW7+J3p9105DYMVenEejEbnq5wA37w==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": ">=20.0.0", + "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", - "@types/ws": "^8.18.1", - "buffer-image-size": "^0.6.4", - "entities": "^7.0.1", - "whatwg-mimetype": "^3.0.0", - "ws": "^8.21.0" + "whatwg-mimetype": "^3.0.0" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -899,6 +1135,29 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -998,6 +1257,87 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/rolldown": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", @@ -1032,6 +1372,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1108,9 +1457,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1322,28 +1671,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/apps/web/package.json b/apps/web/package.json index 7fdadd5..b53eb11 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,7 +2,7 @@ "name": "process-lens-web", "version": "0.1.0", "private": true, - "description": "The local operator page for ProcessLens runs.", + "description": "The ProcessLens web app: Home, Tasks, Runs and Needs help.", "type": "module", "scripts": { "dev": "vite", @@ -11,11 +11,22 @@ "test": "vitest run", "check": "npm run typecheck && npm run test && npm run build" }, + "dependencies": { + "@fontsource/public-sans": "5.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.30.6" + }, "devDependencies": { - "@types/node": "^22.10.0", - "happy-dom": "^20.0.0", - "typescript": "^5.6.3", - "vite": "^8.3.4", - "vitest": "^4.1.11" + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.8", + "@types/node": "22.20.5", + "@types/react": "18.3.31", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "6.0.0", + "happy-dom": "20.0.0", + "typescript": "5.6.3", + "vite": "8.3.4", + "vitest": "4.1.11" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..e681d48 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,40 @@ +import { Route, Routes } from "react-router-dom"; + +import { ComingSoon } from "./pages/ComingSoon"; +import { Home } from "./pages/Home"; +import { NeedsHelp } from "./pages/NeedsHelp"; +import { RunDetail } from "./pages/RunDetail"; +import { Runs } from "./pages/Runs"; +import { TaskDetail } from "./pages/TaskDetail"; +import { Tasks } from "./pages/Tasks"; +import { Shell } from "./shell/Shell"; + +/** + * The router. Every sidebar item lands on a real page: the ones this ticket does not build show a + * designed empty state, so nothing is ever blank. + * + * `main.tsx` wraps this in a `BrowserRouter`; the tests wrap it in a `MemoryRouter`, which is why + * the router lives only here. + */ +export function App(): JSX.Element { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts deleted file mode 100644 index 1242682..0000000 --- a/apps/web/src/api.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * The API client. Every path is relative, so the page talks to the API it was served by and never - * to a host it made up. - */ - -import type { OperatorInfo } from "./types"; - -export interface ApiSuccess { - ok: true; - data: T; -} - -export interface ApiFailure { - ok: false; - status: number; - /** The API's own `detail` text where there is one; otherwise a plain sentence. */ - detail: string; -} - -export type ApiResult = ApiSuccess | ApiFailure; - -export interface ApiClientOptions { - fetchImpl?: typeof fetch; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function parseJson(text: string): unknown { - if (text.trim() === "") return null; - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function detailFromPayload(status: number, payload: unknown): string { - if (payload !== null && typeof payload === "object" && "detail" in payload) { - const detail = (payload as { detail: unknown }).detail; - if (typeof detail === "string" && detail.trim() !== "") return detail; - if (detail !== undefined && detail !== null) return JSON.stringify(detail); - } - return `The API refused the request (HTTP ${status}).`; -} - -export class ApiClient { - readonly #fetchImpl: typeof fetch; - - constructor(options: ApiClientOptions = {}) { - const impl = options.fetchImpl; - this.#fetchImpl = - impl ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)); - } - - get(path: string): Promise> { - return this.#request("GET", path); - } - - /** A POST with no body sends no Content-Type, which is what the API's front door allows. */ - post(path: string, body?: unknown): Promise> { - return this.#request("POST", path, body); - } - - async #request(method: "GET" | "POST", path: string, body?: unknown): Promise> { - const headers: Record = { Accept: "application/json" }; - const init: RequestInit = { method, headers }; - if (body !== undefined) { - headers["Content-Type"] = "application/json"; - init.body = JSON.stringify(body); - } - let response: Response; - try { - response = await this.#fetchImpl(path, init); - } catch (error) { - return { ok: false, status: 0, detail: `Could not reach the API: ${errorMessage(error)}` }; - } - let text = ""; - try { - text = await response.text(); - } catch { - text = ""; - } - const payload = parseJson(text); - if (response.ok !== true) { - return { ok: false, status: response.status, detail: detailFromPayload(response.status, payload) }; - } - return { ok: true, data: payload as T }; - } -} - -/** `GET /api/v1/operator`, narrowed: 200 carries the id, 503 carries the reason. */ -export function readOperatorId(result: ApiResult): string | null { - if (result.ok === false) return null; - const id = result.data.operator_id; - return typeof id === "string" && id !== "" ? id : null; -} - -export function operatorProblemText(result: ApiResult): string | null { - if (result.ok === true) { - return readOperatorId(result) === null ? "The API returned no operator id." : null; - } - return result.detail; -} diff --git a/apps/web/src/app.ts b/apps/web/src/app.ts deleted file mode 100644 index faf6e08..0000000 --- a/apps/web/src/app.ts +++ /dev/null @@ -1,394 +0,0 @@ -/** - * The page's wiring: read the API, keep the state, render it, and do exactly the action a person - * clicked — never a retry of its own. - * - * The fetch implementation, the poll interval and the timers are all injectable, so the tests drive - * real behaviour instead of scraping the DOM. - */ - -import { ApiClient, operatorProblemText, readOperatorId } from "./api"; -import * as logic from "./logic"; -import * as view from "./view"; -import type { - ApprovalDecision, - ApprovalDecisionBody, - ApprovalRequest, - OperatorInfo, - RecordActionBody, - RecordActionResponse, - RecordRow, - RunDetail, - RunSummary, - Transition, -} from "./types"; - -export interface MountOptions { - fetchImpl?: typeof fetch; - intervalMs?: number; - initialHash?: string; - timers?: logic.Timers; - listenToHashChange?: boolean; -} - -export interface AppStatus { - route: logic.Route; - runId: string | null; - runStatus: string | null; - polling: boolean; - operatorId: string | null; - operatorProblem: string | null; -} - -export interface AppHandle { - refresh: () => Promise; - showRuns: () => Promise; - openRun: (runId: string) => Promise; - render: () => void; - status: () => AppStatus; - destroy: () => void; -} - -interface AppState { - route: logic.Route; - operatorChecked: boolean; - operatorId: string | null; - operatorProblem: string | null; - runs: RunSummary[]; - run: RunDetail | null; - records: RecordRow[]; - transitions: Map; - approvals: ApprovalRequest[]; - expanded: Set; - confirmed: Map; - errors: Map; - pageError: string | null; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function mountApp(root: HTMLElement, options: MountOptions = {}): AppHandle { - const api = new ApiClient(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }); - const intervalMs = options.intervalMs ?? logic.POLL_INTERVAL_MS; - const timers = options.timers ?? logic.DEFAULT_TIMERS; - - const state: AppState = { - route: logic.routeFromHash(options.initialHash ?? ""), - operatorChecked: false, - operatorId: null, - operatorProblem: null, - runs: [], - run: null, - records: [], - transitions: new Map(), - approvals: [], - expanded: new Set(), - confirmed: new Map(), - errors: new Map(), - pageError: null, - }; - - let destroyed = false; - - const poller = logic.createPoller({ - intervalMs, - timers, - onTick: () => { - void refresh(); - }, - shouldContinue: () => state.run !== null && logic.shouldPoll(state.run.status), - }); - - function syncPoller(): void { - if (destroyed === false && state.run !== null && logic.shouldPoll(state.run.status)) { - poller.start(); - } else { - poller.stop(); - } - } - - async function loadOperator(): Promise { - const result = await api.get(logic.operatorPath()); - state.operatorChecked = true; - const id = readOperatorId(result); - if (id !== null) { - state.operatorId = id; - state.operatorProblem = null; - return; - } - state.operatorId = null; - state.operatorProblem = operatorProblemText(result) ?? logic.OPERATOR_UNAVAILABLE_MESSAGE; - } - - async function loadRuns(): Promise { - const result = await api.get(logic.runsPath()); - if (result.ok === false) { - state.runs = []; - state.pageError = result.detail; - return; - } - state.runs = Array.isArray(result.data) ? result.data : []; - } - - async function loadTransitions(runId: string, reset: boolean): Promise { - let after = 0; - if (reset) { - state.transitions.clear(); - } else { - for (const list of state.transitions.values()) { - for (const transition of list) after = Math.max(after, transition.id); - } - } - const result = await api.get(logic.transitionsPath(runId, after)); - if (result.ok === false || Array.isArray(result.data) === false) return; - for (const transition of result.data) { - const existing = state.transitions.get(transition.run_record_id) ?? []; - existing.push(transition); - state.transitions.set(transition.run_record_id, existing); - } - } - - async function loadRun(runId: string): Promise { - const detail = await api.get(logic.runPath(runId)); - if (detail.ok === false) { - state.run = null; - state.records = []; - state.approvals = []; - state.pageError = detail.detail; - return; - } - state.run = detail.data; - const [records, approvals] = await Promise.all([ - api.get(logic.recordsPath(runId)), - api.get(logic.approvalsPath(runId)), - ]); - state.records = records.ok === true && Array.isArray(records.data) ? records.data : []; - state.approvals = approvals.ok === true && Array.isArray(approvals.data) ? approvals.data : []; - if (state.expanded.size > 0) await loadTransitions(runId, false); - } - - async function refresh(): Promise { - if (destroyed) return; - state.pageError = null; - if (state.operatorChecked === false) await loadOperator(); - if (state.route.name === "runs") { - state.run = null; - await loadRuns(); - } else { - await loadRun(state.route.id); - } - syncPoller(); - render(); - } - - function confirmedFor(recordId: string): boolean { - return state.confirmed.get(recordId) === true; - } - - async function runAction(action: logic.RunAction): Promise { - const run = state.run; - if (run === null) return; - const key = `run:${action}`; - state.errors.delete(key); - render(); - const result = await api.post(logic.runActionPath(run.id, action)); - if (result.ok === false) { - state.errors.set(key, result.detail); - render(); - return; - } - await refresh(); - } - - async function recordAction(recordId: string, action: logic.RecordAction): Promise { - const record = state.records.find((candidate) => candidate.id === recordId); - if (record === undefined) return; - const key = `${recordId}:${action}`; - state.errors.delete(key); - const gate = logic.actionGate({ - state: record.state, - commitAttempted: record.commit_attempted, - confirmed: confirmedFor(recordId), - operatorId: state.operatorId, - }); - if (gate.disabled) { - render(); - return; - } - let body: RecordActionBody; - try { - body = logic.actionBody({ - commitAttempted: record.commit_attempted, - confirmed: confirmedFor(recordId), - operatorId: state.operatorId, - }); - } catch (error) { - state.errors.set(key, errorMessage(error)); - render(); - return; - } - const result = await api.post(logic.recordActionPath(recordId, action), body); - if (result.ok === false) { - state.errors.set(key, result.detail); - render(); - return; - } - await refresh(); - } - - async function decide(requestId: string, decision: ApprovalDecision): Promise { - const key = `approval:${requestId}:${decision}`; - state.errors.delete(key); - let body: ApprovalDecisionBody; - try { - body = logic.approvalBody({ requestId, decision, operatorId: state.operatorId }); - } catch (error) { - state.errors.set(key, errorMessage(error)); - render(); - return; - } - const result = await api.post(logic.approvalDecisionPath(), body); - if (result.ok === false) { - state.errors.set(key, result.detail); - render(); - return; - } - await refresh(); - } - - async function toggleRecord(recordId: string): Promise { - if (state.expanded.has(recordId)) { - state.expanded.delete(recordId); - } else { - state.expanded.add(recordId); - if (state.run !== null) await loadTransitions(state.run.id, true); - } - render(); - } - - function renderRunSection(run: RunDetail): HTMLElement { - const section = view.el("section", { className: "run" }); - section.appendChild( - view.renderRunHeader({ - run, - errors: state.errors, - onAction: (action) => { - void runAction(action); - }, - }), - ); - section.appendChild( - view.renderRecordsTable({ - records: state.records, - expanded: state.expanded, - transitionsOf: (recordId) => state.transitions.get(recordId) ?? [], - confirmed: state.confirmed, - operatorId: state.operatorId, - errors: state.errors, - onToggle: (recordId) => { - void toggleRecord(recordId); - }, - onConfirm: (recordId, checked) => { - state.confirmed.set(recordId, checked); - render(); - }, - onAction: (recordId, action) => { - void recordAction(recordId, action); - }, - }), - ); - section.appendChild( - view.renderApprovals({ - approvals: state.approvals, - operatorId: state.operatorId, - errors: state.errors, - onDecision: (requestId, decision) => { - void decide(requestId, decision); - }, - }), - ); - return section; - } - - function render(): void { - view.clearNode(root); - const page = view.el("div", { className: "page" }); - if (state.operatorProblem !== null) { - page.appendChild( - view.el("p", { - className: "operator-problem", - text: `${logic.OPERATOR_UNAVAILABLE_MESSAGE} The API said: ${state.operatorProblem}`, - attrs: { role: "status" }, - }), - ); - } - if (state.pageError !== null) { - page.appendChild( - view.el("p", { className: "page-error", text: state.pageError, attrs: { role: "alert" } }), - ); - } - if (state.route.name === "runs") { - page.appendChild(view.renderRunsList(state.runs)); - } else if (state.run === null) { - page.appendChild(view.el("p", { className: "muted", text: "Loading the run…" })); - } else { - page.appendChild(renderRunSection(state.run)); - } - root.appendChild(page); - } - - async function showRuns(): Promise { - state.route = { name: "runs" }; - state.expanded.clear(); - state.transitions.clear(); - await refresh(); - } - - async function openRun(runId: string): Promise { - state.route = { name: "run", id: runId }; - state.expanded.clear(); - state.transitions.clear(); - state.confirmed.clear(); - state.errors.clear(); - await refresh(); - } - - const maybeWindow: Window | undefined = typeof window === "undefined" ? undefined : window; - const onHashChange = (): void => { - const route = logic.routeFromHash(maybeWindow?.location.hash ?? ""); - if (route.name === "run") { - void openRun(route.id); - } else { - void showRuns(); - } - }; - if (options.listenToHashChange === true && maybeWindow !== undefined) { - maybeWindow.addEventListener("hashchange", onHashChange); - } - - render(); - - return { - refresh, - showRuns, - openRun, - render, - status: () => ({ - route: state.route, - runId: state.run === null ? null : state.run.id, - runStatus: state.run === null ? null : state.run.status, - polling: poller.isRunning(), - operatorId: state.operatorId, - operatorProblem: state.operatorProblem, - }), - destroy: () => { - destroyed = true; - poller.stop(); - if (options.listenToHashChange === true && maybeWindow !== undefined) { - maybeWindow.removeEventListener("hashchange", onHashChange); - } - view.clearNode(root); - }, - }; -} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..afd63ae --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,87 @@ +/** + * The API client. Every path is relative, so the page talks to the API it was served by and never + * to a host it made up. + * + * A refusal carries the API's own `detail` text, so a page can show it next to the button that + * caused it instead of inventing a sentence of its own. + */ + +export class ApiError extends Error { + readonly status: number; + readonly detail: string; + + constructor(status: number, detail: string) { + super(detail); + this.name = "ApiError"; + this.status = status; + this.detail = detail; + } +} + +const REFUSED = "The API refused the request"; + +function parseJson(text: string): unknown { + if (text.trim() === "") return null; + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +function detailFromPayload(status: number, payload: unknown): string { + if (payload !== null && typeof payload === "object" && "detail" in payload) { + const detail = (payload as { detail: unknown }).detail; + if (typeof detail === "string" && detail.trim() !== "") return detail; + } + return `${REFUSED} (HTTP ${status}).`; +} + +async function request( + method: "GET" | "POST", + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + const headers: Record = { Accept: "application/json" }; + const init: RequestInit = { method, headers }; + if (signal !== undefined) init.signal = signal; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + let response: Response; + try { + response = await fetch(path, init); + } catch (error) { + if (signal !== undefined && signal.aborted) throw error; + throw new ApiError(0, "Could not reach the API."); + } + let text = ""; + try { + text = await response.text(); + } catch { + text = ""; + } + if (!response.ok) { + throw new ApiError(response.status, detailFromPayload(response.status, parseJson(text))); + } + return parseJson(text) as T; +} + +/** `GET` one JSON path, or throw an `ApiError` carrying the API's own explanation. */ +export function apiGet(path: string, signal?: AbortSignal): Promise { + return request("GET", path, undefined, signal); +} + +/** `POST` a JSON body (omit it when the API sends nothing) and read the JSON answer. */ +export function apiPost(path: string, body?: unknown, signal?: AbortSignal): Promise { + return request("POST", path, body, signal); +} + +/** The API's `detail` text, so a refusal can be shown next to the button that caused it. */ +export function errorText(error: unknown): string { + if (error instanceof ApiError) return error.detail; + if (error instanceof Error && error.message.trim() !== "") return error.message; + return "Something went wrong."; +} diff --git a/apps/web/src/lib/useJson.ts b/apps/web/src/lib/useJson.ts new file mode 100644 index 0000000..09886d1 --- /dev/null +++ b/apps/web/src/lib/useJson.ts @@ -0,0 +1,64 @@ +/** + * One hook for every read: fetch a path once, and poll it while a number of milliseconds is given. + * + * The interval stops the moment the component goes away or `pollMs` becomes `null`, so a page that + * is finished polling makes no further requests. + */ + +import { useCallback, useEffect, useState } from "react"; + +import { apiGet, errorText } from "./api"; + +/** How often a live page asks the API again. */ +export const POLL_INTERVAL_MS = 2000; + +export interface JsonState { + data: T | null; + error: string | null; + loading: boolean; + /** Fetch once more now, without waiting for the next poll. */ + reload: () => void; +} + +export function useJson(path: string | null, pollMs: number | null = null): JsonState { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(path !== null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + if (path === null) { + setLoading(false); + return; + } + setLoading(true); + const controller = new AbortController(); + let cancelled = false; + const load = async (): Promise => { + try { + const payload = await apiGet(path, controller.signal); + if (!cancelled) { + setData(payload); + setError(null); + } + } catch (failure) { + if (!cancelled && !controller.signal.aborted) setError(errorText(failure)); + } finally { + if (!cancelled) setLoading(false); + } + }; + void load(); + // A number means "keep asking"; `null` means "this page is done". + const interval = + pollMs === null ? undefined : setInterval(() => void load(), Math.max(pollMs, 1)); + return () => { + cancelled = true; + controller.abort(); + if (interval !== undefined) clearInterval(interval); + }; + }, [path, pollMs, nonce]); + + const reload = useCallback(() => setNonce((value) => value + 1), []); + + return { data, error, loading, reload }; +} diff --git a/apps/web/src/lib/words.ts b/apps/web/src/lib/words.ts new file mode 100644 index 0000000..dbff147 --- /dev/null +++ b/apps/web/src/lib/words.ts @@ -0,0 +1,163 @@ +/** + * The whole plain-words vocabulary. Every screen goes through this module, so the engine's words + * never reach a person's eyes (DESIGN.md §2, requirement 7). + * + * The mapping is the ticket's table; a state the table does not know falls back to "Waiting", and a + * reason it does not know is shown as it stands, because the runner writes those for people. + */ + +export type Tone = "ok" | "help" | "live" | "idle"; + +export interface Outcome { + label: string; + tone: Tone; +} + +const WAITING: Outcome = { label: "Waiting", tone: "idle" }; +const CHECKING: Outcome = { label: "Checking…", tone: "live" }; +const DONE: Outcome = { label: "Done", tone: "ok" }; +const ALREADY_THERE: Outcome = { label: "Already there", tone: "idle" }; +const NEEDS_HELP: Outcome = { label: "Needs your help", tone: "help" }; +const DIDNT_WORK: Outcome = { label: "Didn't work", tone: "help" }; + +/* engine state -> on screen */ +const ROW_STATES: Record = { + pending: WAITING, + claimed: WAITING, + prechecked: WAITING, + filling: WAITING, + submitting: CHECKING, + submitted_unverified: CHECKING, + reconciling: CHECKING, + verified: DONE, + skipped: ALREADY_THERE, + parked: NEEDS_HELP, + failed: DIDNT_WORK, +}; + +const RUN_STATUSES: Record = { + queued: { label: "Queued", tone: "idle" }, + running: { label: "Running", tone: "live" }, + paused: { label: "Paused", tone: "help" }, + completed: { label: "Finished", tone: "ok" }, + cancelled: { label: "Stopped", tone: "idle" }, + failed: DIDNT_WORK, +}; + +/** A row's outcome in plain words. Unknown states fall back to "Waiting". */ +export function rowOutcome(state: string): Outcome { + return ROW_STATES[state] ?? WAITING; +} + +/** A run's status in plain words. */ +export function runStatusWords(status: string): Outcome { + return RUN_STATUSES[status] ?? WAITING; +} + +/* the reasons the runner writes -> one plain sentence */ +const REASONS: Record = { + "attempts exhausted": "It didn't work after three tries.", + "already committed in an earlier run": "It was already added in an earlier run.", + "run cancelled": "The run was stopped before this row was reached.", + "dismissed by a person": "You dismissed this row.", + duplicate: "It was already there.", + "the option doesn't exist": "That answer isn't in the list on the site.", + "a value is missing": "A value it needs is missing from the row.", +}; + +/** "some_reason_code" -> "Some reason code", so a code nobody taught us is still readable. */ +function sentence(token: string): string { + const words = token.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); + if (words === "") return ""; + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** The reason a row needs a person, in one plain sentence. */ +export function plainReason(reason: string | null, state?: string): string { + const key = (reason ?? "").trim(); + if (key === "") { + return state === "skipped" ? "It was already there." : "Something went wrong."; + } + const known = REASONS[key.toLowerCase()]; + if (known !== undefined) return known; + // The runner already writes its reasons for people, but a code we have never seen must still not + // reach the screen as an identifier. + const readable = sentence(key); + return /\.$/.test(readable) ? readable : `${readable}.`; +} + +/* + * Every pause reason the PL-015 operator page already put into words, carried forward unchanged so + * this rebuild is not a regression: `paused_by_operator` is what the runs API sets, `operator_paused` + * is the older spelling, and both are accepted. + */ +const PAUSE_REASONS: Record = { + login_required: "Log in to the site in the bound tab, then resume.", + challenge: "Solve the security challenge the site is showing in the bound tab, then resume.", + approval_required: "An approval request is waiting for your decision, then resume.", + awaiting_approval: "An approval request is waiting for your decision, then resume.", + paused_by_operator: "You paused this run. Resume when you are ready.", + operator_paused: "You paused this run. Resume when you are ready.", + manual_review: "A row in the table below needs your decision, then resume.", + rate_limited: "The site is rate limiting us. Wait a moment, then resume.", + destination_unreachable: "The site could not be reached. Check it, then resume.", +}; + +/** A pause reason in one actionable sentence, or `null` when there is none. */ +export function pauseReasonWords(reason: string | null): string | null { + const key = (reason ?? "").trim().toLowerCase(); + if (key === "") return null; + const known = PAUSE_REASONS[key]; + if (known !== undefined) return known; + return `Paused: ${sentence(key)}. Sort that out, then resume.`; +} + +/** The states a run is still working through: `queued`, `running` or `paused`. */ +export function isOpenStatus(status: string | null): boolean { + return status === "queued" || status === "running" || status === "paused"; +} + +/** Which buttons a run's status offers. A finished run offers none of the three. */ +export function runActions(status: string): { pause: boolean; resume: boolean; cancel: boolean } { + const open = isOpenStatus(status); + return { pause: open, resume: status === "paused", cancel: open }; +} + +function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +/** When something happened, as a person would say it. */ +export function relativeTime(iso: string | null, now: Date = new Date()): string { + if (iso === null || iso.trim() === "") return "Never"; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return "Never"; + const seconds = Math.floor((now.getTime() - at.getTime()) / 1000); + if (seconds < 60) return "Now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${plural(minutes, "minute")} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${plural(hours, "hour")} ago`; + const days = Math.floor(hours / 24); + if (days === 1) return "Yesterday"; + return `${plural(days, "day")} ago`; +} + +/** "6 h 12 m" — minutes as hours and minutes, never negative. */ +export function durationWords(minutes: number): string { + const total = Math.max(0, Math.round(minutes)); + const hours = Math.floor(total / 60); + const rest = total % 60; + if (hours === 0) return `${rest} m`; + if (rest === 0) return `${hours} h`; + return `${hours} h ${rest} m`; +} + +/** "About 52 minutes saved" (or "About 1 hour saved"). */ +export function minutesSavedWords(minutes: number): string { + const total = Math.max(0, Math.round(minutes)); + if (total === 0) return "Nothing saved yet"; + if (total < 60) return `About ${plural(total, "minute")} saved`; + const hours = Math.max(1, Math.round(total / 60)); + return `About ${plural(hours, "hour")} saved`; +} diff --git a/apps/web/src/logic.ts b/apps/web/src/logic.ts deleted file mode 100644 index 821032d..0000000 --- a/apps/web/src/logic.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * The pure decisions this page makes: which buttons exist, what a pause reason means, when the - * destination check gates an action, which ids a request body carries, and how the poll runs. - * - * Everything here is a plain exported function with no DOM in sight, so the tests next to it are - * honest unit tests instead of DOM scraping. - */ - -import type { ApprovalDecision, ApprovalDecisionBody, RecordActionBody } from "./types"; - -export const POLL_INTERVAL_MS = 2000; - -/** The exact words the confirmation carries. The ticket fixes them. */ -export const DESTINATION_CHECK_LABEL = "I checked the site and this record is not there"; - -/** Said once, near the top of the page, when `GET /api/v1/operator` refuses. */ -export const OPERATOR_UNAVAILABLE_MESSAGE = - "The API did not give this page an operator identity, so every action that has to record who did it is disabled. Set PROCESSLENS_OPERATOR_ID and restart the API."; - -export type RunStatus = "queued" | "running" | "paused" | "completed" | "cancelled" | "failed"; - -export const RUN_STATUSES: readonly RunStatus[] = [ - "queued", - "running", - "paused", - "completed", - "cancelled", - "failed", -]; - -/** "Open" is queued, running or paused. */ -export const OPEN_STATUSES: readonly string[] = ["queued", "running", "paused"]; - -export function isOpenStatus(status: string): boolean { - return OPEN_STATUSES.includes(status); -} - -export function isFinishedStatus(status: string): boolean { - return isOpenStatus(status) === false; -} - -/** Poll while — and only while — the run is open. */ -export function shouldPoll(status: string | null | undefined): boolean { - if (status === null || status === undefined) return false; - return isOpenStatus(status); -} - -export interface RunActionAvailability { - pause: boolean; - resume: boolean; - cancel: boolean; -} - -/** Pause and Cancel for an open run; Resume only when the run is paused. */ -export function runActions(status: string): RunActionAvailability { - const open = isOpenStatus(status); - return { pause: open, resume: status === "paused", cancel: open }; -} - -export type RunAction = "pause" | "resume" | "cancel"; - -export function runActionLabel(action: RunAction): string { - if (action === "pause") return "Pause"; - if (action === "resume") return "Resume"; - return "Cancel"; -} - -/** "/" + words, with no host: the page and the API share one origin. */ -function path(...segments: string[]): string { - return `/api/v1/${segments.join("/")}`; -} - -export function runsPath(): string { - return path("runs"); -} - -export function runPath(runId: string): string { - return path("runs", encodeURIComponent(runId)); -} - -export function runActionPath(runId: string, action: RunAction): string { - return path("runs", encodeURIComponent(runId), action); -} - -export function recordsPath(runId: string): string { - return path("runs", encodeURIComponent(runId), "records"); -} - -export function transitionsPath(runId: string, after = 0): string { - const cursor = Number.isFinite(after) ? Math.max(0, Math.trunc(after)) : 0; - return `${path("runs", encodeURIComponent(runId), "transitions")}?after=${cursor}`; -} - -export function approvalsPath(runId: string): string { - return path("runs", encodeURIComponent(runId), "approvals"); -} - -export function resultsCsvPath(runId: string): string { - return path("runs", encodeURIComponent(runId), "results.csv"); -} - -export function operatorPath(): string { - return path("operator"); -} - -export function approvalDecisionPath(): string { - return path("approvals"); -} - -export type RecordAction = "requeue" | "dismiss"; - -export function recordActionPath(recordId: string, action: RecordAction): string { - return path("records", encodeURIComponent(recordId), action); -} - -export function runHash(runId: string): string { - return `#/runs/${encodeURIComponent(runId)}`; -} - -export interface RunsRoute { - name: "runs"; -} - -export interface RunRoute { - name: "run"; - id: string; -} - -export type Route = RunsRoute | RunRoute; - -/** The hash router: one page, no server-side rewrite, so `StaticFiles` needs no fallback. */ -export function routeFromHash(hash: string): Route { - const withoutHash = hash.startsWith("#") ? hash.slice(1) : hash; - const withSlash = withoutHash.startsWith("/") ? withoutHash : `/${withoutHash}`; - const match = /^\/runs\/([^/?#]+)/.exec(withSlash); - const captured = match === null ? undefined : match[1]; - if (captured !== undefined && captured !== "") { - return { name: "run", id: decodeURIComponent(captured) }; - } - return { name: "runs" }; -} - -/** "login_required" and friends, capitalised and readable. */ -export function humanizeToken(token: string): string { - const words = token.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); - if (words === "") return ""; - return words.charAt(0).toUpperCase() + words.slice(1); -} - -const RUN_STATUS_TEXT: Record = { - queued: "Queued", - running: "Running", - paused: "Paused", - completed: "Completed", - cancelled: "Cancelled", - failed: "Failed", -}; - -export function humanizeStatus(status: string): string { - const known = RUN_STATUS_TEXT[status]; - return known === undefined ? humanizeToken(status) : known; -} - -const PAUSE_REASON_TEXT: Record = { - login_required: "Log in to the site in the bound tab, then resume.", - challenge: "Solve the security challenge the site is showing in the bound tab, then resume.", - approval_required: "An approval request is waiting for your decision, then resume.", - awaiting_approval: "An approval request is waiting for your decision, then resume.", - operator_paused: "You paused this run. Resume when you are ready.", - manual_review: "A record in the table below needs your decision, then resume.", - rate_limited: "The site is rate limiting us. Wait a moment, then resume.", - destination_unreachable: "The site could not be reached. Check it, then resume.", -}; - -/** The pause reason in words a person understands, or null when there is no reason. */ -export function humanizePauseReason(reason: string | null | undefined): string | null { - if (reason === null || reason === undefined) return null; - const key = reason.trim().toLowerCase(); - if (key === "") return null; - const known = PAUSE_REASON_TEXT[key]; - if (known !== undefined) return known; - return `Paused: ${humanizeToken(key)}. Sort that out, then resume.`; -} - -export const COUNT_ORDER: readonly string[] = [ - "total", - "verified", - "skipped", - "parked", - "failed", - "in_progress", -]; - -export interface CountEntry { - key: string; - label: string; - value: number; -} - -/** Every count the API sent, in a stable order. */ -export function countEntries(counts: Record): CountEntry[] { - const keys = [...COUNT_ORDER, ...Object.keys(counts).filter((key) => COUNT_ORDER.includes(key) === false)]; - const entries: CountEntry[] = []; - for (const key of keys) { - const value = counts[key]; - if (value === undefined) continue; - entries.push({ key, label: humanizeToken(key), value }); - } - return entries; -} - -export interface RecordActions { - requeue: boolean; - dismiss: boolean; -} - -/** Re-queue and Dismiss appear for parked and failed records only. */ -export function recordActions(state: string): RecordActions { - const actionable = state === "parked" || state === "failed"; - return { requeue: actionable, dismiss: actionable }; -} - -export interface ActionGateInput { - state: string; - commitAttempted: boolean; - confirmed: boolean; - operatorId: string | null; -} - -export interface ActionGate { - shown: boolean; - /** A commit-attempted record needs the destination check ticked first. */ - needsConfirmation: boolean; - /** Only the checked-destination path has to send the operator id. */ - needsOperator: boolean; - disabled: boolean; -} - -/** - * The one place the page can get past the ledger's duplicate protection, so it is never one click. - * A commit-attempted record keeps both buttons disabled until the confirmation is ticked, and - * clearing the tick disables them again. - */ -export function actionGate(input: ActionGateInput): ActionGate { - const actions = recordActions(input.state); - if (actions.requeue === false) { - return { shown: false, needsConfirmation: false, needsOperator: false, disabled: true }; - } - const needsConfirmation = input.commitAttempted === true; - const needsOperator = needsConfirmation; - const confirmationMissing = needsConfirmation && input.confirmed !== true; - const operatorMissing = needsOperator && (input.operatorId === null || input.operatorId === ""); - return { - shown: true, - needsConfirmation, - needsOperator, - disabled: confirmationMissing || operatorMissing, - }; -} - -export interface ActionBodyInput { - commitAttempted: boolean; - confirmed: boolean; - operatorId: string | null; - reason?: string | null; -} - -/** - * The exact body the record endpoints get. `destination_checked_by` is sent only after the check - * has been confirmed, and the id is never invented. - */ -export function actionBody(input: ActionBodyInput): RecordActionBody { - const body: RecordActionBody = {}; - const reason = input.reason === undefined || input.reason === null ? "" : input.reason.trim(); - if (reason !== "") body.reason = reason; - if (input.commitAttempted === true) { - if (input.confirmed !== true) { - throw new Error("A commit-attempted record needs the destination check confirmed first."); - } - if (input.operatorId === null || input.operatorId === "") { - throw new Error("A commit-attempted record needs the API's operator identity."); - } - body.destination_checked_by = input.operatorId; - } - return body; -} - -export interface ApprovalBodyInput { - requestId: string; - decision: ApprovalDecision; - operatorId: string | null; - reason?: string | null; -} - -export function approvalBody(input: ApprovalBodyInput): ApprovalDecisionBody { - if (input.operatorId === null || input.operatorId === "") { - throw new Error("Answering an approval needs the API's operator identity."); - } - const body: ApprovalDecisionBody = { - request_id: input.requestId, - decision: input.decision, - decided_by: input.operatorId, - }; - const reason = input.reason === undefined || input.reason === null ? "" : input.reason.trim(); - if (reason !== "") body.reason = reason; - return body; -} - -export interface Timers { - setInterval: (handler: () => void, timeout: number) => unknown; - clearInterval: (handle: unknown) => void; -} - -/** The real clock, swappable so a test can drive it. */ -export const DEFAULT_TIMERS: Timers = { - setInterval: (handler, timeout) => setInterval(handler, timeout) as unknown, - clearInterval: (handle) => { - clearInterval(handle as ReturnType); - }, -}; - -export interface Poller { - start: () => void; - stop: () => void; - isRunning: () => boolean; -} - -export interface PollerOptions { - onTick: () => void; - shouldContinue: () => boolean; - intervalMs?: number; - timers?: Timers; -} - -/** - * A poll that stops itself as soon as it is no longer wanted. The interval is injectable and the - * timer functions are looked up at call time, so fake timers work with it. - */ -export function createPoller(options: PollerOptions): Poller { - const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS; - const timers = options.timers ?? DEFAULT_TIMERS; - let handle: unknown = null; - const stop = (): void => { - if (handle === null) return; - timers.clearInterval(handle); - handle = null; - }; - return { - start: (): void => { - if (handle !== null) return; - handle = timers.setInterval(() => { - options.onTick(); - if (options.shouldContinue() === false) stop(); - }, intervalMs); - }, - stop, - isRunning: (): boolean => handle !== null, - }; -} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts deleted file mode 100644 index 3cd15d9..0000000 --- a/apps/web/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import "./styles.css"; -import { mountApp } from "./app"; - -const root = document.getElementById("app"); -if (root === null) { - throw new Error("The page has no #app element to render into."); -} - -const app = mountApp(root, { listenToHashChange: true }); -void app.refresh(); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..292d11a --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,31 @@ +/** + * The page's entry point: the self-hosted Public Sans faces, the shared styles, and the app. + * + * The four weights are the only ones the design uses, so exactly four faces are loaded. Nothing + * here reads `prefers-color-scheme`: the app is light only (DESIGN.md §4). + */ + +import "@fontsource/public-sans/300.css"; +import "@fontsource/public-sans/400.css"; +import "@fontsource/public-sans/500.css"; +import "@fontsource/public-sans/600.css"; +import "./styles/tokens.css"; +import "./styles/global.css"; +import "./shell/shell.css"; + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; + +import { App } from "./App"; + +const container = document.getElementById("root"); +if (container !== null) { + createRoot(container).render( + + + + + , + ); +} diff --git a/apps/web/src/pages/ComingSoon.tsx b/apps/web/src/pages/ComingSoon.tsx new file mode 100644 index 0000000..d1ec365 --- /dev/null +++ b/apps/web/src/pages/ComingSoon.tsx @@ -0,0 +1,24 @@ +import { useLocation } from "react-router-dom"; + +import { EmptyState } from "../shell/EmptyState"; + +/** + * Every screen this ticket does not build yet: one sentence about what will appear here, and + * "Coming soon". The sentence is chosen from the path, so the router only has to render this. + */ +const SENTENCES: Record = { + "/teach": "Teaching a new task from a recording is coming next.", + "/spreadsheets": "Your spreadsheets and the problems found in them will appear here.", + "/documents": "Your PDFs, and what was read from them, will appear here.", + "/saved-answers": "The answers you save for later runs will appear here.", + "/apps": "The sites each task may use will appear here.", + "/settings": "Your workspace, your people and how long screenshots are kept will appear here.", + "/help": "Short guides for each screen will appear here.", +}; + +const FALLBACK = "This screen will be built in a later release."; + +export function ComingSoon(): JSX.Element { + const { pathname } = useLocation(); + return ; +} diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx new file mode 100644 index 0000000..80f156e --- /dev/null +++ b/apps/web/src/pages/Home.tsx @@ -0,0 +1,6 @@ +import { EmptyState } from "../shell/EmptyState"; + +/** Placeholder: builder B replaces this file with the Home page of the approved mock. */ +export function Home(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/NeedsHelp.tsx b/apps/web/src/pages/NeedsHelp.tsx new file mode 100644 index 0000000..a2f2a03 --- /dev/null +++ b/apps/web/src/pages/NeedsHelp.tsx @@ -0,0 +1,6 @@ +import { EmptyState } from "../shell/EmptyState"; + +/** Placeholder: builder D replaces this file with the rows that need a person. */ +export function NeedsHelp(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/RunDetail.tsx b/apps/web/src/pages/RunDetail.tsx new file mode 100644 index 0000000..6ffe97c --- /dev/null +++ b/apps/web/src/pages/RunDetail.tsx @@ -0,0 +1,6 @@ +import { EmptyState } from "../shell/EmptyState"; + +/** Placeholder: builder C replaces this file with the run page: progress and results. */ +export function RunDetail(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/Runs.tsx b/apps/web/src/pages/Runs.tsx new file mode 100644 index 0000000..9cb467c --- /dev/null +++ b/apps/web/src/pages/Runs.tsx @@ -0,0 +1,6 @@ +import { EmptyState } from "../shell/EmptyState"; + +/** Placeholder: builder C replaces this file with the list of every run, live and past. */ +export function Runs(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/TaskDetail.tsx b/apps/web/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..cca787b --- /dev/null +++ b/apps/web/src/pages/TaskDetail.tsx @@ -0,0 +1,6 @@ +import { EmptyState } from "../shell/EmptyState"; + +/** Placeholder: builder C replaces this file with the task page (steps in plain words, and runs). */ +export function TaskDetail(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/Tasks.tsx b/apps/web/src/pages/Tasks.tsx new file mode 100644 index 0000000..0738232 --- /dev/null +++ b/apps/web/src/pages/Tasks.tsx @@ -0,0 +1,6 @@ +import { EmptyState } from "../shell/EmptyState"; + +/** Placeholder: builder C replaces this file with the Tasks list of the approved mock. */ +export function Tasks(): JSX.Element { + return ; +} diff --git a/apps/web/src/shell/EmptyState.tsx b/apps/web/src/shell/EmptyState.tsx new file mode 100644 index 0000000..4c7da7d --- /dev/null +++ b/apps/web/src/shell/EmptyState.tsx @@ -0,0 +1,18 @@ +import type { CSSProperties, ReactNode } from "react"; + +/** + * One sentence about what will appear here, plus "Coming soon". + * + * Every screen the ticket does not build yet uses this, so no page is ever blank. + */ +export function EmptyState(props: { sentence: string; action?: ReactNode }): JSX.Element { + return ( +
    +
    +

    {props.sentence}

    +

    Coming soon

    + {props.action === undefined ? null :
    {props.action}
    } +
    +
    + ); +} diff --git a/apps/web/src/shell/Header.tsx b/apps/web/src/shell/Header.tsx new file mode 100644 index 0000000..31f3d45 --- /dev/null +++ b/apps/web/src/shell/Header.tsx @@ -0,0 +1,26 @@ +import type { CSSProperties, ReactNode } from "react"; + +import { Search } from "./Search"; + +/** + * The header: the page's title, Search with its `Ctrl K` hint, the page's one primary action, and + * the avatar. + * + * The avatar is a plain circle with a person's outline -- nothing in this repository knows the + * operator's name, so no initials are invented. + */ +export function Header(props: { title: string; action?: ReactNode }): JSX.Element { + return ( +
    +

    {props.title}

    + + {props.action === undefined || props.action === null ? null : props.action} + + + +
    + ); +} diff --git a/apps/web/src/shell/Icons.tsx b/apps/web/src/shell/Icons.tsx new file mode 100644 index 0000000..6742aea --- /dev/null +++ b/apps/web/src/shell/Icons.tsx @@ -0,0 +1,86 @@ +/** + * The icon sprite from the approved mock, and the one way to use an icon: ``. + * + * The symbols live in one hidden `` rendered by the shell, exactly as the mock does it, so the + * stroke weight and the 16 px box are the design's own. + */ + +export type IconName = + | "home" + | "tasks" + | "hist" + | "help" + | "sheet" + | "doc" + | "map" + | "apps" + | "q" + | "gear" + | "search" + | "plus"; + +/** One icon from the sprite. Decorative: the label beside it carries the meaning. */ +export function Icon({ name }: { name: IconName }): JSX.Element { + return ( + + ); +} + +/** The hidden sprite itself, rendered once by the shell. */ +export function IconSprite(): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/shell/Search.tsx b/apps/web/src/shell/Search.tsx new file mode 100644 index 0000000..5754eb9 --- /dev/null +++ b/apps/web/src/shell/Search.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { useJson } from "../lib/useJson"; +import type { TaskRow } from "../types"; +import { Icon } from "./Icons"; + +/** + * The header's search: a real, focusable panel over the task names. + * + * `Ctrl K` (or `Cmd K`) opens it, `Escape` closes it, and the typing filters the tasks the API + * knows about. It only asks for the list while it is open. + */ +export function Search(): JSX.Element { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const { data } = useJson(open ? "/api/v1/tasks" : null); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key.toLowerCase() === "k" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + setOpen(true); + return; + } + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const tasks = data ?? []; + const wanted = query.trim().toLowerCase(); + const matches = tasks.filter((task) => task.name.toLowerCase().includes(wanted)); + + return ( + <> + + {open ? ( +
    + setQuery(event.target.value)} + /> + {matches.length === 0 ? ( +

    No task has that name yet.

    + ) : ( +
      + {matches.slice(0, 8).map((task) => ( +
    • + setOpen(false)}> + {task.name} + + {task.app ?? ""} +
    • + ))} +
    + )} +
    + ) : null} + + ); +} diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx new file mode 100644 index 0000000..679214f --- /dev/null +++ b/apps/web/src/shell/Shell.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { Link, Outlet, useLocation } from "react-router-dom"; + +import { Header } from "./Header"; +import { Icon, IconSprite } from "./Icons"; +import { Sidebar } from "./Sidebar"; + +interface PageChrome { + title: string; + /** The page's one primary action. At most one screen has one, and it is the near-black button. */ + action?: () => ReactNode; + match: (path: string) => boolean; +} + +/** + * The one primary action this ticket can offer: teaching the next task opens the Teach page, which + * arrives in a later ticket. Pages that own their own primary button leave this unset. + */ +function teachAction(): ReactNode { + return ( + + + Teach a new task + + ); +} + +/* The order matters: a detail route is tested before its list route. */ +const PAGES: PageChrome[] = [ + { match: (path) => path === "/", title: "Home", action: teachAction }, + { match: (path) => path === "/needs-help", title: "Needs help" }, + { match: (path) => path.startsWith("/tasks/"), title: "Task" }, + { match: (path) => path === "/tasks", title: "Tasks", action: teachAction }, + { match: (path) => path.startsWith("/runs/"), title: "Run" }, + { match: (path) => path === "/runs", title: "Runs" }, + { match: (path) => path === "/teach", title: "Teach a new task" }, + { match: (path) => path === "/spreadsheets", title: "Spreadsheets" }, + { match: (path) => path === "/documents", title: "Documents" }, + { match: (path) => path === "/saved-answers", title: "Saved answers" }, + { match: (path) => path === "/apps", title: "Apps" }, + { match: (path) => path === "/settings", title: "Settings" }, + { match: (path) => path === "/help", title: "Help" }, +]; + +/** + * The app shell: the sidebar and the content column, with the header above whichever page the + * router chose. + */ +export function Shell(): JSX.Element { + const { pathname } = useLocation(); + const page = PAGES.find((entry) => entry.match(pathname)); + const action = page?.action; + + return ( +
    + + +
    +
    + +
    +
    + ); +} diff --git a/apps/web/src/shell/Sidebar.tsx b/apps/web/src/shell/Sidebar.tsx new file mode 100644 index 0000000..c2ef363 --- /dev/null +++ b/apps/web/src/shell/Sidebar.tsx @@ -0,0 +1,110 @@ +import { NavLink } from "react-router-dom"; + +import { useJson } from "../lib/useJson"; +import type { ExecutorSession, NeedsHelpRow } from "../types"; +import { Icon, type IconName } from "./Icons"; + +interface Item { + to: string; + label: string; + icon: IconName; + /** Only Home lights up for exactly `/`, so a detail page does not light up two items at once. */ + end?: boolean; +} + +const TOP: Item[] = [ + { to: "/", label: "Home", icon: "home", end: true }, + { to: "/needs-help", label: "Needs help", icon: "help" }, +]; + +const AUTOMATE: Item[] = [ + { to: "/tasks", label: "Tasks", icon: "tasks" }, + { to: "/runs", label: "Runs", icon: "hist" }, +]; + +const YOUR_DATA: Item[] = [ + { to: "/spreadsheets", label: "Spreadsheets", icon: "sheet" }, + { to: "/documents", label: "Documents", icon: "doc" }, + { to: "/saved-answers", label: "Saved answers", icon: "map" }, +]; + +const CONNECT: Item[] = [{ to: "/apps", label: "Apps", icon: "apps" }]; + +const FOOT: Item[] = [ + { to: "/settings", label: "Settings", icon: "gear" }, + { to: "/help", label: "Help", icon: "q" }, +]; + +function itemLink(item: Item, extra?: React.ReactNode): JSX.Element { + return ( + (isActive ? "on" : undefined)} + > + + {item.label} + {extra} + + ); +} + +function Logo(): JSX.Element { + return ( + + + ProcessLens + + ); +} + +/** + * The sidebar of DESIGN.md §6: the places, then the groups, then settings, help and the extension + * status at the bottom. The count beside "Needs help" is the same set the Needs help page shows, + * and it appears only when there is something to count. + */ +export function Sidebar(): JSX.Element { + const help = useJson("/api/v1/needs-help"); + const session = useJson("/api/v1/executor/session"); + const count = help.data?.length ?? 0; + const attached = session.data?.attached === true; + + return ( +